diff options
| -rw-r--r-- | management/commands/timetables.py | 35 | ||||
| -rw-r--r-- | management/parsers/abstractparser.py | 52 | ||||
| -rw-r--r-- | management/parsers/ups2017.py | 259 | 
3 files changed, 205 insertions, 141 deletions
| 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 <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,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 <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 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 | 
