wcs : meilleur support des workflow, extraction triée par statut
[progfou.git] / wcs / wcs-dynexport
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3 """
4 Outil d'export dynamique de données w.c.s.
5
6 Copyright : Agence universitaire de la Francophonie — www.auf.org
7 Licence : GNU General Public Licence, version 2
8 Auteur : Jean Christophe André
9 Date de création : 13 mars 2013
10
11 Depends: wcs, python-simplejson, python-magic
12
13 URL d'accès :
14 - /dynexport => liste des formulaires pour le domaine courant
15 - /dynexport/domains.json => liste des domaines disponibles
16 - /dynexport/formulaire => liste des options ci-dessous
17 - /dynexport/formulaire/fields.json
18 - /dynexport/formulaire/field-names.json
19 - /dynexport/formulaire/field-names.txt
20 - /dynexport/formulaire/data.json
21 - /dynexport/formulaire/last-run.log
22 - /dynexport/formulaire/liste-dossiers.json
23 - /dynexport/formulaire/clear-cache => vide le cache
24 - /dynexport/formulaire/data/nom-dossier.json
25 - /dynexport/formulaire/data/nom-dossier_attachement-1.xxx
26 - /dynexport/formulaire/data/nom-dossier_attachement-2.xxx
27 - /dynexport/formulaire/data/nom-dossier_attachement-…
28 """
29 import sys
30 import os
31 import os.path
32 import logging
33 import time # time, gmtime, strftime, strptime, struct_time
34 import simplejson as json
35 import magic
36 import mimetypes
37 import unicodedata
38 from cStringIO import StringIO
39 from gzip import GzipFile
40 from re import match
41
42 DELAIS = 5 # maximum 5 secondes en cache
43 TIME_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' # format date pour HTTP
44
45 WCS_ROOT_DIR = '/var/lib/wcs'
46 WCS_DOMAIN_SUFFIX = '.auf.org'
47 WCS_FORM_PREFIX = 'form-'
48 WCS_CACHE_DIR = '/var/tmp'
49
50 #--------------------------------------------------------------------------
51 # fonctions de traitement
52 #--------------------------------------------------------------------------
53
54 def http_redirect(location, code='302'):
55     headers = {}
56     headers['Content-Type'] = 'text/plain; charset=utf-8'
57     headers['Status'] = '302 Redirection'
58     headers['Location'] = location
59     data = """If you see this, it means the automatic redirection has failed.
60 Please go to ${location}"""
61     # envoi de la réponse
62     headers = ''.join(map(lambda x: "%s: %s\r\n" % (x, headers[x]), headers))
63     f = open('/dev/stdout', 'wb')
64     f.write(headers + "\r\n")
65     if data:
66         f.write(data)
67     f.flush()
68     # arrêt du traitement
69     sys.exit(0)
70
71 def http_reply_and_exit(data, mime_type='text/html', charset='utf-8'):
72     # références horaires
73     current_time = time.time()
74     mtime = time.gmtime(current_time)
75     etime = time.gmtime(current_time + DELAIS)
76     if os.environ.has_key('HTTP_IF_MODIFIED_SINCE'):
77         try:
78             itime = time.strptime(os.environ['HTTP_IF_MODIFIED_SINCE'], TIME_FORMAT)
79         except ValueError:
80             itime = None
81     else:
82         itime = None
83     # préparation des en-têtes et données
84     headers = {}
85     headers['Content-Type'] = '%s; charset=%s' % (mime_type, charset)
86     headers['Last-Modified'] = time.strftime(TIME_FORMAT, mtime)
87     headers['Expires'] = time.strftime(TIME_FORMAT, etime)
88     if os.environ['REQUEST_METHOD'] == 'GET' and (not itime or mtime > itime):
89         # détermination de la version demandée (compressée ou non)
90         if os.environ.get('HTTP_ACCEPT_ENCODING','').split(',').count('gzip') > 0:
91             zdata = StringIO()
92             GzipFile('', 'w', 9, zdata).write(data)
93             data = zdata.getvalue()
94             headers['Content-Encoding'] = 'gzip'
95         headers['Vary'] = 'Content-Encoding'
96         headers['Content-Length'] = len(data)
97     else:
98         data = None
99     # envoi de la réponse
100     headers = ''.join(map(lambda x: "%s: %s\r\n" % (x, headers[x]), headers))
101     f = open('/dev/stdout', 'wb')
102     f.write(headers + "\r\n")
103     if data:
104         f.write(data)
105     f.flush()
106     # arrêt du traitement
107     sys.exit(0)
108
109
110 def _reduce_to_alnum(s, replacement_char='-'):
111     """réduction d'une chaîne de caractères à de l'alpha-numérique"""
112
113     if type(s) is not unicode:
114         s = unicode(s, 'utf-8')
115     s = unicodedata.normalize('NFKD', s).encode('ASCII', 'ignore')
116     r = ''
117     for c in s:
118         if ('a' <= c.lower() <= 'z') or ('0' <= c <= '9'):
119             r += c
120         elif len(r) > 0 and r[-1] != replacement_char:
121             r += replacement_char
122         else: # r == '' or r[-1] == replacement_char
123             pass
124     return r.strip(replacement_char)
125
126 def _make_wcs_cache_name(domain, form, name):
127     return 'wcs-%s-%s-%s' % (domain, form, name)
128
129 def set_wcs_cache(domain, form, name, data):
130     os.umask(0022)
131     cache_filename = _make_wcs_cache_name(domain, form, name)
132     f = open(os.path.join(WCS_CACHE_DIR, cache_filename), 'wb')
133     f.write(data)
134     f.close()
135
136 def get_wcs_cache(domain, form, name):
137     data = None
138     cache_filename = _make_wcs_cache_name(domain, form, name)
139     cache_filename = os.path.join(WCS_CACHE_DIR, cache_filename)
140     if os.path.exists(cache_filename):
141         f = open(cache_filename, 'rb')
142         data = f.read()
143         f.close()
144     return data
145
146 def clear_wcs_cache(domain, form):
147     cache_filename = _make_wcs_cache_name(domain, form, '')
148     for f in os.listdir(WCS_CACHE_DIR):
149         if f.startswith(cache_filename):
150             os.unlink(os.path.join(WCS_CACHE_DIR, f))
151
152 def get_wcs_domains():
153     root = WCS_ROOT_DIR
154     suffix = WCS_DOMAIN_SUFFIX
155     try:
156         l = os.listdir(root)
157     except OSError:
158         return None
159     return [x for x in l if os.path.isdir(os.path.join(root, x)) and x.endswith(suffix)]
160
161 def get_wcs_forms(domain):
162     root = os.path.join(WCS_ROOT_DIR, domain)
163     prefix = WCS_FORM_PREFIX
164     try:
165         l = os.listdir(root)
166     except OSError:
167         return None
168     return [x[len(prefix):] for x in l if os.path.isdir(os.path.join(root, x)) and x.startswith(prefix)]
169
170
171 def get_wcs_form_data(domain, form):
172     """extraction des données du formulaire"""
173     data = get_wcs_cache(domain, form, 'metadata.json')
174     if data is not None:
175         return json.loads(data, encoding='utf-8')
176     # dictionnaire des metadonnées (qui seront mises en cache)
177     metadata = {}
178
179     os.umask(0022)
180     logname = _make_wcs_cache_name(domain, form, 'last-run.log')
181     logging.basicConfig(level=logging.DEBUG,
182         format='%(asctime)s %(levelname)s %(message)s',
183         filename=os.path.join(WCS_CACHE_DIR, logname),
184         filemode='w')
185
186     logging.info('Début.')
187
188     from wcs import publisher
189     from wcs.formdef import FormDef
190     from wcs.fields import TitleField, CommentField, TextField, \
191                            StringField, ItemField, ItemsField, EmailField, \
192                            DateField, FileField, BoolField, TableField
193
194     pub = publisher.WcsPublisher.create_publisher()
195     pub.app_dir = os.path.join(pub.app_dir, domain)
196     formdef = FormDef.get_by_urlname(form)
197
198     # nommage des champs de façon unique
199     fields = {}
200     field_names = {}
201     field_names_duplicates = {}
202     for field in formdef.fields:
203         if isinstance(field, TitleField) or isinstance(field, CommentField):
204             continue
205         if field.varname:
206             name = field.varname
207         else:
208             name = _reduce_to_alnum(field.label,'_').lower()
209         if name in field_names.values(): # duplicat
210             field_names_duplicates[name] = field_names_duplicates.get(name, 1) + 1
211             name = '%s_%d' % (name, field_names_duplicates[name])
212         field_names.update({field.id: name})
213         fields.update({field.id: {'name': field_names[field.id], 'label': field.label, 'varname': field.varname and field.varname or ''}})
214
215     data = json.dumps(fields, ensure_ascii=False).encode('utf-8')
216     set_wcs_cache(domain, form, 'fields.json', data)
217     metadata.update({'fields': fields})
218
219     # on charge la base des types MIME une fois pour toutes
220     #magicmime = magic.Magic(mime=True) => ce sera pour plus tard…
221     magicmime = magic.open(magic.MAGIC_MIME)
222     magicmime.load()
223
224     liste_dossiers = []
225     liste_attachements = {}
226     for object in formdef.data_class().select():
227         if object.user is None:
228             logging.warning("Dossier '%s' sans utilisateur associé ?!?"\
229                             " On ignore...", object.id)
230             continue
231
232         result = {
233             'num_dossier': object.id,
234             'wcs_status': object.status,
235             'wcs_workflow_status': (object.status.startswith('wf-') and \
236                                 object.get_workflow_status().name or None),
237             'wcs_user_email': object.user.email,
238             'wcs_user_display_name': object.user.display_name,
239            #'wcs_last_modified': time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(object.last_modified())),
240             'wcs_comments': [],
241         }
242
243         if object.evolution is not None:
244             for e in object.evolution:
245                 if e.comment is not None:
246                     who = pub.user_class.get(e.who).display_name
247                     e_time = time.strftime('%Y-%m-%d %H:%M:%S', e.time)
248                     comment = '%s -- %s %s' % (e.comment, who, e_time)
249                     result['wcs_comments'].append(comment)
250
251         qfiles = { }
252         for field in formdef.fields:
253             field_id = str(field.id)
254             if not field_id in object.data:
255                 continue
256             if isinstance(field, TitleField) or isinstance(field, CommentField):
257                 continue
258             field_name = fields[field_id]['name']
259             data = object.data.get(field_id)
260             if data is None:
261                 result[field_name] = None
262                 continue
263             if isinstance(field, StringField) or isinstance(field, TextField) \
264             or isinstance(field, EmailField) or isinstance(field, ItemField):
265                 result[field_name] = data
266             elif isinstance(field, ItemsField) or isinstance(field, TableField):
267                 result[field_name] = data # liste => peux-être joindre sur ';'
268             elif isinstance(field, BoolField):
269                 result[field_name] = (data == 'True')
270             elif isinstance(field, DateField):
271                 if isinstance(data, time.struct_time):
272                     result[field_name] = '%04d-%02d-%02d' % (data.tm_year,
273                                                     data.tm_mon, data.tm_mday)
274                 else:
275                     result[field_name] = data
276             elif isinstance(field, FileField):
277                 if '.' in data.orig_filename:
278                     extension = data.orig_filename.rpartition('.')[2].lower()
279                 else: # il n'y a pas d'extension dans le nom de fichier
280                     p = os.path.join(pub.app_dir, 'uploads', data.qfilename)
281                     try:
282                         #m = magicmime.from_file(p) => ce sera pour plus tard…
283                         m = magicmime.file(p).split()[0].strip(';')
284                         extension = mimetypes.guess_extension(m)
285                     except:
286                         logging.warning("Type de fichier inconnu pour '%s'.", p)
287                         extension = None
288                     if extension is not None:
289                         extension = extension[1:]
290                     else:
291                         extension = 'unknown'
292                 result[field_name] = "%s.%s" % (field_name, extension)
293                 qfiles[field_name] = data.qfilename
294             else:
295                 logging.warning("Type de champ inconnu '%s' pour '%s' (%s).",
296                             field.__class__.__name__, field_name, field.label)
297
298         num_dossier = result['num_dossier']
299         nom = _reduce_to_alnum(result.get('nom','sans-nom')).upper()
300         prenom = _reduce_to_alnum(result.get('prenom','sans-prenom')).upper()
301         adel = result.get('adresse_electronique','sans-adel').replace('@','-').lower()
302
303         filename = "%04d-%s-%s-%s" % (num_dossier, nom, prenom, adel)
304         liste_dossiers.append(filename + '.json')
305
306         # sauvegarde des chemins d'accès aux fichiers joints
307         for f in qfiles:
308             dst = filename + '_' + result[f]
309             src = os.path.join(pub.app_dir, 'uploads', qfiles[f])
310             liste_attachements.update({dst: src})
311             # on renomme le fichier joint indiqué dans le dossier
312             result[f] = dst
313
314         # génération du fichier JSON
315         data = json.dumps(result, ensure_ascii=False).encode('utf-8')
316         set_wcs_cache(domain, form, 'data_%s.json' % filename, data)
317
318         logging.info("Dossier '%s' : %s.",
319                                     filename, result['wcs_workflow_status'])
320
321     data = json.dumps(liste_attachements, ensure_ascii=False).encode('utf-8')
322     set_wcs_cache(domain, form, 'data-files.json', data)
323     metadata.update({'attachements': liste_attachements})
324
325     liste_dossiers.sort()
326     data = json.dumps(liste_dossiers, ensure_ascii=False).encode('utf-8')
327     set_wcs_cache(domain, form, 'liste-dossiers.json', data)
328     metadata.update({'dossiers': liste_dossiers})
329
330     logging.info('Fin.')
331
332     data = json.dumps(metadata, ensure_ascii=False).encode('utf-8')
333     set_wcs_cache(domain, form, 'metadata.json', data)
334
335 #if __name__ == '__main__':
336 #    try:
337 #        extract_data(formdef, OUTPUT_DIRECTORY)
338 #    except:
339 #        logging.exception("Interruption du traitement pour cause d'erreur !")
340
341 #--------------------------------------------------------------------------
342 # gestion des requêtes web
343 #--------------------------------------------------------------------------
344
345 #l = []
346 #for k in sorted(os.environ):
347 #    l.append('%s=%s\n' % (k, os.environ[k]))
348 #data = ''.join(l)
349 #http_reply_and_exit(data, 'text/plain')
350
351 domain = os.environ.get('HTTP_HOST', '')
352 if domain not in get_wcs_domains():
353     http_reply_and_exit("Domaine '%s' inconnu." % domain, 'text/plain')
354
355 path_info = os.environ.get('PATH_INFO', '')
356
357 path_prefix = os.environ.get('REQUEST_URI', '')
358 if len(path_info) > 0:
359     path_prefix = path_prefix[:-len(path_info)]
360
361 if path_info == '':
362     http_redirect(path_prefix + '/')
363
364 if path_info == '/':
365     # liste des formulaires disponibles
366     l = sorted(get_wcs_forms(domain))
367     l = ['<li><a href="%s/">%s</a></li>' % (f, f) for f in l]
368     title = '<p>Liste des formulaires disponibles&nbsp;:</p>\n'
369     data = '<html>\n' + title + '<ul>\n' + '\n'.join(l) + '\n</ul>\n</html>'
370     http_reply_and_exit(data, 'text/html')
371
372 if path_info == '/index.json':
373     # liste des formulaires disponibles
374     l = sorted(get_wcs_forms(domain))
375     data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
376     http_reply_and_exit(data, 'application/json')
377
378 if path_info == '/domains.json':
379     # liste des domaines disponibles
380     l = get_wcs_domains()
381     data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
382     http_reply_and_exit(data, 'application/json')
383
384 if match(r'^/[a-z0-9-]+$', path_info):
385     http_redirect(path_prefix + path_info + '/')
386
387 if match(r'^/[a-z0-9-]+/$', path_info):
388     form = path_info.split('/')[1]
389     if form not in get_wcs_forms(domain):
390         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
391     l = [ 'fields.json', 'field-names.json', 'field-names.txt', 'last-run.log', 'liste-dossiers.json' ]
392     l = ['<li><a href="%s">%s</a></li>' % (f, f) for f in l]
393     title = '<p>Liste des informations disponibles&nbsp;:</p>\n'
394     action1 = """<p><a href="data/">Export des données</a></p>\n"""
395     action2 = """<p><a href="clear-cache">Suppression du cache</a> (pour ré-export)</p>\n"""
396     data = '<html>\n' + title + '<ul>\n' + '\n'.join(l) + '\n</ul>\n' + action1 + action2 + '</html>'
397     http_reply_and_exit(data, 'text/html')
398
399 if match(r'^/[a-z0-9-]+/index.json$', path_info):
400     form = path_info.split('/')[1]
401     if form not in get_wcs_forms(domain):
402         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
403     l = [ 'fields.json', 'field-names.json', 'field-names.txt', 'last-run.log', 'liste-dossiers.json', 'data', 'clear-cache' ]
404     data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
405     http_reply_and_exit(data, 'application/json')
406
407 if match(r'^/[a-z0-9-]+/clear-cache$', path_info):
408     form = path_info.split('/')[1]
409     if form not in get_wcs_forms(domain):
410         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
411     clear_wcs_cache(domain, form)
412     http_reply_and_exit('Ok.', 'text/plain')
413
414 if match(r'^/[a-z0-9-]+/fields.json$', path_info):
415     form = path_info.split('/')[1]
416     if form not in get_wcs_forms(domain):
417         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
418     get_wcs_form_data(domain, form)
419     d = json.loads(get_wcs_cache(domain, form, 'fields.json'), encoding='utf-8')
420     data = json.dumps(d, ensure_ascii=False, indent=' ').encode('utf-8')
421     http_reply_and_exit(data, 'application/json')
422
423 if match(r'^/[a-z0-9-]+/field-names.json$', path_info):
424     form = path_info.split('/')[1]
425     if form not in get_wcs_forms(domain):
426         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
427     get_wcs_form_data(domain, form)
428     d = json.loads(get_wcs_cache(domain, form, 'fields.json'), encoding='utf-8')
429     d = dict([(k, d[k]['name']) for k in d])
430     data = json.dumps(d, ensure_ascii=False, indent=' ').encode('utf-8')
431     http_reply_and_exit(data, 'application/json')
432
433 if match(r'^/[a-z0-9-]+/field-names.txt$', path_info):
434     form = path_info.split('/')[1]
435     if form not in get_wcs_forms(domain):
436         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
437     get_wcs_form_data(domain, form)
438     d = json.loads(get_wcs_cache(domain, form, 'fields.json'), encoding='utf-8')
439     d = [(k, d[k]['name'], d[k]['label']) for k in d]
440     d = sorted(d, key=lambda x: int(x[0]))
441     text = u''.join([u'%s:%s:%s\n' % (x[0], x[1], x[2]) for x in d])
442     data = text.encode('utf-8')
443     http_reply_and_exit(data, 'text/plain')
444
445 if match(r'^/[a-z0-9-]+/last-run.log$', path_info):
446     form = path_info.split('/')[1]
447     if form not in get_wcs_forms(domain):
448         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
449     get_wcs_form_data(domain, form)
450     data = get_wcs_cache(domain, form, 'last-run.log')
451     http_reply_and_exit(data, 'text/plain')
452
453 if match(r'^/[a-z0-9-]+/liste-dossiers.json$', path_info):
454     form = path_info.split('/')[1]
455     if form not in get_wcs_forms(domain):
456         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
457     get_wcs_form_data(domain, form)
458     data = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
459     data = json.dumps(data, ensure_ascii=False, indent=' ').encode('utf-8')
460     http_reply_and_exit(data, 'application/json')
461
462 if match(r'^/[a-z0-9-]+/data$', path_info):
463     http_redirect(path_prefix + path_info + '/')
464
465 if match(r'^/[a-z0-9-]+/data/$', path_info):
466     form = path_info.split('/')[1]
467     if form not in get_wcs_forms(domain):
468         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
469     get_wcs_form_data(domain, form)
470     dossiers = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
471     attachements = json.loads(get_wcs_cache(domain, form, 'data-files.json'), encoding='utf-8')
472     l = sorted(dossiers + attachements.keys())
473     if len(l) > 0:
474         l = ['<li><a href="%s">%s</a></li>' % (f, f) for f in l]
475         title = '<p>Liste des documents disponibles&nbsp;:</p>\n'
476         data = '<html>\n' + title + '<ul>\n' + '\n'.join(l) + '\n</ul>\n</html>'
477     else:
478         data = '<html>\n<p>Aucun document disponible.</p>\n</html>'
479     http_reply_and_exit(data, 'text/html')
480
481 if match(r'^/[a-z0-9-]+/data/index.json$', path_info):
482     form = path_info.split('/')[1]
483     if form not in get_wcs_forms(domain):
484         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
485     get_wcs_form_data(domain, form)
486     dossiers = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
487     attachements = json.loads(get_wcs_cache(domain, form, 'data-files.json'), encoding='utf-8')
488     l = sorted(dossiers + attachements.keys())
489     data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
490     http_reply_and_exit(data, 'application/json')
491
492 if match(r'^/[a-z0-9-]+/data/[^/]+$', path_info):
493     form = path_info.split('/')[1]
494     if form not in get_wcs_forms(domain):
495         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
496     get_wcs_form_data(domain, form)
497     doc = path_info.split('/')[3]
498     dossiers = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
499     if doc in dossiers:
500         data = get_wcs_cache(domain, form, 'data_' + doc)
501         data = json.loads(data, encoding='utf-8')
502         data = json.dumps(data, ensure_ascii=False, indent=' ').encode('utf-8')
503         http_reply_and_exit(data, 'application/json')
504     attachements = json.loads(get_wcs_cache(domain, form, 'data-files.json'), encoding='utf-8')
505     if doc in attachements:
506         data = open(attachements[doc], 'rb').read()
507         mime_type = mimetypes.guess_type(doc)[0]
508         if mime_type is None:
509             mime_type = 'application/octet-stream'
510         http_reply_and_exit(data, mime_type)
511     http_reply_and_exit("Document '%s' inconnu." % path_info, 'text/plain')
512
513 http_reply_and_exit("Requête '%s' inconnue." % path_info, 'text/plain')