aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlban Gruin2019-09-01 20:47:49 +0200
committerAlban Gruin2019-09-01 20:47:49 +0200
commit00d70c4d65967987c254c72f8bef5fda991f859b (patch)
tree670b2a78bfeb151096cfa844d6b48559dfc3641b
parentfe83f55800f78ff6ced9a13cf5a9c22fde0ead12 (diff)
parenta6eaecf48f63c37cf851386a45cd4b25ac3ed1e0 (diff)
Merge branch 'futur'
-rw-r--r--.mailmap1
-rw-r--r--Documentation/dev/roadmap.rst6
-rw-r--r--Documentation/index.rst2
-rw-r--r--Documentation/usage/commands/printvalues.rst48
-rw-r--r--Documentation/usage/installation.rst64
-rw-r--r--Documentation/usage/rest.rst753
-rw-r--r--Documentation/usage/versions.rst32
-rw-r--r--admin.py16
-rw-r--r--api/serializers.py57
-rw-r--r--api/urls.py25
-rw-r--r--api/views.py191
-rw-r--r--management/commands/__parsercommand.py26
-rw-r--r--management/commands/printvalues.py45
-rw-r--r--management/commands/timetables.py16
-rw-r--r--management/parsers/ups2018.py28
-rw-r--r--management/parsers/ups2019.py126
-rw-r--r--models.py28
-rw-r--r--requirements.txt13
-rw-r--r--templates/calendars.html2
-rw-r--r--templates/index.html2
-rw-r--r--templates/mention_list.html2
-rw-r--r--templatetags/rooms.py14
-rw-r--r--tests.py357
-rw-r--r--tests/data/2018/empty.html50
-rw-r--r--tests/data/2018/october.html50
-rw-r--r--tests/data/2018/september.html51
-rw-r--r--urls.py16
-rw-r--r--views.py16
28 files changed, 1952 insertions, 85 deletions
diff --git a/.mailmap b/.mailmap
new file mode 100644
index 0000000..392fcc0
--- /dev/null
+++ b/.mailmap
@@ -0,0 +1 @@
+Alban Gruin <alban@pa1ch.fr> <alban.gruin@gmail.com>
diff --git a/Documentation/dev/roadmap.rst b/Documentation/dev/roadmap.rst
index 14cad2a..1ae17af 100644
--- a/Documentation/dev/roadmap.rst
+++ b/Documentation/dev/roadmap.rst
@@ -2,14 +2,12 @@
Feuille de route
================
-.. _ref-ver-0.15:
+.. _ref-ver-0.16:
-Version 0.15
+Version 0.16
============
- Optimisation des requêtes en utilisant des fonctionnalités
spécifiques à PostgreSQL si nécessaire
- - 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 ?
diff --git a/Documentation/index.rst b/Documentation/index.rst
index e793dd2..7073b0d 100644
--- a/Documentation/index.rst
+++ b/Documentation/index.rst
@@ -61,8 +61,10 @@ Utilisation de celcatsanitizer
usage/installation
usage/commands/cleancourses
usage/commands/listtimetables
+ usage/commands/printvalues
usage/commands/reparse
usage/commands/timetables
+ usage/rest
usage/versions
Développement
diff --git a/Documentation/usage/commands/printvalues.rst b/Documentation/usage/commands/printvalues.rst
new file mode 100644
index 0000000..7d53d44
--- /dev/null
+++ b/Documentation/usage/commands/printvalues.rst
@@ -0,0 +1,48 @@
+===============
+``printvalues``
+===============
+
+``printvalues`` affiche le contenu brut d’un ou plusieurs cours, sans
+chercher à interpréter son contenu ou à l’enregistrer dans la base de
+données.
+
+Utilisation
+===========
+.. code:: shell
+
+ $ ./manage.py printvalues --source id [--limit nb]
+
+``--source`` permet de spécifier la source depuis laquelle les cours
+doivent être récupérés. ``id`` correspond à l’ID de la source,
+trouvable à l’aide de la commande :doc:`listtimetables`.
+
+``--limit`` permet de limiter le nombre de cours affichés. ``nb``
+correspond au nombre maximum de cours affichés.
+
+Format de sortie
+================
+::
+
+ {
+ "backColor": "#7D4F72",
+ "clickDisabled": true,
+ "doubleClickDisabled": true,
+ "end": "2019-01-28T09:45:00",
+ "html": "<div style=\"color:White \">(07:45-09:45)<br>COURS/TD<br>ELINF6Q1 - BIOLOGIE<br>L3 INFO s2 CMA<br>U3-307</div>",
+ "id": "76330023",
+ "moveDisabled": true,
+ "resizeDisabled": true,
+ "sort": [],
+ "start": "2019-01-28T07:45:00",
+ "tag": [
+ "celcat",
+ "sat_notvalid",
+ "1",
+ "reg_notmark",
+ "ELINF6Q1",
+ "7491453"
+ ],
+ "text": "(07:45-09:45)<br>COURS/TD<br>ELINF6Q1 - BIOLOGIE<br>L3 INFO s2 CMA<br>U3-307",
+ "toolTip": "(07:45-09:45)<br>COURS/TD<br>ELINF6Q1 - BIOLOGIE<br>L3 INFO s2 CMA<br>U3-307"
+ }
+ Done.
diff --git a/Documentation/usage/installation.rst b/Documentation/usage/installation.rst
index 4dde4f4..b86b257 100644
--- a/Documentation/usage/installation.rst
+++ b/Documentation/usage/installation.rst
@@ -7,15 +7,18 @@ Dépendances
celcatsanitizer est écrit en Python 3. Il dépend des bibliothèques
suivantes :
- - `Django 2.0`_
+ - `Django 2.2`_
- requests_, pour récupérer les emplois du temps en HTTP(S)
- 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.
+Une dépendance est optionnelle :
+
+ - `Django REST Framework`_, pour l’:doc:`API REST <rest>`.
+
+celcatsanitizer requiert Python 3.6 au minimum. Les versions
+supérieures devraient fonctionner sans problèmes, mais pas les
+versions antérieures.
*A priori*, il est possible d’utiliser n’importe quel SGBD supporté
par Django avec celcatsanitizer. Cependant, l’utilisation de
@@ -24,13 +27,14 @@ d’installer le module psycopg2_.
Pour l’instant, l’installation doit passer par git_.
-.. _Django 2.0: https://www.djangoproject.com/
+.. _Django 2.2: https://www.djangoproject.com/
.. _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
+.. _Django REST Framework: https://www.django-rest-framework.org/
.. _PostgreSQL: https://www.postgresql.org/
.. _psycopg2: http://initd.org/psycopg/docs/install.html
.. _git: https://git-scm.com/
@@ -41,12 +45,9 @@ celcatsanitizer utilise des versions assez récentes de Django,
notamment en ce qui concerne son ORM. Le passage de Django 1.10 à
Django 1.11 s’est fait pour utiliser l’annotation ``ExtractWeek``, le
passage de Django 1.11 à Django 2.0 pour utiliser l’attribut
-``distinct`` de l’aggrégat ``ArrayAgg``.
-
-celcatsanitizer passera à Django 2.1 lorsqu’il sortira pour utiliser
-l’annotation ``TruncWeek``, pour l’instant implémenté avec une requête
-SQL brute. Cette fonctionnalité ne sera nécessaire que pour les
-utilisateurs de PostgreSQL.
+``distinct`` de l’aggrégat ``ArrayAgg``. Il utilise l’aggrégat
+``TruncWeek`` apparu dans Django 2.1, et se base officiellement sur
+Django 2.2 pour bénéficier du support à long terme.
Installation
============
@@ -95,6 +96,13 @@ psycopg2 :
$ pip install psycopg2-binary
+Si vous souhaitez activer l’API REST, vous devez installer le module
+Django Rest Framework :
+
+.. code:: shell
+
+ $ pip install djangorestframework
+
Si vous êtes en production, il est recommandé d’utiliser gunicorn_ si
vous n’utilisez pas le serveur Apache. Installez-le de la même
manière :
@@ -179,7 +187,7 @@ __
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
+:ref:`version 0.14 <ref-ver-0.14>`, et embarque par défaut trois
parseurs :
- ``edt.management.parsers.ups2017``, pour le format utilisé par
@@ -189,6 +197,9 @@ parseurs :
- ``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.
+ - ``edt.management.parsers.ups2019``, pour le format utilisé par
+ l’Université Paul Sabatier en 2019. Ce parseur utilise le module
+ JSON standard.
Pour spécifier le parseur à utiliser, il faut rajouter une variable
``CS_PARSER``, contenant le parseur à utiliser sous forme de chaîne de
@@ -232,6 +243,33 @@ Ce paramètre est **optionnel**.
__ https://docs.djangoproject.com/fr/2.0/topics/i18n/
+Activation de l’API REST et configuration de Django Rest Framework
+``````````````````````````````````````````````````````````````````
+L’API REST permet à des outils tiers d’accéder facilement aux données
+gérées par celcatsanitizer. Elle est optionnelle, et est basée sur
+Django Rest Framework. :doc:`Plus d’informations sur la page de l’API
+REST <rest>`.
+
+Si vous souhaitez l’activer, vous devez d’abord avoir installé Django
+REST Framework, puis mettre la variable ``CS_ENABLE_API`` à ``True``.
+
+.. code:: Python
+
+ CS_ENABLE_API = True
+
+Ajoutez ensuite la chaîne de caractère ``rest_framework`` à la liste
+``INSTALLED_APPS``.
+
+Libre à vous de configurer DRF de la manière dont vous le souhaitez.
+`Les différents paramètres sont accessibles ici`__. Les plus
+intéressants sont ``DEFAULT_PERMISSION_CLASSES``,
+``DEFAULT_RENDERER_CLASSES``, ``DEFAULT_PAGINATION_CLASS`` et
+``PAGE_SIZE``.
+
+__ https://www.django-rest-framework.org/api-guide/settings/
+
+Cette étape est **optionnelle**.
+
``urls.py``
-----------
Dans le fichier ``celcatsanitizer/urls.py``, importez la fonction
diff --git a/Documentation/usage/rest.rst b/Documentation/usage/rest.rst
new file mode 100644
index 0000000..d18eaa5
--- /dev/null
+++ b/Documentation/usage/rest.rst
@@ -0,0 +1,753 @@
+========
+API REST
+========
+
+celcatsanitizer dispose d’une API REST pour permettre à des outils
+tiers d’accéder facilement à ses données, qui sont renvoyées en JSON.
+Pour l’instant, il ne permet pas la modification des données.
+
+Le point d’entrée se trouve à l’adresse ``api/`` de l’instance de
+celcatsanitizer. Il retourne la liste des autres points d’accès en
+JSON.
+
+En fonction de la configuration de celcatsanitizer, l’API ne sera
+peut-être pas disponible.
+
+Années
+======
+
+``api/years/``
+--------------
+Liste les années par ordre alphabétique de nom. :ref:`Le résultat
+peut être paginé <ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 4,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 3,
+ "name": "L1",
+ "slug": "l1"
+ },
+ {
+ "id": 4,
+ "name": "L2",
+ "slug": "l2"
+ },
+ {
+ "id": 1,
+ "name": "L3",
+ "slug": "l3"
+ },
+ {
+ "id": 2,
+ "name": "M1",
+ "slug": "m1"
+ }
+ ]
+ }
+
+``api/years/<id>/``
+-------------------
+Retourne seulement une année.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "id": 1,
+ "name": "L3",
+ "slug": "l3"
+ }
+
+``api/years/<id>/timetables/``
+------------------------------
+Liste les emplois du temps associés à une année par ordre alphabétique
+de nom. :ref:`Le résultat peut être paginé <ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 2,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 2,
+ "name": "1ere année SRI",
+ "slug": "1ere-annee-sri",
+ "year": 1,
+ "source": 2
+ },
+ {
+ "id": 1,
+ "name": "Info",
+ "slug": "info",
+ "year": 1,
+ "source": 1
+ }
+ ]
+ }
+
+Emplois du temps
+================
+
+``api/timetables/``
+-------------------
+Liste les emplois du temps par ordre d’année (ID associé) puis de nom.
+:ref:`Le résultat peut être paginé <ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 1,
+ "name": "Info",
+ "slug": "info",
+ "year": 1,
+ "source": 1
+ }
+ ]
+ }
+
+``api/timetables/<id>/``
+------------------------
+Retourne seulement un emploi du temps.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "id": 1,
+ "name": "Info",
+ "slug": "info",
+ "year": 1,
+ "source": 1
+ }
+
+``api/timetables/<id>/groups/``
+-------------------------------
+Retourne la liste des groupes associés à un emploi du temps, triés par
+ordre alphabétique. :ref:`Le résultat peut être paginé
+<ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 2,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 207,
+ "name": "L2 Info s1 CMA",
+ "celcat_name": "L2 Info s1 CMA",
+ "mention": "L2 Info",
+ "semester": 1,
+ "subgroup": "A",
+ "slug": "l2-info-s1-cma",
+ "hidden": false,
+ "source": 1
+ },
+ {
+ "id": 208,
+ "name": "L3 INFO (toutes sections et semestres confondus)",
+ "celcat_name": "L3 INFO (toutes sections et semestres confondus)",
+ "mention": "L3 INFO",
+ "semester": null,
+ "subgroup": "",
+ "slug": "l3-info-toutes-sections-et-semestres-confondus",
+ "hidden": false,
+ "source": 1
+ }
+ ]
+ }
+
+Sources
+=======
+
+``api/sources/``
+----------------
+Retourne la liste des sources par ordre d’ID. :ref:`Le résultat peut
+être paginé <ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 2,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 1,
+ "url": "https://edt.univ-tlse3.fr/calendar/default.aspx?View=month&Type=group&ResourceN ame=formation_ELINFE",
+ "last_update_date": null
+ },
+ {
+ "id": 2,
+ "url": "https://edt.univ-tlse3.fr/calendar/default.aspx?View=month&Type=group&ResourceN ame=formation_ELUSR1_s1",
+ "last_update_date": null
+ }
+ ]
+ }
+
+``api/sources/<id>/``
+---------------------
+Renvoie seulement une source.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "id": 1,
+ "url": "https://edt.univ-tlse3.fr/calendar/default.aspx?View=month&Type=group&ResourceName=formation_ELINFE",
+ "last_update_date": null
+ }
+
+``api/sources/<id>/timetables/``
+--------------------------------
+Renvoie la liste des emplois du temps associé à une source triés par
+ordre alphabétique. :ref:`Le résultat peut être paginé
+<ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 1,
+ "name": "Info",
+ "slug": "info",
+ "year": 1,
+ "source": 1
+ }
+ ]
+ }
+
+Groupes
+=======
+
+``api/groups/``
+---------------
+Liste les groupes par ordre alphabétique. :ref:`Le résultat peut être
+paginé <ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 2,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 207,
+ "name": "L2 Info s1 CMA",
+ "celcat_name": "L2 Info s1 CMA",
+ "mention": "L2 Info",
+ "semester": 1,
+ "subgroup": "A",
+ "slug": "l2-info-s1-cma",
+ "hidden": false,
+ "source": 1
+ },
+ {
+ "id": 208,
+ "name": "L3 INFO (toutes sections et semestres confondus)",
+ "celcat_name": "L3 INFO (toutes sections et semestres confondus)",
+ "mention": "L3 INFO",
+ "semester": null,
+ "subgroup": "",
+ "slug": "l3-info-toutes-sections-et-semestres-confondus",
+ "hidden": false,
+ "source": 1
+ }
+ ]
+ }
+
+``api/groups/<id>/``
+--------------------
+Affiche seulement un groupe.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "id": 207,
+ "name": "L2 Info s1 CMA",
+ "celcat_name": "L2 Info s1 CMA",
+ "mention": "L2 Info",
+ "semester": 1,
+ "subgroup": "A",
+ "slug": "l2-info-s1-cma",
+ "hidden": false,
+ "source": 1
+ }
+
+.. _ref-groups-courses:
+
+``api/groups/<id>/courses/``
+----------------------------
+Retourne tous les cours d’un groupe et de ses parents triés par ordre
+de début. :ref:`Le résultat peut être paginé <ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 2,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 34723,
+ "groups": [
+ {
+ "id": 98,
+ "name": "L3 INFO s1 CMA",
+ "celcat_name": "L3 INFO s1 CMA",
+ "mention": "L3 INFO",
+ "semester": 1,
+ "subgroup": "A",
+ "slug": "l3-info-s1-cma",
+ "hidden": false,
+ "source": 1
+ },
+ {
+ "id": 207,
+ "name": "L2 Info s1 CMA",
+ "celcat_name": "L2 Info s1 CMA",
+ "mention": "L2 Info",
+ "semester": 1,
+ "subgroup": "A",
+ "slug": "l2-info-s1-cma",
+ "hidden": false,
+ "source": 1
+ }
+ ],
+ "rooms": [],
+ "name": "REUNION / RENCONTRE",
+ "type": null,
+ "notes": "Nuit de l'info",
+ "begin": "2018-12-06T13:30:00+01:00",
+ "end": "2018-12-06T23:45:00+01:00",
+ "last_update": "2018-12-31T13:26:57.122490+01:00",
+ "source": 1
+ },
+ {
+ "id": 34727,
+ "groups": [
+ {
+ "id": 98,
+ "name": "L3 INFO s1 CMA",
+ "celcat_name": "L3 INFO s1 CMA",
+ "mention": "L3 INFO",
+ "semester": 1,
+ "subgroup": "A",
+ "slug": "l3-info-s1-cma",
+ "hidden": false,
+ "source": 1
+ },
+ {
+ "id": 207,
+ "name": "L2 Info s1 CMA",
+ "celcat_name": "L2 Info s1 CMA",
+ "mention": "L2 Info",
+ "semester": 1,
+ "subgroup": "A",
+ "slug": "l2-info-s1-cma",
+ "hidden": false,
+ "source": 1
+ }
+ ],
+ "rooms": [],
+ "name": "REUNION / RENCONTRE",
+ "type": null,
+ "notes": "Nuit de l'info",
+ "begin": "2018-12-07T07:45:00+01:00",
+ "end": "2018-12-07T23:45:00+01:00",
+ "last_update": "2018-12-31T13:26:57.136381+01:00",
+ "source": 1
+ }
+ ]
+ }
+
+``api/groups/<id>/courses/days/current/``
+-----------------------------------------
+Retourne la liste des cours du groupe et de ses parents d’un groupe
+pendant le jour courant, par ordre de début. :ref:`Le résultat peut
+être paginé <ref-pagination>`. Le format du résultat est identique à
+celui de :ref:`api/groups/\<id>/courses/ <ref-groups-courses>`.
+
+.. _ref-groups-courses-day-arg:
+
+``api/groups/<id>/courses/days/<year>/<month>/<day>/``
+------------------------------------------------------
+Retourne la liste des cours du groupe et de ses parents pendant le
+jour spécifié, par ordre de début. Si l’année, le mois ou le jour ne
+sont pas des nombres, un code 404 est renvoyé. Si la date est
+invalide, une erreur 400 est renvoyée, et les erreurs rencontrées sont
+renvoyées. :ref:`Le résultat peut être paginé <ref-pagination>`. Le
+format du résultat est identique à celui de
+:ref:`api/groups/\<id>/courses/ <ref-groups-courses>`.
+
+Exemple d’erreur (``api/groups/<id>/courses/days/2018/111/22``) :
+`````````````````````````````````````````````````````````````````
+.. code:: json
+
+ {
+ "month": "Rentrez un mois valide"
+ }
+
+Exemple d’erreur (``api/groups/<id>/courses/days/2018/11/33``) :
+````````````````````````````````````````````````````````````````
+.. code:: json
+
+ {
+ "day": "Numéro de jour invalide pour le mois"
+ }
+
+``api/groups/<id>/courses/weeks/``
+----------------------------------
+Retourne la liste des semaines de cours d’un groupe.
+
+Exemple :
+`````````
+.. code:: json
+
+ [
+ "2018-12-03T00:00:00+01:00"
+ ]
+
+``api/groups/<id>/courses/weeks/current/``
+------------------------------------------
+Retourne la liste des cours du groupe et de ses parents pendant la
+semaine courante (ou prochaine lors du week-end) d’un groupe, par
+ordre de début. :ref:`Le résultat peut être paginé <ref-pagination>`.
+Le format du résultat est identique à celui de
+:ref:`api/groups/\<id>/courses/ <ref-groups-courses>`.
+
+.. _ref-groups-courses-week-arg:
+
+``api/groups/<id>/courses/weeks/<year>/<week>/``
+------------------------------------------------
+Retourne la liste des cours du groupe et de ses parents pendant la
+semaine spécifiée, par ordre de début. Si l’année ou la semaine ne
+sont pas des nombres, un code 404 est renvoyé. Si la semaine n’est
+pas comprise entre 1 et 53, une erreur 400 est renvoyée, et les
+erreurs rencontrées sont renvoyées. :ref:`Le résultat peut être
+paginé <ref-pagination>`. Le format du résultat est identique à celui
+de :ref:`api/groups/\<id>/courses/ <ref-groups-courses>`.
+
+Exemple d’erreur (``api/groups/<id>/courses/weeks/2018/111/``) :
+````````````````````````````````````````````````````````````````
+.. code:: json
+
+ {
+ "week": "Rentrez une semaine valide"
+ }
+
+Salles
+======
+
+``api/rooms/``
+--------------
+Liste les salles par ordre alphabétique. :ref:`Le résultat peut être
+paginé <ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 3,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 26,
+ "name": "1R1-010",
+ "slug": "1r1-010"
+ },
+ {
+ "id": 11,
+ "name": "1TP1-B08",
+ "slug": "1tp1-b08"
+ },
+ {
+ "id": 5,
+ "name": "1TP1-B08bis",
+ "slug": "1tp1-b08bis"
+ }
+ ]
+ }
+
+``api/rooms/<id>/``
+-------------------
+Renvoie une seule salle.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "id": 26,
+ "name": "1R1-010",
+ "slug": "1r1-010"
+ }
+
+``api/rooms/<id>/courses/``
+---------------------------
+Renvoie la liste des cours se déroulant dans une salle par ordre de
+début. :ref:`Le résultat peut être paginé <ref-pagination>`. Le
+format du résultat est identique à celui de
+:ref:`api/groups/\<id>/courses/ <ref-groups-courses>`.
+
+``api/rooms/<id>/courses/days/current/``
+----------------------------------------
+Retourne la liste des cours se déroulant dans une salle pendant le
+jour courant, par ordre de début. :ref:`Le résultat peut être paginé
+<ref-pagination>`. Le format du résultat est identique à celui de
+:ref:`api/groups/\<id>/courses/ <ref-groups-courses>`.
+
+``api/rooms/<id>/courses/days/<year>/<month>/<day>/``
+-----------------------------------------------------
+Retourne la liste des cours se déroulant dans une salle pendant le
+jour spécifié, par ordre de début. Si l’année, le mois ou le jour ne
+sont pas des nombres, un code 404 est renvoyé. Si la date est
+invalide, une erreur 400 est renvoyée, et les erreurs rencontrées sont
+renvoyées. :ref:`Le résultat peut être paginé <ref-pagination>`. Le
+format du résultat est identique à celui de
+:ref:`api/groups/\<id>/courses/days/\<year>/\<month>/\<day>/
+<ref-groups-courses-day-arg>`.
+
+``api/rooms/<id>/courses/weeks/current/``
+-----------------------------------------
+Renvoie la liste des cours se déroulant dans une salle pendant la
+semaine courante (ou la semaine prochaine le week-end) par ordre de
+début. :ref:`Le résultat peut être paginé <ref-pagination>`. Le
+format du résultat est identique à celui de
+:ref:`api/groups/\<id>/courses/ <ref-groups-courses>`.
+
+``api/rooms/<id>/courses/weeks/<year>/<week>/``
+-----------------------------------------------
+Renvoie la liste des cours se déroulant dans une salle pendant la
+semaine spécifiée. Si l’année ou la semaine ne sont pas des nombres,
+un code 404 est renvoyé. Si la semaine n’est pas comprise entre 1 et
+53, une erreur 400 est renvoyée, et les erreurs rencontrées sont
+renvoyées. :ref:`Le résultat peut être paginé <ref-pagination>`. Le
+format du résultat est identique à celui de
+:ref:`api/groups/\<id>/courses/weeks/\<year>/\<week>/
+<ref-groups-courses-week-arg>`.
+
+``api/rooms/qsjps/<day>/<begin>/<end>/``
+----------------------------------------
+Fournit un accès à QSJPS. ``<day>`` est une date devant être formatée
+de cette manière : ``YYYY-MM-DD``. ``<begin>`` et ``<end>`` sont des
+heures qui doivent être formatées de cette manière : ``HH:mm``. La
+valeur de ``<begin>`` doit être inférieure à celle de ``<end>``.
+
+Renvoie la liste des salles vides le début du jour ``<day>`` de
+``<begin>`` à ``<end>``.
+
+En cas de mauvais formatage, une erreur 400 est renvoyée, et les
+erreurs sont détaillées dans le corps de la réponse. Sinon, la liste
+des salles libres est renvoyée.
+
+Exemple :
+`````````
+.. code:: json
+
+ [
+ {
+ "id": 26,
+ "name": "1R1-010",
+ "slug": "1r1-010"
+ },
+ {
+ "id": 11,
+ "name": "1TP1-B08",
+ "slug": "1tp1-b08"
+ },
+ {
+ "id": 5,
+ "name": "1TP1-B08bis",
+ "slug": "1tp1-b08bis"
+ }
+ ]
+
+Exemple d’erreur (``api/rooms/qsjps/2019-01-35/12:00/10:00/``) :
+````````````````````````````````````````````````````````````````
+.. code:: json
+
+ {
+ "day": [
+ "Saisissez une date valide."
+ ],
+ "end": [
+ "L’heure de début doit être supérieure à celle de fin."
+ ]
+ }
+
+Exemple d’erreur (``api/rooms/qsjps/2019-01-35/12:70/10:70/``) :
+````````````````````````````````````````````````````````````````
+.. code:: json
+
+ {
+ "day": [
+ "Saisissez une date valide."
+ ],
+ "begin": [
+ "Saisissez une heure valide."
+ ],
+ "end": [
+ "Saisissez une heure valide."
+ ]
+ }
+
+Cours
+=====
+
+``api/courses/``
+----------------
+Renvoie la liste des cours. :ref:`Le résultat peut être paginé
+<ref-pagination>`.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "count": 4766,
+ "next": "http://localhost:8000/api/courses/?page=2",
+ "previous": null,
+ "results": [
+ {
+ "id": 22133,
+ "groups": [
+ {
+ "id": 98,
+ "name": "L3 INFO s1 CMA",
+ "celcat_name": "L3 INFO s1 CMA",
+ "mention": "L3 INFO",
+ "semester": 1,
+ "subgroup": "A",
+ "slug": "l3-info-s1-cma",
+ "hidden": false,
+ "source": 1
+ }
+ ],
+ "rooms": [
+ {
+ "id": 1,
+ "name": "Amphi AMPERE (3A)",
+ "slug": "amphi-ampere-3a"
+ }
+ ],
+ "name": "REUNION / RENCONTRE",
+ "type": null,
+ "notes": null,
+ "begin": "2018-09-04T15:45:00+02:00",
+ "end": "2018-09-04T16:45:00+02:00",
+ "last_update": "2018-09-26T19:34:12.924533+02:00",
+ "source": 1
+ },
+ …
+ ]
+ }
+
+``api/courses/<id>/``
+---------------------
+Renvoie un seul cours.
+
+Exemple :
+`````````
+.. code:: json
+
+ {
+ "id": 22133,
+ "groups": [
+ {
+ "id": 98,
+ "name": "L3 INFO s1 CMA",
+ "celcat_name": "L3 INFO s1 CMA",
+ "mention": "L3 INFO",
+ "semester": 1,
+ "subgroup": "A",
+ "slug": "l3-info-s1-cma",
+ "hidden": false,
+ "source": 1
+ }
+ ],
+ "rooms": [
+ {
+ "id": 1,
+ "name": "Amphi AMPERE (3A)",
+ "slug": "amphi-ampere-3a"
+ }
+ ],
+ "name": "REUNION / RENCONTRE",
+ "type": null,
+ "notes": null,
+ "begin": "2018-09-04T15:45:00+02:00",
+ "end": "2018-09-04T16:45:00+02:00",
+ "last_update": "2018-09-26T19:34:12.924533+02:00",
+ "source": 1
+ }
+
+.. _ref-pagination:
+
+Pagination
+==========
+Il est possible que les résultats soient paginés. Cela dépend de la
+configuration de l’instance de celcatsanitizer. Dans ce cas, tous les
+appels à des points d’accès renvoyant des résultats pouvant être
+paginés se trouvent dans ce genre de structure :
+
+.. code:: json
+
+ {
+ "count": 4766,
+ "next": "http://localhost:8000/api/courses/?page=2",
+ "previous": null,
+ "results": [
+ …
+ ]
+ }
+
+- ``count`` représente le nombre d’éléments au total (et non pas sur
+ la page).
+- ``next`` est le lien de la page de résultats suivants, si il y en a
+ une.
+- ``previous`` est le lien de la page de résultats précédents, si il
+ y en a une.
+- ``results`` est la liste des résultats, si il y en a.
diff --git a/Documentation/usage/versions.rst b/Documentation/usage/versions.rst
index ed76ce5..d4e6d1f 100644
--- a/Documentation/usage/versions.rst
+++ b/Documentation/usage/versions.rst
@@ -118,3 +118,35 @@ Version 0.14.4
--------------
- Ajout d’une liste de logiciels lisant les calendriers au format ICS
et déconseillant l’usage de Google Calendar.
+
+.. _ref-ver-0.15:
+
+Version 0.15
+============
+Changements externes
+--------------------
+ - Utilisation du nouveau nom de ICSdroid (maintenant ICSx⁵) dans la
+ page des calendriers.
+ - Copyright 2019.
+
+Changements internes
+--------------------
+ - Ajout de tests pour le parseur UPS2018.
+ - Ajout du parseur UPS2019.
+ - Ajout d’une table « modules ».
+ - Mise à jour de Python. La version minimale supportée est la 3.6.
+ - Mise à jour de Django et utilisation de TruncWeek.
+
+Autres remarques
+----------------
+Les objectifs originaux de celcatsanitizer consistaient en ceux de la
+:ref:`version 0.16 <ref-ver-0.16>`, à 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_.
+
+.. _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.16.
diff --git a/admin.py b/admin.py
index 0dc7987..527b1eb 100644
--- a/admin.py
+++ b/admin.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2018 Alban Gruin
+# Copyright (C) 2017-2019 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
@@ -14,7 +14,7 @@
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
from django.contrib import admin
-from .models import Course, Group, Room, Source, Timetable, Year
+from .models import Course, Group, Module, Room, Source, Timetable, Year
def make_hidden(modeladmin, request, queryset):
@@ -38,7 +38,7 @@ class YearAdmin(admin.ModelAdmin):
@admin.register(Source)
class SourceAdmin(admin.ModelAdmin):
- list_display = ("url", "last_update_date",)
+ list_display = ("url", "metadata", "last_update_date",)
@admin.register(Timetable)
@@ -62,7 +62,7 @@ class GroupAdmin(admin.ModelAdmin):
actions = (make_hidden, make_visible,)
-@admin.register(Room)
+@admin.register(Room, Module)
class RoomAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)}
list_display = ("name",)
@@ -72,10 +72,12 @@ class RoomAdmin(admin.ModelAdmin):
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
fieldsets = (
- (None, {"fields": ("name", "type", "source", "groups", "rooms",
- "last_update",)}),
+ (None, {"fields": ("name", "type", "source", "groups", "rooms",)}),
("Horaires", {"fields": ("begin", "end",)}),
- ("Remarques", {"fields": ("notes",)}),)
+ ("Remarques", {"fields": ("notes",)}),
+ ("Avancé", {"fields": ("celcat_id", "last_update", "buggy",),
+ "classes": ("collapse",)}),)
list_display = ("name", "type", "source", "begin", "end",)
list_filter = ("type", "source__timetables", "groups",)
ordering = ("begin",)
+ readonly_fields = ("celcat_id", "last_update",)
diff --git a/api/serializers.py b/api/serializers.py
new file mode 100644
index 0000000..5d81f44
--- /dev/null
+++ b/api/serializers.py
@@ -0,0 +1,57 @@
+# Copyright (C) 2019 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 rest_framework import serializers
+
+from ..models import Course, Group, Room, Source, Timetable, Year
+
+
+class YearSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Year
+ fields = "__all__"
+
+
+class SourceSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Source
+ fields = "__all__"
+
+
+class TimetableSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Timetable
+ fields = "__all__"
+
+
+class GroupSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Group
+ fields = "__all__"
+
+
+class RoomSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Room
+ fields = "__all__"
+
+
+class CourseSerializer(serializers.ModelSerializer):
+ groups = GroupSerializer(many=True, read_only=True)
+ rooms = RoomSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = Course
+ fields = "__all__"
diff --git a/api/urls.py b/api/urls.py
new file mode 100644
index 0000000..0164a4f
--- /dev/null
+++ b/api/urls.py
@@ -0,0 +1,25 @@
+# Copyright (C) 2019 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 rest_framework import routers
+from . import views
+
+router = routers.DefaultRouter()
+router.register(r"years", views.YearViewSet)
+router.register(r"sources", views.SourceViewSet)
+router.register(r"timetables", views.TimetableViewSet)
+router.register(r"rooms", views.RoomViewSet)
+router.register(r"groups", views.GroupViewSet)
+router.register(r"courses", views.CourseViewSet)
diff --git a/api/views.py b/api/views.py
new file mode 100644
index 0000000..2459455
--- /dev/null
+++ b/api/views.py
@@ -0,0 +1,191 @@
+# Copyright (C) 2019 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
+
+from django.db.models import Count
+from django.db.models.functions import ExtractWeek, ExtractYear
+from django.utils import timezone
+
+from rest_framework import viewsets
+from rest_framework.decorators import action
+from rest_framework.response import Response
+
+from ..forms import QSJPSForm
+from ..models import Course, Group, Room, Source, Timetable, Year
+from ..utils import get_current_or_next_week, get_week
+
+from .serializers import CourseSerializer, GroupSerializer, RoomSerializer, \
+ SourceSerializer, TimetableSerializer, YearSerializer
+
+
+class YearViewSet(viewsets.ReadOnlyModelViewSet):
+ queryset = Year.objects.all().order_by("name")
+ serializer_class = YearSerializer
+
+ @action(detail=True, methods=["get"], url_path="timetables")
+ def timetable_list(self, request, pk):
+ year = self.get_object()
+ timetables = Timetable.objects.filter(year=year).distinct() \
+ .order_by("name")
+ timetables_json = TimetableSerializer(
+ self.paginate_queryset(timetables), many=True)
+ return self.get_paginated_response(timetables_json.data)
+
+
+class SourceViewSet(viewsets.ReadOnlyModelViewSet):
+ queryset = Source.objects.all().order_by("pk")
+ serializer_class = SourceSerializer
+
+ @action(detail=True, methods=["get"], url_path="timetables")
+ def timetable_list(self, request, pk):
+ source = self.get_object()
+ timetables = Timetable.objects.filter(source=source).distinct() \
+ .order_by("name")
+ timetables_json = TimetableSerializer(
+ self.paginate_queryset(timetables), many=True)
+ return self.get_paginated_response(timetables_json.data)
+
+
+class TimetableViewSet(viewsets.ReadOnlyModelViewSet):
+ queryset = Timetable.objects.all().select_related("source") \
+ .order_by("year", "name")
+ serializer_class = TimetableSerializer
+
+ @action(detail=True, methods=["get"], url_path="groups")
+ def group_list(self, request, pk):
+ timetable = self.get_object()
+ groups = Group.objects.filter(source=timetable.source).distinct() \
+ .order_by("name")
+ groups_json = GroupSerializer(self.paginate_queryset(groups),
+ many=True)
+ return self.get_paginated_response(groups_json.data)
+
+
+class CourseListGroupSet(viewsets.ReadOnlyModelViewSet):
+ @action(detail=True, methods=["get"], url_path="courses")
+ def course_list(self, request, pk):
+ obj = self.get_object()
+ courses = Course.objects.get_courses(obj).prefetch_related("groups")
+ courses_json = CourseSerializer(self.paginate_queryset(courses),
+ many=True)
+ return self.get_paginated_response(courses_json.data)
+
+ def __get_courses(self, obj, start, end):
+ courses = Course.objects.get_courses(obj,
+ begin__gte=start, end__lt=end) \
+ .prefetch_related("groups")
+ courses_json = CourseSerializer(self.paginate_queryset(courses),
+ many=True)
+ return self.get_paginated_response(courses_json.data)
+
+ @action(detail=True, methods=["get"], url_path="courses/days/current")
+ def current_day(self, request, pk):
+ obj = self.get_object()
+ start = datetime.date.today()
+ end = start + datetime.timedelta(days=1)
+ return self.__get_courses(obj, start, end)
+
+ @action(detail=True, methods=["get"],
+ url_path="courses/days/(?P<year>\d+)/(?P<month>\d+)/(?P<day>\d+)")
+ def other_day(self, request, pk, year, month, day):
+ obj = self.get_object()
+
+ try:
+ start = datetime.date(int(year), int(month), int(day))
+ except ValueError as v:
+ errors = {}
+ message = v.args[0]
+ if message.split(" ")[0] == "month":
+ errors["month"] = "Rentrez un mois invalide"
+ else:
+ errors["day"] = "Numéro de jour invalide pour le mois"
+ return Response(errors, status=400)
+
+ end = start + datetime.timedelta(days=1)
+ return self.__get_courses(obj, start, end)
+
+ @action(detail=True, methods=["get"], url_path="courses/weeks/current")
+ def current_week(self, request, pk):
+ obj = self.get_object()
+ start, end = get_week(*get_current_or_next_week())
+ return self.__get_courses(obj, start, end)
+
+ @action(detail=True, methods=["get"],
+ url_path="courses/weeks/(?P<year>\d+)/(?P<week>\d+)")
+ def other_week(self, request, pk, year, week):
+ obj = self.get_object()
+
+ errors = {}
+ if not year.isdigit():
+ errors["year"] = "Rentrez une année valide"
+ if not week.isdigit() or not 0 < int(week) <= 53:
+ errors["week"] = "Rentrez une semaine valide"
+ if errors:
+ return Response(errors, status=400)
+
+ return self.__get_courses(obj, *get_week(int(year), int(week)))
+
+
+class GroupViewSet(CourseListGroupSet):
+ queryset = Group.objects.all().order_by("name")
+ serializer_class = GroupSerializer
+
+ @action(detail=True, methods=["get"], url_path="courses/weeks")
+ def weeks(self, request, pk):
+ group = self.get_object()
+ groups = Group.objects.get_parents(group)
+
+ courses = Course.objects.filter(groups__in=groups) \
+ .order_by("year", "week") \
+ .annotate(year=ExtractYear("begin"),
+ week=ExtractWeek("begin")) \
+ .values("year", "week") \
+ .annotate(c=Count("*"))
+
+ weeks = [get_week(course["year"], course["week"])[0]
+ for course in courses]
+
+ return Response(weeks)
+
+
+class RoomViewSet(CourseListGroupSet):
+ queryset = Room.objects.all().order_by("name")
+ serializer_class = RoomSerializer
+
+ @action(
+ methods=["get"],
+ detail=False,
+ url_path="qsjps/(?P<day>[0-9\-]+)/(?P<begin>[0-9:]+)/(?P<end>[0-9:]+)")
+ def qsjps(self, request, day, begin, end):
+ form = QSJPSForm({"day": day, "begin": begin, "end": end})
+ if not form.is_valid():
+ return Response(form.errors, status=400)
+
+ day = form.cleaned_data["day"]
+ begin_hour = form.cleaned_data["begin"]
+ end_hour = form.cleaned_data["end"]
+
+ begin = timezone.make_aware(datetime.datetime.combine(day, begin_hour))
+ end = timezone.make_aware(datetime.datetime.combine(day, end_hour))
+
+ rooms = Room.objects.qsjps(begin, end)
+ rooms_json = RoomSerializer(rooms, many=True)
+ return Response(rooms_json.data)
+
+
+class CourseViewSet(viewsets.ReadOnlyModelViewSet):
+ queryset = Course.objects.all().prefetch_related("groups", "rooms")
+ serializer_class = CourseSerializer
diff --git a/management/commands/__parsercommand.py b/management/commands/__parsercommand.py
new file mode 100644
index 0000000..99480cc
--- /dev/null
+++ b/management/commands/__parsercommand.py
@@ -0,0 +1,26 @@
+# Copyright (C) 2019 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 importlib import import_module
+
+from django.conf import settings
+
+DEFAULT_PARSER = "edt.management.parsers.ups2017"
+
+
+class ParserCommand:
+ def get_parser(self):
+ parser_module = getattr(settings, "CS_PARSER", DEFAULT_PARSER)
+ return getattr(import_module(parser_module), "Parser")
diff --git a/management/commands/printvalues.py b/management/commands/printvalues.py
new file mode 100644
index 0000000..91dd18b
--- /dev/null
+++ b/management/commands/printvalues.py
@@ -0,0 +1,45 @@
+# Copyright (C) 2019 Alban Gruin
+#
+# celcatsanitizer is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# celcatsanitizer is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
+
+from django.core.management.base import BaseCommand
+
+from ...models import Source
+from .__parsercommand import ParserCommand
+
+import json
+
+
+class Command(BaseCommand, ParserCommand):
+ help = "List values from courses from a source"
+
+ def add_arguments(self, parser):
+ parser.add_argument("--source", type=int, nargs=1, required=True)
+ parser.add_argument("--limit", type=int, nargs=1)
+
+ def handle(self, *args, **options):
+ source = Source.objects.get(pk=options["source"][0])
+ parser = self.get_parser()(source)
+ events = [event for month in parser.get_source() for event in month]
+
+ i = 0
+ limit = len(events)
+ if options["limit"] is not None:
+ limit = min(options["limit"][0], limit)
+
+ while i < limit:
+ self.stdout.write(json.dumps(events[i], indent=4, sort_keys=True))
+ i += 1
+
+ self.stdout.write(self.style.SUCCESS("Done."))
diff --git a/management/commands/timetables.py b/management/commands/timetables.py
index ee33f7e..f71accf 100644
--- a/management/commands/timetables.py
+++ b/management/commands/timetables.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2018 Alban Gruin
+# Copyright (C) 2017-2019 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,20 +13,16 @@
# 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
from ...models import Course, Source
from ...utils import get_week, tz_now
-
-DEFAULT_PARSER = "edt.management.parsers.ups2017"
+from .__parsercommand import ParserCommand
def delete_courses_in_week(source, year, week, today):
@@ -122,7 +118,7 @@ def process_timetable(source, force, parser_cls, year=None, weeks=None):
process_timetable_week(source, force, parser)
-class Command(BaseCommand):
+class Command(BaseCommand, ParserCommand):
help = "Fetches registered celcat timetables"
def add_arguments(self, parser):
@@ -134,14 +130,10 @@ 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()
+ parser = self.get_parser()
if options["all"]:
weeks = None
diff --git a/management/parsers/ups2018.py b/management/parsers/ups2018.py
index f1da5bf..ad8322c 100644
--- a/management/parsers/ups2018.py
+++ b/management/parsers/ups2018.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2018 Alban Gruin
+# Copyright (C) 2018-2019 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
@@ -26,12 +26,16 @@ from django.utils import timezone
import lxml.html
import requests
-from ...models import Course, Group, Room
+from ...models import Course, Group, Module, Room
from ...utils import get_current_week, get_week
from .abstractparser import AbstractParser, ParserError
VARNAME = "v.events.list = "
+GROUP_PREFIXES = ("L1 ", "L2 ", "L3 ", "L3P ", "M1 ", "M2 ", "DEUST ", "MAG1 ",
+ "1ERE ANNEE ", "2EME ANNEE ", "3EME ANNEE ",
+ "MAT-Agreg Interne ")
+
def find_events_list(soup):
res = []
@@ -114,7 +118,8 @@ class Parser(AbstractParser):
return
course = Course.objects.create(
- source=self.source, begin=begin, end=end
+ source=self.source, begin=begin, end=end,
+ celcat_id=int(event["id"])
)
min_i = 0
@@ -122,11 +127,7 @@ class Parser(AbstractParser):
min_i = 1
i = min_i
- 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 ")
- ):
+ while i < len(data) and not data[i].startswith(GROUP_PREFIXES):
i += 1
groups = data[i]
@@ -136,6 +137,13 @@ class Parser(AbstractParser):
# par un dictionnaire classique.
names = OrderedDict.fromkeys(data[i - 1].split(';'))
course.name = ", ".join(names.keys())
+
+ module_names = [t for t in event["tag"]
+ if len(t) > 0 and
+ any(n.startswith(t) for n in names.keys())]
+ if len(module_names) > 0:
+ module, _ = Module.objects.get_or_create(name=module_names[0])
+ course.module = module
else:
course.name = "Sans nom"
if i - 2 >= min_i:
@@ -213,10 +221,10 @@ class Parser(AbstractParser):
responses = yield from asyncio.gather(*futures)
return responses
- def get_source_from_months(self, async=True):
+ def get_source_from_months(self, asynchronous=True):
events = []
- if async:
+ if asynchronous:
loop = asyncio.get_event_loop()
events = loop.run_until_complete(self.get_months_async())
else:
diff --git a/management/parsers/ups2019.py b/management/parsers/ups2019.py
new file mode 100644
index 0000000..c6bd7e3
--- /dev/null
+++ b/management/parsers/ups2019.py
@@ -0,0 +1,126 @@
+# Copyright (C) 2019 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 date, datetime, timedelta
+from html import unescape
+
+from django.utils import timezone
+
+import requests
+
+from ...models import Course, Group, Module, Room
+from ...utils import get_current_week, get_week
+from .abstractparser import AbstractParser
+from .ups2018 import GROUP_PREFIXES
+
+
+class Parser(AbstractParser):
+ def __get_event(self, event, year, week):
+ if event["allDay"]:
+ return
+
+ 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 year is not None and week is not None:
+ event_year, event_week, _ = begin.isocalendar()
+ if event_year != year or event_week != week:
+ return
+
+ data = [unescape(st.strip())
+ for st in event["description"].split("<br />")]
+ groups = []
+ rooms = []
+
+ course = Course.objects.create(
+ source=self.source, begin=begin, end=end,
+ celcat_id=event["id"]
+ )
+
+ max_i = len(data)
+
+ if event.get("eventCategory") is not None and \
+ len(event.get("eventCategory", "")) > 0:
+ course.type = event["eventCategory"]
+ max_i -= 1
+
+ if event.get("module", "") is not None and \
+ len(event.get("module", "")) > 0:
+ module, _ = Module.objects.get_or_create(name=event["module"])
+ course.module = module
+
+ i = 0
+ while i < max_i and not data[i].startswith(GROUP_PREFIXES):
+ rooms.append(data[i])
+ i += 1
+ course.rooms.add(*Room.objects.filter(name__in=rooms))
+
+ if len(rooms) != course.rooms.count():
+ print(rooms, course.rooms)
+
+ while i < max_i and data[i].startswith(GROUP_PREFIXES):
+ group, _ = Group.objects.get_or_create(source=self.source,
+ celcat_name=data[i])
+ groups.append(group)
+ i += 1
+ course.groups.add(*groups)
+
+ if i < max_i and course.module is not None and \
+ data[i].startswith(course.module.name):
+ course.name = data[i]
+ i += 1
+
+ course.notes = "\n".join(data[i:max_i]).strip()
+ if "other" in data[i]:
+ print("Warning: \"other\" in notes")
+
+ return course
+
+ def get_events(self, today, year=None, week=None):
+ for event in self.events:
+ course = self.__get_event(event, year, week)
+ if course is not None:
+ yield course
+
+ def get_update_date(self):
+ return
+
+ 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 get_source(self):
+ start = date.today()
+ end = start + timedelta(days=365)
+
+ req = requests.post(self.source.url,
+ headers={"User-Agent": self.user_agent},
+ data={"calView": "month",
+ "resType": 103,
+ "federationIds[]": self.source.metadata,
+ "start": start.strftime("%Y-%m-%d"),
+ "end": end.strftime("%Y-%m-%d")})
+ req.encoding = "uft8"
+ req.raise_for_status()
+
+ self.events = req.json()
+ return self.events
diff --git a/models.py b/models.py
index c8e7b3d..448b996 100644
--- a/models.py
+++ b/models.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2018 Alban Gruin
+# Copyright (C) 2017-2019 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
@@ -48,13 +48,15 @@ class Year(SlugModel):
class Source(models.Model):
- url = models.URLField(max_length=255, verbose_name="URL", unique=True)
+ url = models.URLField(max_length=255, verbose_name="URL")
+ metadata = models.CharField(max_length=256, verbose_name="Métadonnée",
+ blank=True, null=True)
last_update_date = models.DateTimeField(null=True, blank=True,
verbose_name="dernière mise à jour"
" Celcat")
def __str__(self):
- return self.url
+ return "{}, {}".format(self.url, self.metadata)
@property
def formatted_timetables(self):
@@ -62,6 +64,7 @@ class Source(models.Model):
self.timetables.all()])
class Meta:
+ unique_together = (("url", "metadata",),)
verbose_name = "source d’emploi du temps"
verbose_name_plural = "sources d’emploi du temps"
@@ -200,6 +203,18 @@ class Room(SlugModel):
verbose_name_plural = "salles"
+class Module(SlugModel):
+ name = models.CharField(max_length=255, unique=True, verbose_name="nom")
+ slug = models.SlugField(max_length=64, default="", unique=True)
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ verbose_name = "module"
+ verbose_name_plural = "modules"
+
+
class CourseManager(Manager):
def get_courses(self, obj, **criteria):
qs = self.get_queryset()
@@ -246,6 +261,12 @@ class Course(models.Model):
last_update = models.DateTimeField(verbose_name="dernière mise à jour",
default=timezone.now)
+ celcat_id = models.CharField(max_length=64, verbose_name="ID Celcat",
+ null=True)
+ module = models.ForeignKey(Module, on_delete=models.SET_NULL, null=True)
+
+ buggy = models.BooleanField(verbose_name="Bogué", default=False)
+
def __str__(self):
return self.name
@@ -253,6 +274,7 @@ class Course(models.Model):
if self.type is not None:
self.type = self.type.replace("COURS", "cours")
self.type = self.type.replace("REUNION", "réunion")
+ self.type = self.type.replace("RENCONTRE", "rencontre")
if self.name is not None:
self.name = self.name.split("(")[0].strip()
diff --git a/requirements.txt b/requirements.txt
index f2c309f..1a7f902 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,8 @@
-beautifulsoup4==4.6.3
-Django==2.0.8
+beautifulsoup4==4.8.0
+Django==2.2.4
+djangorestframework==3.10.2
gunicorn==19.9.0
-icalendar==4.0.2
-lxml==4.2.4
-psycopg2-binary==2.7.5
-requests==2.19.1
+icalendar==4.0.3
+lxml==4.4.1
+psycopg2-binary==2.8.3
+requests==2.22.0
diff --git a/templates/calendars.html b/templates/calendars.html
index 98c57e2..95cda8f 100644
--- a/templates/calendars.html
+++ b/templates/calendars.html
@@ -15,7 +15,7 @@
<li>Sur Linux et Windows, l’extension Lightning du logiciel
Thunderbird ;</li>
<li>Sur Mac et iOS, iCloud ;</li>
- <li>Sur Android, l’application libre ICSDroid peut les
+ <li>Sur Android, l’application libre ICSx<sup>5</sup> peut les
récupérer périodiquement et les afficher sur l’application
Agenda de base.
<a href="https://f-droid.org/fr/packages/at.bitfire.icsdroid/">Elle
diff --git a/templates/index.html b/templates/index.html
index e86038e..53cefd5 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -25,7 +25,7 @@
{% endblock %}
</div>
<footer>
- <p>(c) 2018 &ndash; Alban Gruin &ndash; <a href="{% url "django.contrib.flatpages.views.flatpage" url="contact/" %}">contacter</a> &ndash; celcatsanitizer {{ celcatsanitizer_version }} &ndash; <a href="{% url "django.contrib.flatpages.views.flatpage" url="a-propos/" %}">à propos</a><br />
+ <p>(c) 2019 &ndash; Alban Gruin &ndash; <a href="{% url "django.contrib.flatpages.views.flatpage" url="contact/" %}">contacter</a> &ndash; celcatsanitizer {{ celcatsanitizer_version }} &ndash; <a href="{% url "django.contrib.flatpages.views.flatpage" url="a-propos/" %}">à propos</a><br />
Design inspiré par <a href="https://bestmotherfucking.website/">https://bestmotherfucking.website/</a></p>
</footer>
</body>
diff --git a/templates/mention_list.html b/templates/mention_list.html
index 3b9d8e8..08d3d4a 100644
--- a/templates/mention_list.html
+++ b/templates/mention_list.html
@@ -1,6 +1,6 @@
{% extends "index.html" %}
{% block title %}{{ year }} &ndash; {% endblock %}
-{% block pagetitle %}{{ year }} &ndash; Choississez votre mention{% endblock %}
+{% block pagetitle %}{{ year }} &ndash; Choisissez votre mention{% endblock %}
{% block url %}{% url "groups" year.slug element.slug %}{% endblock %}
{% block navigation %}<a href="{% url "index" %}">Retour à la liste des années</a>{% endblock %}
diff --git a/templatetags/rooms.py b/templatetags/rooms.py
index f0e1b2e..d8b0e23 100644
--- a/templatetags/rooms.py
+++ b/templatetags/rooms.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017 Alban Gruin
+# Copyright (C) 2017, 2019 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
@@ -18,11 +18,17 @@ from django import template
register = template.Library()
+def __filter_room_name(name):
+ if '/' in name:
+ return name.split('/')[1].strip()
+ return name
+
+
@register.filter
def format_rooms(rooms):
- amphi_list = [room.name for room in rooms if room.name.startswith("Amphi")]
- room_list = [room.name for room in rooms
- if not room.name.startswith("Amphi")]
+ names = [__filter_room_name(room.name) for room in rooms]
+ amphi_list = [name for name in names if name.startswith("Amphi")]
+ room_list = [name for name in names if not name.startswith("Amphi")]
amphis = ", ".join(amphi_list)
joined = ", ".join(room_list)
diff --git a/tests.py b/tests.py
index 2568688..1e80b48 100644
--- a/tests.py
+++ b/tests.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2018 Alban Gruin
+# Copyright (C) 2017-2019 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,13 +13,46 @@
# 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 unittest import mock
+
from django.test import TestCase
from django.utils import timezone
-from .models import Course, Group, Room, Source, Timetable, Year
+from .management.parsers.abstractparser import ParserError
+from .management.parsers.ups2018 import Parser as UPS2018Parser
+from .models import Course, Group, Module, Room, Source, Timetable, Year
+from .templatetags.rooms import format_rooms
from .utils import tz_now
import datetime
+import os
+
+
+def mock_requests_get(*args, **kwargs):
+ class MockedResponse:
+ def __init__(self, content=""):
+ self.encoding = "utf-8"
+ self.content = content
+ self.status = 200
+
+ def raise_for_status(self):
+ return
+
+ def mocked_response_from_file(filename):
+ module_dir = os.path.dirname(__file__)
+ filepath = os.path.join(module_dir, filename)
+ with open(filepath, "r") as response:
+ return MockedResponse(response.read())
+
+ if args[0] == "https://example.org/2018":
+ if "params" not in kwargs or not kwargs["params"]:
+ return mocked_response_from_file("tests/data/2018/september.html")
+ elif kwargs["params"].get("Date") == "20181001":
+ return mocked_response_from_file("tests/data/2018/october.html")
+ else:
+ return mocked_response_from_file("tests/data/2018/empty.html")
+
+ return MockedResponse("<html></html>")
class CourseTestCase(TestCase):
@@ -51,7 +84,7 @@ class CourseTestCase(TestCase):
for group in (cma, tda2, self.tpa21, cmb, tdb2, self.tpb21,):
course = Course.objects.create(
name="{0} course".format(group.name), type="cours",
- source=source, begin=dt, end=dt)
+ source=source, begin=dt, end=dt, celcat_id=0)
course.groups.add(group)
def test_get_courses_for_group(self):
@@ -300,13 +333,23 @@ class RoomTestCase(TestCase):
group = Group.objects.create(celcat_name="L1 info s2 CMA",
source=self.source)
- self.rooms = [Room.objects.create(name="0"),
- Room.objects.create(name="1"),
- Room.objects.create(name="2"),
- Room.objects.create(name="3"),
- Room.objects.create(name="4"),
- Room.objects.create(name="5"),
- Room.objects.create(name="6")]
+ self.rooms = [
+ Room.objects.create(name=str(i))
+ for i in range(5)
+ ] + [
+ Room.objects.create(name="Amphi {}".format(i))
+ for i in range(5, 7)
+ ]
+
+ # On n’insère pas ces salles dans la base de données, elles ne
+ # servent que pour le test de formatage.
+ self.formatted_rooms = [
+ Room(name="FSI / {}".format(str(i)))
+ for i in range(5)
+ ] + [
+ Room(name="FSI / Amphi {}".format(str(i)))
+ for i in range(5, 7)
+ ]
hours = [({"begin": datetime.time(hour=14, minute=0)},),
({"begin": datetime.time(hour=16, minute=0)},),
@@ -326,10 +369,34 @@ class RoomTestCase(TestCase):
end = begin + datetime.timedelta(hours=rn.get("duration", 2))
course = Course.objects.create(source=self.source,
- begin=begin, end=end)
+ begin=begin, end=end,
+ celcat_id=0)
course.groups.add(group)
course.rooms.add(room)
+ def __test_format(self, rooms, amphis):
+ self.assertEqual(format_rooms([]), "")
+ self.assertEqual(format_rooms(rooms[:1]), "Salle 0")
+ self.assertEqual(format_rooms(rooms[:2]), "Salles 0, 1")
+ self.assertEqual(format_rooms([amphis[0]]), "Amphi 5")
+ self.assertEqual(format_rooms(amphis), "Amphi 5, Amphi 6")
+ self.assertEqual(format_rooms([amphis[0]] + rooms[:1]),
+ "Amphi 5, salle 0")
+ self.assertEqual(format_rooms([amphis[0]] + rooms[:2]),
+ "Amphi 5, salles 0, 1")
+ self.assertEqual(format_rooms(amphis + rooms[:1]),
+ "Amphi 5, Amphi 6, salle 0")
+ self.assertEqual(format_rooms(amphis + rooms[:2]),
+ "Amphi 5, Amphi 6, salles 0, 1")
+
+ def test_format(self):
+ amphis = self.rooms[-2:]
+ self.__test_format(self.rooms, amphis)
+
+ def test_reformat(self):
+ amphis = self.formatted_rooms[-2:]
+ self.__test_format(self.formatted_rooms, amphis)
+
def test_qsjps(self):
begin = timezone.make_aware(datetime.datetime.combine(
self.day, datetime.time(hour=15, minute=0)))
@@ -345,3 +412,271 @@ class RoomTestCase(TestCase):
self.assertNotIn(self.rooms[4], rooms)
self.assertIn(self.rooms[5], rooms)
self.assertIn(self.rooms[6], rooms)
+
+
+class UPS2018ParserTestCase(TestCase):
+ @mock.patch("requests.get", side_effect=mock_requests_get)
+ def setUp(self, *args, **kwargs):
+ source = Source.objects.create(url="https://example.org/2018")
+ self.room = Room.objects.create(name="Salle quelconque")
+ self.room2 = Room.objects.create(name="Salle quelconque 2")
+
+ self.group = Group(celcat_name="L3 Info s1 CMA")
+ self.group.source = source
+ self.group.save()
+
+ self.parser = UPS2018Parser(source)
+
+ def test_get_event(self):
+ get_event = self.parser._Parser__get_event
+ count = Course.objects.count()
+ module_count = Module.objects.count()
+
+ event = get_event(
+ {"start": "2018-09-21T10:00:00", "end": "2018-09-21T12:00:00",
+ "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque;AAA"
+ "<br>L3 Info s1 CMA;L3 Info s1 TDA2<br>"
+ "Salle quelconque;Salle quelconque 2<br>Commentaire", "id": "0",
+ "tag": ["abc", "def", "AAA"]},
+ timezone.make_aware(datetime.datetime(2018, 9, 21)),
+ timezone.make_aware(datetime.datetime(2018, 9, 1)),
+ timezone.make_aware(datetime.datetime(2018, 10, 1)),
+ 2018, 38)
+
+ ngroup = Group.objects.filter(name="L3 Info s1 TDA2").first()
+ self.assertIsNotNone(ngroup)
+
+ self.assertEqual(event.name, "Cours quelconque, AAA")
+ self.assertEqual(event.type, "COURS/TD")
+ self.assertIn(self.group, event.groups.all())
+ self.assertIn(ngroup, event.groups.all())
+ self.assertEqual(event.groups.count(), 2)
+ self.assertIn(self.room, event.rooms.all())
+ self.assertIn(self.room2, event.rooms.all())
+ self.assertEqual(event.rooms.count(), 2)
+ self.assertEqual(event.notes, "Commentaire")
+ self.assertEqual(event.celcat_id, 0)
+ self.assertEqual(event.begin, timezone.make_aware(
+ datetime.datetime(2018, 9, 21, 10, 0, 0)))
+ self.assertEqual(event.end, timezone.make_aware(
+ datetime.datetime(2018, 9, 21, 12, 0, 0)))
+ self.assertEqual(event.module.name, "AAA")
+
+ count += 1
+ module_count += 1
+
+ self.assertEqual(count, Course.objects.count())
+ self.assertEqual(module_count, Module.objects.count())
+
+ events = [
+ {
+ "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque"
+ "<br>L3 Info s1 CMA<br>Salle quelconque",
+ "name": "Cours quelconque", "type": "COURS/TD",
+ "group": self.group,
+ "room": self.room,
+ "id": "1",
+ "tag": ["aaa", "Cours"],
+ "meta": 1
+ },
+ {
+ "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque"
+ "<br>L3 Info s1 TDA2<br>Salle quelconque 3",
+ "name": "Cours quelconque",
+ "type": "COURS/TD",
+ "group": ngroup,
+ "notes": "Salle quelconque 3",
+ "id": "2"
+ },
+ {
+ "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque"
+ "<br>L3 Info s1 CMA<br>Salle quelconque 3<br>Commentaire",
+ "name": "Cours quelconque",
+ "type": "COURS/TD",
+ "group": self.group,
+ "notes": "Salle quelconque 3\nCommentaire",
+ "id": "3"
+ },
+ {
+ "text": "(10:00-12:00)<br>COURS/TD"
+ "<br>L3 Info s1 CMA<br>Salle quelconque 3",
+ "name": "COURS/TD",
+ "group": self.group,
+ "notes": "Salle quelconque 3",
+ "id": "4"
+ },
+ {
+ "text": "COURS/TD<br>L3 Info s1 CMA<br>Salle quelconque 3",
+ "name": "COURS/TD",
+ "group": self.group,
+ "notes": "Salle quelconque 3",
+ "id": "5"
+ },
+ {
+ "text": "L3 Info s1 CMA<br>Salle quelconque",
+ "name": "Sans nom",
+ "group": self.group,
+ "room": self.room,
+ "id": "6"
+ },
+ {
+ "text": "L3 Info s1 CMA<br>Salle quelconque 3",
+ "name": "Sans nom",
+ "group": self.group,
+ "notes": "Salle quelconque 3",
+ "id": "7"
+ },
+ {
+ "text": "(10:00-12:00)<br>L3 Info s1 CMA<br>Salle quelconque",
+ "name": "Sans nom",
+ "group": self.group,
+ "room": self.room,
+ "id": "8"
+ }
+ ]
+
+ for e in events:
+ event = get_event(
+ {"start": "2018-09-21T10:00:00", "end": "2018-09-21T12:00:00",
+ "text": e["text"], "id": e["id"], "tag": e.get("tag", [])},
+ timezone.make_aware(datetime.datetime(2018, 9, 21)),
+ timezone.make_aware(datetime.datetime(2018, 9, 1)),
+ timezone.make_aware(datetime.datetime(2018, 10, 1)),
+ 2018, 38)
+
+ self.assertEqual(event.name, e["name"])
+ self.assertIn(e["group"], event.groups.all())
+ self.assertEqual(event.groups.count(), 1)
+ self.assertEqual(str(event.celcat_id), e["id"])
+
+ if "type" in e:
+ self.assertEqual(event.type, e["type"])
+ else:
+ self.assertIsNone(event.type)
+
+ if "room" in e:
+ self.assertIn(e["room"], event.rooms.all())
+ self.assertEqual(event.rooms.count(), 1)
+ else:
+ self.assertEqual(event.rooms.count(), 0)
+
+ if "notes" in e:
+ self.assertEqual(event.notes, e["notes"])
+ else:
+ self.assertIsNone(event.notes)
+
+ if "tag" in e:
+ self.assertEqual(event.module.name, e["tag"][e["meta"]])
+ module_count += 1
+ else:
+ self.assertIsNone(event.module)
+
+ count += 1
+
+ self.assertEqual(count, Course.objects.count())
+ self.assertEqual(module_count, Module.objects.count())
+
+ event = get_event(
+ {"start": "2018-09-21T10:00:00", "end": "2018-09-21T12:00:00",
+ "text": "Global Event"},
+ timezone.make_aware(datetime.datetime(2018, 9, 21)),
+ timezone.make_aware(datetime.datetime(2018, 9, 1)),
+ timezone.make_aware(datetime.datetime(2018, 10, 1)),
+ 2018, 38)
+ self.assertIsNone(event)
+ self.assertEqual(count, Course.objects.count())
+
+ event = get_event(
+ {"start": "2018-09-21T10:00:00", "end": "2018-09-21T12:00:00",
+ "text": "L3 Info s1 CMA<br>Salle quelconque 2"},
+ timezone.make_aware(datetime.datetime(2018, 9, 21)),
+ timezone.make_aware(datetime.datetime(2018, 9, 1)),
+ timezone.make_aware(datetime.datetime(2018, 10, 1)),
+ 2018, 39)
+ self.assertIsNone(event)
+ self.assertEqual(count, Course.objects.count())
+
+ @mock.patch("requests.get", side_effect=mock_requests_get)
+ def test_get_events(self, *args, **kwargs):
+ self.parser.get_source()
+ courses = [
+ {"begin":
+ timezone.make_aware(datetime.datetime(2018, 9, 21, 10, 00, 00)),
+ "end":
+ timezone.make_aware(datetime.datetime(2018, 9, 21, 12, 00, 00)),
+ "id": 0},
+ {"begin":
+ timezone.make_aware(datetime.datetime(2018, 10, 22, 10, 00, 00)),
+ "end":
+ timezone.make_aware(datetime.datetime(2018, 10, 22, 12, 00, 00)),
+ "id": 2}
+ ]
+
+ for i, course in enumerate(self.parser.get_events(
+ timezone.make_aware(datetime.datetime(2018, 9, 21)))):
+ self.assertEqual(course.name, "Cours quelconque")
+ self.assertEqual(course.type, "COURS/TD")
+ self.assertIn(self.group, course.groups.all())
+ self.assertEqual(course.groups.count(), 1)
+ self.assertIn(self.room, course.rooms.all())
+ self.assertEqual(course.rooms.count(), 1)
+ self.assertIsNone(course.notes)
+ self.assertEqual(course.begin, courses[i]["begin"])
+ self.assertEqual(course.end, courses[i]["end"])
+ self.assertEqual(course.celcat_id, courses[i]["id"])
+ self.assertEqual(course.module, None)
+
+ self.assertEqual(i, len(courses) - 1)
+
+ @mock.patch("requests.get", side_effect=mock_requests_get)
+ def test_get_source(self, *args, **kwargs):
+ events = self.parser.get_source()
+ self.assertEquals(events, [
+ [{
+ "start": "2018-09-21T10:00:00", "end": "2018-09-21T12:00:00",
+ "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque"
+ "<br>L3 Info s1 CMA<br>Salle quelconque",
+ "id": "0",
+ "tag": [],
+ }], [{
+ "start": "2018-09-21T10:00:00", "end": "2018-09-21T12:00:00",
+ "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque"
+ "<br>L3 Info s1 CMA<br>Salle quelconque",
+ "id": "1",
+ "tag": [],
+ }, {
+ "start": "2018-10-22T10:00:00", "end": "2018-10-22T12:00:00",
+ "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque"
+ "<br>L3 Info s1 CMA<br>Salle quelconque",
+ "id": "2",
+ "tag": [],
+ }], [], [], [], [], [], [], [], [], []])
+
+ def test_get_update_date(self):
+ # Pas de date de mise à jour dans ce format
+ self.assertIsNone(self.parser.get_update_date())
+
+
+class UPS2018BrokenSourceTestCase(TestCase):
+ @mock.patch("requests.get")
+ def test_broken_source(self, mock_get):
+ mock_get.return_value = mock_requests_get("")
+
+ source = Source.objects.create(url="https://example.org/2018")
+ with self.assertRaises(ParserError):
+ UPS2018Parser(source)
+
+ @mock.patch("requests.get")
+ def test_half_broken_source(self, mock_get):
+ source = Source.objects.create(url="https://example.org/2018")
+ mock_get.side_effect = [
+ mock_requests_get(""),
+ mock_requests_get(source.url)
+ ]
+
+ parser = UPS2018Parser(source)
+ self.assertEqual(parser.months, [
+ "September, 2018", "October, 2018", "November, 2018",
+ "December, 2018", "January, 2019", "February, 2019", "March, 2019",
+ "April, 2019", "May, 2019", "June, 2019", "July, 2019"
+ ])
diff --git a/tests/data/2018/empty.html b/tests/data/2018/empty.html
new file mode 100644
index 0000000..dde78b6
--- /dev/null
+++ b/tests/data/2018/empty.html
@@ -0,0 +1,50 @@
+<script>
+function do_something() {
+alert("something");
+}
+</script>
+
+ <option value="August, 2017">August, 2017</option>
+ <option value="September, 2017">September, 2017</option>
+ <option value="October, 2017">October, 2017</option>
+ <option value="November, 2017">November, 2017</option>
+ <option value="December, 2017">December, 2017</option>
+ <option value="January, 2018">January, 2018</option>
+ <option value="February, 2018">February, 2018</option>
+ <option value="March, 2018">March, 2018</option>
+ <option value="April, 2018">April, 2018</option>
+ <option value="May, 2018">May, 2018</option>
+ <option value="June, 2018">June, 2018</option>
+ <option value="July, 2018">July, 2018</option>
+ <option value="August, 2018">August, 2018</option>
+ <option value="September, 2018">September, 2018</option>
+ <option value="October, 2018">October, 2018</option>
+ <option value="November, 2018">November, 2018</option>
+ <option value="December, 2018">December, 2018</option>
+ <option value="January, 2019">January, 2019</option>
+ <option value="February, 2019">February, 2019</option>
+ <option value="March, 2019">March, 2019</option>
+ <option value="April, 2019">April, 2019</option>
+ <option value="May, 2019">May, 2019</option>
+ <option value="June, 2019">June, 2019</option>
+ <option value="July, 2019">July, 2019</option>
+
+<script>
+function do_something_else() {
+var v = "a variable";
+var vv = "another_variable";
+do_something();
+}
+</script>
+
+<script>
+function courses() {
+var v = {};
+v.events.list = [];;
+}
+</script>
+
+<script>
+courses();
+do_something_else();
+</script>
diff --git a/tests/data/2018/october.html b/tests/data/2018/october.html
new file mode 100644
index 0000000..ab3da0b
--- /dev/null
+++ b/tests/data/2018/october.html
@@ -0,0 +1,50 @@
+<script>
+function do_something() {
+alert("something");
+}
+</script>
+
+ <option value="August, 2017">August, 2017</option>
+ <option value="September, 2017">September, 2017</option>
+ <option value="October, 2017">October, 2017</option>
+ <option value="November, 2017">November, 2017</option>
+ <option value="December, 2017">December, 2017</option>
+ <option value="January, 2018">January, 2018</option>
+ <option value="February, 2018">February, 2018</option>
+ <option value="March, 2018">March, 2018</option>
+ <option value="April, 2018">April, 2018</option>
+ <option value="May, 2018">May, 2018</option>
+ <option value="June, 2018">June, 2018</option>
+ <option value="July, 2018">July, 2018</option>
+ <option value="August, 2018">August, 2018</option>
+ <option value="September, 2018">September, 2018</option>
+ <option selected="selected" value="October, 2018">October, 2018</option>
+ <option value="November, 2018">November, 2018</option>
+ <option value="December, 2018">December, 2018</option>
+ <option value="January, 2019">January, 2019</option>
+ <option value="February, 2019">February, 2019</option>
+ <option value="March, 2019">March, 2019</option>
+ <option value="April, 2019">April, 2019</option>
+ <option value="May, 2019">May, 2019</option>
+ <option value="June, 2019">June, 2019</option>
+ <option value="July, 2019">July, 2019</option>
+
+<script>
+function do_something_else() {
+var v = "a variable";
+var vv = "another_variable";
+do_something();
+}
+</script>
+
+<script>
+function courses() {
+var v = {};
+v.events.list = [{"start": "2018-09-21T10:00:00", "end": "2018-09-21T12:00:00", "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque<br>L3 Info s1 CMA<br>Salle quelconque", "id": "1", "tag": []}, {"start": "2018-10-22T10:00:00", "end": "2018-10-22T12:00:00", "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque<br>L3 Info s1 CMA<br>Salle quelconque", "id": "2", "tag": []}];;
+}
+</script>
+
+<script>
+courses();
+do_something_else();
+</script>
diff --git a/tests/data/2018/september.html b/tests/data/2018/september.html
new file mode 100644
index 0000000..c81fc3b
--- /dev/null
+++ b/tests/data/2018/september.html
@@ -0,0 +1,51 @@
+<script>
+function do_something() {
+alert("something");
+}
+</script>
+
+ <option value="August, 2017">August, 2017</option>
+ <option value="September, 2017">September, 2017</option>
+ <option value="October, 2017">October, 2017</option>
+ <option value="November, 2017">November, 2017</option>
+ <option value="December, 2017">December, 2017</option>
+ <option value="January, 2018">January, 2018</option>
+ <option value="February, 2018">February, 2018</option>
+ <option value="March, 2018">March, 2018</option>
+ <option value="April, 2018">April, 2018</option>
+ <option value="May, 2018">May, 2018</option>
+ <option value="June, 2018">June, 2018</option>
+ <option value="July, 2018">July, 2018</option>
+ <option value="August, 2018">August, 2018</option>
+ <option selected="selected" value="September, 2018">September, 2018</option>
+ <option value="October, 2018">October, 2018</option>
+ <option value="November, 2018">November, 2018</option>
+ <option value="December, 2018">December, 2018</option>
+ <option value="January, 2019">January, 2019</option>
+ <option value="February, 2019">February, 2019</option>
+ <option value="March, 2019">March, 2019</option>
+ <option value="April, 2019">April, 2019</option>
+ <option value="May, 2019">May, 2019</option>
+ <option value="June, 2019">June, 2019</option>
+ <option value="July, 2019">July, 2019</option>
+
+<script>
+ function do_something_else() {
+ var v = "a variable";
+ var vv = "another_variable";
+
+ do_something();
+ }
+</script>
+
+<script>
+function courses() {
+var v = {};
+v.events.list = [{"start": "2018-09-21T10:00:00", "end": "2018-09-21T12:00:00", "text": "(10:00-12:00)<br>COURS/TD<br>Cours quelconque<br>L3 Info s1 CMA<br>Salle quelconque", "id": "0", "tag": []}];;
+}
+</script>
+
+<script>
+courses();
+do_something_else();
+</script>
diff --git a/urls.py b/urls.py
index 977ac8d..a5f6b9b 100644
--- a/urls.py
+++ b/urls.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2018 Alban Gruin
+# Copyright (C) 2017-2019 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,11 +13,23 @@
# You should have received a copy of the GNU Affero General Public License
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
+from django.conf import settings
from django.urls import include, path
+
from . import feeds, views
urlpatterns = [
- path("", views.index, name="index"),
+ path("", views.index, name="index")
+]
+
+if getattr(settings, "CS_ENABLE_API", False):
+ from .api.urls import router
+
+ urlpatterns += [
+ path("api/", include(router.urls)),
+ ]
+
+urlpatterns += [
path("pages/", include("django.contrib.flatpages.urls")),
path("salles/", views.rooms, name="rooms"),
path("salles/qsjps", views.qsjps, name="qsjps"),
diff --git a/views.py b/views.py
index de38f35..7bb3916 100644
--- a/views.py
+++ b/views.py
@@ -17,7 +17,8 @@ import datetime
from django.db import connection
from django.db.models import Count, Max
-from django.db.models.functions import ExtractWeek, ExtractYear, Length
+from django.db.models.functions import ExtractWeek, ExtractYear, Length, \
+ TruncWeek
from django.http import Http404
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
@@ -32,7 +33,6 @@ import edt
if connection.vendor == "postgresql":
from django.contrib.postgres.aggregates import ArrayAgg
- from django.db.models.expressions import RawSQL
def index(request):
@@ -195,9 +195,8 @@ def rooms(request):
rooms = Room.objects.filter(course__begin__gte=start,
course__begin__lt=end) \
.order_by("name") \
- .annotate(weeks=ArrayAgg(
- RawSQL("date_trunc('week', edt_course.begin)",
- []), distinct=True))
+ .annotate(weeks=ArrayAgg(TruncWeek("week"),
+ distinct=True))
return render(request, "room_list.html", {"elements": rooms})
@@ -210,8 +209,7 @@ def rooms(request):
rooms = Room.objects.filter(course__begin__gte=start,
course__begin__lt=end) \
.order_by("name") \
- .annotate(year=ExtractYear("course__begin"),
- week=ExtractWeek("course__begin"),
+ .annotate(week=TruncWeek("course__begin"),
c=Count("*"))
# Regroupement des semaines dans une liste de chaque objet salle
@@ -226,10 +224,8 @@ def rooms(request):
room.weeks = []
rooms_weeks.append(room)
- # On récupère le premier jour de la semaine
- date, _ = get_week(room.year, room.week)
# Et on le rajoute dans la liste des semaines de la salle.
- rooms_weeks[-1].weeks.append(date)
+ rooms_weeks[-1].weeks.append(room.week)
# Rendu de la page.
return render(request, "room_list.html", {"elements": rooms_weeks})