2725bbedd7d464c201182b0c796f3392345fa6d2
[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 uuid
10 import vobject
11 from babel.dates import get_timezone_name
12 from django.contrib.auth.models import User
13 from django.db import models
14 from django.db.models import Q, Max
15 from django.db.models.signals import pre_delete
16 from auf_savoirs_en_partage.backend_config import RESOURCES
17 from savoirs.globals import META
18 from settings import CALENDRIER_URL, SITE_ROOT_URL
19 from datamaster_modeles.models import Thematique, Pays, Region
20 from lib.calendrier import combine
21 from caldav.lib import error
22
23 class RandomQuerySetMixin(object):
24 """Mixin pour les modèles.
25
26 ORDER BY RAND() est très lent sous MySQL. On a besoin d'une autre
27 méthode pour récupérer des objets au hasard.
28 """
29
30 def random(self, n=1):
31 """Récupère aléatoirement un nombre donné d'objets."""
32 count = self.count()
33 positions = random.sample(xrange(count), min(n, count))
34 return [self[p] for p in positions]
35
36 class Discipline(models.Model):
37 id = models.IntegerField(primary_key=True, db_column='id_discipline')
38 nom = models.CharField(max_length=765, db_column='nom_discipline')
39
40 def __unicode__ (self):
41 return self.nom
42
43 class Meta:
44 db_table = u'discipline'
45 ordering = ["nom",]
46
47 class SourceActualite(models.Model):
48 nom = models.CharField(max_length=255)
49 url = models.CharField(max_length=255, verbose_name='URL')
50
51 def __unicode__(self,):
52 return u"%s" % self.nom
53
54 class Meta:
55 verbose_name = u'fil RSS syndiqué'
56 verbose_name_plural = u'fils RSS syndiqués'
57
58 def update(self):
59 """Mise à jour du fil RSS."""
60 feed = feedparser.parse(self.url)
61 for entry in feed.entries:
62 if Actualite.all_objects.filter(url=entry.link).count() == 0:
63 ts = entry.updated_parsed
64 date = datetime.date(ts.tm_year, ts.tm_mon, ts.tm_mday)
65 a = self.actualites.create(titre=entry.title,
66 texte=entry.summary_detail.value,
67 url=entry.link, date=date)
68
69 class ActualiteManager(models.Manager):
70
71 def get_query_set(self):
72 return ActualiteQuerySet(self.model)
73
74 def search(self, text):
75 return self.get_query_set().search(text)
76
77 def filter_region(self, region):
78 return self.get_query_set().filter_region(region)
79
80 def filter_discipline(self, discipline):
81 return self.get_query_set().filter_discipline(discipline)
82
83 class ActualiteQuerySet(models.query.QuerySet, RandomQuerySetMixin):
84
85 def search(self, text):
86 q = None
87 for word in text.split():
88 part = (Q(titre__icontains=word) | Q(texte__icontains=word) |
89 Q(regions__nom__icontains=word) | Q(disciplines__nom__icontains=word))
90 if q is None:
91 q = part
92 else:
93 q = q & part
94 return self.filter(q).distinct() if q is not None else self
95
96 def filter_discipline(self, discipline):
97 """Ne conserve que les actualités dans la discipline donnée.
98
99 Si ``disicipline`` est None, ce filtre n'a aucun effet."""
100 if discipline is None:
101 return self
102 if not isinstance(discipline, Discipline):
103 discipline = Discipline.objects.get(pk=discipline)
104 return self.filter(Q(disciplines=discipline) |
105 Q(titre__icontains=discipline.nom) |
106 Q(texte__icontains=discipline.nom)).distinct()
107
108 def filter_region(self, region):
109 """Ne conserve que les actualités dans la région donnée.
110
111 Si ``region`` est None, ce filtre n'a aucun effet."""
112 if region is None:
113 return self
114 if not isinstance(region, Region):
115 region = Region.objects.get(pk=region)
116 return self.filter(Q(regions=region) |
117 Q(titre__icontains=region.nom) |
118 Q(texte__icontains=region.nom)).distinct()
119
120 class Actualite(models.Model):
121 id = models.AutoField(primary_key=True, db_column='id_actualite')
122 titre = models.CharField(max_length=765, db_column='titre_actualite')
123 texte = models.TextField(db_column='texte_actualite')
124 url = models.CharField(max_length=765, db_column='url_actualite')
125 date = models.DateField(db_column='date_actualite')
126 visible = models.BooleanField(db_column='visible_actualite', default=False)
127 ancienid = models.IntegerField(db_column='ancienId_actualite', blank=True, null=True)
128 source = models.ForeignKey(SourceActualite, blank=True, null=True)
129 disciplines = models.ManyToManyField(Discipline, blank=True, related_name="actualites")
130 regions = models.ManyToManyField(Region, blank=True, related_name="actualites", verbose_name='régions')
131
132 objects = ActualiteManager()
133
134 class Meta:
135 db_table = u'actualite'
136 ordering = ["-date"]
137
138 def __unicode__ (self):
139 return "%s" % (self.titre)
140
141 def assigner_disciplines(self, disciplines):
142 self.disciplines.add(*disciplines)
143
144 def assigner_regions(self, regions):
145 self.regions.add(*regions)
146
147 class EvenementManager(models.Manager):
148
149 def get_query_set(self):
150 return EvenementQuerySet(self.model)
151
152 def search(self, text):
153 return self.get_query_set().search(text)
154
155 def filter_region(self, region):
156 return self.get_query_set().filter_region(region)
157
158 def filter_discipline(self, discipline):
159 return self.get_query_set().filter_discipline(discipline)
160
161 class EvenementQuerySet(models.query.QuerySet, RandomQuerySetMixin):
162
163 def search(self, text):
164 q = None
165 for word in text.split():
166 part = (Q(titre__icontains=word) |
167 Q(mots_cles__icontains=word) |
168 Q(discipline__nom__icontains=word) |
169 Q(discipline_secondaire__nom__icontains=word) |
170 Q(type__icontains=word) |
171 Q(lieu__icontains=word) |
172 Q(description__icontains=word) |
173 Q(contact__icontains=word) |
174 Q(regions__nom__icontains=word))
175 if q is None:
176 q = part
177 else:
178 q = q & part
179 return self.filter(q).distinct() if q is not None else self
180
181 def search_titre(self, text):
182 qs = self
183 for word in text.split():
184 qs = qs.filter(titre__icontains=word)
185 return qs
186
187 def filter_discipline(self, discipline):
188 """Ne conserve que les évènements dans la discipline donnée.
189
190 Si ``disicipline`` est None, ce filtre n'a aucun effet."""
191 if discipline is None:
192 return self
193 if not isinstance(discipline, Discipline):
194 discipline = Discipline.objects.get(pk=discipline)
195 return self.filter(Q(discipline=discipline) |
196 Q(discipline_secondaire=discipline) |
197 Q(titre__icontains=discipline.nom) |
198 Q(mots_cles__icontains=discipline.nom) |
199 Q(description__icontains=discipline.nom))
200
201 def filter_region(self, region):
202 """Ne conserve que les évènements dans la région donnée.
203
204 Si ``region`` est None, ce filtre n'a aucun effet."""
205 if region is None:
206 return self
207 if not isinstance(region, Region):
208 region = Region.objects.get(pk=region)
209 return self.filter(Q(regions=region) |
210 Q(titre__icontains=region.nom) |
211 Q(mots_cles__icontains=region.nom) |
212 Q(description__icontains=region.nom) |
213 Q(lieu__icontains=region.nom)).distinct()
214
215 def build_time_zone_choices():
216 fr_names = set()
217 tzones = []
218 now = datetime.datetime.now()
219 for tzname in pytz.common_timezones:
220 tz = pytz.timezone(tzname)
221 fr_name = get_timezone_name(tz, locale='fr_FR')
222 if fr_name in fr_names:
223 continue
224 fr_names.add(fr_name)
225 offset = tz.utcoffset(now)
226 seconds = offset.seconds + offset.days * 86400
227 (hours, minutes) = divmod(seconds // 60, 60)
228 offset_str = 'UTC%+d:%d' % (hours, minutes) if minutes else 'UTC%+d' % hours
229 tzones.append((seconds, tzname, '%s - %s' % (offset_str, fr_name)))
230 tzones.sort()
231 return [(tz[1], tz[2]) for tz in tzones]
232
233 class Evenement(models.Model):
234 TYPE_CHOICES = ((u'Colloque', u'Colloque'),
235 (u'Conférence', u'Conférence'),
236 (u'Appel à contribution', u'Appel à contribution'),
237 (u'Journée d\'étude', u'Journée d\'étude'),
238 (None, u'Autre'))
239 TIME_ZONE_CHOICES = build_time_zone_choices()
240
241 uid = models.CharField(max_length=255, default=str(uuid.uuid1()))
242 approuve = models.BooleanField(default=False, verbose_name=u'approuvé')
243 titre = models.CharField(max_length=255)
244 discipline = models.ForeignKey('Discipline', related_name = "discipline",
245 blank = True, null = True)
246 discipline_secondaire = models.ForeignKey('Discipline', related_name="discipline_secondaire",
247 verbose_name=u"discipline secondaire",
248 blank=True, null=True)
249 mots_cles = models.TextField('Mots-Clés', blank=True, null=True)
250 type = models.CharField(max_length=255, choices=TYPE_CHOICES)
251 lieu = models.TextField()
252 debut = models.DateTimeField(default=datetime.datetime.now)
253 fin = models.DateTimeField(default=datetime.datetime.now)
254 fuseau = models.CharField(max_length=100, choices=TIME_ZONE_CHOICES, verbose_name='fuseau horaire')
255 description = models.TextField(blank=True, null=True)
256 contact = models.TextField(blank=True, null=True)
257 url = models.CharField(max_length=255, blank=True, null=True)
258 piece_jointe = models.FileField(upload_to='agenda/pj', blank=True, verbose_name='pièce jointe')
259 regions = models.ManyToManyField(Region, blank=True, related_name="evenements", verbose_name='régions')
260
261 objects = EvenementManager()
262
263 class Meta:
264 ordering = ['-debut']
265
266 def __unicode__(self,):
267 return "[%s] %s" % (self.uid, self.titre)
268
269 def piece_jointe_display(self):
270 return self.piece_jointe and os.path.basename(self.piece_jointe.name)
271
272 def clean(self):
273 from django.core.exceptions import ValidationError
274 if self.debut > self.fin:
275 raise ValidationError('La date de fin ne doit pas être antérieure à la date de début')
276
277 def save(self, *args, **kwargs):
278 """Sauvegarde l'objet dans django et le synchronise avec caldav s'il a été
279 approuvé"""
280 self.clean()
281 self.update_vevent()
282 super(Evenement, self).save(*args, **kwargs)
283
284 # methodes de commnunications avec CALDAV
285 def as_ical(self,):
286 """Retourne l'evenement django sous forme d'objet icalendar"""
287 cal = vobject.iCalendar()
288 cal.add('vevent')
289
290 # fournit son propre uid
291 if self.uid in [None, ""]:
292 self.uid = str(uuid.uuid1())
293
294 cal.vevent.add('uid').value = self.uid
295
296 cal.vevent.add('summary').value = self.titre
297
298 if self.mots_cles is None:
299 kw = []
300 else:
301 kw = self.mots_cles.split(",")
302
303 try:
304 kw.append(self.discipline.nom)
305 kw.append(self.discipline_secondaire.nom)
306 kw.append(self.type)
307 except: pass
308
309 kw = [x.strip() for x in kw if len(x.strip()) > 0 and x is not None]
310 for k in kw:
311 cal.vevent.add('x-auf-keywords').value = k
312
313 description = self.description
314 if len(kw) > 0:
315 if len(self.description) > 0:
316 description += "\n"
317 description += u"Mots-clés: " + ", ".join(kw)
318
319 cal.vevent.add('dtstart').value = combine(self.debut, pytz.timezone(self.fuseau))
320 cal.vevent.add('dtend').value = combine(self.fin, pytz.timezone(self.fuseau))
321 cal.vevent.add('created').value = combine(datetime.datetime.now(), "UTC")
322 cal.vevent.add('dtstamp').value = combine(datetime.datetime.now(), "UTC")
323 if len(description) > 0:
324 cal.vevent.add('description').value = description
325 if len(self.contact) > 0:
326 cal.vevent.add('contact').value = self.contact
327 if len(self.url) > 0:
328 cal.vevent.add('url').value = self.url
329 if len(self.lieu) > 0:
330 cal.vevent.add('location').value = self.lieu
331 if self.piece_jointe:
332 url = self.piece_jointe.url
333 if not url.startswith('http://'):
334 url = SITE_ROOT_URL + url
335 cal.vevent.add('attach').value = url
336 return cal
337
338 def update_vevent(self,):
339 """Essaie de créer l'évènement sur le serveur ical.
340 En cas de succès, l'évènement local devient donc inactif et approuvé"""
341 try:
342 if self.approuve:
343 event = self.as_ical()
344 client = caldav.DAVClient(CALENDRIER_URL)
345 cal = caldav.Calendar(client, url = CALENDRIER_URL)
346 e = caldav.Event(client, parent = cal, data = event.serialize(), id=self.uid)
347 e.save()
348 except:
349 self.approuve = False
350
351 def delete_vevent(self,):
352 """Supprime l'evenement sur le serveur caldav"""
353 try:
354 if self.approuve:
355 event = self.as_ical()
356 client = caldav.DAVClient(CALENDRIER_URL)
357 cal = caldav.Calendar(client, url = CALENDRIER_URL)
358 e = cal.event(self.uid)
359 e.delete()
360 except error.NotFoundError:
361 pass
362
363 def assigner_regions(self, regions):
364 self.regions.add(*regions)
365
366 def assigner_disciplines(self, disciplines):
367 if len(disciplines) == 1:
368 if self.discipline:
369 self.discipline_secondaire = disciplines[0]
370 else:
371 self.discipline = disciplines[0]
372 elif len(disciplines) >= 2:
373 self.discipline = disciplines[0]
374 self.discipline_secondaire = disciplines[1]
375
376
377 # Surcharge du comportement de suppression
378 # La méthode de connexion par signals est préférable à surcharger la méthode delete()
379 # car dans le cas de la suppression par lots, cell-ci n'est pas invoquée
380 def delete_vevent(sender, instance, *args, **kwargs):
381 instance.delete_vevent()
382
383 pre_delete.connect(delete_vevent, sender = Evenement)
384
385
386 class ListSet(models.Model):
387 spec = models.CharField(primary_key = True, max_length = 255)
388 name = models.CharField(max_length = 255)
389 server = models.CharField(max_length = 255)
390 validated = models.BooleanField(default = True)
391
392 def __unicode__(self,):
393 return self.name
394
395 class RecordManager(models.Manager):
396
397 def get_query_set(self):
398 return RecordQuerySet(self.model)
399
400 def search(self, text):
401 return self.get_query_set().search(text)
402
403 def validated(self):
404 return self.get_query_set().validated()
405
406 def filter_region(self, region):
407 return self.get_query_set().filter_region(region)
408
409 def filter_discipline(self, discipline):
410 return self.get_query_set().filter_discipline(discipline)
411
412 class RecordQuerySet(models.query.QuerySet, RandomQuerySetMixin):
413
414 def search(self, text):
415 qs = self
416 words = text.split()
417
418 # Ne garder que les ressources qui contiennent tous les mots
419 # demandés.
420 q = None
421 for word in words:
422 matching_pays = list(Pays.objects.filter(Q(nom__icontains=word) | Q(region__nom__icontains=word)).values_list('pk', flat=True))
423 part = (Q(title__icontains=word) | Q(description__icontains=word) |
424 Q(creator__icontains=word) | Q(contributor__icontains=word) |
425 Q(subject__icontains=word) | Q(disciplines__nom__icontains=word) |
426 Q(regions__nom__icontains=word) | Q(pays__in=matching_pays) |
427 Q(publisher__icontains=word))
428 if q is None:
429 q = part
430 else:
431 q = q & part
432 if q is not None:
433 qs = qs.filter(q).distinct()
434
435 # On donne un point pour chaque mot présent dans le titre.
436 if words:
437 score_expr = ' + '.join(['(title LIKE %s)'] * len(words))
438 score_params = ['%' + word + '%' for word in words]
439 qs = qs.extra(
440 select={'score': score_expr},
441 select_params=score_params
442 ).order_by('-score')
443 return qs
444
445 def search_auteur(self, text):
446 qs = self
447 for word in text.split():
448 qs = qs.filter(Q(creator__icontains=word) | Q(contributor__icontains=word))
449 return qs
450
451 def search_sujet(self, text):
452 qs = self
453 for word in text.split():
454 qs = qs.filter(subject__icontains=word)
455 return qs
456
457 def search_titre(self, text):
458 qs = self
459 for word in text.split():
460 qs = qs.filter(title__icontains=word)
461 return qs
462
463 def filter_discipline(self, discipline):
464 """Ne conserve que les ressources dans la discipline donnée.
465
466 Si ``disicipline`` est None, ce filtre n'a aucun effet."""
467 if discipline is None:
468 return self
469 if not isinstance(discipline, Discipline):
470 discipline = Discipline.objects.get(pk=discipline)
471 return self.filter(Q(disciplines=discipline) |
472 Q(title__icontains=discipline.nom) |
473 Q(description__icontains=discipline.nom) |
474 Q(subject__icontains=discipline.nom)).distinct()
475
476 def filter_region(self, region):
477 """Ne conserve que les ressources dans la région donnée.
478
479 Si ``region`` est None, ce filtre n'a aucun effet."""
480 if region is None:
481 return self
482 if not isinstance(region, Region):
483 region = Region.objects.get(pk=region)
484 return self.filter(Q(pays__region=region) |
485 Q(regions=region) |
486 Q(title__icontains=region.nom) |
487 Q(description__icontains=region.nom) |
488 Q(subject__icontains=region.nom)).distinct()
489
490 def validated(self):
491 """Ne garder que les ressources validées et qui sont soit dans aucun
492 listset ou au moins dans un listset validé."""
493 qs = self.filter(validated=True)
494 qs = qs.filter(Q(listsets__isnull=True) | Q(listsets__validated=True))
495 return qs.distinct()
496
497 def filter(self, *args, **kwargs):
498 """Gère des filtres supplémentaires pour l'admin.
499
500 C'est la seule façon que j'ai trouvée de contourner les mécanismes
501 de recherche de l'admin."""
502 search = kwargs.pop('admin_search', None)
503 search_titre = kwargs.pop('admin_search_titre', None)
504 search_sujet = kwargs.pop('admin_search_sujet', None)
505 search_description = kwargs.pop('admin_search_description', None)
506 search_auteur = kwargs.pop('admin_search_auteur', None)
507
508 if search:
509 qs = self
510 search_all = not (search_titre or search_description or search_sujet or search_auteur)
511 fields = []
512 if search_titre or search_all:
513 fields += ['title', 'alt_title']
514 if search_description or search_all:
515 fields += ['description', 'abstract']
516 if search_sujet or search_all:
517 fields += ['subject']
518 if search_auteur or search_all:
519 fields += ['creator', 'contributor']
520
521 for bit in search.split():
522 or_queries = [Q(**{field + '__icontains': bit}) for field in fields]
523 qs = qs.filter(reduce(operator.or_, or_queries))
524
525 if args or kwargs:
526 qs = super(RecordQuerySet, qs).filter(*args, **kwargs)
527 return qs
528 else:
529 return super(RecordQuerySet, self).filter(*args, **kwargs)
530
531 class Record(models.Model):
532
533 #fonctionnement interne
534 id = models.AutoField(primary_key = True)
535 server = models.CharField(max_length = 255, verbose_name=u'serveur')
536 last_update = models.CharField(max_length = 255)
537 last_checksum = models.CharField(max_length = 255)
538 validated = models.BooleanField(default=True, verbose_name=u'validé')
539
540 #OAI
541 title = models.TextField(null=True, blank=True, verbose_name=u'titre')
542 creator = models.TextField(null=True, blank=True, verbose_name=u'auteur')
543 description = models.TextField(null=True, blank=True)
544 modified = models.CharField(max_length=255, null=True, blank=True)
545 identifier = models.CharField(max_length = 255, null = True, blank = True, unique = True)
546 uri = models.CharField(max_length = 255, null = True, blank = True, unique = True)
547 source = models.TextField(null = True, blank = True)
548 contributor = models.TextField(null = True, blank = True)
549 subject = models.TextField(null=True, blank=True, verbose_name='sujet')
550 publisher = models.TextField(null = True, blank = True)
551 type = models.TextField(null = True, blank = True)
552 format = models.TextField(null = True, blank = True)
553 language = models.TextField(null = True, blank = True)
554
555 listsets = models.ManyToManyField(ListSet, null = True, blank = True)
556
557 #SEP 2 (aucune données récoltées)
558 alt_title = models.TextField(null = True, blank = True)
559 abstract = models.TextField(null = True, blank = True)
560 creation = models.CharField(max_length = 255, null = True, blank = True)
561 issued = models.CharField(max_length = 255, null = True, blank = True)
562 isbn = models.TextField(null = True, blank = True)
563 orig_lang = models.TextField(null = True, blank = True)
564
565 # Metadata AUF multivaluées
566 disciplines = models.ManyToManyField(Discipline, blank=True)
567 thematiques = models.ManyToManyField(Thematique, blank=True, verbose_name='thématiques')
568 pays = models.ManyToManyField(Pays, blank=True)
569 regions = models.ManyToManyField(Region, blank=True, verbose_name='régions')
570
571 # Manager
572 objects = RecordManager()
573
574 class Meta:
575 verbose_name = 'ressource'
576
577 def __unicode__(self):
578 return "[%s] %s" % (self.server, self.title)
579
580 def getServeurURL(self):
581 """Retourne l'URL du serveur de provenance"""
582 return RESOURCES[self.server]['url']
583
584 def est_complet(self):
585 """teste si le record à toutes les données obligatoires"""
586 return self.disciplines.count() > 0 and \
587 self.thematiques.count() > 0 and \
588 self.pays.count() > 0 and \
589 self.regions.count() > 0
590
591 def assigner_regions(self, regions):
592 self.regions.add(*regions)
593
594 def assigner_disciplines(self, disciplines):
595 self.disciplines.add(*disciplines)
596
597 class Serveur(models.Model):
598 """Identification d'un serveur d'ou proviennent les références"""
599 nom = models.CharField(primary_key = True, max_length = 255)
600
601 def __unicode__(self,):
602 return self.nom
603
604 def conf_2_db(self,):
605 for k in RESOURCES.keys():
606 s, created = Serveur.objects.get_or_create(nom=k)
607 s.nom = k
608 s.save()
609
610 class Profile(models.Model):
611 user = models.ForeignKey(User, unique=True)
612 serveurs = models.ManyToManyField(Serveur, null = True, blank = True)
613
614 class HarvestLog(models.Model):
615 context = models.CharField(max_length = 255)
616 name = models.CharField(max_length = 255)
617 date = models.DateTimeField(auto_now = True)
618 added = models.IntegerField(null = True, blank = True)
619 updated = models.IntegerField(null = True, blank = True)
620 processed = models.IntegerField(null = True, blank = True)
621 record = models.ForeignKey(Record, null = True, blank = True)
622
623 @staticmethod
624 def add(message):
625 logger = HarvestLog()
626 if message.has_key('record_id'):
627 message['record'] = Record.all_objects.get(id=message['record_id'])
628 del(message['record_id'])
629
630 for k,v in message.items():
631 setattr(logger, k, v)
632 logger.save()