diff options
author | Alban Gruin | 2019-09-01 20:47:49 +0200 |
---|---|---|
committer | Alban Gruin | 2019-09-01 20:47:49 +0200 |
commit | 00d70c4d65967987c254c72f8bef5fda991f859b (patch) | |
tree | 670b2a78bfeb151096cfa844d6b48559dfc3641b | |
parent | fe83f55800f78ff6ced9a13cf5a9c22fde0ead12 (diff) | |
parent | a6eaecf48f63c37cf851386a45cd4b25ac3ed1e0 (diff) |
Merge branch 'futur'
-rw-r--r-- | .mailmap | 1 | ||||
-rw-r--r-- | Documentation/dev/roadmap.rst | 6 | ||||
-rw-r--r-- | Documentation/index.rst | 2 | ||||
-rw-r--r-- | Documentation/usage/commands/printvalues.rst | 48 | ||||
-rw-r--r-- | Documentation/usage/installation.rst | 64 | ||||
-rw-r--r-- | Documentation/usage/rest.rst | 753 | ||||
-rw-r--r-- | Documentation/usage/versions.rst | 32 | ||||
-rw-r--r-- | admin.py | 16 | ||||
-rw-r--r-- | api/serializers.py | 57 | ||||
-rw-r--r-- | api/urls.py | 25 | ||||
-rw-r--r-- | api/views.py | 191 | ||||
-rw-r--r-- | management/commands/__parsercommand.py | 26 | ||||
-rw-r--r-- | management/commands/printvalues.py | 45 | ||||
-rw-r--r-- | management/commands/timetables.py | 16 | ||||
-rw-r--r-- | management/parsers/ups2018.py | 28 | ||||
-rw-r--r-- | management/parsers/ups2019.py | 126 | ||||
-rw-r--r-- | models.py | 28 | ||||
-rw-r--r-- | requirements.txt | 13 | ||||
-rw-r--r-- | templates/calendars.html | 2 | ||||
-rw-r--r-- | templates/index.html | 2 | ||||
-rw-r--r-- | templates/mention_list.html | 2 | ||||
-rw-r--r-- | templatetags/rooms.py | 14 | ||||
-rw-r--r-- | tests.py | 357 | ||||
-rw-r--r-- | tests/data/2018/empty.html | 50 | ||||
-rw-r--r-- | tests/data/2018/october.html | 50 | ||||
-rw-r--r-- | tests/data/2018/september.html | 51 | ||||
-rw-r--r-- | urls.py | 16 | ||||
-rw-r--r-- | views.py | 16 |
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. @@ -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 @@ -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 – Alban Gruin – <a href="{% url "django.contrib.flatpages.views.flatpage" url="contact/" %}">contacter</a> – celcatsanitizer {{ celcatsanitizer_version }} – <a href="{% url "django.contrib.flatpages.views.flatpage" url="a-propos/" %}">à propos</a><br /> + <p>(c) 2019 – Alban Gruin – <a href="{% url "django.contrib.flatpages.views.flatpage" url="contact/" %}">contacter</a> – celcatsanitizer {{ celcatsanitizer_version }} – <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 }} – {% endblock %} -{% block pagetitle %}{{ year }} – Choississez votre mention{% endblock %} +{% block pagetitle %}{{ year }} – 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) @@ -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> @@ -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"), @@ -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}) |