diff options
| -rw-r--r-- | Documentation/conf.py | 4 | ||||
| -rw-r--r-- | Documentation/dev/roadmap.rst | 14 | ||||
| -rw-r--r-- | Documentation/usage/installation.rst | 38 | ||||
| -rw-r--r-- | Documentation/usage/versions.rst | 48 | ||||
| -rw-r--r-- | __init__.py | 4 | ||||
| -rw-r--r-- | admin.py | 4 | ||||
| -rw-r--r-- | management/commands/_private.py | 164 | ||||
| -rw-r--r-- | management/commands/timetables.py | 79 | ||||
| -rw-r--r-- | management/parsers/abstractparser.py | 52 | ||||
| -rw-r--r-- | management/parsers/ups2017.py | 162 | ||||
| -rw-r--r-- | management/parsers/ups2018.py | 213 | ||||
| -rw-r--r-- | models.py | 2 | ||||
| -rw-r--r-- | requirements.txt | 13 | ||||
| -rw-r--r-- | tests.py | 33 | ||||
| -rw-r--r-- | utils.py | 27 | 
15 files changed, 630 insertions, 227 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/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 <ref-ver-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 <commands/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 <ref-ver-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. 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 <http://www.gnu.org/licenses/>. -VERSION = "0.13.0" +VERSION = "0.14.0"  __version__ = VERSION  default_app_config = "edt.apps.EdtConfig" @@ -58,13 +58,15 @@ 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,)  @admin.register(Room)  class RoomAdmin(admin.ModelAdmin):      prepopulated_fields = {"slug": ("name",)} +    list_display = ("name",) +    ordering = ("name",)  @admin.register(Course) 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 <http://www.gnu.org/licenses/>. - -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..ee33f7e 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 <http://www.gnu.org/licenses/>. +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,13 +26,17 @@ 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 +DEFAULT_PARSER = "edt.management.parsers.ups2017" + + +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 -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) @@ -63,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 @@ -74,41 +81,45 @@ def process_timetable_week(source, soup, weeks_in_soup, force,         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(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(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 get_events(source, soup, weeks_in_soup, 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      source.save() -def process_timetable(source, force, year=None, weeks=None): -    soup = get_xml(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): @@ -123,9 +134,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 @@ -149,7 +165,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 <http://www.gnu.org/licenses/>. + +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 new file mode 100644 index 0000000..99ce34d --- /dev/null +++ b/management/parsers/ups2017.py @@ -0,0 +1,162 @@ +#    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 <http://www.gnu.org/licenses/>. + +import datetime +import re + +from bs4 import BeautifulSoup +from django.utils import timezone + +from ...models import Course, Group, Room +from .abstractparser import AbstractParser + + +def add_time(date, time): +    ptime = datetime.datetime.strptime(time, "%H:%M") +    delta = datetime.timedelta(hours=ptime.hour, minutes=ptime.minute) +    return date + delta + + +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 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 <http://www.gnu.org/licenses/>. + +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("<br>") +        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 @@ -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) diff --git a/requirements.txt b/requirements.txt index cc4ccf8..f2c309f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -beautifulsoup4==4.6.0 -Django==2.0.4 -gunicorn==19.7.1 -icalendar==4.0.1 -psycopg2-binary==2.7.4 -requests==2.18.4 +beautifulsoup4==4.6.3 +Django==2.0.8 +gunicorn==19.9.0 +icalendar==4.0.2 +lxml==4.2.4 +psycopg2-binary==2.7.5 +requests==2.19.1 @@ -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)") @@ -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 @@ -53,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:  | 
