From e7f2ccdd870f998b9199b85bf2c486f8d1d0cceb Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Sat, 25 Aug 2018 22:54:33 +0200 Subject: management: création d’un sous-module parser Signed-off-by: Alban Gruin --- management/commands/_private.py | 164 -------------------------------------- management/commands/timetables.py | 4 +- management/parsers/ups2017.py | 164 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 166 deletions(-) delete mode 100644 management/commands/_private.py create mode 100644 management/parsers/ups2017.py diff --git a/management/commands/_private.py b/management/commands/_private.py deleted file mode 100644 index 94c1918..0000000 --- a/management/commands/_private.py +++ /dev/null @@ -1,164 +0,0 @@ -# Copyright (C) 2017-2018 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 . - -import datetime -import re - -from bs4 import BeautifulSoup -from django.utils import timezone - -from ...models import Course, Group, Room -from ...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 delete_courses_in_week(source, year, week, today): - start, end = get_week(year, week) - Course.objects.filter(begin__gte=max(start, today), begin__lt=end, - source=source).delete() - - -def get_event(source, 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 - date = event_week + datetime.timedelta(int(event.day.text)) - 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 today is not None and begin < today: - return - - # Création de l’objet cours - course = Course.objects.create(source=source, begin=begin, end=end) - - # On récupère les groupes concernés par les cours - groups = [Group.objects.get_or_create(source=source, - celcat_name=item.text)[0] - for item in event.resources.group.find_all("item")] - course.groups.add(*groups) - - # On récupère le champ « remarque » - if event.notes is not None: - course.notes = "\n".join(event.notes.find_all(text=True)) - - # On récupère le champ « nom » - if event.resources.module is not None: - course.name = event.resources.module.item.text - 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, 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: - course.type = event.category.text - - # Si un cours a une salle attribuée (oui, il est possible qu’il n’y - # en ait pas… qui sont ils, leurs réseaux, tout ça…), on les insère - # dans la base de données, et on les ajoute dans l’objet cours - if event.resources.room is not None: - rooms = [Room.objects.get_or_create(name=item.text)[0] - for item in event.resources.room.find_all("item")] - course.rooms.add(*rooms) - - return course - - -def get_events(source, 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"): - event_week = weeks_in_soup[event.rawweeks.text] - event_week_num = event_week.isocalendar()[1] # Numéro de semaine - - # On passe le traitement si la semaine de l’événement ne correspond pas - # à la semaine passée, ou qu’il ne contient pas de groupe ou n’a pas de - # date de début ou de fin. - if (event_week_num == week and event_week.year == year or - 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: - course = get_event(source, 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 - # - # (\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+) - # (\d+) au moins un nombre - # / un slash - # (\d+) au moins un nombre - # / un slash - # (\d+) au moins un nombre - # \s+ au moins un espace - # (\d+) au moins un nombre - # : un deux-points - # (\d+) au moins un nombre - # : un deux-points - # (\d+) au moins un nombre - datetime_regex = re.compile(r"(\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)") - search = datetime_regex.search(soup.footer.text) - if search is None: - return None - - day, month, year, hour, minute, second = [int(v) for v in search.groups()] - date = datetime.datetime(year, month, day, hour, minute, second) - return timezone.make_aware(date) - - -def get_weeks(soup): - # Les semaines sont référencées de manière assez… exotique - # En gros, il y a une liste d’éléments span qui contiennent une sorte d’ID - # de la semaine, formaté de la manière suivante : - # NNNNNNNNNNNNNNNNNNNYNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN - # Tous sont de la même longueur, contiennent 51 N et un seul Y. - # Allez savoir pourquoi. Il se trouve dans la balise « alleventweeks ». - # Un paramètre du span (« date ») représente la date de début. - # Un cours contient donc un ID de semaine, puis le nombre de jours après le - # début de cette semaine. - weeks = {} - for span in soup.find_all("span"): # Liste de toutes les semaines définies - # On parse la date et on la fait correspondre à l’ID - weeks[span.alleventweeks.text] = timezone.make_aware( - datetime.datetime.strptime(span["date"], "%d/%m/%Y")) - - return weeks - - -def get_xml(url): - user_agent = "celcatsanitizer/" + edt.VERSION - req = requests.get(url, headers={"User-Agent": user_agent}) - req.encoding = "utf8" - - soup = BeautifulSoup(req.content, "html.parser") - return soup diff --git a/management/commands/timetables.py b/management/commands/timetables.py index f92ad4e..8fc8ed6 100644 --- a/management/commands/timetables.py +++ b/management/commands/timetables.py @@ -23,8 +23,8 @@ from django.db.models import Min from ...models import Course, Source from ...utils import get_week, tz_now -from ._private import delete_courses_in_week, get_events, get_update_date, \ - get_weeks, get_xml +from ..parsers.ups2017 import delete_courses_in_week, get_events, \ + get_update_date, get_weeks, get_xml @transaction.atomic diff --git a/management/parsers/ups2017.py b/management/parsers/ups2017.py new file mode 100644 index 0000000..94c1918 --- /dev/null +++ b/management/parsers/ups2017.py @@ -0,0 +1,164 @@ +# Copyright (C) 2017-2018 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 . + +import datetime +import re + +from bs4 import BeautifulSoup +from django.utils import timezone + +from ...models import Course, Group, Room +from ...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 delete_courses_in_week(source, year, week, today): + start, end = get_week(year, week) + Course.objects.filter(begin__gte=max(start, today), begin__lt=end, + source=source).delete() + + +def get_event(source, 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 + date = event_week + datetime.timedelta(int(event.day.text)) + 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 today is not None and begin < today: + return + + # Création de l’objet cours + course = Course.objects.create(source=source, begin=begin, end=end) + + # On récupère les groupes concernés par les cours + groups = [Group.objects.get_or_create(source=source, + celcat_name=item.text)[0] + for item in event.resources.group.find_all("item")] + course.groups.add(*groups) + + # On récupère le champ « remarque » + if event.notes is not None: + course.notes = "\n".join(event.notes.find_all(text=True)) + + # On récupère le champ « nom » + if event.resources.module is not None: + course.name = event.resources.module.item.text + 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, 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: + course.type = event.category.text + + # Si un cours a une salle attribuée (oui, il est possible qu’il n’y + # en ait pas… qui sont ils, leurs réseaux, tout ça…), on les insère + # dans la base de données, et on les ajoute dans l’objet cours + if event.resources.room is not None: + rooms = [Room.objects.get_or_create(name=item.text)[0] + for item in event.resources.room.find_all("item")] + course.rooms.add(*rooms) + + return course + + +def get_events(source, 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"): + event_week = weeks_in_soup[event.rawweeks.text] + event_week_num = event_week.isocalendar()[1] # Numéro de semaine + + # On passe le traitement si la semaine de l’événement ne correspond pas + # à la semaine passée, ou qu’il ne contient pas de groupe ou n’a pas de + # date de début ou de fin. + if (event_week_num == week and event_week.year == year or + 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: + course = get_event(source, 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 + # + # (\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+) + # (\d+) au moins un nombre + # / un slash + # (\d+) au moins un nombre + # / un slash + # (\d+) au moins un nombre + # \s+ au moins un espace + # (\d+) au moins un nombre + # : un deux-points + # (\d+) au moins un nombre + # : un deux-points + # (\d+) au moins un nombre + datetime_regex = re.compile(r"(\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)") + search = datetime_regex.search(soup.footer.text) + if search is None: + return None + + day, month, year, hour, minute, second = [int(v) for v in search.groups()] + date = datetime.datetime(year, month, day, hour, minute, second) + return timezone.make_aware(date) + + +def get_weeks(soup): + # Les semaines sont référencées de manière assez… exotique + # En gros, il y a une liste d’éléments span qui contiennent une sorte d’ID + # de la semaine, formaté de la manière suivante : + # NNNNNNNNNNNNNNNNNNNYNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + # Tous sont de la même longueur, contiennent 51 N et un seul Y. + # Allez savoir pourquoi. Il se trouve dans la balise « alleventweeks ». + # Un paramètre du span (« date ») représente la date de début. + # Un cours contient donc un ID de semaine, puis le nombre de jours après le + # début de cette semaine. + weeks = {} + for span in soup.find_all("span"): # Liste de toutes les semaines définies + # On parse la date et on la fait correspondre à l’ID + weeks[span.alleventweeks.text] = timezone.make_aware( + datetime.datetime.strptime(span["date"], "%d/%m/%Y")) + + return weeks + + +def get_xml(url): + user_agent = "celcatsanitizer/" + edt.VERSION + req = requests.get(url, headers={"User-Agent": user_agent}) + req.encoding = "utf8" + + soup = BeautifulSoup(req.content, "html.parser") + return soup -- cgit v1.2.1 From 77b6ade4a7d465ca0fbc6b82950f3b54689d60e3 Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Sat, 25 Aug 2018 23:01:31 +0200 Subject: parsers: déplacement de delete_courses_in_week() vers timetable.py Signed-off-by: Alban Gruin --- management/commands/timetables.py | 12 +++++++++--- management/parsers/ups2017.py | 9 +-------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/management/commands/timetables.py b/management/commands/timetables.py index 8fc8ed6..c148fed 100644 --- a/management/commands/timetables.py +++ b/management/commands/timetables.py @@ -23,8 +23,14 @@ from django.db.models import Min from ...models import Course, Source from ...utils import get_week, tz_now -from ..parsers.ups2017 import delete_courses_in_week, get_events, \ - get_update_date, get_weeks, get_xml +from ..parsers.ups2017 import get_events, get_update_date, get_weeks, \ + get_source + + +def delete_courses_in_week(source, year, week, today): + start, end = get_week(year, week) + Course.objects.filter(begin__gte=max(start, today), begin__lt=end, + source=source).delete() @transaction.atomic @@ -100,7 +106,7 @@ def process_timetable_week(source, soup, weeks_in_soup, force, def process_timetable(source, force, year=None, weeks=None): - soup = get_xml(source.url) + soup = get_source(source.url) weeks_in_soup = get_weeks(soup) if year is not None and weeks is not None: diff --git a/management/parsers/ups2017.py b/management/parsers/ups2017.py index 94c1918..8522793 100644 --- a/management/parsers/ups2017.py +++ b/management/parsers/ups2017.py @@ -20,7 +20,6 @@ from bs4 import BeautifulSoup from django.utils import timezone from ...models import Course, Group, Room -from ...utils import get_week import requests import edt @@ -32,12 +31,6 @@ def add_time(date, time): return date + delta -def delete_courses_in_week(source, year, week, today): - start, end = get_week(year, week) - Course.objects.filter(begin__gte=max(start, today), begin__lt=end, - source=source).delete() - - def get_event(source, 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 @@ -155,7 +148,7 @@ def get_weeks(soup): return weeks -def get_xml(url): +def get_source(url): user_agent = "celcatsanitizer/" + edt.VERSION req = requests.get(url, headers={"User-Agent": user_agent}) req.encoding = "utf8" -- cgit v1.2.1 From d02046f9255a07c4eb2bda9eb73d229cdb4f4a53 Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Mon, 27 Aug 2018 17:43:16 +0200 Subject: parsers: parseur orienté objet avec une classe abstraite Signed-off-by: Alban Gruin --- management/commands/timetables.py | 35 +++-- management/parsers/abstractparser.py | 52 +++++++ management/parsers/ups2017.py | 259 ++++++++++++++++++----------------- 3 files changed, 205 insertions(+), 141 deletions(-) create mode 100644 management/parsers/abstractparser.py diff --git a/management/commands/timetables.py b/management/commands/timetables.py index c148fed..c5159b7 100644 --- a/management/commands/timetables.py +++ b/management/commands/timetables.py @@ -13,9 +13,12 @@ # You should have received a copy of the GNU Affero General Public License # along with celcatsanitizer. If not, see . +from importlib import import_module + import datetime import traceback +from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction from django.db.models import Min @@ -23,8 +26,7 @@ from django.db.models import Min from ...models import Course, Source from ...utils import get_week, tz_now -from ..parsers.ups2017 import get_events, get_update_date, get_weeks, \ - get_source +DEFAULT_PARSER = "edt.management.parsers.ups2017" def delete_courses_in_week(source, year, week, today): @@ -34,8 +36,7 @@ def delete_courses_in_week(source, year, week, today): @transaction.atomic -def process_timetable_week(source, soup, weeks_in_soup, force, - year=None, week=None): +def process_timetable_week(source, force, parser, year=None, week=None): if year is not None and week is not None: begin, end = get_week(year, week) @@ -69,7 +70,7 @@ def process_timetable_week(source, soup, weeks_in_soup, force, 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) + new_update_date = parser.get_update_date() # 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 @@ -88,7 +89,7 @@ def process_timetable_week(source, soup, weeks_in_soup, force, # 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()) + delete_from = min(parser.weeks.values()) if not force: # Si jamais on force la MàJ, on efface tout à partir de la # première semaine @@ -97,7 +98,7 @@ def process_timetable_week(source, soup, weeks_in_soup, force, # 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(source, soup, weeks_in_soup, today, year, week): + for course in parser.get_events(today, year, week): course.save() # On renseigne la date de mise à jour de Celcat, à des fins de statistiques @@ -105,16 +106,16 @@ def process_timetable_week(source, soup, weeks_in_soup, force, source.save() -def process_timetable(source, force, year=None, weeks=None): - soup = get_source(source.url) - weeks_in_soup = get_weeks(soup) +def process_timetable(source, force, parser_cls, year=None, weeks=None): + parser = parser_cls(source) + parser.get_source() + parser.get_weeks() if year is not None and weeks is not None: for week in weeks: - process_timetable_week(source, soup, weeks_in_soup, force, - year, week) + process_timetable_week(source, force, parser, year, week) else: - process_timetable_week(source, soup, weeks_in_soup, force) + process_timetable_week(source, force, parser) class Command(BaseCommand): @@ -129,9 +130,14 @@ class Command(BaseCommand): nargs="+") parser.add_argument("--year", type=int, nargs=1) + def __get_parser(self): + parser_module = getattr(settings, "CS_PARSER", DEFAULT_PARSER) + return getattr(import_module(parser_module), "Parser") + def handle(self, *args, **options): year = None errcount = 0 + parser = self.__get_parser() if options["all"]: weeks = None @@ -155,7 +161,8 @@ class Command(BaseCommand): source.formatted_timetables)) try: - process_timetable(source, options["force"], year, weeks) + process_timetable(source, options["force"], parser, + year, weeks) except KeyboardInterrupt: break except Exception: diff --git a/management/parsers/abstractparser.py b/management/parsers/abstractparser.py new file mode 100644 index 0000000..8d55b6d --- /dev/null +++ b/management/parsers/abstractparser.py @@ -0,0 +1,52 @@ +# Copyright (C) 2018 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 . + +import abc +import requests + +import edt + + +class AbstractParser(metaclass=abc.ABCMeta): + def __init__(self, source): + self.source = source + self.user_agent = "celcatsanitizer/" + edt.VERSION + + def _make_request(self, url, user_agent=None, encoding="utf8", **kwargs): + user_agent = user_agent if user_agent is not None else self.user_agent + + params = kwargs["params"] if "params" in kwargs else {} + headers = kwargs["headers"] if "headers" in kwargs else {} + headers["User-Agent"] = user_agent + + req = requests.get(url, headers=headers, params=params) + req.encoding = encoding + + return req + + @abc.abstractmethod + def get_events(self): + pass + + @abc.abstractmethod + def get_update_date(self): + pass + + @abc.abstractmethod + def get_weeks(self): + pass + + def get_source(self): + return self._make_request(self.source.url) diff --git a/management/parsers/ups2017.py b/management/parsers/ups2017.py index 8522793..99ce34d 100644 --- a/management/parsers/ups2017.py +++ b/management/parsers/ups2017.py @@ -20,9 +20,7 @@ from bs4 import BeautifulSoup from django.utils import timezone from ...models import Course, Group, Room - -import requests -import edt +from .abstractparser import AbstractParser def add_time(date, time): @@ -31,127 +29,134 @@ def add_time(date, time): return date + delta -def get_event(source, 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 - date = event_week + datetime.timedelta(int(event.day.text)) - 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 today is not None and begin < today: - return - - # Création de l’objet cours - course = Course.objects.create(source=source, begin=begin, end=end) - - # On récupère les groupes concernés par les cours - groups = [Group.objects.get_or_create(source=source, - celcat_name=item.text)[0] - for item in event.resources.group.find_all("item")] - course.groups.add(*groups) - - # On récupère le champ « remarque » - if event.notes is not None: - course.notes = "\n".join(event.notes.find_all(text=True)) - - # On récupère le champ « nom » - if event.resources.module is not None: - course.name = event.resources.module.item.text - 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, 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: - course.type = event.category.text - - # Si un cours a une salle attribuée (oui, il est possible qu’il n’y - # en ait pas… qui sont ils, leurs réseaux, tout ça…), on les insère - # dans la base de données, et on les ajoute dans l’objet cours - if event.resources.room is not None: - rooms = [Room.objects.get_or_create(name=item.text)[0] - for item in event.resources.room.find_all("item")] - course.rooms.add(*rooms) - - return course - - -def get_events(source, 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"): - event_week = weeks_in_soup[event.rawweeks.text] - event_week_num = event_week.isocalendar()[1] # Numéro de semaine - - # On passe le traitement si la semaine de l’événement ne correspond pas - # à la semaine passée, ou qu’il ne contient pas de groupe ou n’a pas de - # date de début ou de fin. - if (event_week_num == week and event_week.year == year or - 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: - course = get_event(source, 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 - # - # (\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+) - # (\d+) au moins un nombre - # / un slash - # (\d+) au moins un nombre - # / un slash - # (\d+) au moins un nombre - # \s+ au moins un espace - # (\d+) au moins un nombre - # : un deux-points - # (\d+) au moins un nombre - # : un deux-points - # (\d+) au moins un nombre - datetime_regex = re.compile(r"(\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)") - search = datetime_regex.search(soup.footer.text) - if search is None: - return None - - day, month, year, hour, minute, second = [int(v) for v in search.groups()] - date = datetime.datetime(year, month, day, hour, minute, second) - return timezone.make_aware(date) - - -def get_weeks(soup): - # Les semaines sont référencées de manière assez… exotique - # En gros, il y a une liste d’éléments span qui contiennent une sorte d’ID - # de la semaine, formaté de la manière suivante : - # NNNNNNNNNNNNNNNNNNNYNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN - # Tous sont de la même longueur, contiennent 51 N et un seul Y. - # Allez savoir pourquoi. Il se trouve dans la balise « alleventweeks ». - # Un paramètre du span (« date ») représente la date de début. - # Un cours contient donc un ID de semaine, puis le nombre de jours après le - # début de cette semaine. - weeks = {} - for span in soup.find_all("span"): # Liste de toutes les semaines définies - # On parse la date et on la fait correspondre à l’ID - weeks[span.alleventweeks.text] = timezone.make_aware( - datetime.datetime.strptime(span["date"], "%d/%m/%Y")) - - return weeks - - -def get_source(url): - user_agent = "celcatsanitizer/" + edt.VERSION - req = requests.get(url, headers={"User-Agent": user_agent}) - req.encoding = "utf8" - - soup = BeautifulSoup(req.content, "html.parser") - return soup +class Parser(AbstractParser): + def __get_event(self, event, event_week, today): + """Renvoie une classe Course à partir d’un événement lu 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 + date = event_week + datetime.timedelta(int(event.day.text)) + 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 today is not None and begin < today: + return + + # Création de l’objet cours + course = Course.objects.create(source=self.source, begin=begin, + end=end) + + # On récupère les groupes concernés par les cours + groups = [ + Group.objects.get_or_create( + source=self.source, celcat_name=item.text + )[0] + for item in event.resources.group.find_all("item") + ] + course.groups.add(*groups) + + # On récupère le champ « remarque » + if event.notes is not None: + course.notes = "\n".join(event.notes.find_all(text=True)) + + # On récupère le champ « nom » + if event.resources.module is not None: + course.name = event.resources.module.item.text + elif event.category is not None: + # Il est possible qu’un cours n’ait pas de nom. 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, il obtiendra une valeur par + # défaut définie à l’avance. + + # Récupération du type de cours + if event.category is not None: + course.type = event.category.text + + # Si un cours a une salle attribuée, on les insère dans la + # base de données, et on les ajoute dans l’objet cours + if event.resources.room is not None: + rooms = [ + Room.objects.get_or_create(name=item.text)[0] + for item in event.resources.room.find_all("item") + ] + course.rooms.add(*rooms) + + return course + + def get_events(self, 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 self.soup.find_all("event"): + event_week = self.weeks[event.rawweeks.text] + event_week_num = event_week.isocalendar()[1] # Numéro de semaine + + # On passe le traitement si la semaine de l’événement ne + # correspond pas à la semaine passée, ou qu’il ne contient + # pas de groupe ou n’a pas de date de début ou de fin. + if ( + ( + event_week_num == week + and event_week.year == year + or 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 + ): + course = self.__get_event(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(self): + # Explication de la regex + # + # (\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+) + # (\d+) au moins un nombre + # / un slash + # (\d+) au moins un nombre + # / un slash + # (\d+) au moins un nombre + # \s+ au moins un espace + # (\d+) au moins un nombre + # : un deux-points + # (\d+) au moins un nombre + # : un deux-points + # (\d+) au moins un nombre + datetime_regex = re.compile(r"(\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)") + search = datetime_regex.search(self.soup.footer.text) + if search is None: + return None + + day, month, year, hour, minute, second = [ + int(v) for v in search.groups() + ] + date = datetime.datetime(year, month, day, hour, minute, second) + return timezone.make_aware(date) + + def get_weeks(self): + # Les semaines présentes dans l’emploi du temps sont toutes + # stockées dans un élément span. Il contient une chaîne de + # caractère qui correspond à une forme d’ID, et un champ date, + # qui correspond au lundi de cette semaine. Un cours contient + # un ID correspondant à une semaine, puis le nombre de jours + # après le début de cette semaine. + self.weeks = {} + + # Liste de toutes les semaines définies + for span in self.soup.find_all("span"): + # On parse la date et on la fait correspondre à l’ID + self.weeks[span.alleventweeks.text] = timezone.make_aware( + datetime.datetime.strptime(span["date"], "%d/%m/%Y") + ) + + return self.weeks + + def get_source(self): + req = super(Parser, self).get_source() + self.soup = BeautifulSoup(req.content, "html.parser") + return self.soup -- cgit v1.2.1 From b3c62075deb0cf082d99a647123bf1e92b8a9c7a Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Mon, 3 Sep 2018 13:55:41 +0200 Subject: parsers: nouveau parseur pour le format utilisé par l’UPS en 2018 Signed-off-by: Alban Gruin --- management/parsers/ups2018.py | 213 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 2 files changed, 214 insertions(+) create mode 100644 management/parsers/ups2018.py diff --git a/management/parsers/ups2018.py b/management/parsers/ups2018.py new file mode 100644 index 0000000..8d97517 --- /dev/null +++ b/management/parsers/ups2018.py @@ -0,0 +1,213 @@ +# Copyright (C) 2018 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 . + +from datetime import datetime, timedelta + +import asyncio +import calendar +import json + +from django.utils import timezone + +import lxml.html +import requests + +from ...models import Course, Group, Room +from ...utils import get_current_week, get_week +from .abstractparser import AbstractParser + +VARNAME = "v.events.list = " + + +def find_events_list(soup): + res = [] + for script in soup.xpath("//script/text()"): + if VARNAME in script: + for var in script.split('\n'): + if var.startswith(VARNAME): + res = json.loads(var[len(VARNAME):-2]) + + return res + + +def get_next_month(dt): + n = dt.replace(day=1) + timedelta(days=32) + return n.replace(day=1) + + +class Parser(AbstractParser): + def __init__(self, source): + super(Parser, self).__init__(source) + + # En-tête tiré de mon Firefox… + base_req = self._make_request( + source.url, headers={"Accept-Language": "en-US,en;q=0.5"} + ) + + parser = lxml.html.HTMLParser(encoding="utf-8") + self.soup = lxml.html.document_fromstring( + base_req.content, parser=parser + ) + + self.months = [] + for option in self.soup.xpath("//option"): + if option.get("selected") is not None or len(self.months) > 0: + self.months.append(option.text) + + def __get_event(self, event, today, + beginning_of_month, end_of_month, + year, week): + begin = timezone.make_aware( + datetime.strptime(event["start"], "%Y-%m-%dT%H:%M:%S") + ) + end = timezone.make_aware( + datetime.strptime(event["end"], "%Y-%m-%dT%H:%M:%S") + ) + + if begin < beginning_of_month or begin >= end_of_month or \ + (today is not None and begin < today): + return + + if year is not None and week is not None: + event_year, event_week, _ = begin.isocalendar() + if event_year != year or event_week != week: + return + + course = Course.objects.create( + source=self.source, begin=begin, end=end + ) + + data = event["text"].split("
") + rooms = None + if data[0] == "Global Event": + return + + i = 1 + while i < len(data) and not data[i].startswith( + ("L1 ", "L2 ", "L3 ", "L3P ", "M1 ", "M2 ", "DEUST ", "MAG1 ", + "1ERE ANNEE ", "2EME ANNEE ", "3EME ANNEE ", + "MAT-Agreg Interne ") + ): + i += 1 + + groups = data[i] + if i - 1 > 0: + course.name = ", ".join(set(data[i - 1].split(';'))) + else: + course.name = "Sans nom" + if i - 2 > 0: + course.type = data[i - 2] + if len(data) >= i + 2: + rooms = data[i + 1] + if len(data) >= i + 3: + course.notes = data[i + 2] + + groups = [ + Group.objects.get_or_create( + source=self.source, celcat_name=name + )[0] + for name in groups.split(';') + ] + course.groups.add(*groups) + + if rooms is not None: + rooms_objs = Room.objects.filter(name__in=rooms.split(';')) + if rooms_objs.count() > 0: + course.rooms.add(*rooms_objs) + elif course.notes: + course.notes = "{0}\n{1}".format(rooms, course.notes) + else: + course.notes = rooms + + if course.notes is not None: + course.notes = course.notes.strip() + + return course + + def get_events(self, today, year=None, week=None): + for i, month in enumerate(self.events): + beginning_of_month = timezone.make_aware( + datetime.strptime(self.months[i], "%B, %Y") + ) + end_of_month = get_next_month(beginning_of_month) + + for event in month: + course = self.__get_event(event, today, + beginning_of_month, end_of_month, + year, week) + if course is not None: + yield course + + def get_update_date(self): + return None # Pas de date de mise à jour dans ce format + + def get_weeks(self): + # FIXME: détection automatique à partir des événements présents + beginning, _ = get_week(*get_current_week()) + self.weeks = {"1": beginning} + + return self.weeks + + def ajax_req(self, month): + month = datetime.strptime(month, "%B, %Y") + first_monday = min( + week[calendar.MONDAY] + for week in calendar.monthcalendar(month.year, month.month) + if week[calendar.MONDAY] > 0 + ) + month_str = month.replace(day=first_monday).strftime("%Y%m%d") + + req = self._make_request( + self.source.url, + headers={ + "Accept-Language": "en-US,en;q=0.5", + }, + params={"Date": month_str}, + ) + req.raise_for_status() + + parser = lxml.html.HTMLParser(encoding="utf8") + soup = lxml.html.document_fromstring(req.content, parser=parser) + + return find_events_list(soup) + + @asyncio.coroutine + def get_months_async(self): + loop = asyncio.get_event_loop() + futures = [] + + for month in self.months[1:]: + futures.append(loop.run_in_executor(None, self.ajax_req, month)) + + responses = yield from asyncio.gather(*futures) + return responses + + def get_source_from_months(self, async=True): + events = [] + + if async: + loop = asyncio.get_event_loop() + events = loop.run_until_complete(self.get_months_async()) + else: + for month in self.months[1:]: + events.append(self.ajax_req(month)) + + return events + + def get_source(self): + self.events = [ + find_events_list(self.soup) + ] + self.get_source_from_months() + return self.events diff --git a/requirements.txt b/requirements.txt index cc4ccf8..534f73d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,6 @@ beautifulsoup4==4.6.0 Django==2.0.4 gunicorn==19.7.1 icalendar==4.0.1 +lxml==4.2.4 psycopg2-binary==2.7.4 requests==2.18.4 -- cgit v1.2.1 From ac1e21312beefeaa29cf8c580ef9094abf370969 Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Mon, 3 Sep 2018 13:45:24 +0200 Subject: utils: correction du format des semaines dans `get_weeks()` Les semaines étaient parsées avec le format de base de Python au lieu du format ISO-601. Selon le format de Python, le 1er Janvier 2019 fait partie de la 53ème semaine de l’an 2018, alors que selon ISO, il fait partie de la 1ère semaine de 2019. Étant donné que d’autres parties de celcatsanitizer gèrent les dates selon ISO, cela posait des problèmes de cohérence. Signed-off-by: Alban Gruin --- utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utils.py b/utils.py index cd7f1f8..55fce0b 100644 --- a/utils.py +++ b/utils.py @@ -34,6 +34,8 @@ def get_current_or_next_week(): def get_week(year, week): start = timezone.make_aware(datetime.datetime.strptime( "{0}-W{1}-1".format(year, week), "%Y-W%W-%w")) + if datetime.datetime(year, 1, 4).isoweekday() > 4: + start -= datetime.timedelta(weeks=1) end = start + datetime.timedelta(weeks=1) return start, end -- cgit v1.2.1 From 066391b376649214266f48ab95a021cb98b9dfa2 Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Mon, 3 Sep 2018 13:50:59 +0200 Subject: timetables: ne rien faire si une source ne contient pas de semaines Correction d’un bogue qui faisait planter le parseur si on demandait une mise à jour complète alors que la source ne contenait pas de semaines. Désormais, si une source ne contient pas de semaines, la date de mise à jour de la source est modifiée, et aucun cours n’est supprimé ou rajouté. Signed-off-by: Alban Gruin --- management/commands/timetables.py | 42 +++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/management/commands/timetables.py b/management/commands/timetables.py index c5159b7..ee33f7e 100644 --- a/management/commands/timetables.py +++ b/management/commands/timetables.py @@ -81,25 +81,29 @@ def process_timetable_week(source, force, parser, year=None, week=None): new_update_date is not None and last_update_date >= new_update_date: return - if year is not None and week is not None: - # On efface la semaine à partir de maintenant si jamais - # on demande le traitement d’une seule semaine - delete_courses_in_week(source, year, week, today) - else: - # 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(parser.weeks.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(source=source, 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 parser.get_events(today, year, week): - course.save() + # Pas de traitement si il n’y a pas de semaine dans l’emploi du temps + if len(parser.weeks.values()): + if year is not None and week is not None: + # On efface la semaine à partir de maintenant si jamais + # on demande le traitement d’une seule semaine + delete_courses_in_week(source, year, week, today) + else: + # 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(parser.weeks.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( + source=source, 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 parser.get_events(today, year, week): + course.save() # On renseigne la date de mise à jour de Celcat, à des fins de statistiques source.last_update_date = new_update_date -- cgit v1.2.1 From 13934cdbc8437a6f2a40b18ff6be2ab221a92d05 Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Thu, 6 Sep 2018 16:41:05 +0200 Subject: admin: tri des salles par ordre alphabétique par défaut Signed-off-by: Alban Gruin --- admin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/admin.py b/admin.py index def84f0..928b63e 100644 --- a/admin.py +++ b/admin.py @@ -65,6 +65,8 @@ class GroupAdmin(admin.ModelAdmin): @admin.register(Room) class RoomAdmin(admin.ModelAdmin): prepopulated_fields = {"slug": ("name",)} + list_display = ("name",) + ordering = ("name",) @admin.register(Course) -- cgit v1.2.1 From cbdc0e00476f66b48097ee236f627e2b8b2f4eac Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Thu, 6 Sep 2018 16:41:29 +0200 Subject: admin: la mention, le semestre et le sous-groupe deviennent éditables Les champs mention, semestre et sous-groupe d’un groupe n’étaient pas éditables depuis l’interface d’administration, car ils sont censés êtres générés automatiquement. Cela permet de pouvoir éditer les attributs d’un groupe en attendant de pouvoir corriger la regex des groupes. Signed-off-by: Alban Gruin --- admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin.py b/admin.py index 928b63e..0dc7987 100644 --- a/admin.py +++ b/admin.py @@ -58,7 +58,7 @@ class GroupAdmin(admin.ModelAdmin): list_editable = ("hidden",) list_filter = ("source__timetables",) ordering = ("source", "name",) - readonly_fields = ("celcat_name", "mention", "semester", "subgroup",) + readonly_fields = ("celcat_name",) actions = (make_hidden, make_visible,) -- cgit v1.2.1 From eaed30e578f7e403a83fe0adff555e5c95654989 Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Thu, 6 Sep 2018 20:58:39 +0200 Subject: models: augmentation de la limite de taille de cours à 511 caractères Certains cours ont des noms trop long pour entrer dans la limite de 255 caractères. Signed-off-by: Alban Gruin --- models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models.py b/models.py index e56d33d..c8e7b3d 100644 --- a/models.py +++ b/models.py @@ -229,7 +229,7 @@ class CourseManager(Manager): class Course(models.Model): objects = CourseManager() - name = models.CharField(max_length=255, verbose_name="nom", + name = models.CharField(max_length=511, verbose_name="nom", default="Sans nom") type_ = models.CharField(name="type", max_length=255, verbose_name="type de cours", null=True) -- cgit v1.2.1 From 4b53705a15d07caa12c2ca43b00ad03a81e600bd Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Thu, 6 Sep 2018 17:10:54 +0200 Subject: groupes: ajout du support d’une nouvelle syntaxe Certains groupes ont la syntaxe suivante : L1 4L s1 CM4L L1 4L s1 TD4L1 L1 4L s1 TP4L12 etc. Le « 4 » entre le CM/TD/TP et le « numéro » fait échouer la regex. Ce commit rajoute le support de cette syntaxe, et ajoute les cas de test adéquats. Signed-off-by: Alban Gruin --- tests.py | 33 +++++++++++++++++++++++++++++++++ utils.py | 25 +++++++++++++------------ 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/tests.py b/tests.py index c3d34fd..2568688 100644 --- a/tests.py +++ b/tests.py @@ -113,6 +113,11 @@ class GroupTestCase(TestCase): Group.objects.create(celcat_name="M1 CHI-TCCM (EM) s2 TPA12", source=self.source) + # Cas spécial avec un nombre supplémentaire dans le nom de groupe + Group.objects.create(celcat_name="L1 4L s1 CM4L", source=self.source) + Group.objects.create(celcat_name="L1 4L s1 TD4L1", source=self.source) + Group.objects.create(celcat_name="L1 4L s1 TP4L12", source=self.source) + def test_corresponds(self): cma = Group.objects.get(celcat_name="L1 info s2 CMA", source=self.source) @@ -178,6 +183,22 @@ class GroupTestCase(TestCase): self.assertTrue(ga111.corresponds_to(*general.group_info)) self.assertFalse(general.corresponds_to(*ga111.group_info)) + def test_corresponds_number(self): + cm4l = Group.objects.get(celcat_name="L1 4L s1 CM4L", + source=self.source) + td4l1 = Group.objects.get(celcat_name="L1 4L s1 TD4L1", + source=self.source) + tp4l12 = Group.objects.get(celcat_name="L1 4L s1 TP4L12", + source=self.source) + + self.assertFalse(cm4l.corresponds_to(*td4l1.group_info)) + self.assertFalse(cm4l.corresponds_to(*tp4l12.group_info)) + self.assertFalse(td4l1.corresponds_to(*tp4l12.group_info)) + + self.assertTrue(td4l1.corresponds_to(*cm4l.group_info)) + self.assertTrue(tp4l12.corresponds_to(*cm4l.group_info)) + self.assertTrue(tp4l12.corresponds_to(*td4l1.group_info)) + def test_correspond_parenthesis(self): general = Group.objects.get(celcat_name="M1 CHI-TCCM (EM) (toutes" " sections et semestres confondus)") @@ -246,6 +267,18 @@ class GroupTestCase(TestCase): self.assertEqual(general.group_info, ("M1 GC", None, "")) self.assertEqual(ga111.group_info, ("M1 GC", 2, "A111")) + def test_parse_number(self): + cm4l = Group.objects.get(celcat_name="L1 4L s1 CM4L", + source=self.source) + td4l1 = Group.objects.get(celcat_name="L1 4L s1 TD4L1", + source=self.source) + tp4l12 = Group.objects.get(celcat_name="L1 4L s1 TP4L12", + source=self.source) + + self.assertEqual(cm4l.group_info, ("L1 4L", 1, "4L")) + self.assertEqual(td4l1.group_info, ("L1 4L", 1, "4L1")) + self.assertEqual(tp4l12.group_info, ("L1 4L", 1, "4L12")) + def test_parse_parenthesis(self): general = Group.objects.get(celcat_name="M1 CHI-TCCM (EM) (toutes" " sections et semestres confondus)") diff --git a/utils.py b/utils.py index 55fce0b..26de36e 100644 --- a/utils.py +++ b/utils.py @@ -55,19 +55,20 @@ def group_courses(courses): def parse_group(name): # Explication de la regex # - # ^(.+?)\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+ 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 plusieurs caractères (exceptés des espaces) entre parenthèses - # $ fin de la ligne + # ^(.+?)\s*(s(\d)\s+(CM|TD|TP|G)(\d?\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+ un s suivi d’un nombre et d’un ou plusieurs espaces + # (CM|TD|TP|G) « CM » ou « TD » ou « TP » ou « G » + # (\d?\w\d{0,3}) un chiffre optionnel, un caractère, entre 0 et 3 chiffres + # )? groupe optionnel + # (\s+ un ou plusieurs espaces + # \([^\(\)]+\))? un ou plusieurs 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+\([^\(\)]+\))?$") + r"^(.+?)\s*(s(\d)\s+(CM|TD|TP|G)(\d?\w\d{0,3}))?(\s+\([^\(\)]+\))?$") search = group_regex.search(name) if search is None: -- cgit v1.2.1 From 935a8d7d2b7a9a0dc3b6c0480ee29c0432412db9 Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Mon, 3 Sep 2018 13:57:55 +0200 Subject: requirements: mise à jour des modules de celcatsanitizer Signed-off-by: Alban Gruin --- requirements.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index 534f73d..f2c309f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -beautifulsoup4==4.6.0 -Django==2.0.4 -gunicorn==19.7.1 -icalendar==4.0.1 +beautifulsoup4==4.6.3 +Django==2.0.8 +gunicorn==19.9.0 +icalendar==4.0.2 lxml==4.2.4 -psycopg2-binary==2.7.4 -requests==2.18.4 +psycopg2-binary==2.7.5 +requests==2.19.1 -- cgit v1.2.1 From 169226eda0a5ab4711fe0a0097f211ff31353708 Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Mon, 3 Sep 2018 13:53:43 +0200 Subject: documentation: mise à jour de la documentation Rajout des nouveautés de la (future) version 0.14, met à jour la feuille de route pour la version 0.15, rajout d’informations par rapport aux parseurs lors de l’installation, rajout de LXML dans la liste des modules nécessaires, rajout d’un paragraphe sur les versions de Python 3 testées. Signed-off-by: Alban Gruin --- Documentation/dev/roadmap.rst | 14 +++++------ Documentation/usage/installation.rst | 38 +++++++++++++++++++++++++++- Documentation/usage/versions.rst | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/Documentation/dev/roadmap.rst b/Documentation/dev/roadmap.rst index a7db062..14cad2a 100644 --- a/Documentation/dev/roadmap.rst +++ b/Documentation/dev/roadmap.rst @@ -2,19 +2,19 @@ Feuille de route ================ -Version 0.14 +.. _ref-ver-0.15: + +Version 0.15 ============ - Optimisation des requêtes en utilisant des fonctionnalités spécifiques à PostgreSQL si nécessaire - - Remplacement du moteur de templates de Django par Jinja2_. + - Utilisation de Django 2.1 et de l’aggrégat ``TruncWeek``. + - Amélioration du parseur UPS2018 et de sa documentation. + - Remplacement du moteur de templates de Django par Jinja2_ ? + - Améliorations de certaines pages ? .. _Jinja2: http://jinja.pocoo.org/ Version 1.0 =========== - Paquetage permettant l’installation par ``pip``. - -Futures fonctionnalités -======================= - - Utilisation de l’aggrégat ``TruncWeek`` dès que Django le proposera - (*a priori*, dès la version 2.1). diff --git a/Documentation/usage/installation.rst b/Documentation/usage/installation.rst index 2455996..4dde4f4 100644 --- a/Documentation/usage/installation.rst +++ b/Documentation/usage/installation.rst @@ -9,9 +9,14 @@ suivantes : - `Django 2.0`_ - requests_, pour récupérer les emplois du temps en HTTP(S) - - BeautifulSoup4_, pour parser les emplois du temps en XML + - BeautifulSoup4_ et LXML_, pour parser les emplois du temps en XML - icalendar_, pour générer des fichiers ICS_. +celcatsanitizer requiert Python 3.4 au minimum, et marche avec les +versions 3.5 et 3.6. Les versions antérieures de Python 3 n’ont pas +étés testées, et les versions supérieures devraient fonctionner sans +problèmes. + *A priori*, il est possible d’utiliser n’importe quel SGBD supporté par Django avec celcatsanitizer. Cependant, l’utilisation de PostgreSQL_ est fortement recommandée. Dans ce cas, vous aurez besoin @@ -23,6 +28,7 @@ Pour l’instant, l’installation doit passer par git_. .. _requests: http://docs.python-requests.org/en/master/ .. _BeautifulSoup4: https://www.crummy.com/software/BeautifulSoup/bs4/doc/ +.. _LXML: https://lxml.de/ .. _icalendar: https://icalendar.readthedocs.io/en/latest/ .. _ICS: https://fr.wikipedia.org/wiki/ICalendar .. _PostgreSQL: https://www.postgresql.org/ @@ -170,6 +176,36 @@ Cette étape est **obligatoire**. __ https://docs.djangoproject.com/fr/2.0/ref/contrib/flatpages/#installation +Sélection du parseur +```````````````````` +celcatsanitizer dispose d’un système de parseurs modulaires depuis la +:ref:`version 0.14 `, et embarque par défaut deux +parseurs : + + - ``edt.management.parsers.ups2017``, pour le format utilisé par + l’Université Paul Sabatier en 2017. C’est le parseur utilisé par + défaut si aucun autre n’est spécifié. Ce parseur utilise + BeautifulSoup4_. + - ``edt.management.parsers.ups2018``, pour le format utilisé par + l’Université Paul Sabatier en 2018. Ce parseur utilise LXML_ et + exploite l’IO asynchrone de Python. + +Pour spécifier le parseur à utiliser, il faut rajouter une variable +``CS_PARSER``, contenant le parseur à utiliser sous forme de chaîne de +caractères. Pour utiliser le parseur +``edt.management.parsers.ups2018``, il faut donc rajouter cette +ligne : + +.. code:: Python + + CS_PARSERS = "edt.management.parsers.ups2018" + +Pour l’instant, le parseur est global. Il n’est pas encore possible +d’en spécifier un par source d’emploi du temps. + +Vous **devez** vérifier le format des emplois du temps à parser, cette +étape est donc **obligatoire**. + Gestion des fichiers statiques `````````````````````````````` Si vous êtes en production, vous devez renseigner l’emplacement de diff --git a/Documentation/usage/versions.rst b/Documentation/usage/versions.rst index d6b8f00..3b45c59 100644 --- a/Documentation/usage/versions.rst +++ b/Documentation/usage/versions.rst @@ -33,3 +33,51 @@ Changements internes salles. - Ajout de la commande :doc:`reparse ` - Meilleure abstraction des templates, notamment de ``index.html`` + +.. _ref-ver-0.14: + +Version 0.14 +============ +Changements externes +-------------------- + - Tri des salles par ordre alphabétique dans l’interface + d’administration. + - Les champs de mention, de semestre et de sous-groupe d’un groupe ne + sont plus en lecture seule dans l’interface d’administration. + +Changements internes +-------------------- + - Modularisation du parseur d’emplois du temps. + - Nouveau parseur pour supporter le format utilisé en 2018 par + l’Université Paul Sabatier. + - Correction d’un bogue qui faisait planter le parseur si on + demandait une mise à jour complète alors que la source ne contenait + pas de semaines ; désormais, si une source ne contient pas de + semaines, la date de mise à jour de la source est modifiée, et + aucun cours n’est supprimé ou rajouté. + - Correction du format des semaines dans ``get_week()``. Elles + étaient parsées avec le format de base de Python au lieu du format + ISO-8601. Selon le format de Python, le 1er janvier 2019 fait + partie de la 53ème semaine de l’an 2018, alors que selon ISO, il + fait partie de la 1ère semaine de 2019. Étant donné que d’autres + parties de celcatsanitizer gèrent les dates selon ISO, cela posait + des problèmes de cohérence. + - Support des sous-groupes contenant un chiffre avant le premier + caractère. + - Augmentation du nombre de caractères maximum du nom d’un cours de + 255 à 511 caractères. + +Remarques supplémentaires +------------------------- +Les objectifs originaux de celcatsanitizer consistaient en ceux de la +:ref:`version 0.15 `, à savoir : + + - Optimisation des requêtes en utilisant des fonctionnalités + spécifiques à PostgreSQL si nécessaire + - Remplacement du moteur de templates de Django par Jinja2_. + - Utilisation de Django 2.1 et de l’aggrégat ``TruncWeek``. + +.. _Jinja2: http://jinja.pocoo.org/ + +Ils n’ont pas pu être suivis à cause d’un manque de temps et de tests +et ont étés reportés à la version 0.15. -- cgit v1.2.1 From b4fde18263de491650c71bd31dffe3c324e97879 Mon Sep 17 00:00:00 2001 From: Alban Gruin Date: Thu, 6 Sep 2018 17:20:52 +0200 Subject: Version 0.14.0 Signed-off-by: Alban Gruin --- Documentation/conf.py | 4 ++-- __init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Documentation/conf.py b/Documentation/conf.py index 683b981..385d7c7 100644 --- a/Documentation/conf.py +++ b/Documentation/conf.py @@ -14,8 +14,8 @@ year = datetime.now().year copyright = u'%d, Alban Gruin' % year author = u'Alban Gruin' -version = u'0.13' -release = u'0.13.0' +version = u'0.14' +release = u'0.14.0' language = 'fr' diff --git a/__init__.py b/__init__.py index eb06e42..746391c 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2017 Alban Gruin +# Copyright (C) 2017-2018 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 @@ -13,7 +13,7 @@ # You should have received a copy of the GNU Affero General Public License # along with celcatsanitizer. If not, see . -VERSION = "0.13.0" +VERSION = "0.14.0" __version__ = VERSION default_app_config = "edt.apps.EdtConfig" -- cgit v1.2.1