Formulaire recherche: Recherche -> Rechercher
[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
6d885e0c 655class Serveur(models.Model):
b7a741ad 656 """Identification d'un serveur d'ou proviennent les références"""
6d885e0c 657 nom = models.CharField(primary_key = True, max_length = 255)
658
659 def __unicode__(self,):
660 return self.nom
661
662 def conf_2_db(self,):
663 for k in RESOURCES.keys():
664 s, created = Serveur.objects.get_or_create(nom=k)
665 s.nom = k
666 s.save()
667
668class Profile(models.Model):
669 user = models.ForeignKey(User, unique=True)
670 serveurs = models.ManyToManyField(Serveur, null = True, blank = True)
0cc5f772
CR
671
672class HarvestLog(models.Model):
23b5b3d5 673 context = models.CharField(max_length = 255)
674 name = models.CharField(max_length = 255)
0cc5f772 675 date = models.DateTimeField(auto_now = True)
23b5b3d5 676 added = models.IntegerField(null = True, blank = True)
677 updated = models.IntegerField(null = True, blank = True)
a85ba76e 678 processed = models.IntegerField(null = True, blank = True)
23b5b3d5 679 record = models.ForeignKey(Record, null = True, blank = True)
680
681 @staticmethod
682 def add(message):
683 logger = HarvestLog()
684 if message.has_key('record_id'):
d566e9c1 685 message['record'] = Record.all_objects.get(id=message['record_id'])
23b5b3d5 686 del(message['record_id'])
687
688 for k,v in message.items():
689 setattr(logger, k, v)
690 logger.save()
f09bc1c6
EMS
691
692# Pages statiques
693
694class PageStatique(models.Model):
20430b8a 695 id = models.CharField(max_length=32, primary_key=True)
f09bc1c6
EMS
696 titre = models.CharField(max_length=100)
697 contenu = models.TextField()
698
699 class Meta:
700 verbose_name_plural = 'pages statiques'
fdcf5874
EMS
701
702# Recherches
703
704class GlobalSearchResults(object):
705
706 def __init__(self, actualites=None, appels=None, evenements=None,
057c3327
PP
707 ressources=None, chercheurs=None, groupes=None,
708 sites=None, sites_auf=None):
fdcf5874
EMS
709 self.actualites = actualites
710 self.appels = appels
711 self.evenements = evenements
712 self.ressources = ressources
713 self.chercheurs = chercheurs
057c3327 714 self.groupes = groupes
fdcf5874
EMS
715 self.sites = sites
716 self.sites_auf = sites_auf
717
4b89a7df
EMS
718 def __nonzero__(self):
719 return bool(self.actualites or self.appels or self.evenements or
057c3327
PP
720 self.ressources or self.chercheurs or self.groupes or
721 self.sites or self.sites_auf)
4b89a7df 722
fdcf5874
EMS
723class Search(models.Model):
724 user = models.ForeignKey(User, editable=False)
725 content_type = models.ForeignKey(ContentType, editable=False)
726 nom = models.CharField(max_length=100, verbose_name="nom de la recherche")
4b89a7df
EMS
727 alerte_courriel = models.BooleanField(verbose_name="Envoyer une alerte courriel")
728 derniere_alerte = models.DateField(verbose_name="Date d'envoi de la dernière alerte courriel", null=True, editable=False)
d57ca08f 729 q = models.CharField(max_length=100, blank=True, verbose_name="dans tous les champs")
fdcf5874
EMS
730 discipline = models.ForeignKey(Discipline, blank=True, null=True)
731 region = models.ForeignKey(Region, blank=True, null=True, verbose_name='région',
732 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.")
733
734 def query_string(self):
735 params = dict()
736 for field in self._meta.fields:
737 if field.name in ['id', 'user', 'nom', 'search_ptr', 'content_type']:
738 continue
739 value = getattr(self, field.column)
740 if value:
741 if isinstance(value, datetime.date):
742 params[field.name] = value.strftime('%d/%m/%Y')
743 else:
4b89a7df 744 params[field.name] = smart_str(value)
fdcf5874
EMS
745 return urlencode(params)
746
747 class Meta:
748 verbose_name = 'recherche transversale'
749 verbose_name_plural = "recherches transversales"
750
2094c7e5
PP
751 def __unicode__(self):
752 return self.nom
753
fdcf5874 754 def save(self):
a83b8efb
EMS
755 if self.alerte_courriel:
756 try:
757 original_search = Search.objects.get(id=self.id)
758 if not original_search.alerte_courriel:
759 # On a nouvellement activé l'alerte courriel. Notons la
760 # date.
761 self.derniere_alerte = datetime.date.today() - datetime.timedelta(days=1)
762 except Search.DoesNotExist:
763 self.derniere_alerte = datetime.date.today() - datetime.timedelta(days=1)
fdcf5874
EMS
764 if (not self.content_type_id):
765 self.content_type = ContentType.objects.get_for_model(self.__class__)
766 super(Search, self).save()
767
768 def as_leaf_class(self):
769 content_type = self.content_type
770 model = content_type.model_class()
771 if(model == Search):
772 return self
773 return model.objects.get(id=self.id)
774
4b89a7df 775 def run(self, min_date=None, max_date=None):
057c3327 776 from chercheurs.models import Chercheur, Groupe
fdcf5874
EMS
777 from sitotheque.models import Site
778
fdcf5874
EMS
779 actualites = Actualite.objects
780 evenements = Evenement.objects
781 ressources = Record.objects
782 chercheurs = Chercheur.objects
057c3327 783 groupes = Groupe.objects
fdcf5874
EMS
784 sites = Site.objects
785 if self.q:
786 actualites = actualites.search(self.q)
787 evenements = evenements.search(self.q)
788 ressources = ressources.search(self.q)
789 chercheurs = chercheurs.search(self.q)
057c3327 790 groupes = groupes.search(self.q)
fdcf5874
EMS
791 sites = sites.search(self.q)
792 if self.discipline:
793 actualites = actualites.filter_discipline(self.discipline)
794 evenements = evenements.filter_discipline(self.discipline)
795 ressources = ressources.filter_discipline(self.discipline)
796 chercheurs = chercheurs.filter_discipline(self.discipline)
797 sites = sites.filter_discipline(self.discipline)
798 if self.region:
799 actualites = actualites.filter_region(self.region)
800 evenements = evenements.filter_region(self.region)
801 ressources = ressources.filter_region(self.region)
802 chercheurs = chercheurs.filter_region(self.region)
803 sites = sites.filter_region(self.region)
4b89a7df
EMS
804 if min_date:
805 actualites = actualites.filter_date(min=min_date)
a83b8efb 806 evenements = evenements.filter_date_modification(min=min_date)
4b89a7df
EMS
807 ressources = ressources.filter_modified(min=min_date)
808 chercheurs = chercheurs.filter_date_modification(min=min_date)
809 sites = sites.filter_date_maj(min=min_date)
810 if max_date:
811 actualites = actualites.filter_date(max=max_date)
a83b8efb 812 evenements = evenements.filter_date_modification(max=max_date)
4b89a7df
EMS
813 ressources = ressources.filter_modified(max=max_date)
814 chercheurs = chercheurs.filter_date_modification(max=max_date)
815 sites = sites.filter_date_maj(max=max_date)
816
fdcf5874
EMS
817 try:
818 sites_auf = google_search(0, self.q)['results']
819 except:
820 sites_auf = []
821
822 return GlobalSearchResults(
823 actualites=actualites.order_by('-date').filter_type('actu'),
824 appels=actualites.order_by('-date').filter_type('appels'),
825 evenements=evenements.order_by('-debut'),
826 ressources=ressources.order_by('-id'),
827 chercheurs=chercheurs.order_by('-date_modification'),
057c3327 828 groupes=groupes.order_by('nom'),
fdcf5874
EMS
829 sites=sites.order_by('-date_maj'),
830 sites_auf=sites_auf
831 )
832
833 def url(self):
c956b333
PP
834
835 if self.content_type.model != 'search':
836 obj = self.content_type.get_object_for_this_type(pk=self.pk)
837 return obj.url()
838
fdcf5874
EMS
839 url = ''
840 if self.discipline:
841 url += '/discipline/%d' % self.discipline.id
842 if self.region:
843 url += '/region/%d' % self.region.id
844 url += '/recherche/'
845 if self.q:
4b89a7df 846 url += '?' + urlencode({'q': smart_str(self.q)})
fdcf5874
EMS
847 return url
848
da5bf7e9
EMS
849 def rss_url(self):
850 return None
851
4b89a7df
EMS
852 def send_email_alert(self):
853 """Envoie une alerte courriel correspondant à cette recherche"""
854 yesterday = datetime.date.today() - datetime.timedelta(days=1)
855 if self.derniere_alerte is not None:
856 results = self.as_leaf_class().run(min_date=self.derniere_alerte, max_date=yesterday)
857 if results:
858 subject = 'Savoirs en partage - ' + self.nom
a83b8efb 859 from_email = CONTACT_EMAIL
4b89a7df
EMS
860 to_email = self.user.email
861 text_content = u'Voici les derniers résultats correspondant à votre recherche sauvegardée.\n\n'
862 text_content += self.as_leaf_class().get_email_alert_content(results)
863 text_content += u'''
864
865Pour modifier votre abonnement aux alertes courriel de Savoirs en partage,
866rendez-vous sur le [gestionnaire de recherches sauvegardées](%s%s)''' % (SITE_ROOT_URL, reverse('recherches'))
867 html_content = '<div style="font-family: Arial, sans-serif">\n' + markdown(smart_str(text_content)) + '</div>\n'
868 msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email])
869 msg.attach_alternative(html_content, "text/html")
870 msg.send()
871 self.derniere_alerte = yesterday
872 self.save()
4b89a7df
EMS
873
874 def get_email_alert_content(self, results):
875 content = ''
876 if results.chercheurs:
877 content += u'\n### Nouveaux chercheurs\n\n'
878 for chercheur in results.chercheurs:
879 content += u'- [%s %s](%s%s) \n' % (chercheur.nom.upper(),
880 chercheur.prenom,
881 SITE_ROOT_URL,
882 chercheur.get_absolute_url())
883 content += u' %s\n\n' % chercheur.etablissement_display
884 if results.ressources:
885 content += u'\n### Nouvelles ressources\n\n'
886 for ressource in results.ressources:
887 content += u'- [%s](%s%s)\n\n' % (ressource.title,
888 SITE_ROOT_URL,
889 ressource.get_absolute_url())
890 if ressource.description:
891 content += '\n'
892 content += ''.join([' %s\n' % line for line in textwrap.wrap(ressource.description)])
893 content += '\n'
894
895 if results.actualites:
896 content += u'\n### Nouvelles actualités\n\n'
897 for actualite in results.actualites:
898 content += u'- [%s](%s%s)\n\n' % (actualite.titre,
899 SITE_ROOT_URL,
900 actualite.get_absolute_url())
901 if actualite.texte:
902 content += '\n'
903 content += ''.join([' %s\n' % line for line in textwrap.wrap(actualite.texte)])
904 content += '\n'
905 if results.appels:
906 content += u"\n### Nouveaux appels d'offres\n\n"
907 for appel in results.appels:
908 content += u'- [%s](%s%s)\n\n' % (appel.titre,
909 SITE_ROOT_URL,
910 appel.get_absolute_url())
911 if appel.texte:
912 content += '\n'
913 content += ''.join([' %s\n' % line for line in textwrap.wrap(appel.texte)])
914 content += '\n'
915 if results.evenements:
916 content += u"\n### Nouveaux évènements\n\n"
917 for evenement in results.evenements:
918 content += u'- [%s](%s%s) \n' % (evenement.titre,
919 SITE_ROOT_URL,
920 evenement.get_absolute_url())
a83b8efb 921 content += u' où ? : %s \n' % evenement.lieu
4b89a7df
EMS
922 content += evenement.debut.strftime(u' quand ? : %d/%m/%Y %H:%M \n')
923 content += u' durée ? : %s\n\n' % evenement.duration_display()
924 content += u' quoi ? : '
925 content += '\n '.join(textwrap.wrap(evenement.description))
926 content += '\n\n'
927 if results.sites:
928 content += u"\n### Nouveaux sites\n\n"
929 for site in results.sites:
930 content += u'- [%s](%s%s)\n\n' % (site.titre,
931 SITE_ROOT_URL,
932 site.get_absolute_url())
933 if site.description:
934 content += '\n'
935 content += ''.join([' %s\n' % line for line in textwrap.wrap(site.description)])
936 content += '\n'
937 return content
938
fdcf5874
EMS
939class RessourceSearch(Search):
940 auteur = models.CharField(max_length=100, blank=True, verbose_name="auteur ou contributeur")
941 titre = models.CharField(max_length=100, blank=True)
942 sujet = models.CharField(max_length=100, blank=True)
943 publisher = models.CharField(max_length=100, blank=True, verbose_name="éditeur")
ede02173 944 categorie = models.ForeignKey(RecordCategorie, blank=True, null=True, verbose_name='catégorie')
fdcf5874
EMS
945
946 class Meta:
947 verbose_name = 'recherche de ressources'
948 verbose_name_plural = "recherches de ressources"
949
4b89a7df 950 def run(self, min_date=None, max_date=None):
fdcf5874
EMS
951 results = Record.objects
952 if self.q:
953 results = results.search(self.q)
954 if self.auteur:
955 results = results.add_to_query('@(creator,contributor) ' + self.auteur)
956 if self.titre:
957 results = results.add_to_query('@title ' + self.titre)
958 if self.sujet:
959 results = results.add_to_query('@subject ' + self.sujet)
960 if self.publisher:
961 results = results.add_to_query('@publisher ' + self.publisher)
ede02173
PP
962 if self.categorie:
963 results = results.add_to_query('@categorie %s' % self.categorie.id)
fdcf5874
EMS
964 if self.discipline:
965 results = results.filter_discipline(self.discipline)
966 if self.region:
967 results = results.filter_region(self.region)
4b89a7df
EMS
968 if min_date:
969 results = results.filter_modified(min=min_date)
970 if max_date:
971 results = results.filter_modified(max=max_date)
fdcf5874
EMS
972 if not self.q:
973 """Montrer les résultats les plus récents si on n'a pas fait
974 une recherche par mots-clés."""
230671ff 975 results = results.order_by('-modified')
fdcf5874
EMS
976 return results.all()
977
978 def url(self):
979 qs = self.query_string()
980 return reverse('ressources') + ('?' + qs if qs else '')
981
da5bf7e9
EMS
982 def rss_url(self):
983 qs = self.query_string()
984 return reverse('rss_ressources') + ('?' + qs if qs else '')
985
4b89a7df
EMS
986 def get_email_alert_content(self, results):
987 content = ''
988 for ressource in results:
989 content += u'- [%s](%s%s)\n\n' % (ressource.title,
990 SITE_ROOT_URL,
991 ressource.get_absolute_url())
992 if ressource.description:
993 content += '\n'
994 content += ''.join([' %s\n' % line for line in textwrap.wrap(ressource.description)])
995 content += '\n'
996 return content
997
fdcf5874
EMS
998class ActualiteSearchBase(Search):
999 date_min = models.DateField(blank=True, null=True, verbose_name="depuis le")
1000 date_max = models.DateField(blank=True, null=True, verbose_name="jusqu'au")
1001
1002 class Meta:
1003 abstract = True
1004
4b89a7df 1005 def run(self, min_date=None, max_date=None):
fdcf5874
EMS
1006 results = Actualite.objects
1007 if self.q:
1008 results = results.search(self.q)
1009 if self.discipline:
1010 results = results.filter_discipline(self.discipline)
1011 if self.region:
1012 results = results.filter_region(self.region)
1013 if self.date_min:
1014 results = results.filter_date(min=self.date_min)
1015 if self.date_max:
1016 results = results.filter_date(max=self.date_max)
4b89a7df
EMS
1017 if min_date:
1018 results = results.filter_date(min=min_date)
1019 if max_date:
1020 results = results.filter_date(max=max_date)
fdcf5874
EMS
1021 return results.all()
1022
4b89a7df
EMS
1023 def get_email_alert_content(self, results):
1024 content = ''
1025 for actualite in results:
1026 content += u'- [%s](%s%s)\n\n' % (actualite.titre,
1027 SITE_ROOT_URL,
1028 actualite.get_absolute_url())
1029 if actualite.texte:
1030 content += '\n'
1031 content += ''.join([' %s\n' % line for line in textwrap.wrap(actualite.texte)])
1032 content += '\n'
1033 return content
1034
fdcf5874
EMS
1035class ActualiteSearch(ActualiteSearchBase):
1036
1037 class Meta:
1038 verbose_name = "recherche d'actualités"
1039 verbose_name_plural = "recherches d'actualités"
1040
4b89a7df
EMS
1041 def run(self, min_date=None, max_date=None):
1042 return super(ActualiteSearch, self).run(min_date=min_date, max_date=max_date).filter_type('actu')
fdcf5874
EMS
1043
1044 def url(self):
1045 qs = self.query_string()
1046 return reverse('actualites') + ('?' + qs if qs else '')
1047
da5bf7e9
EMS
1048 def rss_url(self):
1049 qs = self.query_string()
1050 return reverse('rss_actualites') + ('?' + qs if qs else '')
1051
fdcf5874
EMS
1052class AppelSearch(ActualiteSearchBase):
1053
1054 class Meta:
1055 verbose_name = "recherche d'appels d'offres"
1056 verbose_name_plural = "recherches d'appels d'offres"
1057
4b89a7df
EMS
1058 def run(self, min_date=None, max_date=None):
1059 return super(AppelSearch, self).run(min_date=min_date, max_date=max_date).filter_type('appels')
fdcf5874
EMS
1060
1061 def url(self):
1062 qs = self.query_string()
1063 return reverse('appels') + ('?' + qs if qs else '')
1064
da5bf7e9
EMS
1065 def rss_url(self):
1066 qs = self.query_string()
1067 return reverse('rss_appels') + ('?' + qs if qs else '')
1068
fdcf5874
EMS
1069class EvenementSearch(Search):
1070 titre = models.CharField(max_length=100, blank=True, verbose_name="Intitulé")
1071 type = models.CharField(max_length=100, blank=True, choices=Evenement.TYPE_CHOICES)
1072 date_min = models.DateField(blank=True, null=True, verbose_name="depuis le")
1073 date_max = models.DateField(blank=True, null=True, verbose_name="jusqu'au")
1074
1075 class Meta:
1076 verbose_name = "recherche d'évènements"
1077 verbose_name_plural = "recherches d'évènements"
1078
4b89a7df 1079 def run(self, min_date=None, max_date=None):
fdcf5874
EMS
1080 results = Evenement.objects
1081 if self.q:
1082 results = results.search(self.q)
1083 if self.titre:
1084 results = results.add_to_query('@titre ' + self.titre)
1085 if self.discipline:
1086 results = results.filter_discipline(self.discipline)
1087 if self.region:
1088 results = results.filter_region(self.region)
1089 if self.type:
1090 results = results.filter_type(self.type)
1091 if self.date_min:
1092 results = results.filter_debut(min=self.date_min)
1093 if self.date_max:
1094 results = results.filter_debut(max=self.date_max)
4b89a7df 1095 if min_date:
a83b8efb 1096 results = results.filter_date_modification(min=min_date)
4b89a7df 1097 if max_date:
a83b8efb 1098 results = results.filter_date_modification(max=max_date)
fdcf5874
EMS
1099 return results.all()
1100
1101 def url(self):
1102 qs = self.query_string()
1103 return reverse('agenda') + ('?' + qs if qs else '')
1104
da5bf7e9
EMS
1105 def rss_url(self):
1106 qs = self.query_string()
1107 return reverse('rss_agenda') + ('?' + qs if qs else '')
4b89a7df
EMS
1108
1109 def get_email_alert_content(self, results):
1110 content = ''
1111 for evenement in results:
1112 content += u'- [%s](%s%s) \n' % (evenement.titre,
1113 SITE_ROOT_URL,
1114 evenement.get_absolute_url())
a83b8efb 1115 content += u' où ? : %s \n' % evenement.lieu
4b89a7df
EMS
1116 content += evenement.debut.strftime(u' quand ? : %d/%m/%Y %H:%M \n')
1117 content += u' durée ? : %s\n\n' % evenement.duration_display()
1118 content += u' quoi ? : '
1119 content += '\n '.join(textwrap.wrap(evenement.description))
1120 content += '\n\n'
1121 return content