wcs-dynexport : ajout d'un index pour ordonner les champs
[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 i, field in enumerate(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: {'index': i, '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         try:
233             workflow_status = object.status.startswith('wf-') and \
234                             object.get_workflow_status().name or None
235         except:
236             workflow_status = None
237
238         result = {
239             'num_dossier': object.id,
240             'wcs_status': object.status,
241             'wcs_workflow_status': workflow_status,
242             'wcs_user_email': object.user.email,
243             'wcs_user_display_name': object.user.display_name,
244            #'wcs_last_modified': time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(object.last_modified())),
245             'wcs_comments': [],
246         }
247
248         if object.evolution is not None:
249             for e in object.evolution:
250                 if e.comment is not None:
251                     who = pub.user_class.get(e.who).display_name
252                     e_time = time.strftime('%Y-%m-%d %H:%M:%S', e.time)
253                     comment = '%s -- %s %s' % (e.comment, who, e_time)
254                     result['wcs_comments'].append(comment)
255
256         qfiles = { }
257         for field in formdef.fields:
258             field_id = str(field.id)
259             if not field_id in object.data:
260                 continue
261             if isinstance(field, TitleField) or isinstance(field, CommentField):
262                 continue
263             field_name = fields[field_id]['name']
264             data = object.data.get(field_id)
265             if data is None:
266                 result[field_name] = None
267                 continue
268             if isinstance(field, StringField) or isinstance(field, TextField) \
269             or isinstance(field, EmailField) or isinstance(field, ItemField):
270                 result[field_name] = data
271             elif isinstance(field, ItemsField) or isinstance(field, TableField):
272                 result[field_name] = data # liste => peux-être joindre sur ';'
273             elif isinstance(field, BoolField):
274                 result[field_name] = (data == 'True')
275             elif isinstance(field, DateField):
276                 if isinstance(data, time.struct_time):
277                     result[field_name] = '%04d-%02d-%02d' % (data.tm_year,
278                                                     data.tm_mon, data.tm_mday)
279                 else:
280                     result[field_name] = data
281             elif isinstance(field, FileField):
282                 if '.' in data.orig_filename:
283                     extension = data.orig_filename.rpartition('.')[2].lower()
284                 else: # il n'y a pas d'extension dans le nom de fichier
285                     p = os.path.join(pub.app_dir, 'uploads', data.qfilename)
286                     try:
287                         #m = magicmime.from_file(p) => ce sera pour plus tard…
288                         m = magicmime.file(p).split()[0].strip(';')
289                         extension = mimetypes.guess_extension(m)
290                     except:
291                         logging.warning("Type de fichier inconnu pour '%s'.", p)
292                         extension = None
293                     if extension is not None:
294                         extension = extension[1:]
295                     else:
296                         extension = 'unknown'
297                 result[field_name] = "%s.%s" % (field_name, extension)
298                 qfiles[field_name] = data.qfilename
299             else:
300                 logging.warning("Type de champ inconnu '%s' pour '%s' (%s).",
301                             field.__class__.__name__, field_name, field.label)
302
303         num_dossier = result['num_dossier']
304         nom = _reduce_to_alnum(result.get('nom','sans-nom')).upper()
305         prenom = _reduce_to_alnum(result.get('prenom','sans-prenom')).upper()
306         adel = result.get('adresse_electronique','sans-adel').replace('@','-').lower()
307
308         filename = "%04d-%s-%s-%s" % (num_dossier, nom, prenom, adel)
309         liste_dossiers.append(filename + '.json')
310
311         # sauvegarde des chemins d'accès aux fichiers joints
312         for f in qfiles:
313             dst = filename + '_' + result[f]
314             src = os.path.join(pub.app_dir, 'uploads', qfiles[f])
315             liste_attachements.update({dst: src})
316             # on renomme le fichier joint indiqué dans le dossier
317             result[f] = dst
318
319         # génération du fichier JSON
320         data = json.dumps(result, ensure_ascii=False).encode('utf-8')
321         set_wcs_cache(domain, form, 'data_%s.json' % filename, data)
322
323         logging.info("Dossier '%s' : %s.",
324                                     filename, result['wcs_workflow_status'])
325
326     data = json.dumps(liste_attachements, ensure_ascii=False).encode('utf-8')
327     set_wcs_cache(domain, form, 'data-files.json', data)
328     metadata.update({'attachements': liste_attachements})
329
330     liste_dossiers.sort()
331     data = json.dumps(liste_dossiers, ensure_ascii=False).encode('utf-8')
332     set_wcs_cache(domain, form, 'liste-dossiers.json', data)
333     metadata.update({'dossiers': liste_dossiers})
334
335     logging.info('Fin.')
336
337     data = json.dumps(metadata, ensure_ascii=False).encode('utf-8')
338     set_wcs_cache(domain, form, 'metadata.json', data)
339
340 #if __name__ == '__main__':
341 #    try:
342 #        extract_data(formdef, OUTPUT_DIRECTORY)
343 #    except:
344 #        logging.exception("Interruption du traitement pour cause d'erreur !")
345
346 #--------------------------------------------------------------------------
347 # gestion des requêtes web
348 #--------------------------------------------------------------------------
349
350 #l = []
351 #for k in sorted(os.environ):
352 #    l.append('%s=%s\n' % (k, os.environ[k]))
353 #data = ''.join(l)
354 #http_reply_and_exit(data, 'text/plain')
355
356 domain = os.environ.get('HTTP_HOST', '')
357 if domain not in get_wcs_domains():
358     http_reply_and_exit("Domaine '%s' inconnu." % domain, 'text/plain')
359
360 path_info = os.environ.get('PATH_INFO', '')
361
362 path_prefix = os.environ.get('REQUEST_URI', '')
363 if len(path_info) > 0:
364     path_prefix = path_prefix[:-len(path_info)]
365
366 if path_info == '':
367     http_redirect(path_prefix + '/')
368
369 if path_info == '/':
370     # liste des formulaires disponibles
371     l = sorted(get_wcs_forms(domain))
372     l = ['<li><a href="%s/">%s</a></li>' % (f, f) for f in l]
373     title = '<p>Liste des formulaires disponibles&nbsp;:</p>\n'
374     data = '<html>\n' + title + '<ul>\n' + '\n'.join(l) + '\n</ul>\n</html>'
375     http_reply_and_exit(data, 'text/html')
376
377 if path_info == '/index.json':
378     # liste des formulaires disponibles
379     l = sorted(get_wcs_forms(domain))
380     data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
381     http_reply_and_exit(data, 'application/json')
382
383 if path_info == '/domains.json':
384     # liste des domaines disponibles
385     l = get_wcs_domains()
386     data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
387     http_reply_and_exit(data, 'application/json')
388
389 if match(r'^/[a-z0-9-]+$', path_info):
390     http_redirect(path_prefix + path_info + '/')
391
392 if match(r'^/[a-z0-9-]+/$', path_info):
393     form = path_info.split('/')[1]
394     if form not in get_wcs_forms(domain):
395         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
396     l = [ 'fields.json', 'field-names.json', 'field-names.txt', 'last-run.log', 'liste-dossiers.json' ]
397     l = ['<li><a href="%s">%s</a></li>' % (f, f) for f in l]
398     title = '<p>Liste des informations disponibles&nbsp;:</p>\n'
399     action1 = """<p><a href="data/">Export des données</a></p>\n"""
400     action2 = """<p><a href="clear-cache">Suppression du cache</a> (pour ré-export)</p>\n"""
401     data = '<html>\n' + title + '<ul>\n' + '\n'.join(l) + '\n</ul>\n' + action1 + action2 + '</html>'
402     http_reply_and_exit(data, 'text/html')
403
404 if match(r'^/[a-z0-9-]+/index.json$', path_info):
405     form = path_info.split('/')[1]
406     if form not in get_wcs_forms(domain):
407         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
408     l = [ 'fields.json', 'field-names.json', 'field-names.txt', 'last-run.log', 'liste-dossiers.json', 'data', 'clear-cache' ]
409     data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
410     http_reply_and_exit(data, 'application/json')
411
412 if match(r'^/[a-z0-9-]+/clear-cache$', path_info):
413     form = path_info.split('/')[1]
414     if form not in get_wcs_forms(domain):
415         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
416     clear_wcs_cache(domain, form)
417     http_reply_and_exit('Ok.', 'text/plain')
418
419 if match(r'^/[a-z0-9-]+/fields.json$', path_info):
420     form = path_info.split('/')[1]
421     if form not in get_wcs_forms(domain):
422         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
423     get_wcs_form_data(domain, form)
424     d = json.loads(get_wcs_cache(domain, form, 'fields.json'), encoding='utf-8')
425     data = json.dumps(d, ensure_ascii=False, indent=' ').encode('utf-8')
426     http_reply_and_exit(data, 'application/json')
427
428 if match(r'^/[a-z0-9-]+/field-names.json$', path_info):
429     form = path_info.split('/')[1]
430     if form not in get_wcs_forms(domain):
431         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
432     get_wcs_form_data(domain, form)
433     d = json.loads(get_wcs_cache(domain, form, 'fields.json'), encoding='utf-8')
434     d = dict([(k, d[k]['name']) for k in d])
435     data = json.dumps(d, ensure_ascii=False, indent=' ').encode('utf-8')
436     http_reply_and_exit(data, 'application/json')
437
438 if match(r'^/[a-z0-9-]+/field-names.txt$', path_info):
439     form = path_info.split('/')[1]
440     if form not in get_wcs_forms(domain):
441         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
442     get_wcs_form_data(domain, form)
443     d = json.loads(get_wcs_cache(domain, form, 'fields.json'), encoding='utf-8')
444     d = [(k, d[k]['name'], d[k]['label']) for k in d]
445     d = sorted(d, key=lambda x: int(x[0]))
446     text = u''.join([u'%s:%s:%s\n' % (x[0], x[1], x[2]) for x in d])
447     data = text.encode('utf-8')
448     http_reply_and_exit(data, 'text/plain')
449
450 if match(r'^/[a-z0-9-]+/last-run.log$', path_info):
451     form = path_info.split('/')[1]
452     if form not in get_wcs_forms(domain):
453         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
454     get_wcs_form_data(domain, form)
455     data = get_wcs_cache(domain, form, 'last-run.log')
456     http_reply_and_exit(data, 'text/plain')
457
458 if match(r'^/[a-z0-9-]+/liste-dossiers.json$', path_info):
459     form = path_info.split('/')[1]
460     if form not in get_wcs_forms(domain):
461         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
462     get_wcs_form_data(domain, form)
463     data = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
464     data = json.dumps(data, ensure_ascii=False, indent=' ').encode('utf-8')
465     http_reply_and_exit(data, 'application/json')
466
467 if match(r'^/[a-z0-9-]+/data$', path_info):
468     http_redirect(path_prefix + path_info + '/')
469
470 if match(r'^/[a-z0-9-]+/data/$', path_info):
471     form = path_info.split('/')[1]
472     if form not in get_wcs_forms(domain):
473         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
474     get_wcs_form_data(domain, form)
475     dossiers = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
476     attachements = json.loads(get_wcs_cache(domain, form, 'data-files.json'), encoding='utf-8')
477     l = sorted(dossiers + attachements.keys())
478     if len(l) > 0:
479         l = ['<li><a href="%s">%s</a></li>' % (f, f) for f in l]
480         title = '<p>Liste des documents disponibles&nbsp;:</p>\n'
481         data = '<html>\n' + title + '<ul>\n' + '\n'.join(l) + '\n</ul>\n</html>'
482     else:
483         data = '<html>\n<p>Aucun document disponible.</p>\n</html>'
484     http_reply_and_exit(data, 'text/html')
485
486 if match(r'^/[a-z0-9-]+/data/index.json$', path_info):
487     form = path_info.split('/')[1]
488     if form not in get_wcs_forms(domain):
489         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
490     get_wcs_form_data(domain, form)
491     dossiers = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
492     attachements = json.loads(get_wcs_cache(domain, form, 'data-files.json'), encoding='utf-8')
493     l = sorted(dossiers + attachements.keys())
494     data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
495     http_reply_and_exit(data, 'application/json')
496
497 if match(r'^/[a-z0-9-]+/data/[^/]+$', path_info):
498     form = path_info.split('/')[1]
499     if form not in get_wcs_forms(domain):
500         http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
501     get_wcs_form_data(domain, form)
502     doc = path_info.split('/')[3]
503     dossiers = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
504     if doc in dossiers:
505         data = get_wcs_cache(domain, form, 'data_' + doc)
506         data = json.loads(data, encoding='utf-8')
507         data = json.dumps(data, ensure_ascii=False, indent=' ').encode('utf-8')
508         http_reply_and_exit(data, 'application/json')
509     attachements = json.loads(get_wcs_cache(domain, form, 'data-files.json'), encoding='utf-8')
510     if doc in attachements:
511         data = open(attachements[doc], 'rb').read()
512         mime_type = mimetypes.guess_type(doc)[0]
513         if mime_type is None:
514             mime_type = 'application/octet-stream'
515         http_reply_and_exit(data, mime_type)
516     http_reply_and_exit("Document '%s' inconnu." % path_info, 'text/plain')
517
518 http_reply_and_exit("Requête '%s' inconnue." % path_info, 'text/plain')