Enleve le backend_config.py.edit et entrée dans gitignore
[auf_savoirs_en_partage_django.git] / auf_savoirs_en_partage / savoirs / models.py
CommitLineData
92c7413b 1# -*- encoding: utf-8 -*-
db2999fa
EMS
2import caldav
3import datetime
4import feedparser
5import operator
6import os
7import pytz
8import random
4b89a7df 9import textwrap
db2999fa
EMS
10import uuid
11import vobject
5ffde8a4 12from pytz.tzinfo import AmbiguousTimeError, NonExistentTimeError
fdcf5874
EMS
13from urllib import urlencode
14
5212238e 15from backend_config import RESOURCES
86983865 16from babel.dates import get_timezone_name
5212238e 17from caldav.lib import error
6d885e0c 18from django.contrib.auth.models import User
fdcf5874 19from django.contrib.contenttypes.models import ContentType
4b89a7df 20from django.core.mail import EmailMultiAlternatives
fdcf5874 21from django.core.urlresolvers import reverse
d15017b2 22from django.db import models
15261361 23from django.db.models import Q, Max
b7a741ad 24from django.db.models.signals import pre_delete
4b89a7df 25from django.utils.encoding import smart_unicode, smart_str
122c4c3d 26from djangosphinx.models import SphinxQuerySet, SearchError
4b89a7df 27from markdown2 import markdown
fdcf5874
EMS
28
29from datamaster_modeles.models import Region, Pays, Thematique
da9020f3 30from savoirs.globals import META
a83b8efb 31from settings import CALENDRIER_URL, SITE_ROOT_URL, CONTACT_EMAIL
5212238e
EMS
32
33# Fonctionnalités communes à tous les query sets
d15017b2 34
15261361
EMS
35class RandomQuerySetMixin(object):
36 """Mixin pour les modèles.
37
38 ORDER BY RAND() est très lent sous MySQL. On a besoin d'une autre
39 méthode pour récupérer des objets au hasard.
40 """
41
42 def random(self, n=1):
43 """Récupère aléatoirement un nombre donné d'objets."""
bae03b7b
EMS
44 count = self.count()
45 positions = random.sample(xrange(count), min(n, count))
46 return [self[p] for p in positions]
15261361 47
5212238e 48class SEPQuerySet(models.query.QuerySet, RandomQuerySetMixin):
230671ff
EMS
49
50 def _filter_date(self, field, min=None, max=None):
51 """Limite les résultats à ceux dont le champ ``field`` tombe entre
52 les dates ``min`` et ``max``."""
53 qs = self
54 if min:
55 qs = qs.filter(**{field + '__gte': min})
56 if max:
a83b8efb 57 qs = qs.filter(**{field + '__lt': max + datetime.timedelta(days=1)})
230671ff 58 return qs
5212238e
EMS
59
60class SEPSphinxQuerySet(SphinxQuerySet, RandomQuerySetMixin):
61 """Fonctionnalités communes aux query sets de Sphinx."""
62
63 def __init__(self, model=None, index=None, weights=None):
64 SphinxQuerySet.__init__(self, model=model, index=index,
65 mode='SPH_MATCH_EXTENDED2',
66 rankmode='SPH_RANK_PROXIMITY_BM25',
67 weights=weights)
68
69 def add_to_query(self, query):
70 """Ajoute une partie à la requête texte."""
6c78c673
EMS
71
72 # Assurons-nous qu'il y a un nombre pair de guillemets
73 if query.count('"') % 2 != 0:
74 # Sinon, on enlève le dernier (faut choisir...)
75 i = query.rindex('"')
76 query = query[:i] + query[i+1:]
77
5212238e
EMS
78 new_query = smart_unicode(self._query) + ' ' + query if self._query else query
79 return self.query(new_query)
80
81 def search(self, text):
82 """Recherche ``text`` dans tous les champs."""
83 return self.add_to_query('@* ' + text)
84
85 def filter_discipline(self, discipline):
86 """Par défaut, le filtre par discipline cherche le nom de la
87 discipline dans tous les champs."""
88 return self.search('"%s"' % discipline.nom)
89
90 def filter_region(self, region):
91 """Par défaut, le filtre par région cherche le nom de la région dans
92 tous les champs."""
93 return self.search('"%s"' % region.nom)
94
230671ff
EMS
95 def _filter_date(self, field, min=None, max=None):
96 """Limite les résultats à ceux dont le champ ``field`` tombe entre
97 les dates ``min`` et ``max``."""
98 qs = self
99 if min:
100 qs = qs.filter(**{field + '__gte': min.toordinal()+365})
101 if max:
102 qs = qs.filter(**{field + '__lte': max.toordinal()+365})
103 return qs
104
122c4c3d
EMS
105 def _get_sphinx_results(self):
106 try:
107 return SphinxQuerySet._get_sphinx_results(self)
108 except SearchError:
109 # Essayons d'enlever les caractères qui peuvent poser problème.
110 for c in '|!@()~/<=^$':
111 self._query = self._query.replace(c, ' ')
112 try:
113 return SphinxQuerySet._get_sphinx_results(self)
114 except SearchError:
115 # Ça ne marche toujours pas. Enlevons les guillemets et les
116 # tirets.
117 for c in '"-':
118 self._query = self._query.replace(c, ' ')
119 return SphinxQuerySet._get_sphinx_results(self)
120
5212238e
EMS
121class SEPManager(models.Manager):
122 """Lorsque les méthodes ``search``, ``filter_region`` et
123 ``filter_discipline`` sont appelées sur ce manager, le query set
124 Sphinx est créé, sinon, c'est le query set Django qui est créé."""
125
126 def query(self, query):
127 return self.get_sphinx_query_set().query(query)
128
129 def add_to_query(self, query):
130 return self.get_sphinx_query_set().add_to_query(query)
131
132 def search(self, text):
133 return self.get_sphinx_query_set().search(text)
134
135 def filter_region(self, region):
136 return self.get_sphinx_query_set().filter_region(region)
137
138 def filter_discipline(self, discipline):
139 return self.get_sphinx_query_set().filter_discipline(discipline)
140
141# Disciplines
142
d15017b2
CR
143class Discipline(models.Model):
144 id = models.IntegerField(primary_key=True, db_column='id_discipline')
145 nom = models.CharField(max_length=765, db_column='nom_discipline')
6ef8ead4
CR
146
147 def __unicode__ (self):
92c7413b 148 return self.nom
6ef8ead4 149
d15017b2
CR
150 class Meta:
151 db_table = u'discipline'
152 ordering = ["nom",]
153
5212238e
EMS
154# Actualités
155
79c398f6 156class SourceActualite(models.Model):
011804bb
EMS
157 TYPE_CHOICES = (
158 ('actu', 'Actualités'),
159 ('appels', "Appels d'offres"),
160 )
161
79c398f6 162 nom = models.CharField(max_length=255)
011804bb
EMS
163 url = models.CharField(max_length=255, verbose_name='URL', blank=True)
164 type = models.CharField(max_length=10, default='actu', choices=TYPE_CHOICES)
ccbc4363 165
db2999fa
EMS
166 class Meta:
167 verbose_name = u'fil RSS syndiqué'
168 verbose_name_plural = u'fils RSS syndiqués'
169
ccbc4363 170 def __unicode__(self,):
011804bb 171 return u"%s (%s)" % (self.nom, self.get_type_display())
79c398f6 172
db2999fa
EMS
173 def update(self):
174 """Mise à jour du fil RSS."""
011804bb
EMS
175 if not self.url:
176 return
db2999fa
EMS
177 feed = feedparser.parse(self.url)
178 for entry in feed.entries:
179 if Actualite.all_objects.filter(url=entry.link).count() == 0:
180 ts = entry.updated_parsed
181 date = datetime.date(ts.tm_year, ts.tm_mon, ts.tm_mday)
182 a = self.actualites.create(titre=entry.title,
183 texte=entry.summary_detail.value,
184 url=entry.link, date=date)
185
5212238e 186class ActualiteQuerySet(SEPQuerySet):
2f9c4d6c 187
5212238e 188 def filter_date(self, min=None, max=None):
230671ff 189 return self._filter_date('date', min=min, max=max)
da44ce68 190
011804bb
EMS
191 def filter_type(self, type):
192 return self.filter(source__type=type)
193
5212238e 194class ActualiteSphinxQuerySet(SEPSphinxQuerySet):
c1b134f8 195
5212238e 196 def __init__(self, model=None):
4134daa0 197 SEPSphinxQuerySet.__init__(self, model=model, index='savoirsenpartage_actualites',
5212238e 198 weights=dict(titre=3))
c1b134f8 199
7fb6bd61 200 def filter_date(self, min=None, max=None):
230671ff
EMS
201 return self._filter_date('date', min=min, max=max)
202
011804bb
EMS
203 TYPE_CODES = {'actu': 1, 'appels': 2}
204 def filter_type(self, type):
205 return self.filter(type=self.TYPE_CODES[type])
206
5212238e
EMS
207class ActualiteManager(SEPManager):
208
209 def get_query_set(self):
210 return ActualiteQuerySet(self.model).filter(visible=True)
2f9c4d6c 211
5212238e
EMS
212 def get_sphinx_query_set(self):
213 return ActualiteSphinxQuerySet(self.model).order_by('-date')
2f9c4d6c 214
5212238e
EMS
215 def filter_date(self, min=None, max=None):
216 return self.get_query_set().filter_date(min=min, max=max)
bae03b7b 217
011804bb
EMS
218 def filter_type(self, type):
219 return self.get_query_set().filter_type(type)
220
d15017b2 221class Actualite(models.Model):
4f262f90 222 id = models.AutoField(primary_key=True, db_column='id_actualite')
d15017b2
CR
223 titre = models.CharField(max_length=765, db_column='titre_actualite')
224 texte = models.TextField(db_column='texte_actualite')
225 url = models.CharField(max_length=765, db_column='url_actualite')
d15017b2 226 date = models.DateField(db_column='date_actualite')
db2999fa
EMS
227 visible = models.BooleanField(db_column='visible_actualite', default=False)
228 ancienid = models.IntegerField(db_column='ancienId_actualite', blank=True, null=True)
011804bb 229 source = models.ForeignKey(SourceActualite, related_name='actualites')
3a45eb64 230 disciplines = models.ManyToManyField(Discipline, blank=True, related_name="actualites")
a5f76eb4 231 regions = models.ManyToManyField(Region, blank=True, related_name="actualites", verbose_name='régions')
6ef8ead4 232
2f9c4d6c 233 objects = ActualiteManager()
c59dba82 234 all_objects = models.Manager()
2f9c4d6c 235
d15017b2
CR
236 class Meta:
237 db_table = u'actualite'
7020ea3d 238 ordering = ["-date"]
92c7413b 239
264a3210
EMS
240 def __unicode__ (self):
241 return "%s" % (self.titre)
242
230671ff
EMS
243 def get_absolute_url(self):
244 return reverse('actualite', kwargs={'id': self.id})
245
264a3210
EMS
246 def assigner_disciplines(self, disciplines):
247 self.disciplines.add(*disciplines)
248
249 def assigner_regions(self, regions):
250 self.regions.add(*regions)
251
81fe476e
PP
252
253class ActualiteVoir(Actualite):
254
255 class Meta:
256 proxy = True
429222f1
PP
257 verbose_name = 'actualité (visualisation)'
258 verbose_name_plural = 'actualités (visualisation)'
81fe476e 259
5212238e 260# Agenda
4101cfc0 261
5212238e 262class EvenementQuerySet(SEPQuerySet):
4101cfc0 263
5212238e
EMS
264 def filter_type(self, type):
265 return self.filter(type=type)
c1b134f8 266
5212238e 267 def filter_debut(self, min=None, max=None):
a83b8efb
EMS
268 return self._filter_date('debut', min=min, max=max)
269
270 def filter_date_modification(self, min=None, max=None):
271 return self._filter_date('date_modification', min=min, max=max)
c1b134f8 272
5212238e 273class EvenementSphinxQuerySet(SEPSphinxQuerySet):
4101cfc0 274
5212238e 275 def __init__(self, model=None):
4134daa0 276 SEPSphinxQuerySet.__init__(self, model=model, index='savoirsenpartage_evenements',
5212238e 277 weights=dict(titre=3))
116db1fd 278
5212238e
EMS
279 def filter_type(self, type):
280 return self.add_to_query('@type "%s"' % type)
281
282 def filter_debut(self, min=None, max=None):
230671ff 283 return self._filter_date('debut', min=min, max=max)
7bbf600c 284
a83b8efb
EMS
285 def filter_date_modification(self, min=None, max=None):
286 return self._filter_date('date_modification', min=min, max=max)
287
5212238e 288class EvenementManager(SEPManager):
5212238e 289
5212238e
EMS
290 def get_query_set(self):
291 return EvenementQuerySet(self.model).filter(approuve=True)
292
293 def get_sphinx_query_set(self):
294 return EvenementSphinxQuerySet(self.model).order_by('-debut')
295
296 def filter_type(self, type):
297 return self.get_query_set().filter_type(type)
298
299 def filter_debut(self, min=None, max=None):
300 return self.get_query_set().filter_debut(min=min, max=max)
bae03b7b 301
a83b8efb
EMS
302 def filter_date_modification(self, min=None, max=None):
303 return self.get_query_set().filter_date_modification(min=min, max=max)
304
1719bf4e 305def build_time_zone_choices(pays=None):
1719bf4e
EMS
306 timezones = pytz.country_timezones[pays] if pays else pytz.common_timezones
307 result = []
86983865 308 now = datetime.datetime.now()
1719bf4e 309 for tzname in timezones:
86983865
EMS
310 tz = pytz.timezone(tzname)
311 fr_name = get_timezone_name(tz, locale='fr_FR')
5ffde8a4
EMS
312 try:
313 offset = tz.utcoffset(now)
314 except (AmbiguousTimeError, NonExistentTimeError):
315 # oups. On est en train de changer d'heure. Ça devrait être fini
316 # demain
317 offset = tz.utcoffset(now + datetime.timedelta(days=1))
86983865
EMS
318 seconds = offset.seconds + offset.days * 86400
319 (hours, minutes) = divmod(seconds // 60, 60)
320 offset_str = 'UTC%+d:%d' % (hours, minutes) if minutes else 'UTC%+d' % hours
1719bf4e
EMS
321 result.append((seconds, tzname, '%s - %s' % (offset_str, fr_name)))
322 result.sort()
323 return [(x[1], x[2]) for x in result]
86983865 324
92c7413b 325class Evenement(models.Model):
7bbf600c
EMS
326 TYPE_CHOICES = ((u'Colloque', u'Colloque'),
327 (u'Conférence', u'Conférence'),
328 (u'Appel à contribution', u'Appel à contribution'),
329 (u'Journée d\'étude', u'Journée d\'étude'),
ec81ec66 330 (u'Autre', u'Autre'))
86983865
EMS
331 TIME_ZONE_CHOICES = build_time_zone_choices()
332
74b087e5 333 uid = models.CharField(max_length=255, default=str(uuid.uuid1()))
a5f76eb4 334 approuve = models.BooleanField(default=False, verbose_name=u'approuvé')
92c7413b
CR
335 titre = models.CharField(max_length=255)
336 discipline = models.ForeignKey('Discipline', related_name = "discipline",
337 blank = True, null = True)
a5f76eb4
EMS
338 discipline_secondaire = models.ForeignKey('Discipline', related_name="discipline_secondaire",
339 verbose_name=u"discipline secondaire",
340 blank=True, null=True)
74b087e5 341 mots_cles = models.TextField('Mots-Clés', blank=True, null=True)
7bbf600c 342 type = models.CharField(max_length=255, choices=TYPE_CHOICES)
731ef7ab
EMS
343 adresse = models.TextField()
344 ville = models.CharField(max_length=100)
345 pays = models.ForeignKey(Pays, null=True, related_name='evenements')
74b087e5
EMS
346 debut = models.DateTimeField(default=datetime.datetime.now)
347 fin = models.DateTimeField(default=datetime.datetime.now)
86983865 348 fuseau = models.CharField(max_length=100, choices=TIME_ZONE_CHOICES, verbose_name='fuseau horaire')
fe254ccc 349 description = models.TextField()
731ef7ab
EMS
350 contact = models.TextField(null=True) # champ obsolète
351 prenom = models.CharField('prénom', max_length=100)
352 nom = models.CharField(max_length=100)
353 courriel = models.EmailField()
74b087e5
EMS
354 url = models.CharField(max_length=255, blank=True, null=True)
355 piece_jointe = models.FileField(upload_to='agenda/pj', blank=True, verbose_name='pièce jointe')
fe254ccc
EMS
356 regions = models.ManyToManyField(Region, blank=True, related_name="evenements", verbose_name='régions additionnelles',
357 help_text="On considère d'emblée que l'évènement se déroule dans la région "
358 "dans laquelle se trouve le pays indiqué plus haut. Il est possible "
359 "de désigner ici des régions additionnelles.")
a83b8efb 360 date_modification = models.DateTimeField(editable=False, auto_now=True, null=True)
92c7413b 361
4101cfc0 362 objects = EvenementManager()
c59dba82 363 all_objects = models.Manager()
4101cfc0
EMS
364
365 class Meta:
366 ordering = ['-debut']
fe254ccc
EMS
367 verbose_name = u'évènement'
368 verbose_name_plural = u'évènements'
4101cfc0 369
230671ff 370 def __unicode__(self):
020f79a9 371 return "[%s] %s" % (self.uid, self.titre)
372
230671ff
EMS
373 def get_absolute_url(self):
374 return reverse('evenement', kwargs={'id': self.id})
375
8dfe5efa
EMS
376 def duration_display(self):
377 delta = self.fin - self.debut
378 minutes, seconds = divmod(delta.seconds, 60)
379 hours, minutes = divmod(minutes, 60)
380 days = delta.days
381 parts = []
382 if days == 1:
383 parts.append('1 jour')
384 elif days > 1:
385 parts.append('%d jours' % days)
386 if hours == 1:
387 parts.append('1 heure')
388 elif hours > 1:
389 parts.append('%d heures' % hours)
390 if minutes == 1:
391 parts.append('1 minute')
392 elif minutes > 1:
393 parts.append('%d minutes' % minutes)
394 return ' '.join(parts)
395
27fe0d70
EMS
396 def piece_jointe_display(self):
397 return self.piece_jointe and os.path.basename(self.piece_jointe.name)
398
fe254ccc
EMS
399 def courriel_display(self):
400 return self.courriel.replace(u'@', u' (à) ')
401
a83b8efb
EMS
402 @property
403 def lieu(self):
404 bits = []
405 if self.adresse:
406 bits.append(self.adresse)
407 if self.ville:
408 bits.append(self.ville)
409 if self.pays:
410 bits.append(self.pays.nom)
411 return ', '.join(bits)
412
73309469 413 def clean(self):
414 from django.core.exceptions import ValidationError
415 if self.debut > self.fin:
416 raise ValidationError('La date de fin ne doit pas être antérieure à la date de début')
417
b7a741ad 418 def save(self, *args, **kwargs):
419 """Sauvegarde l'objet dans django et le synchronise avec caldav s'il a été
420 approuvé"""
731ef7ab 421 self.contact = '' # Vider ce champ obsolète à la première occasion...
73309469 422 self.clean()
b7a741ad 423 super(Evenement, self).save(*args, **kwargs)
acd5cd8f 424 self.update_vevent()
b7a741ad 425
426 # methodes de commnunications avec CALDAV
427 def as_ical(self,):
428 """Retourne l'evenement django sous forme d'objet icalendar"""
429 cal = vobject.iCalendar()
430 cal.add('vevent')
431
432 # fournit son propre uid
7f56d0d4 433 if self.uid in [None, ""]:
b7a741ad 434 self.uid = str(uuid.uuid1())
435
436 cal.vevent.add('uid').value = self.uid
437
438 cal.vevent.add('summary').value = self.titre
439
440 if self.mots_cles is None:
441 kw = []
442 else:
443 kw = self.mots_cles.split(",")
444
445 try:
446 kw.append(self.discipline.nom)
447 kw.append(self.discipline_secondaire.nom)
448 kw.append(self.type)
449 except: pass
450
79b400f0 451 kw = [x.strip() for x in kw if len(x.strip()) > 0 and x is not None]
b7a741ad 452 for k in kw:
453 cal.vevent.add('x-auf-keywords').value = k
454
455 description = self.description
456 if len(kw) > 0:
457 if len(self.description) > 0:
458 description += "\n"
028f548f 459 description += u"Mots-clés: " + ", ".join(kw)
b7a741ad 460
7f214e0f
EMS
461 cal.vevent.add('dtstart').value = combine(self.debut, pytz.timezone(self.fuseau))
462 cal.vevent.add('dtend').value = combine(self.fin, pytz.timezone(self.fuseau))
b7a741ad 463 cal.vevent.add('created').value = combine(datetime.datetime.now(), "UTC")
464 cal.vevent.add('dtstamp').value = combine(datetime.datetime.now(), "UTC")
79b400f0 465 if len(description) > 0:
b7a741ad 466 cal.vevent.add('description').value = description
467 if len(self.contact) > 0:
468 cal.vevent.add('contact').value = self.contact
469 if len(self.url) > 0:
470 cal.vevent.add('url').value = self.url
fe254ccc 471 cal.vevent.add('location').value = ', '.join([x for x in [self.adresse, self.ville, self.pays.nom] if x])
74b087e5
EMS
472 if self.piece_jointe:
473 url = self.piece_jointe.url
474 if not url.startswith('http://'):
475 url = SITE_ROOT_URL + url
476 cal.vevent.add('attach').value = url
b7a741ad 477 return cal
478
479 def update_vevent(self,):
480 """Essaie de créer l'évènement sur le serveur ical.
481 En cas de succès, l'évènement local devient donc inactif et approuvé"""
482 try:
483 if self.approuve:
484 event = self.as_ical()
485 client = caldav.DAVClient(CALENDRIER_URL)
486 cal = caldav.Calendar(client, url = CALENDRIER_URL)
487 e = caldav.Event(client, parent = cal, data = event.serialize(), id=self.uid)
488 e.save()
489 except:
490 self.approuve = False
491
492 def delete_vevent(self,):
493 """Supprime l'evenement sur le serveur caldav"""
494 try:
495 if self.approuve:
496 event = self.as_ical()
497 client = caldav.DAVClient(CALENDRIER_URL)
498 cal = caldav.Calendar(client, url = CALENDRIER_URL)
499 e = cal.event(self.uid)
500 e.delete()
501 except error.NotFoundError:
502 pass
503
264a3210
EMS
504 def assigner_regions(self, regions):
505 self.regions.add(*regions)
506
507 def assigner_disciplines(self, disciplines):
508 if len(disciplines) == 1:
509 if self.discipline:
510 self.discipline_secondaire = disciplines[0]
511 else:
512 self.discipline = disciplines[0]
513 elif len(disciplines) >= 2:
514 self.discipline = disciplines[0]
515 self.discipline_secondaire = disciplines[1]
516
b7a741ad 517def delete_vevent(sender, instance, *args, **kwargs):
5212238e
EMS
518 # Surcharge du comportement de suppression
519 # La méthode de connexion par signals est préférable à surcharger la méthode delete()
520 # car dans le cas de la suppression par lots, cell-ci n'est pas invoquée
b7a741ad 521 instance.delete_vevent()
5212238e 522pre_delete.connect(delete_vevent, sender=Evenement)
b7a741ad 523
81fe476e
PP
524class EvenementVoir(Evenement):
525
526 class Meta:
527 proxy = True
429222f1
PP
528 verbose_name = 'événement (visualisation)'
529 verbose_name_plural = 'événement (visualisation)'
81fe476e 530
5212238e 531# Ressources
b7a741ad 532
d972b61d 533class ListSet(models.Model):
534 spec = models.CharField(primary_key = True, max_length = 255)
535 name = models.CharField(max_length = 255)
536 server = models.CharField(max_length = 255)
9eda5d6c 537 validated = models.BooleanField(default = True)
d972b61d 538
10d37e44 539 def __unicode__(self,):
540 return self.name
541
656b9c0f
PP
542class RecordCategorie(models.Model):
543 nom = models.CharField(max_length=255)
544
545 class Meta:
546 verbose_name = 'catégorie ressource'
547 verbose_name_plural = 'catégories ressources'
548
549 def __unicode__(self):
550 return self.nom
551
552
230671ff
EMS
553class RecordQuerySet(SEPQuerySet):
554
555 def filter_modified(self, min=None, max=None):
7ed3ee8f 556 return self._filter_date('modified', min=min, max=max)
230671ff 557
5212238e 558class RecordSphinxQuerySet(SEPSphinxQuerySet):
f153be1b 559
5212238e 560 def __init__(self, model=None):
4134daa0 561 SEPSphinxQuerySet.__init__(self, model=model, index='savoirsenpartage_ressources',
5212238e 562 weights=dict(title=3))
c1b134f8 563
230671ff 564 def filter_modified(self, min=None, max=None):
7ed3ee8f 565 return self._filter_date('modified', min=min, max=max)
230671ff 566
5212238e 567class RecordManager(SEPManager):
f12cc7fb 568
5212238e 569 def get_query_set(self):
f153be1b
EMS
570 """Ne garder que les ressources validées et qui sont soit dans aucun
571 listset ou au moins dans un listset validé."""
230671ff 572 qs = RecordQuerySet(self.model)
5212238e 573 qs = qs.filter(validated=True)
82f25472
EMS
574 qs = qs.filter(Q(listsets__isnull=True) | Q(listsets__validated=True))
575 return qs.distinct()
f153be1b 576
5212238e
EMS
577 def get_sphinx_query_set(self):
578 return RecordSphinxQuerySet(self.model)
77b0fac0 579
230671ff
EMS
580 def filter_modified(self, min=None, max=None):
581 return self.get_query_set().filter_modified(min=min, max=max)
582
0cc5f772 583class Record(models.Model):
23b5b3d5 584
585 #fonctionnement interne
0cc5f772 586 id = models.AutoField(primary_key = True)
a5f76eb4 587 server = models.CharField(max_length = 255, verbose_name=u'serveur')
23b5b3d5 588 last_update = models.CharField(max_length = 255)
589 last_checksum = models.CharField(max_length = 255)
a5f76eb4 590 validated = models.BooleanField(default=True, verbose_name=u'validé')
23b5b3d5 591
592 #OAI
18dbd2cf
EMS
593 title = models.TextField(null=True, blank=True, verbose_name=u'titre')
594 creator = models.TextField(null=True, blank=True, verbose_name=u'auteur')
595 description = models.TextField(null=True, blank=True)
596 modified = models.CharField(max_length=255, null=True, blank=True)
23b5b3d5 597 identifier = models.CharField(max_length = 255, null = True, blank = True, unique = True)
598 uri = models.CharField(max_length = 255, null = True, blank = True, unique = True)
599 source = models.TextField(null = True, blank = True)
600 contributor = models.TextField(null = True, blank = True)
18dbd2cf 601 subject = models.TextField(null=True, blank=True, verbose_name='sujet')
23b5b3d5 602 publisher = models.TextField(null = True, blank = True)
603 type = models.TextField(null = True, blank = True)
604 format = models.TextField(null = True, blank = True)
605 language = models.TextField(null = True, blank = True)
da9020f3 606
c88d78dc 607 listsets = models.ManyToManyField(ListSet, null = True, blank = True)
d972b61d 608
da9020f3 609 #SEP 2 (aucune données récoltées)
23b5b3d5 610 alt_title = models.TextField(null = True, blank = True)
611 abstract = models.TextField(null = True, blank = True)
612 creation = models.CharField(max_length = 255, null = True, blank = True)
613 issued = models.CharField(max_length = 255, null = True, blank = True)
614 isbn = models.TextField(null = True, blank = True)
615 orig_lang = models.TextField(null = True, blank = True)
da9020f3 616
8c837517 617 categorie = models.ForeignKey(RecordCategorie, blank=True, null=True, verbose_name='catégorie')
656b9c0f 618
da9020f3 619 # Metadata AUF multivaluées
a342f93a
EMS
620 disciplines = models.ManyToManyField(Discipline, blank=True)
621 thematiques = models.ManyToManyField(Thematique, blank=True, verbose_name='thématiques')
622 pays = models.ManyToManyField(Pays, blank=True)
623 regions = models.ManyToManyField(Region, blank=True, verbose_name='régions')
0cc5f772 624
5212238e 625 # Managers
da44ce68 626 objects = RecordManager()
c59dba82 627 all_objects = models.Manager()
da44ce68 628
18dbd2cf
EMS
629 class Meta:
630 verbose_name = 'ressource'
631
264a3210
EMS
632 def __unicode__(self):
633 return "[%s] %s" % (self.server, self.title)
634
230671ff
EMS
635 def get_absolute_url(self):
636 return reverse('ressource', kwargs={'id': self.id})
637
264a3210 638 def getServeurURL(self):
f98ad449 639 """Retourne l'URL du serveur de provenance"""
640 return RESOURCES[self.server]['url']
641
264a3210 642 def est_complet(self):
6d885e0c 643 """teste si le record à toutes les données obligatoires"""
644 return self.disciplines.count() > 0 and \
645 self.thematiques.count() > 0 and \
646 self.pays.count() > 0 and \
647 self.regions.count() > 0
648
264a3210
EMS
649 def assigner_regions(self, regions):
650 self.regions.add(*regions)
da9020f3 651
264a3210
EMS
652 def assigner_disciplines(self, disciplines):
653 self.disciplines.add(*disciplines)
264a3210 654
927764f9
PP
655class RecordEdit(Record):
656
657 class Meta:
658 proxy = True
659 verbose_name = 'ressource (édition)'
660 verbose_name_plural = 'ressources (édition)'
661
6d885e0c 662class Serveur(models.Model):
b7a741ad 663 """Identification d'un serveur d'ou proviennent les références"""
6d885e0c 664 nom = models.CharField(primary_key = True, max_length = 255)
665
666 def __unicode__(self,):
667 return self.nom
668
669 def conf_2_db(self,):
670 for k in RESOURCES.keys():
671 s, created = Serveur.objects.get_or_create(nom=k)
672 s.nom = k
673 s.save()
674
675class Profile(models.Model):
676 user = models.ForeignKey(User, unique=True)
677 serveurs = models.ManyToManyField(Serveur, null = True, blank = True)
0cc5f772
CR
678
679class HarvestLog(models.Model):
23b5b3d5 680 context = models.CharField(max_length = 255)
681 name = models.CharField(max_length = 255)
0cc5f772 682 date = models.DateTimeField(auto_now = True)
23b5b3d5 683 added = models.IntegerField(null = True, blank = True)
684 updated = models.IntegerField(null = True, blank = True)
a85ba76e 685 processed = models.IntegerField(null = True, blank = True)
23b5b3d5 686 record = models.ForeignKey(Record, null = True, blank = True)
687
688 @staticmethod
689 def add(message):
690 logger = HarvestLog()
691 if message.has_key('record_id'):
d566e9c1 692 message['record'] = Record.all_objects.get(id=message['record_id'])
23b5b3d5 693 del(message['record_id'])
694
695 for k,v in message.items():
696 setattr(logger, k, v)
697 logger.save()
f09bc1c6
EMS
698
699# Pages statiques
700
701class PageStatique(models.Model):
20430b8a 702 id = models.CharField(max_length=32, primary_key=True)
f09bc1c6
EMS
703 titre = models.CharField(max_length=100)
704 contenu = models.TextField()
705
706 class Meta:
707 verbose_name_plural = 'pages statiques'
fdcf5874
EMS
708
709# Recherches
710
711class GlobalSearchResults(object):
712
713 def __init__(self, actualites=None, appels=None, evenements=None,
057c3327
PP
714 ressources=None, chercheurs=None, groupes=None,
715 sites=None, sites_auf=None):
fdcf5874
EMS
716 self.actualites = actualites
717 self.appels = appels
718 self.evenements = evenements
719 self.ressources = ressources
720 self.chercheurs = chercheurs
057c3327 721 self.groupes = groupes
fdcf5874
EMS
722 self.sites = sites
723 self.sites_auf = sites_auf
724
4b89a7df
EMS
725 def __nonzero__(self):
726 return bool(self.actualites or self.appels or self.evenements or
057c3327
PP
727 self.ressources or self.chercheurs or self.groupes or
728 self.sites or self.sites_auf)
4b89a7df 729
fdcf5874
EMS
730class Search(models.Model):
731 user = models.ForeignKey(User, editable=False)
732 content_type = models.ForeignKey(ContentType, editable=False)
733 nom = models.CharField(max_length=100, verbose_name="nom de la recherche")
4b89a7df
EMS
734 alerte_courriel = models.BooleanField(verbose_name="Envoyer une alerte courriel")
735 derniere_alerte = models.DateField(verbose_name="Date d'envoi de la dernière alerte courriel", null=True, editable=False)
a3b691a6 736 q = models.CharField(max_length=255, blank=True, verbose_name="dans tous les champs")
fdcf5874
EMS
737 discipline = models.ForeignKey(Discipline, blank=True, null=True)
738 region = models.ForeignKey(Region, blank=True, null=True, verbose_name='région',
739 help_text="La région est ici définie au sens, non strictement géographique, du Bureau régional de l'AUF de référence.")
740
741 def query_string(self):
742 params = dict()
743 for field in self._meta.fields:
744 if field.name in ['id', 'user', 'nom', 'search_ptr', 'content_type']:
745 continue
746 value = getattr(self, field.column)
747 if value:
748 if isinstance(value, datetime.date):
749 params[field.name] = value.strftime('%d/%m/%Y')
750 else:
4b89a7df 751 params[field.name] = smart_str(value)
fdcf5874
EMS
752 return urlencode(params)
753
754 class Meta:
755 verbose_name = 'recherche transversale'
756 verbose_name_plural = "recherches transversales"
757
2094c7e5
PP
758 def __unicode__(self):
759 return self.nom
760
fdcf5874 761 def save(self):
a83b8efb
EMS
762 if self.alerte_courriel:
763 try:
764 original_search = Search.objects.get(id=self.id)
765 if not original_search.alerte_courriel:
766 # On a nouvellement activé l'alerte courriel. Notons la
767 # date.
768 self.derniere_alerte = datetime.date.today() - datetime.timedelta(days=1)
769 except Search.DoesNotExist:
770 self.derniere_alerte = datetime.date.today() - datetime.timedelta(days=1)
fdcf5874
EMS
771 if (not self.content_type_id):
772 self.content_type = ContentType.objects.get_for_model(self.__class__)
773 super(Search, self).save()
774
775 def as_leaf_class(self):
776 content_type = self.content_type
777 model = content_type.model_class()
778 if(model == Search):
779 return self
780 return model.objects.get(id=self.id)
781
4b89a7df 782 def run(self, min_date=None, max_date=None):
057c3327 783 from chercheurs.models import Chercheur, Groupe
fdcf5874
EMS
784 from sitotheque.models import Site
785
fdcf5874
EMS
786 actualites = Actualite.objects
787 evenements = Evenement.objects
788 ressources = Record.objects
789 chercheurs = Chercheur.objects
057c3327 790 groupes = Groupe.objects
fdcf5874
EMS
791 sites = Site.objects
792 if self.q:
793 actualites = actualites.search(self.q)
794 evenements = evenements.search(self.q)
795 ressources = ressources.search(self.q)
796 chercheurs = chercheurs.search(self.q)
057c3327 797 groupes = groupes.search(self.q)
fdcf5874
EMS
798 sites = sites.search(self.q)
799 if self.discipline:
800 actualites = actualites.filter_discipline(self.discipline)
801 evenements = evenements.filter_discipline(self.discipline)
802 ressources = ressources.filter_discipline(self.discipline)
803 chercheurs = chercheurs.filter_discipline(self.discipline)
804 sites = sites.filter_discipline(self.discipline)
805 if self.region:
806 actualites = actualites.filter_region(self.region)
807 evenements = evenements.filter_region(self.region)
808 ressources = ressources.filter_region(self.region)
809 chercheurs = chercheurs.filter_region(self.region)
810 sites = sites.filter_region(self.region)
4b89a7df
EMS
811 if min_date:
812 actualites = actualites.filter_date(min=min_date)
a83b8efb 813 evenements = evenements.filter_date_modification(min=min_date)
4b89a7df
EMS
814 ressources = ressources.filter_modified(min=min_date)
815 chercheurs = chercheurs.filter_date_modification(min=min_date)
816 sites = sites.filter_date_maj(min=min_date)
817 if max_date:
818 actualites = actualites.filter_date(max=max_date)
a83b8efb 819 evenements = evenements.filter_date_modification(max=max_date)
4b89a7df
EMS
820 ressources = ressources.filter_modified(max=max_date)
821 chercheurs = chercheurs.filter_date_modification(max=max_date)
822 sites = sites.filter_date_maj(max=max_date)
823
fdcf5874
EMS
824 try:
825 sites_auf = google_search(0, self.q)['results']
826 except:
827 sites_auf = []
828
829 return GlobalSearchResults(
830 actualites=actualites.order_by('-date').filter_type('actu'),
831 appels=actualites.order_by('-date').filter_type('appels'),
832 evenements=evenements.order_by('-debut'),
833 ressources=ressources.order_by('-id'),
834 chercheurs=chercheurs.order_by('-date_modification'),
057c3327 835 groupes=groupes.order_by('nom'),
fdcf5874
EMS
836 sites=sites.order_by('-date_maj'),
837 sites_auf=sites_auf
838 )
839
840 def url(self):
c956b333
PP
841
842 if self.content_type.model != 'search':
843 obj = self.content_type.get_object_for_this_type(pk=self.pk)
844 return obj.url()
845
fdcf5874
EMS
846 url = ''
847 if self.discipline:
848 url += '/discipline/%d' % self.discipline.id
849 if self.region:
850 url += '/region/%d' % self.region.id
851 url += '/recherche/'
852 if self.q:
4b89a7df 853 url += '?' + urlencode({'q': smart_str(self.q)})
fdcf5874
EMS
854 return url
855
da5bf7e9
EMS
856 def rss_url(self):
857 return None
858
4b89a7df
EMS
859 def send_email_alert(self):
860 """Envoie une alerte courriel correspondant à cette recherche"""
861 yesterday = datetime.date.today() - datetime.timedelta(days=1)
862 if self.derniere_alerte is not None:
863 results = self.as_leaf_class().run(min_date=self.derniere_alerte, max_date=yesterday)
864 if results:
865 subject = 'Savoirs en partage - ' + self.nom
a83b8efb 866 from_email = CONTACT_EMAIL
4b89a7df
EMS
867 to_email = self.user.email
868 text_content = u'Voici les derniers résultats correspondant à votre recherche sauvegardée.\n\n'
869 text_content += self.as_leaf_class().get_email_alert_content(results)
870 text_content += u'''
871
872Pour modifier votre abonnement aux alertes courriel de Savoirs en partage,
873rendez-vous sur le [gestionnaire de recherches sauvegardées](%s%s)''' % (SITE_ROOT_URL, reverse('recherches'))
874 html_content = '<div style="font-family: Arial, sans-serif">\n' + markdown(smart_str(text_content)) + '</div>\n'
875 msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email])
876 msg.attach_alternative(html_content, "text/html")
877 msg.send()
878 self.derniere_alerte = yesterday
879 self.save()
4b89a7df
EMS
880
881 def get_email_alert_content(self, results):
882 content = ''
883 if results.chercheurs:
884 content += u'\n### Nouveaux chercheurs\n\n'
885 for chercheur in results.chercheurs:
886 content += u'- [%s %s](%s%s) \n' % (chercheur.nom.upper(),
887 chercheur.prenom,
888 SITE_ROOT_URL,
889 chercheur.get_absolute_url())
890 content += u' %s\n\n' % chercheur.etablissement_display
891 if results.ressources:
892 content += u'\n### Nouvelles ressources\n\n'
893 for ressource in results.ressources:
894 content += u'- [%s](%s%s)\n\n' % (ressource.title,
895 SITE_ROOT_URL,
896 ressource.get_absolute_url())
897 if ressource.description:
898 content += '\n'
899 content += ''.join([' %s\n' % line for line in textwrap.wrap(ressource.description)])
900 content += '\n'
901
902 if results.actualites:
903 content += u'\n### Nouvelles actualités\n\n'
904 for actualite in results.actualites:
905 content += u'- [%s](%s%s)\n\n' % (actualite.titre,
906 SITE_ROOT_URL,
907 actualite.get_absolute_url())
908 if actualite.texte:
909 content += '\n'
910 content += ''.join([' %s\n' % line for line in textwrap.wrap(actualite.texte)])
911 content += '\n'
912 if results.appels:
913 content += u"\n### Nouveaux appels d'offres\n\n"
914 for appel in results.appels:
915 content += u'- [%s](%s%s)\n\n' % (appel.titre,
916 SITE_ROOT_URL,
917 appel.get_absolute_url())
918 if appel.texte:
919 content += '\n'
920 content += ''.join([' %s\n' % line for line in textwrap.wrap(appel.texte)])
921 content += '\n'
922 if results.evenements:
923 content += u"\n### Nouveaux évènements\n\n"
924 for evenement in results.evenements:
925 content += u'- [%s](%s%s) \n' % (evenement.titre,
926 SITE_ROOT_URL,
927 evenement.get_absolute_url())
a83b8efb 928 content += u' où ? : %s \n' % evenement.lieu
b70d8da4 929 content += evenement.debut.strftime(' quand ? : %d/%m/%Y %H:%M \n')
4b89a7df
EMS
930 content += u' durée ? : %s\n\n' % evenement.duration_display()
931 content += u' quoi ? : '
932 content += '\n '.join(textwrap.wrap(evenement.description))
933 content += '\n\n'
934 if results.sites:
935 content += u"\n### Nouveaux sites\n\n"
936 for site in results.sites:
937 content += u'- [%s](%s%s)\n\n' % (site.titre,
938 SITE_ROOT_URL,
939 site.get_absolute_url())
940 if site.description:
941 content += '\n'
942 content += ''.join([' %s\n' % line for line in textwrap.wrap(site.description)])
943 content += '\n'
944 return content
945
fdcf5874
EMS
946class RessourceSearch(Search):
947 auteur = models.CharField(max_length=100, blank=True, verbose_name="auteur ou contributeur")
948 titre = models.CharField(max_length=100, blank=True)
949 sujet = models.CharField(max_length=100, blank=True)
950 publisher = models.CharField(max_length=100, blank=True, verbose_name="éditeur")
ede02173 951 categorie = models.ForeignKey(RecordCategorie, blank=True, null=True, verbose_name='catégorie')
fdcf5874
EMS
952
953 class Meta:
954 verbose_name = 'recherche de ressources'
955 verbose_name_plural = "recherches de ressources"
956
4b89a7df 957 def run(self, min_date=None, max_date=None):
fdcf5874
EMS
958 results = Record.objects
959 if self.q:
960 results = results.search(self.q)
961 if self.auteur:
962 results = results.add_to_query('@(creator,contributor) ' + self.auteur)
963 if self.titre:
964 results = results.add_to_query('@title ' + self.titre)
965 if self.sujet:
966 results = results.add_to_query('@subject ' + self.sujet)
967 if self.publisher:
968 results = results.add_to_query('@publisher ' + self.publisher)
ede02173
PP
969 if self.categorie:
970 results = results.add_to_query('@categorie %s' % self.categorie.id)
fdcf5874
EMS
971 if self.discipline:
972 results = results.filter_discipline(self.discipline)
973 if self.region:
974 results = results.filter_region(self.region)
4b89a7df
EMS
975 if min_date:
976 results = results.filter_modified(min=min_date)
977 if max_date:
978 results = results.filter_modified(max=max_date)
fdcf5874
EMS
979 if not self.q:
980 """Montrer les résultats les plus récents si on n'a pas fait
981 une recherche par mots-clés."""
230671ff 982 results = results.order_by('-modified')
fdcf5874
EMS
983 return results.all()
984
985 def url(self):
986 qs = self.query_string()
987 return reverse('ressources') + ('?' + qs if qs else '')
988
da5bf7e9
EMS
989 def rss_url(self):
990 qs = self.query_string()
991 return reverse('rss_ressources') + ('?' + qs if qs else '')
992
4b89a7df
EMS
993 def get_email_alert_content(self, results):
994 content = ''
995 for ressource in results:
996 content += u'- [%s](%s%s)\n\n' % (ressource.title,
997 SITE_ROOT_URL,
998 ressource.get_absolute_url())
999 if ressource.description:
1000 content += '\n'
1001 content += ''.join([' %s\n' % line for line in textwrap.wrap(ressource.description)])
1002 content += '\n'
1003 return content
1004
fdcf5874
EMS
1005class ActualiteSearchBase(Search):
1006 date_min = models.DateField(blank=True, null=True, verbose_name="depuis le")
1007 date_max = models.DateField(blank=True, null=True, verbose_name="jusqu'au")
1008
1009 class Meta:
1010 abstract = True
1011
4b89a7df 1012 def run(self, min_date=None, max_date=None):
fdcf5874
EMS
1013 results = Actualite.objects
1014 if self.q:
1015 results = results.search(self.q)
1016 if self.discipline:
1017 results = results.filter_discipline(self.discipline)
1018 if self.region:
1019 results = results.filter_region(self.region)
1020 if self.date_min:
1021 results = results.filter_date(min=self.date_min)
1022 if self.date_max:
1023 results = results.filter_date(max=self.date_max)
4b89a7df
EMS
1024 if min_date:
1025 results = results.filter_date(min=min_date)
1026 if max_date:
1027 results = results.filter_date(max=max_date)
fdcf5874
EMS
1028 return results.all()
1029
4b89a7df
EMS
1030 def get_email_alert_content(self, results):
1031 content = ''
1032 for actualite in results:
1033 content += u'- [%s](%s%s)\n\n' % (actualite.titre,
1034 SITE_ROOT_URL,
1035 actualite.get_absolute_url())
1036 if actualite.texte:
1037 content += '\n'
1038 content += ''.join([' %s\n' % line for line in textwrap.wrap(actualite.texte)])
1039 content += '\n'
1040 return content
1041
fdcf5874
EMS
1042class ActualiteSearch(ActualiteSearchBase):
1043
1044 class Meta:
1045 verbose_name = "recherche d'actualités"
1046 verbose_name_plural = "recherches d'actualités"
1047
4b89a7df
EMS
1048 def run(self, min_date=None, max_date=None):
1049 return super(ActualiteSearch, self).run(min_date=min_date, max_date=max_date).filter_type('actu')
fdcf5874
EMS
1050
1051 def url(self):
1052 qs = self.query_string()
1053 return reverse('actualites') + ('?' + qs if qs else '')
1054
da5bf7e9
EMS
1055 def rss_url(self):
1056 qs = self.query_string()
1057 return reverse('rss_actualites') + ('?' + qs if qs else '')
1058
fdcf5874
EMS
1059class AppelSearch(ActualiteSearchBase):
1060
1061 class Meta:
1062 verbose_name = "recherche d'appels d'offres"
1063 verbose_name_plural = "recherches d'appels d'offres"
1064
4b89a7df
EMS
1065 def run(self, min_date=None, max_date=None):
1066 return super(AppelSearch, self).run(min_date=min_date, max_date=max_date).filter_type('appels')
fdcf5874
EMS
1067
1068 def url(self):
1069 qs = self.query_string()
1070 return reverse('appels') + ('?' + qs if qs else '')
1071
da5bf7e9
EMS
1072 def rss_url(self):
1073 qs = self.query_string()
1074 return reverse('rss_appels') + ('?' + qs if qs else '')
1075
fdcf5874
EMS
1076class EvenementSearch(Search):
1077 titre = models.CharField(max_length=100, blank=True, verbose_name="Intitulé")
1078 type = models.CharField(max_length=100, blank=True, choices=Evenement.TYPE_CHOICES)
1079 date_min = models.DateField(blank=True, null=True, verbose_name="depuis le")
1080 date_max = models.DateField(blank=True, null=True, verbose_name="jusqu'au")
1081
1082 class Meta:
1083 verbose_name = "recherche d'évènements"
1084 verbose_name_plural = "recherches d'évènements"
1085
4b89a7df 1086 def run(self, min_date=None, max_date=None):
fdcf5874
EMS
1087 results = Evenement.objects
1088 if self.q:
1089 results = results.search(self.q)
1090 if self.titre:
1091 results = results.add_to_query('@titre ' + self.titre)
1092 if self.discipline:
1093 results = results.filter_discipline(self.discipline)
1094 if self.region:
1095 results = results.filter_region(self.region)
1096 if self.type:
1097 results = results.filter_type(self.type)
1098 if self.date_min:
1099 results = results.filter_debut(min=self.date_min)
1100 if self.date_max:
1101 results = results.filter_debut(max=self.date_max)
4b89a7df 1102 if min_date:
a83b8efb 1103 results = results.filter_date_modification(min=min_date)
4b89a7df 1104 if max_date:
a83b8efb 1105 results = results.filter_date_modification(max=max_date)
fdcf5874
EMS
1106 return results.all()
1107
1108 def url(self):
1109 qs = self.query_string()
1110 return reverse('agenda') + ('?' + qs if qs else '')
1111
da5bf7e9
EMS
1112 def rss_url(self):
1113 qs = self.query_string()
1114 return reverse('rss_agenda') + ('?' + qs if qs else '')
4b89a7df
EMS
1115
1116 def get_email_alert_content(self, results):
1117 content = ''
1118 for evenement in results:
1119 content += u'- [%s](%s%s) \n' % (evenement.titre,
1120 SITE_ROOT_URL,
1121 evenement.get_absolute_url())
a83b8efb 1122 content += u' où ? : %s \n' % evenement.lieu
b70d8da4 1123 content += evenement.debut.strftime(' quand ? : %d/%m/%Y %H:%M \n')
4b89a7df
EMS
1124 content += u' durée ? : %s\n\n' % evenement.duration_display()
1125 content += u' quoi ? : '
1126 content += '\n '.join(textwrap.wrap(evenement.description))
1127 content += '\n\n'
1128 return content