aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlban Gruin2018-09-06 21:46:51 +0200
committerAlban Gruin2018-09-06 21:46:51 +0200
commit676345434415d40363c80960484abf0295ca800a (patch)
tree76c0f71fd86f19962812a63da109bf79ebd2d43c
parent6b8ea6615de6000ea14396fc2d31eb5c6cb159f9 (diff)
parentb4fde18263de491650c71bd31dffe3c324e97879 (diff)
Merge branch 'stable/0.14.z' into prod/pa1ch/0.y.zv0.14.0-pa1chprod/pa1ch/0.y.z
-rw-r--r--Documentation/conf.py4
-rw-r--r--Documentation/dev/roadmap.rst14
-rw-r--r--Documentation/usage/installation.rst38
-rw-r--r--Documentation/usage/versions.rst48
-rw-r--r--__init__.py4
-rw-r--r--admin.py4
-rw-r--r--management/commands/_private.py164
-rw-r--r--management/commands/timetables.py79
-rw-r--r--management/parsers/abstractparser.py52
-rw-r--r--management/parsers/ups2017.py162
-rw-r--r--management/parsers/ups2018.py213
-rw-r--r--models.py2
-rw-r--r--requirements.txt13
-rw-r--r--tests.py33
-rw-r--r--utils.py27
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"
diff --git a/admin.py b/admin.py
index def84f0..0dc7987 100644
--- a/admin.py
+++ b/admin.py
@@ -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
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)
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
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 cd7f1f8..26de36e 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
@@ -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: