comparison scripts/association/update_garradin.py @ 51:c34633e3f56a

add a script to semi-automatically update garradin database (work in progress)
author souliane <souliane@mailoo.org>
date Mon, 28 Sep 2015 11:16:23 +0200
parents
children
comparison
equal deleted inserted replaced
50:fe116a11199b 51:c34633e3f56a
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Scripts to update Garradin database from various CSV inputs.
5 # Copyright (C) 2015 Adrien Cossa <souliane@mailoo.org>
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 import sqlite3
21 import csv, unicodecsv
22 import os
23 import os.path
24 import tempfile
25 from glob import glob
26 from time import strftime
27 from datetime import datetime
28 from collections import OrderedDict
29
30
31 ### PATHS AND OTHER STUFF TO CONFIGURE ###
32
33
34 DB_FILE = u"./association.sqlite"
35 INPUT_PATH = u"../../../sat_private"
36
37 APAYER_FILES = (u"apayer", u"SALUT-A-TOI_*.csv")
38 CM_FILES = (u"credit_mutuel/dernieres_operations", "*_*.csv")
39 DJANGO_FILES = (u"django", u"sat_website_subscriptions.csv")
40
41 LOG_PATH = u"./OUTPUT"
42 INFO, WARNING = 1, 2
43 LOG_LEVELS = (INFO, WARNING)
44
45
46 def dirtyGetDateOfFile(path):
47 """Extract the date at the end of the filename, just before the extension.
48
49 @return: datetime
50 """
51 # FIXME: there should be a better way to do that
52 token = os.path.basename(path)[-12:-4]
53 return datetime.strptime(token, "%Y%m%d")
54
55
56 ### !!! DO NOT MODIFY BELOW THIS LINE !!! ###
57
58
59 ### DATE AND TIME ###
60
61 DATE_ORIGIN = u"1970-01-01"
62 DATE_FORMAT = "%Y-%m-%d"
63 TODAY = strftime(DATE_FORMAT)
64
65
66 ### RETRIEVE DATABASE INFOS ###
67
68
69 class GarradinDB(object):
70
71 TABLES = ("membres", "cotisations", "cotisations_membres")
72
73 def __init__(self, cursor):
74 self.cursor = cursor
75
76 def getTableInfos(self):
77 infos = {}
78 for table in self.TABLES:
79 cursor.execute("PRAGMA table_info(%s)" % table)
80 rows = cursor.fetchall()
81 infos[table] = OrderedDict()
82 for row in rows:
83 # don't use {row[1]: row[2]...} : it messes the order
84 infos[table][row[1]] = row[2] # field name: field type
85 return infos
86
87 def getLastAutoUpdate(self):
88 """Get the datetime of the last database automatic update.
89
90 @return: datetime
91 """
92 self.cursor.execute("SELECT valeur FROM config WHERE cle='last_auto_update'")
93 row = self.cursor.fetchone()
94 try:
95 date = row[0]
96 except TypeError:
97 date = DATE_ORIGIN
98 return datetime.strptime(date, DATE_FORMAT)
99
100 def setLastAutoUpdate(self, date=TODAY):
101 """Set to date of the last database automatic update to today."""
102 self.cursor.execute("REPLACE INTO config VALUES ('last_auto_update', ?)", (date,))
103
104
105 con = sqlite3.connect(DB_FILE)
106 with con:
107 cursor = con.cursor()
108 db = GarradinDB(cursor)
109 TABLE_INFOS = db.getTableInfos()
110 LAST_AUTO_UPDATE = db.getLastAutoUpdate()
111
112
113 ### LOGGERS ###
114
115
116 class Logger(object):
117
118 def __init__(self, path):
119 if not os.path.isdir(path):
120 os.mkdir(path)
121 basename = "update_garradin_{day}_%s.log".format(day=strftime("%Y%m%d"))
122 self.info_file = os.path.join(path, basename % "info")
123 self.warning_file = os.path.join(path, basename % "warning")
124
125 def write(self, file, msg):
126 with open(file, 'a') as file:
127 file.write((u"[%s] %s\n" % (strftime("%d/%m/%Y@%H:%m:%S"), msg)).encode("utf-8"))
128
129 def info(self, msg):
130 self.write(self.info_file, msg)
131 if INFO in LOG_LEVELS:
132 print msg
133
134 def warning(self, msg):
135 self.write(self.warning_file, msg)
136 if WARNING in LOG_LEVELS:
137 print msg
138
139 logger = Logger(LOG_PATH)
140
141
142 ### CSV DIALECTS ###
143
144
145 # FIXME: Python CSV doesn't handle CSV file starting with a BOM
146
147 class my_dialect(csv.excel):
148 """Describe my properties for CSV files."""
149 delimiter = ';'
150 csv.register_dialect("my_dialect", my_dialect)
151
152
153 ### GENERIC ENTRIES' CLASSES ###
154
155
156 class Entry(OrderedDict):
157
158 TYPE_INTEGER = u"INTEGER"
159 TYPE_TEXT = u"TEXT"
160
161 ID_UNIQUE_KEYS = ("FIELD_NAME", "FIELD_SURNAME", "FIELD_EMAIL") # define unique members
162 ID_EQUAL_KEYS = ID_UNIQUE_KEYS # define equality, not even needed to update the database
163
164 FIELD_NAME = u"prenom"
165 FIELD_SURNAME = u"nom"
166 FIELD_EMAIL = u"email"
167
168 FIELDS = [FIELD_NAME, FIELD_SURNAME, FIELD_EMAIL]
169
170 MATCH_NONE = 0
171 MATCH_INCOMPLETE = 1
172 MATCH_UNIQUE = 2
173 MATCH_EQUAL = 3
174
175 matched = MATCH_NONE
176
177 def __init__(self, entry):
178 """
179
180 @param entry(dict or row): entry data
181 """
182 if isinstance(entry, dict):
183 data = OrderedDict({key: value for key, value in entry.iteritems() if key in self.FIELDS})
184 if hasattr(entry, "identityDict"):
185 data.update(entry.identityDict(self))
186 else:
187 data = self.row2dict(entry)
188 OrderedDict.__init__(self, data)
189
190 def __iter__(self):
191 # XXX: use the order of self.FIELDS and ignore OrderedDict internal order
192 for field in self.FIELDS:
193 if field in self:
194 yield field
195
196 def row2dict(self, row):
197 """Return a dict representation of a row.
198
199 @param row (list[unicode]): a row of information
200 @return: OrderedDict {unicode: unicode} with:
201 - key: field name
202 - value: field value
203 """
204 data = OrderedDict()
205 index = 0
206 for field in self.FIELDS:
207 if row[index]:
208 data[field] = row[index]
209 index += 1
210 return data
211
212 def identityDict(self, cls=None):
213 """Return a dict representing a user identity.
214
215 @param cls (class): if specified, the result dict will use cls's
216 identity fields for its own keys (instead of self's fields).
217 @return: dict{unicode: unicode}
218 """
219 if cls is None:
220 cls = self
221 return {getattr(cls, key): self[getattr(self, key)] for key in self.ID_EQUAL_KEYS}
222
223 def identityMatches(self, entry):
224 """Return a dict compiling the identity matches with another entry.
225
226 @param entry (Entry): another entry
227 @return: dict{unicode: bool}
228 """
229 return {key: self[getattr(self, key)] == entry[getattr(entry, key)] for key in self.ID_EQUAL_KEYS}
230
231 def identityMatch(self, entry):
232 """Return a value representing the matching level with another entry.
233
234 @param entry (Entry): another entry
235 @return: int in (Entry.MATCH_NONE, Entry.MATCH_INCOMPLETE, Entry.MATCH_UNIQUE, Entry.MATCH_EQUAL)
236 """
237 matches = self.identityMatches(entry)
238 if len([match for match in matches.values() if not match]) == 0:
239 return self.MATCH_EQUAL
240 match_name = matches["FIELD_NAME"] and matches["FIELD_SURNAME"]
241 if match_name and matches["FIELD_EMAIL"]:
242 return self.MATCH_UNIQUE
243 if match_name or matches["FIELD_EMAIL"]:
244 return self.MATCH_INCOMPLETE
245 return self.MATCH_NONE
246
247 def identityToString(self):
248 """Return a human-readable string representing a user identity.
249
250 @return: unicode
251 """
252 return "%s %s <%s>" % (self[self.FIELD_NAME], self[self.FIELD_SURNAME], self[self.FIELD_EMAIL])
253
254 def valuesToString(self):
255 """List the values.
256
257 @return: unicode
258 """
259 return u", ".join(self.values())
260
261 def fields(self):
262 """List the fields to which a value is associated.
263
264 @return: list[unicode]
265 """
266 return [field for field in self.FIELDS if field in self]
267
268 def fieldsToString(self):
269 """List the fields to which a value is associated.
270
271 @return: unicode
272 """
273 return u", ".join(self.fields())
274
275 def changeset(self):
276 """Return the list of fields and values as needed to update the database.
277
278 @return: tuple(unicode, list)
279 """
280 changes = ['%s=?' % field for field in self.fields()]
281 return (u", ".join(changes), self.values())
282
283
284 class GarradinDBEntry(Entry):
285
286 def __init__(self, cursor, entry):
287 self.cursor = cursor
288 Entry.__init__(self, entry)
289
290 def insertOrReplace(self):
291 """Insert or replace the entry in the garradin database."""
292 fields = self.fieldsToString()
293 args = self.values()
294 placeholders = ",".join([u"?" for arg in args])
295 # XXX: "INSERT OR REPLACE" is needed for updating lastrowid, "REPLACE" is not enough
296 request = "INSERT OR REPLACE INTO %s (%s) VALUES (%s)" % (self.TABLE, fields, placeholders)
297 self.cursor.execute(request, args)
298
299
300 class CSVEntry(Entry):
301
302 def date(self):
303 """Return the date associated with this entry.
304
305 @return: datetime
306 """
307 try:
308 format = self.DATE_FORMAT
309 except AttributeError:
310 format = DATE_FORMAT
311 return datetime.strptime(self[self.FIELD_DATE], format)
312
313
314 ### MEMBERS ENTRIES ###
315
316
317 class GarradinDBMember(GarradinDBEntry):
318
319 TABLE = u"membres"
320 FIELDS = TABLE_INFOS[TABLE].keys()
321
322 FIELD_ID_CATEGORY = u"id_categorie"
323 FIELD_PASSWORD = u"passe"
324 FIELD_AUTO_UPDATE = u"date_maj_auto"
325 FIELD_ADDRESS = u"adresse"
326
327 ID_EQUAL_KEYS = Entry.ID_UNIQUE_KEYS + ("FIELD_ADDRESS",)
328
329 def __init__(self, cursor, entry):
330 GarradinDBEntry.__init__(self, cursor, entry)
331 try:
332 self.pop(self.FIELD_PASSWORD)
333 except KeyError:
334 pass
335
336 if hasattr(entry, "address"):
337 self[self.FIELD_ADDRESS] = entry.address()
338
339 def setAutoUpdate(self):
340 """Update the value of the field self.FIELD_AUTO_UPDATE."""
341 self[self.FIELD_AUTO_UPDATE] = TODAY
342
343 def values(self):
344 """List the values.
345
346 @return: list
347 """
348 values = []
349 for field, value in self.iteritems():
350 if TABLE_INFOS[self.TABLE].get(field, self.TYPE_TEXT) == self.TYPE_TEXT:
351 value = u"%s" % value.replace('\r', '').replace('\n', '\\n')
352 values.append(unicode(value))
353 return values
354
355
356 class GarradinDBMembership(GarradinDBEntry):
357
358 TABLE = u"cotisations_membres"
359 FIELDS = TABLE_INFOS[TABLE].keys()
360
361 FIELD_MEMBER = u"id_membre"
362 FIELD_MEMBERSHIP = u"id_cotisation"
363 FIELD_DATE = u"date"
364
365
366 class GarradinDBMembershipType(GarradinDBEntry):
367
368 TABLE = u"cotisations"
369 FIELDS = TABLE_INFOS[TABLE].keys()
370
371 FIELD_AMOUNT = u"montant"
372
373
374 class GarradinCSVMember(CSVEntry):
375 """Like GarradinDBMember but with a "categorie" field in second position."""
376
377 FIELD_ID_CATEGORY = u"id_categorie"
378 FIELD_CATEGORY = u"categorie"
379 FIELD_ADDRESS = u"adresse"
380
381 FIELDS = GarradinDBMember.FIELDS[:1] + [FIELD_CATEGORY] + GarradinDBMember.FIELDS[1:]
382
383 def __init__(self, cursor, db_member):
384 """
385
386 @param db_member (GarradinDBMember)
387 """
388 CSVEntry.__init__(self, db_member)
389
390 if self.FIELD_ID_CATEGORY not in self:
391 self[self.FIELD_ID_CATEGORY] = 1
392 if self.FIELD_CATEGORY not in self:
393 cursor.execute("SELECT nom FROM membres_categories WHERE id=?", (self[self.FIELD_ID_CATEGORY],))
394 rows = cursor.fetchone()
395 self[self.FIELD_CATEGORY] = rows[0]
396
397
398 class ApayerCSVEntry(CSVEntry):
399
400 FIELD_NAME = u"Prenom"
401 FIELD_SURNAME = u"Nom"
402 FIELD_EMAIL = u"Courriel"
403 FIELD_AMOUNT = u"Montant"
404 FIELD_DATE = u"DatePaiement"
405 DATE_FORMAT = u"%d/%m/%y"
406
407 # XXX: there's a BOM at the beginning of the CSV file
408 FIELDS = ["Mnemonique", "TPE", "DatePaiement", "ObjetPaiement",
409 "AutreObjet", "Montant", "Devise", "Reference",
410 "Commentaire", "ReferencBancaire", "NumeroAutorisation",
411 "Nom", "Prenom", "Adresse", "CodePostal", "Ville", "Courriel",
412 "Etat", "MotifRefus", "Cvx", "Vld", "Brand", "Status3DS"]
413
414 FILTERED_OUT = ("", u"Mnemonique", "TPE", "Devise", "ReferencBancaire",
415 "NumeroAutorisation", "Cvx", "Vld", "Brand", "Status3DS")
416
417 def address(self):
418 return u"\n".join([self["Adresse"], self["CodePostal"], self["Ville"]])
419
420
421 class DjangoCSVEntry(CSVEntry):
422
423 FIELD_NAME = u"prenom"
424 FIELD_SURNAME = u"nom"
425 FIELD_EMAIL = u"courriel"
426 FIELD_AMOUNT = u"montant"
427 FIELD_DATE = u"date"
428
429 FIELDS = ["date", "prenom", "nom", "adresse", "courriel", "jid", "montant",
430 "moyen_paiement", "reference", "commentaire", "lettre_info",
431 "langue"]
432
433
434
435 ### ENTRIES FOR THE FINANCES ###
436
437
438 class GarradinDBOperation(GarradinDBEntry):
439
440 TABLE = u"compta_journal"
441
442
443 class GarradinCSVOperation(CSVEntry):
444
445 FIELDS = (u"Numéro mouvement", u"Date", u"Type de mouvement", u"Catégorie",
446 u"Libellé", u"Montant", u"Compte de débit - numéro",
447 u"Compte de débit - libellé", u"Compte de crédit - numéro",
448 u"Compte de crédit - libellé", u"Moyen de paiement",
449 u"Numéro de chèque", u"Numéro de pièce", u"Remarques")
450
451
452 class CmCSVOperation(CSVEntry):
453
454 FIELD_DATE = u"Date d'opération"
455 DATE_FORMAT = u"%d/%m/%Y"
456
457 FIELDS = (FIELD_DATE, u"Date de valeur", u"Débit", u"Crédit",
458 u"Libellé", u"Solde")
459
460
461 ### CLASSES FOR THE IMPORTS ###
462
463
464 class EntryMatcher(object):
465
466 def __init__(self, cursor):
467 self.cursor = cursor
468
469 def processEntryForCSVExport(self, entry, context):
470 """Return the processed entry ready to be exported to CSV,
471 or None if the entry is conflicting with another one already existing.
472
473 @param entry (Entry): an Entry instance
474 @param context (unicode): context indication for the user
475 @return: GarradinCSVMember or None
476 """
477 # this method is not optimised but it's sensible... don't mess with it.
478 tmp_entry = GarradinDBMember(self.cursor, entry)
479 base_entry = self.getMatchingEntry(tmp_entry, context)
480 if base_entry is None:
481 return None
482 base_entry.update(tmp_entry)
483
484 new_entry = GarradinCSVMember(self.cursor, base_entry)
485 new_entry.matched = base_entry.matched
486 return new_entry
487
488 def getMatchingEntry(self, new_entry, context):
489 """Try to get a matching entry, the behavior depends of the found match:
490
491 - no match at all: returns a new empty entry
492 - incomplete match: returns None and write a warning in the logs
493 - unique or equal match: returns the matching database entry
494
495 @param new_entry (GarradinDBMember): entry to be matched
496 @param context (unicode): context indication for the user
497 @return: GarradinDBMember or None:
498 """
499 self.cursor.execute("SELECT * FROM %s" % new_entry.TABLE)
500 rows = self.cursor.fetchall()
501
502 for row in rows:
503 row = list(row)
504 current_entry = GarradinDBMember(self, row)
505 match = current_entry.identityMatch(new_entry)
506 if match == Entry.MATCH_NONE:
507 continue # try to match next row
508
509 current_entry.matched = match
510
511 if match == Entry.MATCH_EQUAL:
512 return current_entry # we still need to update the membership
513 if match == Entry.MATCH_INCOMPLETE:
514 log = logger.warning
515 desc = (u"INCOMPLETE", u"A MANUAL check and process is required!")
516 elif match == Entry.MATCH_UNIQUE:
517 log = logger.info
518 desc = (u"unique", u"The database entry will be updated.")
519 log(u"A %s match has been found between these two entries from \"%s\" and garradin's \"%s\" table:" % (desc[0], context, current_entry.TABLE))
520 log(u" - %s" % new_entry.valuesToString())
521 log(u" - %s" % current_entry.valuesToString())
522 log(" --> %s" % desc[1])
523 return current_entry if match == Entry.MATCH_UNIQUE else None
524
525 return GarradinDBMember(self, {})
526
527 class MembershipManager(object):
528 def __init__(self, cursor):
529 self.cursor = cursor
530 self.memberships = {}
531
532 def setCache(self, email, amount, date):
533 """Write a membership in cache for later database update.
534
535 @param email (unicode): email adress of the member
536 @param amount (int): amount of the membership fee
537 @param date (datetime): date of the subscription
538 """
539 self.memberships[email] = (amount, date)
540
541 def getMembershipAmount(self, membership_id):
542 """Return a membership amount.
543
544 @param membership_id (int): membership ID in the "cotisations" table
545 @return: int
546 """
547 self.cursor.execute("SELECT %s FROM %s WHERE id=?" % (GarradinDBMembershipType.FIELD_AMOUNT, GarradinDBMembershipType.TABLE), (membership_id,))
548 return int(self.cursor.fetchone()[0])
549
550 def getMemberIdentity(self, member_id):
551 """Return a human-readable identity.
552
553 @param member_id (int): member ID in the "membres" table
554 @return: unicode
555 """
556 self.cursor.execute("SELECT * FROM %s WHERE id=?" % GarradinDBMember.TABLE, (member_id,))
557 return GarradinDBMember(self, self.cursor.fetchone()).identityToString()
558
559 def getMembershipType(self, amount):
560 """Return the suitable membership type ID and amount for the given amount.
561
562 @param amount (int): payment amount
563 @return: (int, int)
564 """
565 request = "SELECT id, MAX({field}) FROM {table} WHERE {field} <= ?".format(field=GarradinDBMembershipType.FIELD_AMOUNT, table=GarradinDBMembershipType.TABLE)
566 self.cursor.execute(request, (amount,))
567 return self.cursor.fetchone()
568
569 def updateMembershipFromCache(self, member_id, email):
570 """Look in the cache to update the membership fee of a member.
571
572 @param member_id (int): member ID in the "membres" table
573 @param email(unicode): member email
574 """
575 membership = self.memberships.pop(email, None)
576 if membership:
577 amount, date = membership
578 self.updateMembership(member_id, amount, date)
579
580 def updateMembership(self, member_id, amount, date_payment):
581 """Update the membership fee of a member.
582
583 @param member_id (int): member ID in the "membres" table
584 @param amount (float): amount of the membership fee
585 @param date_payment (unicode): date of the payment
586 """
587 member = self.getMemberIdentity(member_id)
588 self.cursor.execute("SELECT * FROM %s WHERE id_membre=?" % GarradinDBMembership.TABLE, (member_id,))
589 rows = self.cursor.fetchall()
590 entries = [GarradinDBMembership(self.cursor, row) for row in rows]
591 entries.sort(key=lambda entry: entry[entry.FIELD_DATE])
592
593 # first we check for inconsistency
594 last_date, last_amount = datetime.strptime(DATE_ORIGIN, DATE_FORMAT), 0
595 for entry in entries:
596 current_date = datetime.strptime(entry[entry.FIELD_DATE], DATE_FORMAT)
597 current_amount = self.getMembershipAmount(entry[entry.FIELD_MEMBERSHIP])
598 if (current_date - last_date).days < 365:
599 logger.warning(u"At least two memberships within less than one year have been registered for %s:" % member)
600 logger.warning(u" - € %d on %s" % (last_amount, last_date))
601 logger.warning(u" - € %d on %s" % (current_amount, current_date))
602 logger.warning(u" --> Please fix it MANUALLY!")
603 last_date, last_amount = current_date, current_amount
604
605 if (date_payment - last_date).days < 365:
606 amount += last_amount # cumulate two payments
607 else:
608 data = {GarradinDBMembership.FIELD_MEMBER: member_id,
609 GarradinDBMembership.FIELD_DATE: date_payment}
610 entry = GarradinDBMembership(self.cursor, data)
611
612 m_type_id, m_type_amount = self.getMembershipType(amount)
613 delta = amount - m_type_amount
614 if delta:
615 logger.warning(u"We received € %.2f from %s: a membership fee of € %d will be registered." % (amount, member, m_type_amount))
616 logger.warning(u" --> Please MANUALLY register a donation of € %.2f." % delta)
617 entry[entry.FIELD_MEMBERSHIP] = m_type_id
618 entry.insertOrReplace()
619
620 class MembersImporter(object):
621
622 def __init__(self, cursor):
623 self.cursor = cursor
624 self.fails = []
625 self.membership_manager = MembershipManager(cursor)
626
627 def fixRowFromLeadingEqualSign(self, row):
628 """Fix leading equal sign in CSV field since it is not handled by Python.
629
630 @param data (dict{unicode: unicode}): row data
631 """
632 for field, value in row.iteritems():
633 if value.startswith('="') and value.endswith('"'):
634 row[field] = value[2:-1]
635
636 def importFromCSV(self, input_csv, output_csv=None):
637 """Import members from a CSV to garradin.
638
639 @param input_csv (unicode): path to the input CSV file
640 @param output_csv (unicode): path to store intermediate Garradin CSV
641 """
642 tmp_csv = output_csv if output_csv else tempfile.mkstemp()[1]
643 matcher = EntryMatcher(self.cursor)
644 with open(input_csv, 'r') as csv_file:
645 reader = unicodecsv.DictReader(csv_file, dialect="my_dialect")
646 logger.info("Processing %s..." % input_csv)
647 with open(tmp_csv, 'w') as tmp_file:
648 writer = unicodecsv.DictWriter(tmp_file, GarradinCSVMember.FIELDS, dialect="my_dialect")
649 fails = []
650 for row in reader:
651 input_entry = self.CSV_ENTRY_CLASS(row)
652
653 date = input_entry.date()
654 if (LAST_AUTO_UPDATE - date).days > self.AGE_LIMIT:
655 continue # skip older entries
656
657 self.fixRowFromLeadingEqualSign(input_entry)
658 if not self.checkEntry(input_entry):
659 self.fails.append(input_entry)
660 continue
661 entry = matcher.processEntryForCSVExport(input_entry, self.CONTEXT)
662 if not entry:
663 continue
664 if entry.matched != Entry.MATCH_EQUAL:
665 writer.writerow(entry)
666
667 self.extraProcessEntry(input_entry, entry)
668 self.printFails()
669
670 self.importFromGarradinCSV(tmp_csv)
671 if not output_csv:
672 os.remove(tmp_csv)
673
674 def checkEntry(self, input_entry):
675 return True
676
677 def printFails(self):
678 pass
679
680 def extraProcessEntry(self, input_entry, entry):
681 pass
682
683 def importFromGarradinCSV(self, csv_file):
684 """Import a garradin CSV to the database.
685
686 @param csv_file (unicode): file to import
687 """
688 with open(csv_file, 'r') as file:
689 reader = unicodecsv.reader(file, dialect="my_dialect")
690 for row in reader:
691 row.pop(1) # remove the category name
692 db_entry = GarradinDBMember(self.cursor, row)
693 db_entry.setAutoUpdate()
694 db_entry.insertOrReplace()
695
696 self.membership_manager.updateMembershipFromCache(self.cursor.lastrowid, db_entry[db_entry.FIELD_EMAIL])
697
698 action = "updated" if db_entry.get('id', None) else "added"
699 logger.warning("The member %s has been %s." % (db_entry.identityToString(), action))
700
701
702 class ApayerImporter(MembersImporter):
703
704 CSV_ENTRY_CLASS = ApayerCSVEntry
705
706 # since we receive the CSV every Sunday, operations can be one week old
707 # without having been processed yet, even if you do a daily auto update
708 AGE_LIMIT = 14
709
710 CONTEXT = u"apayer"
711
712 STATE_ACCEPTED = u"Paiement accepté (payé)"
713
714 def checkEntry(self, input_entry):
715 return input_entry["Etat"] == self.STATE_ACCEPTED
716
717 def printFails(self):
718 if self.fails:
719 logger.warning(u"The following \"apayer\" operations have NOT been completed, you MAY want to contact the users:")
720 for row in self.fails:
721 fields = ["%s: %s" % (key, value) for key, value in row.iteritems() if key not in ApayerCSVEntry.FILTERED_OUT and value]
722 logger.warning(u" - %s" % ", ".join(fields))
723
724 def extraProcessEntry(self, input_entry, entry):
725 # update membership fee
726 date = input_entry.date()
727 amount = float(input_entry[input_entry.FIELD_AMOUNT])
728 if entry.get("id", None):
729 self.membership_manager.updateMembership(entry["id"], amount, date)
730 else: # we don't know the member ID yet
731 self.membership_manager.setCache(entry[entry.FIELD_EMAIL], amount, date)
732
733
734 class DjangoImporter(MembersImporter):
735
736 CSV_ENTRY_CLASS = DjangoCSVEntry
737
738 # if you do a daily auto update, this file is processed everyday
739 AGE_LIMIT = 2
740
741 CONTEXT = u"django form"
742
743 def extraProcessEntry(self, input_entry, entry):
744 date = input_entry.date()
745 if not entry.get("id", None): # new user
746 self.membership_manager.setCache(entry[entry.FIELD_EMAIL], 0, date)
747
748
749 class CmImporter(object):
750
751 AGE_LIMIT = 60
752
753 def importFromCSV(self, csv_file):
754 """Import financial operation from credit mutuel CSV to garradin.
755
756 @param csv_file (unicode): path to django form CSV file
757 """
758 raise NotImplementedError # TODO: update database with entry
759
760
761 ### MAIN CLASS ###
762
763
764 class GarradinUpdater(object):
765
766 def __init__(self, cursor):
767 self.cursor = cursor
768
769 def importFromApayer(self):
770 """Import CSV files from apayer"""
771 for apayer_csv in glob(os.path.join(INPUT_PATH, *APAYER_FILES)):
772 if dirtyGetDateOfFile(apayer_csv) >= LAST_AUTO_UPDATE:
773 ApayerImporter(self.cursor).importFromCSV(apayer_csv)
774
775 def importFromDjango(self):
776 """Import all Django CSV files"""
777 # FIXME: import JID, subscription to mailing list and comment
778 for django_csv in glob(os.path.join(INPUT_PATH, *DJANGO_FILES)):
779 DjangoImporter(self.cursor).importFromCSV(django_csv)
780
781 def importFromCm(self):
782 """Import all credit mutuel CSV files"""
783 for cm_csv in glob(os.path.join(INPUT_PATH, *CM_FILES)):
784 if dirtyGetDateOfFile(cm_csv) >= LAST_AUTO_UPDATE:
785 CmImporter(self.cursor).importFromCSV(cm_csv)
786
787
788 con = sqlite3.connect(DB_FILE)
789 with con:
790 cursor = con.cursor()
791 updater = GarradinUpdater(cursor)
792 updater.importFromApayer()
793 updater.importFromDjango()
794 #updater.importFromCm()
795 GarradinDB(cursor).setLastAutoUpdate("2015-06-01")