diff options
-rw-r--r-- | README.md | 74 | ||||
-rw-r--r-- | __init__.py | 3 | ||||
-rw-r--r-- | admin.py | 4 | ||||
-rw-r--r-- | feeds.py | 17 | ||||
-rw-r--r-- | management/commands/_private.py | 73 | ||||
-rw-r--r-- | management/commands/timetables.py | 78 | ||||
-rw-r--r-- | models.py | 70 | ||||
-rw-r--r-- | templates/calendars.html | 13 | ||||
-rw-r--r-- | templates/contact.html | 9 | ||||
-rw-r--r-- | templates/flatpages/about.html | 6 | ||||
-rw-r--r-- | templates/flatpages/default.html | 8 | ||||
-rw-r--r-- | templates/index.html | 7 | ||||
-rw-r--r-- | templates/timetable.html | 11 | ||||
-rw-r--r-- | templates/timetable_common.html | 5 | ||||
-rw-r--r-- | templatetags/email.py | 24 | ||||
-rw-r--r-- | tests.py | 17 | ||||
-rw-r--r-- | urls.py | 6 | ||||
-rw-r--r-- | utils.py | 46 | ||||
-rw-r--r-- | views.py | 41 |
19 files changed, 286 insertions, 226 deletions
@@ -1,29 +1,29 @@ # celcatsanitizer celcatsanitizer est un système qui permet de récupérer des emplois du temps Celcat au format XML pour les afficher correctement. -## Pourquoi ? +## Pourquoi ? Parce que les emplois du temps Celcat sont peu lisibles et peuvent facilement faire planter un navigateur, à cause du surplus d’informations affichées. -## Comment faire tourner celcatsanitizer chez moi ? -celcatsanitizer est écrit en Python 3. Il dépend des bibliothèques suivantes : +## Comment faire tourner celcatsanitizer chez moi ? +celcatsanitizer est écrit en Python 3. Il dépend des bibliothèques suivantes : * Django 1.11 * requests * BeautifulSoup4 * icalendar -Pour installer celcatsanitizer, il est possible d'utiliser git. +Pour installer celcatsanitizer, il est possible d’utiliser git. -Pour tester celcatsanitizer, il est recommandé d'utiliser SQLite ou PostgreSQL. +Pour tester celcatsanitizer, il est recommandé d’utiliser SQLite ou PostgreSQL. -Pour la production, il est recommandé d'utiliser PostgreSQL (avec le driver psycopg2) et de mettre le tout dans un environnement virtuel. +Pour la production, il est recommandé d’utiliser PostgreSQL (avec le driver psycopg2) et de mettre le tout dans un environnement virtuel. -Aucun autre SGBD n'a été testé, mais depuis la version 0.8.0, celcatsanitizer n'utilise plus de fonctions SQL brutes spécifiques. Tous les SGBD supportés par Django devraient fonctionner sans poser de problèmes. +Aucun autre SGBD n’a été testé, mais depuis la version 0.8.0, celcatsanitizer n’utilise plus de fonctions SQL brutes spécifiques. Tous les SGBD supportés par Django devraient fonctionner sans poser de problèmes. ### Installation -Il est préférable d'utiliser un environnement virtuel, mais ce n'est pas obligatoire. Si vous ne souhaitez pas utiliser un environnement virtuel, passez directement à l'installation des dépendances. +Il est préférable d’utiliser un environnement virtuel, mais ce n’est pas obligatoire. Si vous ne souhaitez pas utiliser un environnement virtuel, passez directement à l’installation des dépendances. -#### Création de l'environnement virtuel -Déplacez-vous dans le répertoire souhaité, installez l'environnement virtuel, et activez-le : +#### Création de l’environnement virtuel +Déplacez-vous dans le répertoire souhaité, installez l’environnement virtuel, et activez-le : > $ virtualenv -p python3 celcatsanitizer @@ -31,21 +31,19 @@ Déplacez-vous dans le répertoire souhaité, installez l'environnement virtuel, > $ source bin/activate -Il est possible que votre version de pip soit ancienne. Si vous le souhaitez, mettez ce programme à jour : +Il est possible que votre version de pip soit ancienne. Si vous le souhaitez, mettez ce programme à jour : > $ pip install --upgrade pip -Notez que cette étape n'est pas obligatoire - #### Installation des dépendances > $ pip install requests django beautifulsoup4 icalendar -Si vous utilisez PostgreSQL, vous allez avoir besoin du driver psycopg2 : +Si vous utilisez PostgreSQL, vous allez avoir besoin du driver psycopg2 : > $ pip install psycopg2 -SQLite n'a pas besoin de driver. +SQLite n’a pas besoin de driver. #### Création du répertoire Django @@ -57,7 +55,7 @@ SQLite n'a pas besoin de driver. > $ git clone https://git.pa1ch.fr/alban/celcatsanitizer.git edt -Pour la production, il est recommandé d'utiliser une version stable, accessibles à travers les tags git. +Pour la production, il est recommandé d’utiliser une version stable, accessibles à travers les tags git. #### Configuration de Django Dans le fichier celcatsanitizer/settings.py, vous devrez renseigner plusieurs informations. @@ -67,8 +65,8 @@ Dans le fichier celcatsanitizer/settings.py, vous devrez renseigner plusieurs in Cette variable est obligatoire. -##### Configuration de l'internationalisation -Ce passage n'est pas obligatoire. [Vous pouvez retrouver la documentation de l'internationalisation sur le site de Django.](https://docs.djangoproject.com/fr/1.11/topics/i18n/) +##### Configuration de l’internationalisation +Ce passage n’est pas obligatoire. [Vous pouvez retrouver la documentation de l’internationalisation sur le site de Django.](https://docs.djangoproject.com/fr/1.11/topics/i18n/) ##### Configuration de la base de données [Vous pouvez retrouver la documentation de la base de données sur le site de Django.](https://docs.djangoproject.com/fr/1.11/ref/settings/#databases) @@ -76,30 +74,54 @@ Ce passage n'est pas obligatoire. [Vous pouvez retrouver la documentation de l'i ##### Configuration du mode de Django Si jamais vous utiliser Django en production, vous **devez** mettre la variable DEBUG à False. -##### Configuration personnalisée nécessaire à celcatsanitizer -celcatsanitizer a besoin d'une variable DEFAULT_DOMAIN qui contient l'URL de base de l'instance. - ##### Ajout de celcatsanitizer dans la liste des applications Django -Ajoutez la chaine de caractère "edt" à la fin de la liste INSTALLED_APPS +Ajoutez la chaine de caractère « edt » à la fin de la liste INSTALLED_APPS. + +##### Configuration des flatpages +celcatsanitizer utilise les flatpages pour rendre les pages « contact » et « à propos ». Vous pouvez retrouver le guide d’installation sur [le site de Django](https://docs.djangoproject.com/fr/1.11/ref/contrib/flatpages/#installation). Effectuez uniquement les deux premières étapes, celcatsanitizer enregistre déjà une route pour les pages statiques, et la commande de l’étape 4 sera effectuée plus loin. + +##### Ajout du processeur de contexte de celcatsanitizer +Cette étape est fortement recommandée, mais pas obligatoire. + +Rajoutez la chaine de caractères 'edt.views.ctx_processor' à la liste 'context_processors' dans la variable « TEMPLATES ». ##### Ajout des URLs de celcatsanitizer Dans le fichier celcatsanitizer/urls.py, importez la fonction django.conf.urls.include, et ajoutez url(r'^', include("edt.urls")) à la **fin** de la liste urlspatterns. ##### Génération de la base de données -Vous avez besoin de générer les migrations de celcatsanitizer, puis appliquez-les : +Générer les migrations de celcatsanitizer, puis appliquez-les : > $ ./manage.py makemigrations edt > $ ./manage.py migrate ##### Gestion des fichiers statiques -Si vous êtes en production, vous devez renseigner l'emplacement de vos fichiers statiques dans la variable [STATIC_ROOT](https://docs.djangoproject.com/fr/1.11/ref/settings/#std:setting-STATIC_ROOT) de la configuration de Django, puis exécuter la commande suivante : +Si vous êtes en production, vous devez renseigner l’emplacement de vos fichiers statiques dans la variable [STATIC_ROOT](https://docs.djangoproject.com/fr/1.11/ref/settings/#std:setting-STATIC_ROOT) de la configuration de Django, puis exécuter la commande suivante : > $ ./manage.py collectstatic +Cette étape est inutile si vous êtes en mode de déboguage. + ### Lancement de celcatsanitizer -Si vous êtes en mode de débuggage, lancez le serveur de cette manière : +Si vous êtes en mode de déboguage, lancez le serveur de cette manière : > $ ./manage.py runserver -Si vous êtes en production, il n'est pas recommandé d'utiliser ce serveur. Exécutez Django avec le module mod_wsgi d'Apache, ou avec un serveur [gunicorn](http://gunicorn.org/) derrière nginx. +Si vous êtes en production, il n’est pas recommandé d’utiliser ce serveur. Exécutez Django avec le module mod_wsgi d’Apache, ou avec un serveur [gunicorn](http://gunicorn.org/) derrière nginx. + +### Configuration de celcatsanitizer +#### Administrateur +Pour avoir accès à l’interface d’administration, vous devez créer un utilisateur avec les droits administrateur. Pour cela, exécutez la commande suivante : + +> $ ./manage.py createsuperuser + +Renseignez ensuite votre nom d’utilisateur, mot de passe et adresse email au fur et à mesure. + +#### Pages statiques +Comme indiqué plus haut, celcatsanitizer utilise l’application flatpages de Django. + +Si vous êtes en production, vous devez changer le site de base (« example.com ») par le site de celcatsanitizer. + +Vous devez ensuite rajouter les pages /a-propos/ et /contact/. + +Vous pouvez effectuer tout ça à partir de l’interface d’administration de Django. diff --git a/__init__.py b/__init__.py index dbcf830..6a6789b 100644 --- a/__init__.py +++ b/__init__.py @@ -13,4 +13,7 @@ # You should have received a copy of the GNU Affero General Public License # along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>. +VERSION = "0.11.0-pa1ch" +__version__ = VERSION + default_app_config = "edt.apps.EdtConfig" @@ -44,12 +44,12 @@ class TimetableAdmin(admin.ModelAdmin): class GroupAdmin(admin.ModelAdmin): fieldsets = ( (None, {"fields": ("name", "celcat_name", "timetable", "hidden",)}), - ("Groupes", {"fields": ("mention", "subgroup", "td", "tp", "parent",)}),) + ("Groupes", {"fields": ("mention", "subgroup",)}),) list_display = ("name", "timetable", "hidden",) list_editable = ("hidden",) list_filter = ("timetable",) ordering = ("timetable",) - readonly_fields = ("celcat_name", "mention", "subgroup", "td", "tp",) + readonly_fields = ("celcat_name", "mention",) actions = (make_hidden, make_visible,) @@ -29,7 +29,7 @@ from .templatetags.rooms import format_rooms from .utils import get_current_or_next_week, get_week, group_courses ICAL_NAMES = ["uid", "summary", "description", "location", - "start", "dtstart", "dtend", "dtstamp"] + "start", "dtstart", "dtend", "dtstamp", "categories"] class IcalFeedGenerator(SyndicationFeed): @@ -66,12 +66,20 @@ class IcalFeed(Feed): else: return group + def item_categories(self, item): + return (item.type,) + def item_description(self, item): return item.notes def item_link(self, item): return "" + def item_summary(self, item): + if item.type is not None: + return item.name + " (" + item.type + ")" + return item.name + def items(self, obj): return Course.objects.get_courses_for_group(obj) @@ -80,10 +88,15 @@ class IcalFeed(Feed): "dtstart": item.begin, "dtend": item.end, "dtstamp": item.last_update, - "summary": item.name + " (" + item.type + ")", + "summary": self.item_summary(item), "location": format_rooms(item.rooms.all())} +class IcalOnlyOneFeed(IcalFeed): + def items(self, obj): + return Course.objects.filter(groups=obj).order_by("begin") + + class RSSFeed(Feed): def get_object(self, request, year_slug, timetable_slug, group_slug): year, week = get_current_or_next_week() diff --git a/management/commands/_private.py b/management/commands/_private.py index 8f195a1..b663454 100644 --- a/management/commands/_private.py +++ b/management/commands/_private.py @@ -23,51 +23,16 @@ from edt.models import Group, Room, Course from edt.utils import get_week import requests +import edt def add_time(date, time): ptime = datetime.datetime.strptime(time, "%H:%M") delta = datetime.timedelta(hours=ptime.hour, minutes=ptime.minute) return date + delta -def consolidate_group(group): - group_content_key = ("mention", "subgroup", "td", "tp") - group_content_list = group.group_info[1:] - - if group.subgroup is not None: - group_content = dict(zip(group_content_key, group_content_list)) - - for i in range(len(group_content_list))[::-1]: - del group_content[group_content_key[i]] - group_content[group_content_key[i] + "__isnull"] = True - - if group_content_list[i] is not None: - break - - group.parent = Group.objects.filter(timetable=group.timetable, - **group_content).first() - group.save() - - if group.tp is None: - group_content = dict(zip(group_content_key, group_content_list)) - last_is_none = False - - for i, key in enumerate(group_content_key): - if group_content_list[i] is None or last_is_none: - del group_content[key] - group_content[key + "__isnull"] = last_is_none - last_is_none = True - - Group.objects.filter(timetable=group.timetable, parent__isnull=True, - **group_content).update(parent=group) - -def consolidate_groups(groups): - for group in groups: - if group.parent is None: - consolidate_group(group) - -def delete_courses_in_week(timetable, year, week): +def delete_courses_in_week(timetable, year, week, today): start, end = get_week(year, week) - Course.objects.filter(begin__gte=start, begin__lt=end, + Course.objects.filter(begin__gte=max(start, today), begin__lt=end, timetable=timetable).delete() def get_from_db_or_create(cls, **kwargs): @@ -80,7 +45,7 @@ def get_from_db_or_create(cls, **kwargs): return obj -def get_event(timetable, event, event_week): +def get_event(timetable, event, event_week, today): """Renvoie une classe Course à partir d’un événement récupéré par BS4""" # On récupère la date de l’évènement à partir de la semaine # et de la semaine référencée, puis l’heure de début et de fin @@ -88,31 +53,34 @@ def get_event(timetable, event, event_week): begin = add_time(date, event.starttime.text) end = add_time(date, event.endtime.text) + # On ne traite pas le cours si il commence après le moment du traitement + if begin < today: + return + # Création de l’objet cours course = Course.objects.create(timetable=timetable, begin=begin, end=end) - # On récupère les groupes concernés par les cours, on les - # « consolide », puis on les insère dans l’objet cours. + # On récupère les groupes concernés par les cours groups = [get_from_db_or_create(Group, timetable=timetable, celcat_name=item.text) for item in event.resources.group.find_all("item")] - consolidate_groups(groups) course.groups.add(*groups) # On récupère le champ « remarque » if event.notes is not None: course.notes = event.notes.text - # On récupère le nom du cours + # On récupère le champ « nom » if event.resources.module is not None: course.name = event.resources.module.item.text - else: + elif event.category is not None: # Il est possible qu’un cours n’ait pas de nom. Oui oui. # Qui sont les concepteurs de ce système ? Quels sont leurs # réseaux ? - # Bref, dans ce cas, on déplace le champ « remarque » de - # l’objet dans le champ « nom ». - course.name, course.notes = course.notes, None + # Bref, dans ce cas, si le cours a un type, il devient son nom. + course.type = event.category.text + # Si il n’a pas de type (mais je ne pense pas que ça soit possible…), + # il obtiendra une valeur par défaut définie à l’avance. # Récupération du type de cours if event.category is not None: @@ -128,7 +96,7 @@ def get_event(timetable, event, event_week): return course -def get_events(timetable, soup, weeks_in_soup, year=None, week=None): +def get_events(timetable, soup, weeks_in_soup, today, year=None, week=None): """Récupère tous les cours disponibles dans l’emploi du temps Celcat. Le traîtement se limitera à la semaine indiquée si il y en a une.""" for event in soup.find_all("event"): @@ -142,7 +110,11 @@ def get_events(timetable, soup, weeks_in_soup, year=None, week=None): year is None or week is None) and \ event.resources.group is not None and \ event.starttime is not None and event.endtime is not None: - yield get_event(timetable, event, event_week) + course = get_event(timetable, event, event_week, today) + + # On renvoie le cours si il n’est pas nul + if course is not None: + yield course def get_update_date(soup): # Explication de la regex @@ -187,7 +159,8 @@ def get_weeks(soup): return weeks def get_xml(url): - req = requests.get(url) + user_agent = "celcatsanitizer/" + edt.VERSION + req = requests.get(url, headers={"User-Agent": user_agent}) req.encoding = "utf8" soup = BeautifulSoup(req.content, "html.parser") diff --git a/management/commands/timetables.py b/management/commands/timetables.py index 76f0a7c..ff00c8f 100644 --- a/management/commands/timetables.py +++ b/management/commands/timetables.py @@ -14,42 +14,80 @@ # along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>. import datetime +import traceback from django.core.management.base import BaseCommand from django.db import transaction from django.db.models import Min -from django.utils import timezone from edt.models import Course, Timetable -from edt.utils import get_week +from edt.utils import get_week, tz_now from ._private import delete_courses_in_week, get_events, get_update_date, get_weeks, get_xml @transaction.atomic def process_timetable_week(timetable, soup, weeks_in_soup, force, year=None, week=None): - criteria = {} if year is not None and week is not None: begin, end = get_week(year, week) - criteria["begin__gte"] = begin - criteria["begin__lt"] = end - last_update_date = Course.objects.filter(timetable=timetable, **criteria) \ - .aggregate(Min("last_update")) \ - ["last_update__min"] + # Si on force la mise à jour, on définit de moment + # de la mise à jour au début de la semaine + if force and year is not None and week is not None: + today = begin + elif force: + # Si la mise à jour est faite sur tout l’emploi du temps, + # alors la date de début est indéfinie. + today = None + else: + today = tz_now() + + # On récupère la mise à jour la plus ancienne dans les cours de l’emploi du temps + last_update_date = Course.objects.filter(timetable=timetable) + + if today is not None: + # Cette date concerne les éléments commençant à partir d’aujourd’hui si la valeur + # n’est pas nulle. + last_update_date = last_update_date.filter(begin__gte=today) + + if year is not None and week is not None: + # Si jamais on traite une semaine spécifique, on limite les cours sélectionnés + # à ceux qui commencent entre le début du traitement et la fin de la semaine + last_update_date = last_update_date.filter(begin__lt=end) + + last_update_date = last_update_date.aggregate(Min("last_update")) \ + ["last_update__min"] + + # Date de mise à jour de Celcat, utilisée à des fins de statistiques new_update_date = get_update_date(soup) + # On ne fait pas la mise à jour si jamais la dernière date de MàJ est plus récente + # que celle indiquée par Celcat. + # Attention, le champ last_update de la classe Course représente l’heure à laquelle + # le cours a été inséré dans la base de données, et non pas la date indiquée par + # Celcat. if not force and last_update_date is not None and new_update_date is not None and \ last_update_date >= new_update_date: return if year is not None and week is not None: - delete_courses_in_week(timetable, year, week) + # On efface la semaine à partir de maintenant si jamais + # on demande le traitement d’une seule semaine + delete_courses_in_week(timetable, year, week, today) else: - Course.objects.filter(timetable=timetable, - begin__gte=min(weeks_in_soup.values())).delete() - - for course in get_events(timetable, soup, weeks_in_soup, year, week): + # Sinon, on efface tous les cours à partir de maintenant. + # Précisément, on prend la plus grande valeur entre la première semaine + # présente dans Celcat et maintenant. + delete_from = min(weeks_in_soup.values()) + if not force: + # Si jamais on force la MàJ, on efface tout à partir de la première semaine + delete_from = max(delete_from, today) + Course.objects.filter(timetable=timetable, begin__gte=delete_from).delete() + + # Tous les cours commençant sur la période traitée + # sont parsés, puis enregistrés dans la base de données. + for course in get_events(timetable, soup, weeks_in_soup, today, year, week): course.save() + # On renseigne la date de mise à jour de Celcat, à des fins de statistiques timetable.last_update_date = new_update_date timetable.save() @@ -80,16 +118,16 @@ class Command(BaseCommand): if options["all"]: weeks = None elif options["week"] is None: - _, week, day = timezone.now().isocalendar() + _, week, day = tz_now().isocalendar() if day >= 6: - year, week, _ = (timezone.now() + datetime.timedelta(weeks=1)).isocalendar() + year, week, _ = (tz_now() + datetime.timedelta(weeks=1)).isocalendar() weeks = [week] else: weeks = options["week"] if not options["all"]: if options["year"] is None and year is None: - year = timezone.now().year + year = tz_now().year elif year is None: year = options["year"][0] @@ -98,9 +136,13 @@ class Command(BaseCommand): try: process_timetable(timetable, options["force"], year, weeks) - except Exception as exc: + except KeyboardInterrupt: + break + except Exception: self.stderr.write( - self.style.ERROR("Failed to process {0}: {1}".format(timetable, exc))) + self.style.ERROR("Failed to process {0}:".format(timetable)) + ) + self.stderr.write(self.style.ERROR(traceback.format_exc())) errcount += 1 if errcount == 0: @@ -13,8 +13,11 @@ # You should have received a copy of the GNU Affero General Public License # along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>. +from functools import reduce + from django.db import models -from django.db.models import Count, Manager, Q +from django.db.models import Count, Manager, Q, Subquery +from django.db.models.expressions import OuterRef from django.db.models.functions import ExtractWeek, ExtractYear from django.utils import timezone from django.utils.text import slugify @@ -67,17 +70,23 @@ class Timetable(SlugModel): class GroupManager(Manager): - def get_relevant_children(self, group): - parent_in = self.get_queryset().filter(parent=group) - return self.get_queryset().filter(Q(parent=group) | Q(parent__in=parent_in)) \ - .annotate(children_count=Count("children")) \ - .filter(children_count=0, hidden=False) \ - .order_by("name") + def get_parents(self, group): + groups_criteria = Q(subgroup__isnull=True) | Q(subgroup__startswith=group.subgroup) | \ + reduce(lambda x, y: x | y, + [Q(subgroup=group.subgroup[:i]) + for i in range(1, len(group.subgroup) + 1)]) + + return self.get_queryset().filter(groups_criteria, mention=group.mention, + timetable=group.timetable) + + def get_relevant_groups(self, timetable, *args, **criteria): + sub = self.get_queryset().filter(timetable=timetable, mention=OuterRef("mention"), + subgroup__startswith=OuterRef("subgroup")) \ + .order_by().values("mention").annotate(c=Count("*")).values("c") - def get_relevant_groups(self, *args, **criteria): - return self.get_queryset().filter(*args, **criteria) \ - .annotate(children_count=Count("children")) \ - .filter(children_count=0, hidden=False) + return self.get_queryset().filter(*args, timetable=timetable, hidden=False, **criteria) \ + .annotate(nbsub=Subquery(sub, output_field=models.IntegerField())) \ + .filter(Q(nbsub=1) | Q(nbsub__isnull=True)).order_by("name") class Group(models.Model): @@ -90,27 +99,26 @@ class Group(models.Model): verbose_name="emploi du temps") mention = models.CharField(max_length=128) - subgroup = models.CharField(max_length=1, verbose_name="sous-groupe", + subgroup = models.CharField(max_length=16, verbose_name="sous-groupe", null=True) - td = models.IntegerField(verbose_name="groupe de TD", null=True) - tp = models.IntegerField(verbose_name="groupe de TP", null=True) - parent = models.ForeignKey("self", verbose_name="groupe parent", null=True, - default=None, related_name="children") slug = models.SlugField(max_length=64, default="") hidden = models.BooleanField(verbose_name="caché", default=False) - def corresponds_to(self, timetable_id, mention, subgroup, td, tp): + def corresponds_to(self, timetable_id, mention, subgroup): + subgroup_corresponds = True + if self.subgroup is not None and subgroup is not None: + subgroup_corresponds = subgroup.startswith(self.subgroup) or \ + self.subgroup.startswith(subgroup) + return self.timetable.id == timetable_id and \ self.mention.startswith(mention) and \ - (self.subgroup == subgroup or self.subgroup is None) and \ - (self.td == td or self.td is None or td is None) and \ - (self.tp == tp or self.tp is None or tp is None) + subgroup_corresponds @property def group_info(self): - return self.timetable.id, self.mention, self.subgroup, self.td, self.tp + return self.timetable.id, self.mention, self.subgroup def __str__(self): return self.name @@ -120,12 +128,12 @@ class Group(models.Model): self.name = self.celcat_name self.slug = slugify(self.name) - self.mention, self.subgroup, self.td, self.tp = parse_group(self.name) + self.mention, self.subgroup = parse_group(self.name) super(Group, self).save() class Meta: - index_together = ("mention", "subgroup", "td", "tp",) + index_together = ("mention", "subgroup",) unique_together = (("name", "timetable",), ("celcat_name", "timetable",), ("slug", "timetable",),) @@ -148,19 +156,8 @@ class Room(models.Model): class CourseManager(Manager): def get_courses_for_group(self, group, **criteria): - groups_criteria = [] - if group.subgroup is not None: - groups_criteria.append(Q(groups__subgroup__isnull=True) | \ - Q(groups__subgroup=group.subgroup)) - if group.td is not None: - groups_criteria.append(Q(groups__td__isnull=True) | Q(groups__td=group.td)) - if group.tp is not None: - groups_criteria.append(Q(groups__tp__isnull=True) | Q(groups__tp=group.tp)) - return self.get_queryset() \ - .filter(*groups_criteria, - groups__mention=group.mention, - timetable=group.timetable, **criteria) \ + .filter(groups__in=Group.objects.get_parents(group), **criteria) \ .order_by("begin") def get_weeks(self, **criteria): @@ -175,8 +172,7 @@ class CourseManager(Manager): class Course(models.Model): objects = CourseManager() - name = models.CharField(max_length=255, verbose_name="nom", default="Sans nom", - null=True) + name = models.CharField(max_length=255, verbose_name="nom", default="Sans nom") type_ = models.CharField(name="type", max_length=255, verbose_name="type de cours", null=True) timetable = models.ForeignKey(Timetable, on_delete=models.CASCADE, diff --git a/templates/calendars.html b/templates/calendars.html new file mode 100644 index 0000000..d97ea78 --- /dev/null +++ b/templates/calendars.html @@ -0,0 +1,13 @@ +{% extends "index.html" %} + +{% block title %}ICS disponibles pour le groupe {{ group }} – {% endblock %} + +{% block body %} + <h2>ICS disponibles pour le groupe {{ group }}</h2> + <ul> + <li><a href="{% url "ics" group.timetable.year.slug group.timetable.slug group.slug %}">Un seul ICS pour tous les cours</a></li> +{% for group in groups %} + <li><a href="{% url "ics-group" group.timetable.year.slug group.timetable.slug group.slug %}">ICS des cours du groupe {{ group }} uniquement</a></li> +{% endfor %} + </ul> +{% endblock %} diff --git a/templates/contact.html b/templates/contact.html deleted file mode 100644 index 1359a16..0000000 --- a/templates/contact.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "index.html" %} -{% load email %} - -{% block title %}Contacter – {% endblock %} - -{% block body %} - <h3>Contacter</h3> - <p>Pour contacter l’administrateur du service, envoyez un mail à l’adresse suivante :<br/>{{ email|format_email }}.</p> -{% endblock %} diff --git a/templates/flatpages/about.html b/templates/flatpages/about.html new file mode 100644 index 0000000..3df7c53 --- /dev/null +++ b/templates/flatpages/about.html @@ -0,0 +1,6 @@ +{% extends "flatpages/default.html" %} + +{% block body %} + <h3>{{ flatpage.title }} – celcatsanitizer {{ celcatsanitizer_version }}</h3> + {{ flatpage.content }} +{% endblock %} diff --git a/templates/flatpages/default.html b/templates/flatpages/default.html new file mode 100644 index 0000000..913cda9 --- /dev/null +++ b/templates/flatpages/default.html @@ -0,0 +1,8 @@ +{% extends "index.html" %} + +{% block title %}{{ flatpage.title }} – {% endblock %} + +{% block body %} + <h3>{{ flatpage.title }}</h3> + {{ flatpage.content }} +{% endblock %} diff --git a/templates/index.html b/templates/index.html index 43c567e..71665bc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,6 +3,7 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> +{% block head %}{% endblock %} <title>{% block title %}{% if year %}{{ year }} – {% endif %}{% endblock %}celcatsanitizer</title> <link rel="stylesheet" href="{% static "celcatsanitizer/style.css" %}"> </head> @@ -23,10 +24,8 @@ {% endblock %} </div> <footer> - <p>(c) 2017 – Alban Gruin – <a href="{% url "contact" %}">contacter</a><br /> - Design inspiré par <a href="https://bestmotherfucking.website/">https://bestmotherfucking.website/</a><br /> - Les informations affichées sur ce site sont actualisées tout les jours à minuit CET. - </p> + <p>(c) 2017 – Alban Gruin – <a href="{% url "django.contrib.flatpages.views.flatpage" url="contact/" %}">contacter</a> – celcatsanitizer {{ celcatsanitizer_version }} – <a href="{% url "django.contrib.flatpages.views.flatpage" url="a-propos/" %}">à propos</a><br /> + Design inspiré par <a href="https://bestmotherfucking.website/">https://bestmotherfucking.website/</a></p> </footer> </body> </html> diff --git a/templates/timetable.html b/templates/timetable.html index 9143c2f..fc2065f 100644 --- a/templates/timetable.html +++ b/templates/timetable.html @@ -1,5 +1,12 @@ {% extends "index.html" %} +{% block head %} + <meta name="description" content="Emploi du temps du groupe {{ group }} – Semaine {{ week }}" /> + <link rel="alternate" type="application/atom+xml" title="Emploi du temps du groupe {{ group }} (Atom)" href="{% url "atom" group.timetable.year.slug group.timetable.slug group.slug %}" /> + <link rel="alternate" type="application/rss+xml" title="Emploi du temps du groupe {{ group }} (RSS)" href="{% url "rss" group.timetable.year.slug group.timetable.slug group.slug %}" /> + <link rel="alternate" type="text/calendar" title="Emploi du temps du groupe {{ group }} (iCalendar)" href="{% url "ics" group.timetable.year.slug group.timetable.slug group.slug %}"> +{% endblock %} + {% block title %}{{ group.timetable }} – {{ group }} – Semaine {{ week }} – {% endblock %} {% block body %} @@ -8,7 +15,7 @@ {% if is_old_timetable %} <b><a href="{% url "timetable" group.timetable.year.slug group.timetable.slug group.slug %}">Accéder à l’emploi du temps de cette semaine.</b></a><br /> {% endif %} - Dernière mise à jour le {{ last_update|date:"l j F o" }} à {{ last_update|date:"H:i" }} + {% if last_update %}Dernière mise à jour le {{ last_update|date:"l j F o" }} à {{ last_update|date:"H:i" }}{% endif %} </p> {% include "timetable_common.html" %} - <p class="subscribe"><a href="{% url "ics" group.timetable.year.slug group.timetable.slug group.slug %}">ICS</a> – <a href="{% url "rss" group.timetable.year.slug group.timetable.slug group.slug %}">RSS</a> – <a href="{% url "atom" group.timetable.year.slug group.timetable.slug group.slug %}">Atom</a></p>{% endblock %} + <p class="subscribe"><a href="{% url "calendars" group.timetable.year.slug group.timetable.slug group.slug %}">ICS</a> – <a href="{% url "rss" group.timetable.year.slug group.timetable.slug group.slug %}">RSS</a> – <a href="{% url "atom" group.timetable.year.slug group.timetable.slug group.slug %}">Atom</a></p>{% endblock %} diff --git a/templates/timetable_common.html b/templates/timetable_common.html index 4319e60..62b1d71 100644 --- a/templates/timetable_common.html +++ b/templates/timetable_common.html @@ -4,9 +4,10 @@ <h3>{% filter title %}{{ day.0.begin|date:"l j F o" }}{% endfilter %} – de {{ day.0.begin|date:"H:i" }} à {% with day|last as last %}{{ last.end|date:"H:i" }}{% endwith %}</h3> <ul>{% for course in day %} <li class="course"> - <b>{{ course }}</b> ({{ course.type }}), de {{ course.begin|date:"H:i" }} à {{ course.end|date:"H:i" }}{% if course.rooms.all|length > 0 %}<br /> + <b>{{ course }}</b>{% if course.type %} ({{ course.type }}){% endif %}, de {{ course.begin|date:"H:i" }} à {{ course.end|date:"H:i" }}{% if course.rooms.all|length > 0 %}<br /> <em>{{ course.rooms.all|format_rooms }}</em>{% endif %}{% if course.notes %}<br /> <small>Remarques : {{ course.notes }}</small>{% endif %} </li>{% endfor %} </ul> - </section>{% endfor %} + </section>{% empty %} + <p>Aucun cours cette semaine.</p>{% endfor %} diff --git a/templatetags/email.py b/templatetags/email.py deleted file mode 100644 index 68dbd84..0000000 --- a/templatetags/email.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (C) 2017 Alban Gruin -# -# celcatsanitizer is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# celcatsanitizer is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>. - -from django import template - -register = template.Library() - -@register.filter -def format_email(address): - return address.replace("+", " [plus] ") \ - .replace("@", " [arobase] ") \ - .replace(".", " [point] ") @@ -14,14 +14,13 @@ # along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>. from django.test import TestCase -from django.utils import timezone - from .models import Course, Group, Timetable, Year +from .utils import tz_now class CourseTestCase(TestCase): def setUp(self): - dt = timezone.now() + dt = tz_now() self.year = Year(name="L2", slug="l2") self.year.save() @@ -129,10 +128,10 @@ class GroupTestCase(TestCase): tdb2 = Group.objects.get(celcat_name="L1 info s2 TDB2", timetable=self.timetable) tpb21 = Group.objects.get(celcat_name="L1 info s2 TPB21", timetable=self.timetable) - self.assertEqual(cma.group_info, (self.timetable.id, "L1 info s2", "A", None, None)) - self.assertEqual(tda2.group_info, (self.timetable.id, "L1 info s2", "A", 2, None)) - self.assertEqual(tpa21.group_info, (self.timetable.id, "L1 info s2", "A", 2, 1)) + self.assertEqual(cma.group_info, (self.timetable.id, "L1 info", "A")) + self.assertEqual(tda2.group_info, (self.timetable.id, "L1 info", "A2")) + self.assertEqual(tpa21.group_info, (self.timetable.id, "L1 info", "A21")) - self.assertEqual(cmb.group_info, (self.timetable.id, "L1 info s2", "B", None, None)) - self.assertEqual(tdb2.group_info, (self.timetable.id, "L1 info s2", "B", 2, None)) - self.assertEqual(tpb21.group_info, (self.timetable.id, "L1 info s2", "B", 2, 1)) + self.assertEqual(cmb.group_info, (self.timetable.id, "L1 info", "B")) + self.assertEqual(tdb2.group_info, (self.timetable.id, "L1 info", "B2")) + self.assertEqual(tpb21.group_info, (self.timetable.id, "L1 info", "B21")) @@ -13,16 +13,18 @@ # You should have received a copy of the GNU Affero General Public License # along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>. -from django.conf.urls import url +from django.conf.urls import include, url from . import feeds, views urlpatterns = [ url(r"^$", views.index, name="index"), - url(r"^contact$", views.contact, name="contact"), + url(r"^pages/", include("django.contrib.flatpages.urls")), url(r"^(?P<year_slug>[-\w]+)/$", views.mention_list, name="mentions"), url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/$", views.group_list, name="groups"), url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/$", views.timetable, name="timetable"), + url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/calendars", views.calendars, name="calendars"), url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/calendar.ics$", feeds.IcalFeed(), name="ics"), + url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/calendar-group.ics$", feeds.IcalOnlyOneFeed(), name="ics-group"), url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/feed.atom$", feeds.AtomFeed(), name="atom"), url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/feed.rss$", feeds.RSSFeed(), name="rss"), url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/(?P<year>[0-9]{4})/(?P<week>[0-4]?[0-9]|5[0-3])/$", views.timetable, name="timetable"), @@ -19,12 +19,12 @@ import re from django.utils import timezone def get_current_week(): - return timezone.now().isocalendar()[:2] + return tz_now().isocalendar()[:2] def get_current_or_next_week(): - year, week, day = timezone.now().isocalendar() + year, week, day = tz_now().isocalendar() if day >= 6: - year, week, _ = (timezone.now() + datetime.timedelta(weeks=1)).isocalendar() + year, week, _ = (tz_now() + datetime.timedelta(weeks=1)).isocalendar() return year, week @@ -48,29 +48,25 @@ def group_courses(courses): def parse_group(name): # Explication de la regex # - # ^(.+?)\s+((CM(\w))|(TD(\w)(\d))|(TP(\w)(\d)(\d)))?(\s+\(.+\))?$ - # ^ début de la ligne - # (.+?) correspond à au moins un caractère - # \s+ un ou plusieurs espaces - # ((CM(\w))| correspond à CM suivi d'une lettre ou… - # (TD(\w)(\d))| … à TD suivi d’une lettre et d'un chiffre ou… - # (TP(\w)(\d)(\d)) … à TP suivi d’une lettre et de deux chiffres - # )? groupe optionnel - # (\s+ un ou plusieurs espaces - # \(.+\)) un ou plusieurs caractères quelconques entre parenthèses - # ? groupe optionnel - # $ fin de la ligne - group_regex = re.compile(r"^(.+?)\s+((CM(\w))|(TD(\w)(\d))|(TP(\w)(\d)(\d)))?(\s+\(.+\))?$") + # ^(.+?)\s*(s\d\s+)?((CM|TD|TP|G)(\w\d{0,3}))?(\s+\(.+\))?$ + # ^ début de la ligne + # (.+?) correspond à au moins un caractère + # \s* éventuellement un ou plusieurs espaces + # (s\d\s+)? éventuellement un s suivi d’un nombre et d’un ou plusieurs espaces + # ((CM|TD|TP|G) « CM » ou « TD » ou « TP » ou « G » + # (\w\d{0,3}) suivi d’un caractère puis entre 0 et 3 chiffres + # )? groupe optionnel + # (\s+ un ou plusieurs espaces + # \(.+\))? un ou pliseurs caractères entre parenthèses + # $ fin de la ligne + group_regex = re.compile(r"^(.+?)\s*(s\d\s+)?((CM|TD|TP|G)(\w\d{0,3}))?(\s+\(.+\))?$") search = group_regex.search(name) if search is None: - return name, None, None, None + return name, None parts = search.groups() - if parts[1] is None: # Pas de groupe précis indiqué - return parts[0], None, None, None - elif parts[2] is not None: # Groupe de CM - return parts[0], parts[3], None, None - elif parts[4] is not None: # Groupe de TD - return parts[0], parts[5], parts[6], None - elif parts[7] is not None: # Groupe de TP - return parts[0], parts[8], parts[9], parts[10] + return parts[0], parts[4] + +def tz_now(): + """Retourne la date et l’heure avec le bon fuseau horaire""" + return timezone.make_aware(datetime.datetime.now()) @@ -15,14 +15,16 @@ import datetime -from django.conf import settings from django.db.models import Max +from django.db.models.functions import Length from django.http import Http404 from django.shortcuts import get_object_or_404, render from .models import Timetable, Group, Course, Year from .utils import get_current_week, get_current_or_next_week, get_week, group_courses +import edt + def index(request): years = Year.objects.order_by("name") return render(request, "index.html", {"elements": years}) @@ -39,13 +41,12 @@ def group_list_common(request, timetable, groups): groups_weeks = Course.objects.get_weeks(begin__gte=start, begin__lt=end, timetable=timetable) \ .values("groups__mention", "groups__subgroup", - "groups__td", "groups__tp", "year", "week") + "year", "week") for group in groups: for group_week in groups_weeks: if group.corresponds_to(timetable.id, group_week["groups__mention"], - group_week["groups__subgroup"], group_week["groups__td"], - group_week["groups__tp"]): + group_week["groups__subgroup"]): if not hasattr(group, "weeks"): group.weeks = [] @@ -60,15 +61,16 @@ def group_list_common(request, timetable, groups): def group_list(request, year_slug, timetable_slug): timetable = get_object_or_404(Timetable, year__slug=year_slug, slug=timetable_slug) - groups = Group.objects.get_relevant_groups(timetable=timetable).order_by("name") + groups = Group.objects.get_relevant_groups(timetable) return group_list_common(request, timetable, groups) def timetable(request, year_slug, timetable_slug, group_slug, year=None, week=None): current_year, current_week = get_current_or_next_week() - is_old_timetable = False + is_old_timetable, provided_week = False, True if year is None or week is None: year, week = current_year, current_week + provided_week = False elif (int(year), int(week)) < (current_year, current_week): is_old_timetable = True @@ -77,20 +79,31 @@ def timetable(request, year_slug, timetable_slug, group_slug, year=None, week=No timetable = get_object_or_404(Timetable, year__slug=year_slug, slug=timetable_slug) group = get_object_or_404(Group, slug=group_slug, timetable=timetable) - if group.children.count(): - return group_list_common(request, timetable, Group.objects.get_relevant_children(group)) + if Group.objects.filter(timetable=timetable, mention=group.mention, + subgroup__startswith=group.subgroup).count() > 1: + subgroups = Group.objects.get_relevant_groups(timetable, mention=group.mention, + subgroup__startswith=group.subgroup) + return group_list_common(request, timetable, subgroups) - courses = Course.objects.get_courses_for_group(group, begin__gte=start, begin__lt=end) \ - .annotate(Max("last_update")) - if courses.count() == 0: + courses = Course.objects.get_courses_for_group(group, begin__gte=start, begin__lt=end) + if courses.count() == 0 and provided_week: raise Http404 + last_update = courses.aggregate(Max("last_update"))["last_update__max"] grouped_courses = group_courses(courses) return render(request, "timetable.html", {"group": group, "courses": grouped_courses, - "last_update": courses.first().last_update__max, + "last_update": last_update, "year": year, "week": int(week), "is_old_timetable": is_old_timetable}) -def contact(request): - return render(request, "contact.html", {"email": settings.ADMINS[0][1]}) +def calendars(request, year_slug, timetable_slug, group_slug): + group = get_object_or_404(Group, timetable__year__slug=year_slug, + timetable__slug=timetable_slug, slug=group_slug) + groups = Group.objects.get_parents(group).annotate(length=Length("subgroup")) \ + .order_by("length") + + return render(request, "calendars.html", {"group": group, "groups": groups}) + +def ctx_processor(request): + return {"celcatsanitizer_version": edt.VERSION} |