2 # -*- coding: utf-8 -*-
4 Outil d'export dynamique de données w.c.s.
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
11 Depends: wcs, python-simplejson, python-magic
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-…
33 import time # time, gmtime, strftime, strptime, struct_time
34 import simplejson as json
38 from cStringIO import StringIO
39 from gzip import GzipFile
42 EXPIRE_DELAY = 5 # maximum 5 secondes en cache web
43 TIME_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' # format date pour HTTP
44 #ETABLISSEMENT_FORMAT = r'^(\w+\s-\s.+)\s\(\d+\s-\s(Nord|Sud)\)$'
46 WCS_ROOT_DIR = '/var/lib/wcs'
47 WCS_DOMAIN_SUFFIX = '.auf.org'
48 WCS_CACHE_DIR = '/var/tmp'
49 WCS_CACHE_DELAY_DEFAULT = 7*24*60*60 # 1 semaine
50 WCS_CACHE_DELAY_FORMS = 5*60 # 5 minutes
52 #--------------------------------------------------------------------------
54 #--------------------------------------------------------------------------
58 #--------------------------------------------------------------------------
59 # fonctions de traitement
60 #--------------------------------------------------------------------------
62 def http_redirect(location, code='302'):
64 headers['Content-Type'] = 'text/plain; charset=utf-8'
65 headers['Status'] = '302 Redirection'
66 headers['Location'] = location
67 data = """If you see this, it means the automatic redirection has failed.
68 Please go to ${location}"""
70 headers = ''.join(map(lambda x: "%s: %s\r\n" % (x, headers[x]), headers))
71 f = open('/dev/stdout', 'wb')
72 f.write(headers + "\r\n")
79 def http_reply_and_exit(data, mime_type='text/html', charset='utf-8'):
81 current_time = time.time()
82 mtime = time.gmtime(current_time)
83 etime = time.gmtime(current_time + EXPIRE_DELAY)
84 if os.environ.has_key('HTTP_IF_MODIFIED_SINCE'):
86 itime = time.strptime(os.environ['HTTP_IF_MODIFIED_SINCE'], TIME_FORMAT)
91 # préparation des en-têtes et données
93 headers['Content-Type'] = '%s; charset=%s' % (mime_type, charset)
94 headers['Last-Modified'] = time.strftime(TIME_FORMAT, mtime)
95 headers['Expires'] = time.strftime(TIME_FORMAT, etime)
96 if os.environ['REQUEST_METHOD'] == 'GET' and (not itime or mtime > itime):
97 # détermination de la version demandée (compressée ou non)
98 if os.environ.get('HTTP_ACCEPT_ENCODING','').split(',').count('gzip') > 0:
100 GzipFile('', 'w', 9, zdata).write(data)
101 data = zdata.getvalue()
102 headers['Content-Encoding'] = 'gzip'
103 headers['Vary'] = 'Content-Encoding'
104 headers['Content-Length'] = len(data)
107 # envoi de la réponse
108 headers = ''.join(map(lambda x: "%s: %s\r\n" % (x, headers[x]), headers))
109 f = open('/dev/stdout', 'wb')
110 f.write(headers + "\r\n")
114 # arrêt du traitement
118 def _reduce_to_alnum(s, replacement_char='-'):
119 """réduction d'une chaîne de caractères à de l'alpha-numérique"""
121 if type(s) is not unicode:
122 s = unicode(s, 'utf-8')
123 s = unicodedata.normalize('NFKD', s).encode('ASCII', 'ignore')
126 if ('a' <= c.lower() <= 'z') or ('0' <= c <= '9'):
128 elif len(r) > 0 and r[-1] != replacement_char:
129 r += replacement_char
130 else: # r == '' or r[-1] == replacement_char
132 return r.strip(replacement_char)
134 def _make_wcs_cache_name(domain, form, name):
135 return 'wcs-%s-%s-%s' % (domain, form, name)
137 def set_wcs_cache(domain, form, name, data, delay=WCS_CACHE_DELAY_DEFAULT):
139 cache_filename = _make_wcs_cache_name(domain, form, name)
140 cache_filename = os.path.join(WCS_CACHE_DIR, cache_filename)
141 f = open(cache_filename, 'wb')
144 # la date de modification est utilisée comme date d'expiration
146 mtime = atime + delay
147 os.utime(cache_filename, (atime, mtime))
149 def get_wcs_cache(domain, form, name):
151 cache_filename = _make_wcs_cache_name(domain, form, name)
152 cache_filename = os.path.join(WCS_CACHE_DIR, cache_filename)
153 if os.path.exists(cache_filename):
154 # la date de modification est utilisée comme date d'expiration
155 if time.time() < os.path.getmtime(cache_filename):
156 data = open(cache_filename, 'rb').read()
158 os.unlink(cache_filename)
161 def clear_wcs_cache(domain, form):
162 cache_filename = _make_wcs_cache_name(domain, form, '')
163 for f in os.listdir(WCS_CACHE_DIR):
164 if f.startswith(cache_filename):
165 os.unlink(os.path.join(WCS_CACHE_DIR, f))
167 def set_wcs_publisher(domain):
170 from wcs import publisher
171 pub = publisher.WcsPublisher.create_publisher()
172 pub.app_dir = os.path.join(pub.app_dir, domain)
175 def get_wcs_domains():
177 suffix = WCS_DOMAIN_SUFFIX
182 return [x for x in l if os.path.isdir(os.path.join(root, x)) and x.endswith(suffix)]
184 def get_wcs_forms(domain):
185 """extraction de la liste des formulaires"""
186 data = get_wcs_cache(domain, 'ALL', 'ALL.json')
188 return json.loads(data, encoding='utf-8')
189 set_wcs_publisher(domain)
190 from wcs.formdef import FormDef
191 forms = [f.url_name for i,f in FormDef.items()]
192 data = json.dumps(forms, ensure_ascii=False).encode('utf-8')
193 set_wcs_cache(domain, 'ALL', 'ALL.json', data, WCS_CACHE_DELAY_FORMS)
196 def get_wcs_form_data(domain, form):
197 """extraction des données du formulaire"""
198 data = get_wcs_cache(domain, form, 'metadata.json')
200 return json.loads(data, encoding='utf-8')
201 # dictionnaire des metadonnées (qui seront mises en cache)
205 logname = _make_wcs_cache_name(domain, form, 'last-run.log')
206 logging.basicConfig(level=logging.DEBUG,
207 format='%(asctime)s %(levelname)s %(message)s',
208 filename=os.path.join(WCS_CACHE_DIR, logname),
211 logging.info('Début.')
213 set_wcs_publisher(domain)
214 from wcs.formdef import FormDef
215 from wcs.fields import TitleField, CommentField, TextField, \
216 StringField, ItemField, ItemsField, EmailField, \
217 DateField, FileField, BoolField, TableField
218 formdef = FormDef.get_by_urlname(form)
220 # nommage des champs de façon unique
223 field_names_duplicates = {}
224 for i, field in enumerate(formdef.fields):
225 if isinstance(field, TitleField) or isinstance(field, CommentField):
230 name = _reduce_to_alnum(field.label,'_').lower()
231 if name in field_names.values(): # duplicat
232 field_names_duplicates[name] = field_names_duplicates.get(name, 1) + 1
233 name = '%s_%d' % (name, field_names_duplicates[name])
234 field_names.update({field.id: name})
235 fields.update({field.id: {'index': i, 'name': field_names[field.id], 'label': field.label, 'varname': field.varname and field.varname or ''}})
237 data = json.dumps(fields, ensure_ascii=False).encode('utf-8')
238 set_wcs_cache(domain, form, 'fields.json', data)
239 metadata.update({'fields': fields})
241 # on charge la base des types MIME une fois pour toutes
242 #magicmime = magic.Magic(mime=True) => ce sera pour plus tard…
243 magicmime = magic.open(magic.MAGIC_MIME)
247 liste_attachements = {}
248 for object in formdef.data_class().select():
249 if object.user is None:
250 logging.warning("Dossier '%s' sans utilisateur associé ?!?"\
251 " On ignore...", object.id)
255 workflow_status = object.status.startswith('wf-') and \
256 object.get_workflow_status().name or None
258 workflow_status = None
261 'num_dossier': object.id,
262 'wcs_status': object.status,
263 'wcs_workflow_status': workflow_status,
264 'wcs_user_email': object.user.email,
265 'wcs_user_display_name': object.user.display_name,
266 #'wcs_last_modified': time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(object.last_modified())),
270 if object.evolution is not None:
271 for e in object.evolution:
272 if e.comment is not None:
274 who = pub.user_class.get(e.who).display_name
277 e_time = time.strftime('%Y-%m-%d %H:%M:%S', e.time)
278 comment = '%s -- %s %s' % (e.comment, who, e_time)
279 result['wcs_comments'].append(comment)
282 for field in formdef.fields:
283 field_id = str(field.id)
284 if not field_id in object.data:
286 if isinstance(field, TitleField) or isinstance(field, CommentField):
288 field_name = fields[field_id]['name']
289 data = object.data.get(field_id)
291 result[field_name] = None
293 if isinstance(field, StringField) or isinstance(field, TextField) \
294 or isinstance(field, EmailField) or isinstance(field, ItemField):
295 # nettoyage du nom d'établissement (suppression id et Nord/Sud)
296 #m = match(ETABLISSEMENT_FORMAT, data)
298 # data = m.groups()[0]
299 result[field_name] = data
300 elif isinstance(field, ItemsField) or isinstance(field, TableField):
301 result[field_name] = data # liste => peux-être joindre sur ';'
302 elif isinstance(field, BoolField):
303 result[field_name] = (data == 'True')
304 elif isinstance(field, DateField):
305 if isinstance(data, time.struct_time):
306 result[field_name] = '%04d-%02d-%02d' % (data.tm_year,
307 data.tm_mon, data.tm_mday)
309 result[field_name] = data
310 elif isinstance(field, FileField):
311 if '.' in data.orig_filename:
312 extension = data.orig_filename.rpartition('.')[2].lower()
313 else: # il n'y a pas d'extension dans le nom de fichier
314 p = os.path.join(pub.app_dir, 'uploads', data.qfilename)
316 #m = magicmime.from_file(p) => ce sera pour plus tard…
317 m = magicmime.file(p).split()[0].strip(';')
318 extension = mimetypes.guess_extension(m)
320 logging.warning("Type de fichier inconnu pour '%s'.", p)
322 if extension is not None:
323 extension = extension[1:]
325 extension = 'unknown'
326 result[field_name] = "%s.%s" % (field_name, extension)
327 qfiles[field_name] = data.qfilename
329 logging.warning("Type de champ inconnu '%s' pour '%s' (%s).",
330 field.__class__.__name__, field_name, field.label)
332 num_dossier = result['num_dossier']
333 nom = _reduce_to_alnum(result.get('nom','sans-nom')).upper()
334 prenom = _reduce_to_alnum(result.get('prenom','sans-prenom')).upper()
335 adel = result.get('adresse_electronique','sans-adel').replace('@','-').lower()
337 filename = "%04d-%s-%s-%s" % (num_dossier, nom, prenom, adel)
338 liste_dossiers.append(filename + '.json')
340 # sauvegarde des chemins d'accès aux fichiers joints
342 dst = filename + '_' + result[f]
343 src = os.path.join(pub.app_dir, 'uploads', qfiles[f])
344 liste_attachements.update({dst: src})
345 # on renomme le fichier joint indiqué dans le dossier
348 # génération du fichier JSON
349 data = json.dumps(result, ensure_ascii=False).encode('utf-8')
350 set_wcs_cache(domain, form, 'data_%s.json' % filename, data)
352 logging.info("Dossier '%s' : %s.",
353 filename, result['wcs_workflow_status'])
355 data = json.dumps(liste_attachements, ensure_ascii=False).encode('utf-8')
356 set_wcs_cache(domain, form, 'data-files.json', data)
357 metadata.update({'attachements': liste_attachements})
359 liste_dossiers.sort()
360 data = json.dumps(liste_dossiers, ensure_ascii=False).encode('utf-8')
361 set_wcs_cache(domain, form, 'liste-dossiers.json', data)
362 metadata.update({'dossiers': liste_dossiers})
366 data = json.dumps(metadata, ensure_ascii=False).encode('utf-8')
367 set_wcs_cache(domain, form, 'metadata.json', data)
369 #if __name__ == '__main__':
371 # extract_data(formdef, OUTPUT_DIRECTORY)
373 # logging.exception("Interruption du traitement pour cause d'erreur !")
375 #--------------------------------------------------------------------------
376 # gestion des requêtes web
377 #--------------------------------------------------------------------------
380 #for k in sorted(os.environ):
381 # l.append('%s=%s\n' % (k, os.environ[k]))
383 #http_reply_and_exit(data, 'text/plain')
385 domain = os.environ.get('HTTP_HOST', '')
386 if domain not in get_wcs_domains():
387 http_reply_and_exit("Domaine '%s' inconnu." % domain, 'text/plain')
389 path_info = os.environ.get('PATH_INFO', '')
391 path_prefix = os.environ.get('REQUEST_URI', '')
392 if len(path_info) > 0:
393 path_prefix = path_prefix[:-len(path_info)]
396 http_redirect(path_prefix + '/')
399 # liste des formulaires disponibles
400 l = sorted(get_wcs_forms(domain))
401 l = ['<li><a href="%s/">%s</a></li>' % (f, f) for f in l]
402 title = '<p>Liste des formulaires disponibles :</p>\n'
403 data = '<html>\n' + title + '<ul>\n' + '\n'.join(l) + '\n</ul>\n</html>'
404 http_reply_and_exit(data, 'text/html')
406 if path_info == '/index.json':
407 # liste des formulaires disponibles
408 l = sorted(get_wcs_forms(domain))
409 data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
410 http_reply_and_exit(data, 'application/json')
412 if path_info == '/domains.json':
413 # liste des domaines disponibles
414 l = get_wcs_domains()
415 data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
416 http_reply_and_exit(data, 'application/json')
418 if match(r'^/[a-z0-9-]+$', path_info):
419 http_redirect(path_prefix + path_info + '/')
421 if match(r'^/[a-z0-9-]+/$', path_info):
422 form = path_info.split('/')[1]
423 if form not in get_wcs_forms(domain):
424 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
425 l = [ 'fields.json', 'field-names.json', 'field-names.txt', 'last-run.log', 'liste-dossiers.json' ]
426 l = ['<li><a href="%s">%s</a></li>' % (f, f) for f in l]
427 title = '<p>Liste des informations disponibles :</p>\n'
428 action1 = """<p><a href="data/">Export des données</a></p>\n"""
429 action2 = """<p><a href="clear-cache">Suppression du cache</a> (pour ré-export)</p>\n"""
430 data = '<html>\n' + title + '<ul>\n' + '\n'.join(l) + '\n</ul>\n' + action1 + action2 + '</html>'
431 http_reply_and_exit(data, 'text/html')
433 if match(r'^/[a-z0-9-]+/index.json$', 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 l = [ 'fields.json', 'field-names.json', 'field-names.txt', 'last-run.log', 'liste-dossiers.json', 'data', 'clear-cache' ]
438 data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
439 http_reply_and_exit(data, 'application/json')
441 if match(r'^/[a-z0-9-]+/clear-cache$', path_info):
442 form = path_info.split('/')[1]
443 if form not in get_wcs_forms(domain):
444 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
445 clear_wcs_cache(domain, form)
446 http_reply_and_exit('Ok.', 'text/plain')
448 if match(r'^/[a-z0-9-]+/fields.json$', path_info):
449 form = path_info.split('/')[1]
450 if form not in get_wcs_forms(domain):
451 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
452 get_wcs_form_data(domain, form)
453 d = json.loads(get_wcs_cache(domain, form, 'fields.json'), encoding='utf-8')
454 data = json.dumps(d, ensure_ascii=False, indent=' ').encode('utf-8')
455 http_reply_and_exit(data, 'application/json')
457 if match(r'^/[a-z0-9-]+/field-names.json$', path_info):
458 form = path_info.split('/')[1]
459 if form not in get_wcs_forms(domain):
460 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
461 get_wcs_form_data(domain, form)
462 d = json.loads(get_wcs_cache(domain, form, 'fields.json'), encoding='utf-8')
463 d = dict([(k, d[k]['name']) for k in d])
464 data = json.dumps(d, ensure_ascii=False, indent=' ').encode('utf-8')
465 http_reply_and_exit(data, 'application/json')
467 if match(r'^/[a-z0-9-]+/field-names.txt$', path_info):
468 form = path_info.split('/')[1]
469 if form not in get_wcs_forms(domain):
470 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
471 get_wcs_form_data(domain, form)
472 d = json.loads(get_wcs_cache(domain, form, 'fields.json'), encoding='utf-8')
473 d = [(k, d[k]['name'], d[k]['label']) for k in d]
474 d = sorted(d, key=lambda x: int(x[0]))
475 text = u''.join([u'%s:%s:%s\n' % (x[0], x[1], x[2]) for x in d])
476 data = text.encode('utf-8')
477 http_reply_and_exit(data, 'text/plain')
479 if match(r'^/[a-z0-9-]+/last-run.log$', path_info):
480 form = path_info.split('/')[1]
481 if form not in get_wcs_forms(domain):
482 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
483 get_wcs_form_data(domain, form)
484 data = get_wcs_cache(domain, form, 'last-run.log')
485 http_reply_and_exit(data, 'text/plain')
487 if match(r'^/[a-z0-9-]+/liste-dossiers.json$', path_info):
488 form = path_info.split('/')[1]
489 if form not in get_wcs_forms(domain):
490 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
491 get_wcs_form_data(domain, form)
492 data = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
493 data = json.dumps(data, ensure_ascii=False, indent=' ').encode('utf-8')
494 http_reply_and_exit(data, 'application/json')
496 if match(r'^/[a-z0-9-]+/data$', path_info):
497 http_redirect(path_prefix + path_info + '/')
499 if match(r'^/[a-z0-9-]+/data/$', path_info):
500 form = path_info.split('/')[1]
501 if form not in get_wcs_forms(domain):
502 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
503 get_wcs_form_data(domain, form)
504 dossiers = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
505 attachements = json.loads(get_wcs_cache(domain, form, 'data-files.json'), encoding='utf-8')
506 l = sorted(dossiers + attachements.keys())
508 l = ['<li><a href="%s">%s</a></li>' % (f, f) for f in l]
509 title = '<p>Liste des documents disponibles :</p>\n'
510 data = '<html>\n' + title + '<ul>\n' + '\n'.join(l) + '\n</ul>\n</html>'
512 data = '<html>\n<p>Aucun document disponible.</p>\n</html>'
513 http_reply_and_exit(data, 'text/html')
515 if match(r'^/[a-z0-9-]+/data/index.json$', path_info):
516 form = path_info.split('/')[1]
517 if form not in get_wcs_forms(domain):
518 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
519 get_wcs_form_data(domain, form)
520 dossiers = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
521 attachements = json.loads(get_wcs_cache(domain, form, 'data-files.json'), encoding='utf-8')
522 l = sorted(dossiers + attachements.keys())
523 data = json.dumps(l, ensure_ascii=False, indent=' ').encode('utf-8')
524 http_reply_and_exit(data, 'application/json')
526 if match(r'^/[a-z0-9-]+/data/[^/]+$', path_info):
527 form = path_info.split('/')[1]
528 if form not in get_wcs_forms(domain):
529 http_reply_and_exit("Formulaire '%s' inconnu." % form, 'text/plain')
530 get_wcs_form_data(domain, form)
531 doc = path_info.split('/')[3]
532 dossiers = json.loads(get_wcs_cache(domain, form, 'liste-dossiers.json'), encoding='utf-8')
534 data = get_wcs_cache(domain, form, 'data_' + doc)
535 data = json.loads(data, encoding='utf-8')
536 data = json.dumps(data, ensure_ascii=False, indent=' ').encode('utf-8')
537 http_reply_and_exit(data, 'application/json')
538 attachements = json.loads(get_wcs_cache(domain, form, 'data-files.json'), encoding='utf-8')
539 if doc in attachements:
540 data = open(attachements[doc], 'rb').read()
541 mime_type = mimetypes.guess_type(doc)[0]
542 if mime_type is None:
543 mime_type = 'application/octet-stream'
544 http_reply_and_exit(data, mime_type)
545 http_reply_and_exit("Document '%s' inconnu." % path_info, 'text/plain')
547 http_reply_and_exit("Requête '%s' inconnue." % path_info, 'text/plain')