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