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})  | 
