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