diff options
46 files changed, 1812 insertions, 500 deletions
| diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..db3c483 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.css diff=css eol=lf +*.html diff=html eol=lf +*.md text eol=lf +*.py diff=python eol=lf +*.rst text eol=lf diff --git a/Documentation/.gitignore b/Documentation/.gitignore new file mode 100644 index 0000000..1d535b9 --- /dev/null +++ b/Documentation/.gitignore @@ -0,0 +1 @@ +_*/ diff --git a/Documentation/Makefile b/Documentation/Makefile new file mode 100644 index 0000000..4c9ce86 --- /dev/null +++ b/Documentation/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS    = +SPHINXBUILD   = sphinx-build +SPHINXPROJ    = celcatsanitizer +SOURCEDIR     = . +BUILDDIR      = _build + +# Put it first so that "make" without argument is like "make help". +help: +	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile +	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file diff --git a/Documentation/conf.py b/Documentation/conf.py new file mode 100644 index 0000000..683b981 --- /dev/null +++ b/Documentation/conf.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +from datetime import datetime + +extensions = [] +templates_path = ['_templates'] +source_suffix = '.rst' +master_doc = 'index' +todo_include_todos = False + +# General information about the project. +project = u'celcatsanitizer' +year = datetime.now().year +copyright = u'%d, Alban Gruin' % year +author = u'Alban Gruin' + +version = u'0.13' +release = u'0.13.0' + +language = 'fr' + +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +pygments_style = 'sphinx' +html_theme = 'alabaster' +html_sidebars = { +    '**': [ +        'about.html', +        'navigation.html', +    ] +} + +html_theme_options = { +    'description': "Un outil pour reformater les emplois du temps de Celcat", +    'extra_nav_links': {"Sources": +                        "https://git.pa1ch.fr/alban/celcatsanitizer"}, +} + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'celcatsanitizerdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +    # The paper size ('letterpaper' or 'a4paper'). +    # +    # 'papersize': 'letterpaper', + +    # The font size ('10pt', '11pt' or '12pt'). +    # +    # 'pointsize': '10pt', + +    # Additional stuff for the LaTeX preamble. +    # +    # 'preamble': '', + +    # Latex figure (float) alignment +    # +    # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +#  author, documentclass [howto, manual, or own class]). +latex_documents = [ +    (master_doc, 'celcatsanitizer.tex', u'celcatsanitizer Documentation', +     u'Alban Gruin', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ +    (master_doc, 'celcatsanitizer', u'celcatsanitizer Documentation', +     [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +#  dir menu entry, description, category) +texinfo_documents = [ +    (master_doc, 'celcatsanitizer', u'celcatsanitizer Documentation', +     author, 'celcatsanitizer', 'One line description of project.', +     'Miscellaneous'), +] diff --git a/Documentation/dev/contribute.rst b/Documentation/dev/contribute.rst new file mode 100644 index 0000000..ed6d44e --- /dev/null +++ b/Documentation/dev/contribute.rst @@ -0,0 +1,55 @@ +======================================= +Guide de contribution à celcatsanitizer +======================================= + +.. _ref-list: + +Liste de diffusion +================== +Le développement se déroule sur la liste de diffusion +``celcatsanitizer [arobase] framalistes [point] org``. Attention, les +messages de cette liste sont archivés publiquement. + +Dépôt +===== +Le dépôt se trouve à l’adresse +https://git.pa1ch.fr/alban/celcatsanitizer.git. Clonez-le en local à +l’aide de git_. + +Bien que la forge logicielle supporte les *issues* et les *pull +requests*, ces fonctionnalités ne sont pas utilisées pour le +développement de celcatsanitizer. + +.. _git: https://git-scm.com/ + +Sur quelle branche travailler ? +=============================== +Pour réaliser des correctifs de bogue dans une version stable, +effectuez vos changements sur la branche ``master``. Ne rajoutez pas +de nouvelle fonctionnalité ou ne changez pas la structure de la base +de données sur cette branche. + +Pour rajouter de nouvelles fonctionnalités, effectuez vos changements +sur la branche ``futur``. Contactez l’équipe de développement pour +avoir un avis. + +Si jamais vous voulez corriger un bogue sur la branche ``futur`` et +que la branche ``master`` est aussi affecté, n’hésitez-pas à le +rétro-porter. + +N’oubliez pas de `signer vos commits`_ (avec ``Signed-off-by:``). Si +vos patches sont conséquents, n’hésitez pas à rajouter votre nom au +*copyright*. + +.. _signer vos commits: +  https://git-scm.com/docs/git-commit#git-commit--s + +Envoyer les patches +=================== +Envoyez vos patches sur :ref:`la liste de diffusion +<ref-list>`. Formattez vos patches avec git-format-patches_ et +envoyez-les avec git-send-email_. Rebasez vos changements si +nécessaire. + +.. _git-format-patches: https://git-scm.com/docs/git-format-patch +.. _git-send-email: https://git-scm.com/docs/git-send-email diff --git a/Documentation/dev/roadmap.rst b/Documentation/dev/roadmap.rst new file mode 100644 index 0000000..a7db062 --- /dev/null +++ b/Documentation/dev/roadmap.rst @@ -0,0 +1,20 @@ +================ +Feuille de route +================ + +Version 0.14 +============ + - 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/ + +Version 1.0 +=========== + - Paquetage permettant l’installation par ``pip``. + +Futures fonctionnalités +======================= + - Utilisation de l’aggrégat ``TruncWeek`` dès que Django le proposera +   (*a priori*, dès la version 2.1). diff --git a/Documentation/dev/xml.rst b/Documentation/dev/xml.rst new file mode 100644 index 0000000..a48a3d7 --- /dev/null +++ b/Documentation/dev/xml.rst @@ -0,0 +1,147 @@ +================================== +Format des emplois du temps Celcat +================================== + +Avant de pouvoir afficher les emplois du temps, il est nécessaire de +parser les fichiers XML générés par Celcat. + +On a besoin de plusieurs informations concernant le cours : + + - son nom ; + - son type ; + - sa semaine et son jour ; + - son début et sa fin ; + - son commentaire ; + - ses salles ; + - ses groupes. + +Certaines de ces informations sont triviales à récupérer (comme son +nom, son type, son commentaire…), mais d’autres (telles que son jour +précis) est un peu plus délicat. + +Parser facilement le XML +======================== +Pour récupérer les fichiers à distance, celcatsanitizer utilise la +bibliothèque requests_, et se sert de BeautifulSoup4_ pour parser les +fichiers XML. + +.. _BeautifulSoup4: +  https://www.crummy.com/software/BeautifulSoup/bs4/doc/ +.. _requests: http://docs.python-requests.org/en/master/ + +Les semaines +============ +La première chose à faire après avoir téléchargé le fichier est de +récupérer la liste des semaines présentes. Les dates sont encodées +d’une manière assez exotique : + +.. code:: xml + +    <span id="1" date="16/10/2017" rawix="9" rawlen="1"> +    <description>Semaine 42, Semaine commençant le 16/10/2017</description> +    <title>42</title> +    <alleventweeks>NNNNNNNNYNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN</alleventweeks> +    <day id="0"> +    <name>lundi</name> +    <row id="0" /> +    <row id="1" /> +    <row id="2" /> +    <row id="3" /> +    <row id="4" /> +    <row id="5" /> +    <row id="6" /></day> +    <day id="1"> +    <name>mardi</name> +    <row id="0" /> +    <row id="1" /> +    <row id="2" /> +    <row id="3" /> +    <row id="4" /> +    <row id="5" /></day> +    <day id="2"> +    <name>mercredi</name> +    <row id="0" /> +    <row id="1" /> +    <row id="2" /> +    <row id="3" /> +    <row id="4" /> +    <row id="5" /> +    <row id="6" /></day> +    <day id="3"> +    <name>jeudi</name> +    <row id="0" /> +    <row id="1" /> +    <row id="2" /> +    <row id="3" /> +    <row id="4" /> +    <row id="5" /></day> +    <day id="4"> +    <name>vendredi</name> +    <row id="0" /> +    <row id="1" /> +    <row id="2" /> +    <row id="3" /> +    <row id="4" /> +    <row id="5" /></day></span> + +Vous voyez donc la date de début, le numéro de semaine, et la +mystérieuse valeur ``<alleventweeks>``. Il s’agit d’un identifiant de +semaine. La propriété ``id`` du ``<span>`` ne semble pas être +nécessaire pour comprendre le reste du fichier. + +.. _ref-week-dict: + +On va donc créer un tableau des semaines en se servant des +``<alleventweeks>`` comme clé, et le premier jour de la semaine comme +valeur. + +Les cours +========= +Voici un exemple de cours : + +.. code:: xml + +    <event id="351687" timesort="07450945" colour="BEA7B8" ecs="4" ecc="11" er="0" scb="1"> +    <day>1</day> +    <prettytimes>07:45-09:45 COURS/TD</prettytimes> +    <starttime>07:45</starttime> +    <endtime>09:45</endtime> +    <category>COURS/TD</category> +    <prettyweeks></prettyweeks> +    <rawweeks>NNNNNNNNYNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN</rawweeks> +    <resources> +    <module title="Matière"> +    <item>EDINF3F1 - Algorithmique et programmation</item></module> +    <group title="Groupe"> +    <item>L2 Info s1 TDA4</item></group> +    <room title="Salle"> +    <item>1TP1-B08bis</item></room></resources></event> + +Les différents éléments sont : + +================= ===================== ============================= +Donnée indiquée   Balise correspondante Plusieurs valeurs possibles ? +================= ===================== ============================= +Nom du cours      ``<module>``          Oui +Groupes concernés ``<group>``           Oui +Salles            ``<room>``            Oui +Type de cours     ``<category>``        Non +Heure de début    ``<starttime>``       Non +Heure de fin      ``<endtime>``         Non +Jour              ``<day>``             Non +Semaine           ``<rawweeks>``        Non +Remarque          ``<notes>``           Non +================= ===================== ============================= + +Quand une donnée peut prendre plusieurs valeurs à la fois, les +différentes valeurs se trouvent dans des balises +``<item>``. celcatsanitizer traîte tous les groupes et toutes les +salles, mais ne lis qu’un seul nom de cours. + +Dans l’exemple donné plus haut, il n’y a pas de champ remarque. + +Pour trouver le jours du cours, on prend la semaine référencée par la +balise ``<rawweeks>``, on retrouve le début de la semaine +correspondante à l’aide du :ref:`dictionnaire des semaines +<ref-week-dict>`, et on ajoute autant de jours qu’indiqué par la +balise ``<day>``. diff --git a/Documentation/index.rst b/Documentation/index.rst new file mode 100644 index 0000000..e793dd2 --- /dev/null +++ b/Documentation/index.rst @@ -0,0 +1,76 @@ +=============== +celcatsanitizer +=============== + +Présentation de celcatsanitizer +=============================== +Qu’est-ce que celcatsanitizer ? +------------------------------- + +celcatsanitizer est un outil basé sur Django_ permettant de reformater +les emplois du temps générés par Celcat. + +Les emplois du temps, accessibles à travers un navigateur web, +deviennent ainsi plus lisibles (notamment sur les appareils mobiles), +plus rapides à charger et plus légers. + +Les pages générées par celcatsanitizer ont étées conçues pour être les +plus légères possibles, tout en restant lisibles et fournies en +informations. Les pages peuvent être chargées sans CSS, et peuvent +êtres rendues sans problèmes par une large gamme de navigateurs. + +celcatsanitizer est aussi capable d’exporter les emplois du temps en +ICS_. + +Le parseur de celcatsanitizer est optimisé pour les emplois du temps +de l’Université Paul Sabatier, mais il est possible de le modifier.  + +Vous pouvez trouver une instance de celcatsanitizer à l’adresse +https://edt.pa1ch.fr/. Cette instance utilise les emplois du temps de +l’Université Paul Sabatier. + +.. _Django: https://www.django-project.com/ +.. _ICS: https://fr.wikipedia.org/wiki/ICalendar + +Qu’est-ce que n’est pas celcatsanitizer ? +----------------------------------------- +celcatsanitizer n’est pas capable de produire un emploi du temps de +lui-même, seulement de reformater ceux produits par Celcat. + +Notes importantes +----------------- +Affiliation +``````````` +celcatsanitizer est un projet mené par des étudiants. Il n’est soutenu +d’aucune manière par Celcat ou l’Université Paul Sabatier. + +Licence +``````` +celcatsanitizer est sous licence AGPL3_, ce qui signifie, entre +autres, que toute modification de son code source **doit** être +redistribuée. + +.. _AGPL3: https://www.gnu.org/licenses/agpl-3.0.fr.html + +Utilisation de celcatsanitizer +============================== + +.. toctree:: +   :maxdepth: 2 + +   usage/installation +   usage/commands/cleancourses +   usage/commands/listtimetables +   usage/commands/reparse +   usage/commands/timetables +   usage/versions + +Développement +============= + +.. toctree:: +   :maxdepth: 2 + +   dev/contribute +   dev/xml +   dev/roadmap diff --git a/Documentation/usage/commands/cleancourses.rst b/Documentation/usage/commands/cleancourses.rst new file mode 100644 index 0000000..6e4f152 --- /dev/null +++ b/Documentation/usage/commands/cleancourses.rst @@ -0,0 +1,20 @@ +================ +``cleancourses`` +================ + +``cleancourses`` permet d’effacer des cours présents dans la base de +données. Il peut soit s’agir de tous les cours ou des cours d’une +seule source. + +**ATTENTION : cette commande est irréversible.** + +Utilisation +=========== + +.. code:: shell + +   $ ./manage.py cleancourses [--source id] + +``--source`` permet de spécifier la suppression des cours provenant +d’une seule source. ``id`` correspond à l’ID de la source, trouvable +à l’aide de la commande :doc:`listtimetables`. diff --git a/Documentation/usage/commands/listtimetables.rst b/Documentation/usage/commands/listtimetables.rst new file mode 100644 index 0000000..94485b6 --- /dev/null +++ b/Documentation/usage/commands/listtimetables.rst @@ -0,0 +1,26 @@ +================== +``listtimetables`` +================== + +``listtimetables`` affiche tous les emplois du temps présents dans la +base de données, avec leur nom, leur source et leur identifiant +interne dans la base de données. + +Utilisation +=========== +.. code:: shell + +    $ ./manage.py listtimetables + +Format de sortie +================ +Cette commande affiche les sources avec leur URL et leur ID interne +(utilisable avec la commande :doc:`cleancourses`), ainsi que la liste +des emplois du temps associés à cette source. + +Exemple de sortie +----------------- +:: + +    L1 Info, L1 Miashs      : https://edt.univ-tlse3.fr/FSI/2017_2018/L1/L1_SN/g222621.xml (id: 3) +    L2 Info : https://edt.univ-tlse3.fr/FSI/2017_2018/L2/L2_Info/g224636.xml (id: 13) diff --git a/Documentation/usage/commands/reparse.rst b/Documentation/usage/commands/reparse.rst new file mode 100644 index 0000000..78a54b7 --- /dev/null +++ b/Documentation/usage/commands/reparse.rst @@ -0,0 +1,11 @@ +=========== +``reparse`` +=========== + +``reparse`` reparse tous les groupes dans la base de données. + +Utilisation +=========== +.. code:: shell + +    $ ./manage.py reparse diff --git a/Documentation/usage/commands/timetables.rst b/Documentation/usage/commands/timetables.rst new file mode 100644 index 0000000..723b5aa --- /dev/null +++ b/Documentation/usage/commands/timetables.rst @@ -0,0 +1,44 @@ +============== +``timetables`` +============== + +``timetables`` met à jour les emplois du temps présents dans la base +de données. + +Il est fortement recommandé d’exécuter régulièrement cette commande +:ref:`à l’aide d’une tâche cron <ref-cron>`. + +Utilisation +=========== +.. code:: shell + +    $ ./manage.py timetables [--all] [--force] [--week week] [--year year] + +Par défaut, ``timetables`` met à jour seulement la semaine courante ou +à venir le week-end, et ne met pas à jour si la dernière mise à jour +présente dans la base de données est plus récente que celle présente +dans la source. Les différents paramètres permettent de contrôler ce +comportement : + +``--all`` permet de mettre à jour toutes les semaines présentes dans +la source. + +``--force`` force la mise à jour, même si la dernière mise à jour des +emplois du temps présente dans la base de données est plus récente que +celle présente dans la source. + +``--week`` permet de spécifier la semaine à mettre à jour. + +``--year`` permet de spécifier l’année à mettre à jour. + +Comportement +============ +Pour chaque emploi du temps, ``timetables`` récupère la source, +supprime les cours sur la période couverte par cette mise à jour, +parse la source et insère les cours dans la base de données. + +Cette mise à jour est effectuée de manière transactionnelle : si la +mise à jour d’une source échoue au milieu du processus, les données +supprimées au début seront entièrement restaurées, les données +rajoutées seront supprimées, et une erreur sera affichée. Cela +n’affecte pas la mise à jour des autres emplois du temps. diff --git a/Documentation/usage/installation.rst b/Documentation/usage/installation.rst new file mode 100644 index 0000000..2455996 --- /dev/null +++ b/Documentation/usage/installation.rst @@ -0,0 +1,285 @@ +=============================== +Installation de celcatsanitizer +=============================== + +Dépendances +=========== +celcatsanitizer est écrit en Python 3. Il dépend des bibliothèques +suivantes : + + - `Django 2.0`_ + - requests_, pour récupérer les emplois du temps en HTTP(S) + - BeautifulSoup4_, pour parser les emplois du temps en XML + - icalendar_, pour générer des fichiers ICS_. + +*A priori*, il est possible d’utiliser n’importe quel SGBD supporté +par Django avec celcatsanitizer. Cependant, l’utilisation de +PostgreSQL_ est fortement recommandée. Dans ce cas, vous aurez besoin +d’installer le module psycopg2_. + +Pour l’instant, l’installation doit passer par git_. + +.. _Django 2.0: https://www.djangoproject.com/ +.. _requests: http://docs.python-requests.org/en/master/ +.. _BeautifulSoup4: +  https://www.crummy.com/software/BeautifulSoup/bs4/doc/ +.. _icalendar: https://icalendar.readthedocs.io/en/latest/ +.. _ICS: https://fr.wikipedia.org/wiki/ICalendar +.. _PostgreSQL: https://www.postgresql.org/ +.. _psycopg2: http://initd.org/psycopg/docs/install.html +.. _git: https://git-scm.com/ + +Notes sur Django +---------------- +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. + +Installation +============ +Création de l’environnement virtuel +----------------------------------- +Cette étape est optionnelle, mais est tout de même fortement +recommandée. + +Placez-vous dans le répertoire souhaité, installez l’environnement +virtuel, puis activez-le : + +.. code:: shell + +    $ virtualenv -p python3 celcatsanitizer +    $ cd celcatsanitizer +    $ source bin/activate + +Il est possible que votre version de pip soit ancienne. Si vous le +souhaitez, mettez-le à jour : + +.. code:: shell + +    $ pip install -U pip + +Installation des dépendances +---------------------------- +Vous pouvez demander à ``pip`` d’installer les dépendances à partir du +fichier ``requirements.txt`` présent dans le dépôt : + +.. code:: shell + +    $ pip install -r requirements.txt + +Cette commande installera aussi ``psycopg2-binary`` et ``gunicorn``. + +Il est aussi possible d’installer les dépendances à la main : + +.. code:: shell + +    $ pip install django beautifulsoup4 icalendar requests + +Si vous utilisez PostgreSQL, vous devez installer le module +psycopg2 : + +.. code:: shell + +    $ pip install psycopg2-binary + +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 : + +.. code:: shell + +    $ pip install gunicorn + +Création du projet Django +------------------------- + +.. code:: shell + +    $ django-admin startproject celcatsanitizer +    $ cd celcatsanitizer + +Récupération des sources de celcatsanitizer +------------------------------------------- + +.. code:: shell + +    $ git clone https://git.pa1ch.fr/alban/celcatsanitizer.git edt + +Pour la production, il est recommandé d’utiliser une version +stable. Elles sont accessibles à travers les tags git. + +Configuration de Django +======================= +Avant de pouvoir lancer celcatsanitizer, vous allez devoir modifier +quelques fichiers. + +``settings.py`` +--------------- +Dans le fichier ``celcatsanitizer/settings.py``, vous devrez renseigner +plusieurs informations. + +Configuration des administrateurs +````````````````````````````````` +Vous pouvez retrouver la documentation de la variable ``ADMIN`` `sur le +site de Django`__. + +Cette variable est **obligatoire**. + +__ https://docs.djangoproject.com/fr/2.0/ref/settings/#admins + +Configuration de la base de données +``````````````````````````````````` +Vous pouvez retrouver la documentation relative à la configuration de +la base de données `sur le site de Django`__. + +Cette étape est **obligatoire**. + +__ https://docs.djangoproject.com/fr/2.0/ref/settings/#databases + +Configuration du mode de Django +``````````````````````````````` +Si jamais vous utilisez Django en production, vous **devez +impérativement** mettre la valeur de la variable ``DEBUG`` à +``False``. + +Ajout de celcatsanitizer à la liste des applications Django +``````````````````````````````````````````````````````````` +Ajoutez la chaîne de caractère ``edt`` à la liste ``INSTALLED_APPS``. + +Cette étape est **obligatoire**. + +.. _ref-flatpages: + +Activation des flatpages +```````````````````````` +celcatsanitizer se sert des flatpages pour rendre les pages "contact" +et "à propos". Vous pouvez retrouver le guide d’installation `sur le +site de Django`__. Effectuez uniquement les deux premières étapes, +celcatsanitizer enregistre déjà une route pour les pages statiques, et +la commande de l’étape 4 sera effectuée plus loin. + +Cette étape est **obligatoire**. + +__ +  https://docs.djangoproject.com/fr/2.0/ref/contrib/flatpages/#installation + +Gestion des fichiers statiques +`````````````````````````````` +Si vous êtes en production, vous devez renseigner l’emplacement de +vos fichiers statiques dans la variable ``STATIC_ROOT`` de la +configuration de Django (vous pouvez retrouver la documentation +correspondante sur le site de Django). + +Cette étape est **obligatoire en production**, mais inutile en +déboguage. + +Ajout du processeur de contexte de celcatsanitizer +`````````````````````````````````````````````````` +Rajoutez la chaîne de caractères ``edt.views.ctx_processor`` à la +liste ``context_processors`` dans la variable ``TEMPLATES``. + +Cette étape est **fortement recommandée**. + +Configuration de l’internationalisation +``````````````````````````````````````` +Vous pouvez retrouver la documentation de l’internationalisation `sur +le site de Django`__. + +Ce paramètre est **optionnel**. + +__ https://docs.djangoproject.com/fr/2.0/topics/i18n/ + +``urls.py`` +----------- +Dans le fichier ``celcatsanitizer/urls.py``, importez la fonction +``django.conf.urls.include`` si elle ne l’est pas déjà, et rajouter +``url(r'^', include("edt.urls"))`` à la *fin* de la liste +``urlspatterns``. + +Cette étape est **obligatoire**. + +Derniers préparatifs +==================== +Génération de la base de données +-------------------------------- +Générez les migrations de Django et de celcatsanitizer, puis +appliquez-les : + +.. code:: shell + +    $ ./manage.py makemigrations edt +    $ ./manage.py migrate + +Collection des fichiers statiques +--------------------------------- +Si vous êtes en production, il faut regrouper les fichiers +statiques. Pour ce faire, exécutez la commande suivante : + +.. code:: shell + +    $ ./manage.py collectstatic + +Cette étape est **obligatoire** en production, mais inutile en +déboguage. + +Création d’un super-utilisateur +------------------------------- +Pour pouvoir accéder à l’interface d’administration, il est important +de créer un super-utilisateur. Pour cela, exécutez la commande +suivante : + +.. code:: shell + +    $ ./manage.py createsuperuser + +Répondez ensuite aux questions posées. + +Cette étape est **fortement recommandée**. + +.. _ref-cron: + +Cron +---- +Pour mettre à jour les emplois du temps de manière régulière, il faut +rajouter :doc:`la commande de mise à jour <commands/timetables>` dans +une tâche cron. + +Lancement +========= +En mode de déboguage +-------------------- +Exécutez tout simplement la commande suivante : + +.. code:: shell + +    $ ./manage.py runserver + +En production +------------- +Le serveur intégré à Django n’est pas adapté pour un usage en +production. Il vaut mieux utiliser Apache avec mod_wsgi, ou avec un +serveur gunicorn_ derrière nginx_. + +.. _gunicorn: https://gunicorn.org/ +.. _nginx: https://nginx.org/en/ + +Ajout des pages statiques +========================= +:ref:`Comme indiqué plus haut <ref-flatpages>`, celcatsanitizer fait +appel aux flatpages de Django. + +À l’aide de l’interface d’administration de Django (si votre instance +se trouve à l’adresse ``example.com``, vous pourrez y accéder à +l’adresse ``example.com/admin``), dans la section "pages statiques", +rajoutez les pages ``/a-propos/`` et ``/contact/``. + +Si vous êtes en production, changez le site de base (``example.com``) +par le site sur lequel se trouvera votre instance de celcatsanitizer, +trouvable dans la section "sites". diff --git a/Documentation/usage/versions.rst b/Documentation/usage/versions.rst new file mode 100644 index 0000000..d6b8f00 --- /dev/null +++ b/Documentation/usage/versions.rst @@ -0,0 +1,35 @@ +================ +Notes de version +================ + +Version 0.13 +============ +Changements externes +-------------------- + - Ajout de l’emploi du temps des salles + - Ajout d’une fonctionnalité permettant de connaître les salles +   disponibles + - Améliorations de la navigabilité du site + +   - Ajout de liens pour revenir en arrière sur le site +   - Ajout de liens pour parcourir les semaines de l’emploi du temps + + - Les groupes qui n’ont plus de cours du tout ne sont plus affichés + - Ajout d’une page contenant la liste complète des groupes + - Ajout d’une page contenant la liste complète des semaines de cours +   pour les groupes et les salles + - Ajout d’un texte de description sur la page des ICS + - Création de la documentation + +Changements internes +-------------------- + - Passage à Django 2.0 + +   - Utilisation des routes ``path()`` au lieu de ``url()`` + + - Création d’une table ``Source`` pour stocker la source des emplois +   du temps. Cela permet d’éviter de récupérer plusieurs fois le même +   fichier et d’éviter les doublons sur les emplois du temps des +   salles. + - Ajout de la commande :doc:`reparse <commands/reparse>` + - Meilleure abstraction des templates, notamment de ``index.html`` @@ -1,192 +1,11 @@  # celcatsanitizer -celcatsanitizer est un système qui permet de récupérer des emplois du -temps Celcat au format XML pour les afficher correctement. -## Pourquoi ? -Parce que les emplois du temps Celcat sont peu lisibles et peuvent -facilement faire planter un navigateur, à cause du surplus -d’informations affichées. +La documentation de celcatsanitizer se trouve dans le dossier +`Documentation/`. Il est possible de la construire à l’aide de +[Sphinx](http://www.sphinx-doc.org/) en exécutant la commande +`make html` dans le dossier `Documentation/`. -## Comment faire tourner celcatsanitizer chez moi ? -celcatsanitizer est écrit en Python 3. Il dépend des bibliothèques -suivantes : - * [Django 1.11](https://www.djangoproject.com/) - * [requests](http://docs.python-requests.org/en/master/) - * [BeautifulSoup4](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) - * [icalendar](https://icalendar.readthedocs.io/en/latest/) +Il est possible de retrouver une copie en ligne de la documentation de +la dernière version stable à l’adresse suivante : -Pour installer celcatsanitizer, il est possible d’utiliser -[git](https://git-scm.com/). - -Pour tester celcatsanitizer, il est recommandé d’utiliser -[SQLite](https://www.sqlite.org/) ou -[PostgreSQL](https://www.postgresql.org/?&). - -Pour la production, il est recommandé d’utiliser PostgreSQL (avec le -driver -[psycopg2](http://initd.org/psycopg/docs/install.html#binary-install-from-pypi)) -et de mettre le tout dans un environnement virtuel. - -Aucun autre SGBD n’a été testé, mais depuis la version 0.8.0, -celcatsanitizer n’utilise plus de fonctions SQL brutes -spécifiques. Tous les SGBD supportés par Django devraient fonctionner -sans poser de problèmes. - -### Installation -Il est préférable d’utiliser un environnement virtuel, mais ce n’est -pas obligatoire. Si vous ne souhaitez pas utiliser un environnement -virtuel, passez directement à l’installation des dépendances. - -#### Création de l’environnement virtuel -Déplacez-vous dans le répertoire souhaité, installez l’environnement -virtuel, et activez-le : - -```bash -$ virtualenv -p python3 celcatsanitizer -$ cd celcatsanitizer -$ source bin/activate -``` - -Il est possible que votre version de pip soit ancienne. Si vous le -souhaitez, mettez ce programme à jour : - -```bash -$ pip install --upgrade pip -``` - -#### Installation des dépendances - -```bash -$ pip install requests django beautifulsoup4 icalendar -``` - -Si vous utilisez PostgreSQL, vous allez avoir besoin du driver -psycopg2 : - -```bash -$ pip install psycopg2 -``` - -SQLite n’a pas besoin de driver. - -#### Création du répertoire Django - -```bash -$ django-admin startproject celcatsanitizer -$ cd celcatsanitizer -``` - -#### Récupération des sources de celcatsanitizer - -```bash -$ git clone https://git.pa1ch.fr/alban/celcatsanitizer.git edt -``` - -Pour la production, il est recommandé d’utiliser une version stable, -accessibles à travers les tags git. - -#### Configuration de Django -Dans le fichier celcatsanitizer/settings.py, vous devrez renseigner -plusieurs informations. - -##### Configuration des administrateurs -[Vous pouvez retrouver la documentation de la variable ADMIN sur le -site de -Django.](https://docs.djangoproject.com/fr/1.11/ref/settings/#admins) - -Cette variable est obligatoire. - -##### Configuration de l’internationalisation -Ce passage n’est pas obligatoire. [Vous pouvez retrouver la -documentation de l’internationalisation sur le site de -Django.](https://docs.djangoproject.com/fr/1.11/topics/i18n/) - -##### Configuration de la base de données -[Vous pouvez retrouver la documentation de la base de données sur le -site de -Django.](https://docs.djangoproject.com/fr/1.11/ref/settings/#databases) - -##### Configuration du mode de Django -Si jamais vous utiliser Django en production, vous **devez** mettre la -variable DEBUG à False. - -##### Ajout de celcatsanitizer dans la liste des applications Django -Ajoutez la chaine de caractère « edt » à la fin de la liste -INSTALLED_APPS. - -##### Configuration des flatpages -celcatsanitizer utilise les flatpages pour rendre les pages -« contact » et « à propos ». Vous pouvez retrouver le guide -d’installation sur [le site de -Django](https://docs.djangoproject.com/fr/1.11/ref/contrib/flatpages/#installation). Effectuez -uniquement les deux premières étapes, celcatsanitizer enregistre déjà -une route pour les pages statiques, et la commande de l’étape 4 sera -effectuée plus loin. - -##### Ajout du processeur de contexte de celcatsanitizer -Cette étape est fortement recommandée, mais pas obligatoire. - -Rajoutez la chaine de caractères 'edt.views.ctx_processor' à la liste -'context_processors' dans la variable « TEMPLATES ». - -##### Ajout des URLs de celcatsanitizer -Dans le fichier celcatsanitizer/urls.py, importez la fonction -django.conf.urls.include, et ajoutez url(r'^', include("edt.urls")) à -la **fin** de la liste urlspatterns. - -##### Génération de la base de données -Générer les migrations de celcatsanitizer, puis appliquez-les : - -```bash -$ ./manage.py makemigrations edt -$ ./manage.py migrate -``` - -##### Gestion des fichiers statiques -Si vous êtes en production, vous devez renseigner l’emplacement de vos -fichiers statiques dans la variable -[STATIC_ROOT](https://docs.djangoproject.com/fr/1.11/ref/settings/#std:setting-STATIC_ROOT) -de la configuration de Django, puis exécuter la commande suivante : - -```bash -$ ./manage.py collectstatic -``` - -Cette étape est inutile si vous êtes en mode de déboguage. - -### Lancement de celcatsanitizer -Si vous êtes en mode de déboguage, lancez le serveur de cette -manière : - -```bash -$ ./manage.py runserver -``` - -Si vous êtes en production, il n’est pas recommandé d’utiliser ce -serveur. Exécutez Django avec le module mod_wsgi d’Apache, ou avec un -serveur [gunicorn](http://gunicorn.org/) derrière nginx. - -### Configuration de celcatsanitizer -#### Administrateur -Pour avoir accès à l’interface d’administration, vous devez créer un -utilisateur avec les droits administrateur. Pour cela, exécutez la -commande suivante : - -```bash -$ ./manage.py createsuperuser -``` - -Renseignez ensuite votre nom d’utilisateur, mot de passe et adresse -email au fur et à mesure. - -#### Pages statiques -Comme indiqué plus haut, celcatsanitizer utilise l’application -flatpages de Django. - -Si vous êtes en production, vous devez changer le site de base -(« example.com ») par le site de celcatsanitizer. - -Vous devez ensuite rajouter les pages /a-propos/ et /contact/. - -Vous pouvez effectuer tout ça à partir de l’interface d’administration -de Django. +> <https://www.pa1ch.fr/projets/celcatsanitizer/> diff --git a/__init__.py b/__init__.py index c438abb..eb06e42 100644 --- a/__init__.py +++ b/__init__.py @@ -13,7 +13,7 @@  #    You should have received a copy of the GNU Affero General Public License  #    along with celcatsanitizer.  If not, see <http://www.gnu.org/licenses/>. -VERSION = "0.12.0-pa1ch" +VERSION = "0.13.0"  __version__ = VERSION  default_app_config = "edt.apps.EdtConfig" @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -14,14 +14,18 @@  #    along with celcatsanitizer.  If not, see <http://www.gnu.org/licenses/>.  from django.contrib import admin -from .models import Timetable, Group, Room, Course, Year +from .models import Course, Group, Room, Source, Timetable, Year +  def make_hidden(modeladmin, request, queryset):      queryset.update(hidden=True) -make_hidden.short_description = "Cacher les groupes sélectionnés" +  def make_visible(modeladmin, request, queryset):      queryset.update(hidden=False) + + +make_hidden.short_description = "Cacher les groupes sélectionnés"  make_visible.short_description = "Afficher les groupes sélectionnés" @@ -32,10 +36,15 @@ class YearAdmin(admin.ModelAdmin):      ordering = ("name",) +@admin.register(Source) +class SourceAdmin(admin.ModelAdmin): +    list_display = ("url", "last_update_date",) + +  @admin.register(Timetable)  class TimetableAdmin(admin.ModelAdmin):      prepopulated_fields = {"slug": ("name",)} -    list_display = ("name", "year", "url",) +    list_display = ("name", "year", "source",)      list_filter = ("year__name",)      ordering = ("year", "name",) @@ -43,27 +52,28 @@ class TimetableAdmin(admin.ModelAdmin):  @admin.register(Group)  class GroupAdmin(admin.ModelAdmin):      fieldsets = ( -        (None, {"fields": ("name", "celcat_name", "timetable", "hidden",)}), +        (None, {"fields": ("name", "celcat_name", "source", "hidden",)}),          ("Groupes", {"fields": ("mention", "semester", "subgroup",)}),) -    list_display = ("name", "timetable", "hidden",) +    list_display = ("name", "source", "hidden",)      list_editable = ("hidden",) -    list_filter = ("timetable",) -    ordering = ("timetable",) -    readonly_fields = ("celcat_name", "mention",) +    list_filter = ("source__timetables",) +    ordering = ("source", "name",) +    readonly_fields = ("celcat_name", "mention", "semester", "subgroup",)      actions = (make_hidden, make_visible,)  @admin.register(Room)  class RoomAdmin(admin.ModelAdmin): -    pass +    prepopulated_fields = {"slug": ("name",)}  @admin.register(Course)  class CourseAdmin(admin.ModelAdmin):      fieldsets = ( -        (None, {"fields": ("name", "type", "timetable", "groups", "rooms", "last_update",)}), +        (None, {"fields": ("name", "type", "source", "groups", "rooms", +                           "last_update",)}),          ("Horaires", {"fields": ("begin", "end",)}),          ("Remarques", {"fields": ("notes",)}),) -    list_display = ("name", "type", "timetable", "begin", "end",) -    list_filter = ("type", "timetable", "groups",) +    list_display = ("name", "type", "source", "begin", "end",) +    list_filter = ("type", "source__timetables", "groups",)      ordering = ("begin",) @@ -21,10 +21,11 @@ from django.db.models.functions import ExtractWeek, ExtractYear  from django.template import loader  from django.urls import reverse  from django.utils.feedgenerator import Atom1Feed, SyndicationFeed +from django.utils.timezone import get_current_timezone_name  from icalendar import Calendar, Event -from .models import Course, Group +from .models import Course, Group, Timetable  from .templatetags.rooms import format_rooms  from .utils import get_current_or_next_week, get_week, group_courses @@ -39,6 +40,11 @@ class IcalFeedGenerator(SyndicationFeed):          calendar = Calendar()          calendar.add("prodid", "-//celcatsanitizer//NONSGML v1.0//EN")          calendar.add("version", "2.0") +        calendar.add("calscale", "GREGORIAN") +        calendar.add("method", "PUBLISH") +        calendar.add("x-wr-timezone", get_current_timezone_name()) +        calendar.add("x-wr-calname", self.feed["title"]) +        calendar.add("x-wr-caldesc", self.feed["title"])          self.write_events(calendar)          outfile.write(calendar.to_ical()) @@ -58,8 +64,9 @@ class IcalFeed(Feed):      def get_object(self, request, year_slug, timetable_slug, group_slug):          try: -            group = Group.objects.get(timetable__year__slug=year_slug, -                                      timetable__slug=timetable_slug, +            timetable = Timetable.objects.get(year__slug=year_slug, +                                              slug=timetable_slug) +            group = Group.objects.get(source=timetable.source,                                        slug=group_slug)          except:              raise ObjectDoesNotExist @@ -81,7 +88,7 @@ class IcalFeed(Feed):          return item.name      def items(self, obj): -        return Course.objects.get_courses_for_group(obj) +        return Course.objects.get_courses(obj)      def item_extra_kwargs(self, item):          return {"uid": "{0}@celcatsanitizer".format(item.id), @@ -91,6 +98,9 @@ class IcalFeed(Feed):                  "summary": self.item_summary(item),                  "location": format_rooms(item.rooms.all())} +    def title(self, obj): +        return "Emploi du temps du groupe {0}".format(obj) +  class IcalOnlyOneFeed(IcalFeed):      def items(self, obj): @@ -103,39 +113,37 @@ class RSSFeed(Feed):          _, end = get_week(year, week)          try: -            group = Group.objects.get(timetable__year__slug=year_slug, -                                      timetable__slug=timetable_slug, -                                      slug=group_slug) +            self.timetable = Timetable.objects.get(year__slug=year_slug, +                                                   slug=timetable_slug) +            self.group = Group.objects.get(source=self.timetable.source, +                                           slug=group_slug)          except:              raise ObjectDoesNotExist          else: -            updates = Course.objects.get_courses_for_group(group, -                                                           begin__lt=end) \ +            updates = Course.objects.get_courses(self.group, begin__lt=end) \                                      .annotate(year=ExtractYear("begin"),                                                week=ExtractWeek("begin")) \                                      .values("year", "week") \                                      .annotate(Count("year", distinct=True),                                                Max("last_update")) \                                      .order_by("-year", "-week")[:5] -            return group, updates +            return updates      def link(self, obj): -        group = obj[0]          link = reverse("timetable", -                       kwargs={"year_slug": group.timetable.year.slug, -                               "timetable_slug": group.timetable.slug, -                               "group_slug": group.slug}) +                       kwargs={"year_slug": self.timetable.year.slug, +                               "timetable_slug": self.timetable.slug, +                               "group_slug": self.group.slug})          return link      def title(self, obj): -        return "Emploi du temps du groupe {0}".format(obj[0]) +        return "Emploi du temps du groupe {0}".format(self.group)      def item_link(self, item): -        group = item["group"]          return reverse("timetable", -                       kwargs={"year_slug": group.timetable.year.slug, -                               "timetable_slug": group.timetable.slug, -                               "group_slug": group.slug, +                       kwargs={"year_slug": self.timetable.year.slug, +                               "timetable_slug": self.timetable.slug, +                               "group_slug": self.group.slug,                                 "year": item["year"],                                 "week": item["week"]}) @@ -143,7 +151,7 @@ class RSSFeed(Feed):          return item["description"]      def item_title(self, item): -        return "{0}, semaine {1} de {2}".format(item["group"], +        return "{0}, semaine {1} de {2}".format(self.group,                                                  item["week"],                                                  item["year"]) @@ -152,23 +160,20 @@ class RSSFeed(Feed):      def items(self, obj):          template = loader.get_template("timetable_common.html") -        group = obj[0] -        for update in obj[1]: +        for update in obj:              start, end = get_week(update["year"], update["week"]) -            courses = Course.objects.get_courses_for_group(group, -                                                           begin__gte=start, -                                                           begin__lt=end) -            context = {"group": group, -                       "courses": group_courses(courses), +            courses = Course.objects.get_courses(self.group, begin__gte=start, +                                                 begin__lt=end) +            context = {"courses": group_courses(courses),                         "last_update": update["last_update__max"],                         "year": update["year"], -                       "week": update["week"]} +                       "week": update["week"], +                       "group_mode": True} -            update["group"] = group              update["description"] = template.render(context) -        return obj[1] +        return obj  class AtomFeed(RSSFeed): diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..00dbf9e --- /dev/null +++ b/forms.py @@ -0,0 +1,58 @@ +#    Copyright (C) 2018  Alban Gruin +# +#    celcatsanitizer is free software: you can redistribute it and/or modify +#    it under the terms of the GNU Affero General Public License as published +#    by the Free Software Foundation, either version 3 of the License, or +#    (at your option) any later version. +# +#    celcatsanitizer is distributed in the hope that it will be useful, +#    but WITHOUT ANY WARRANTY; without even the implied warranty of +#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +#    GNU Affero General Public License for more details. +# +#    You should have received a copy of the GNU Affero General Public License +#    along with celcatsanitizer.  If not, see <http://www.gnu.org/licenses/>. + +from datetime import timedelta + +from django import forms +from django.forms.widgets import DateInput, TimeInput + +from .utils import tz_now + + +class QSJPSForm(forms.Form): +    day = forms.DateField(label="Jour", +                          widget=DateInput(attrs={"type": "date"})) + +    # Ces champs n’acceptent pas les secondes +    begin = forms.TimeField(label="Heure de début", input_formats=("%H:%M",), +                            widget=TimeInput(attrs={"type": "time"})) +    end = forms.TimeField(label="Heure de fin", input_formats=("%H:%M",), +                          widget=TimeInput(attrs={"type": "time"})) + +    def __init__(self, *args, **kwargs): +        super(QSJPSForm, self).__init__(*args, **kwargs) + +        # On définit les valeurs par défaut de cette manière pour +        # éviter les mauvaises surprises.  On retire les secondes des +        # heures de début et de fin. +        self.fields["day"].initial = tz_now().strftime("%Y-%m-%d") +        self.fields["begin"].initial = tz_now().strftime("%H:%M") +        self.fields["end"].initial = (tz_now() + timedelta(hours=1)) \ +                          .strftime("%H:%M") + +    def clean(self): +        form_data = self.cleaned_data + +        # On vérifie que les valeurs de début et de fin sont correctes +        # (si ce n’est pas le cas, elles ne se trouvent pas dans le +        # dictionnaire), et, le cas échéant, on vérifie que l’heure de +        # début est strictement inférieure à l’heure de fin. +        if "begin" in form_data and "end" in form_data and \ +           form_data["begin"] >= form_data["end"]: +            # Si l’heure de fin est plus petite ou égale, on affiche +            # une erreur. +            self._errors["end"].append("L’heure de début doit être supérieure " +                                       "à celle de fin.") +        return form_data diff --git a/management/commands/_private.py b/management/commands/_private.py index 4dd9262..94c1918 100644 --- a/management/commands/_private.py +++ b/management/commands/_private.py @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -19,33 +19,26 @@ import re  from bs4 import BeautifulSoup  from django.utils import timezone -from edt.models import Group, Room, Course -from edt.utils import get_week +from ...models import Course, Group, Room +from ...utils import get_week  import requests  import edt +  def add_time(date, time):      ptime = datetime.datetime.strptime(time, "%H:%M")      delta = datetime.timedelta(hours=ptime.hour, minutes=ptime.minute)      return date + delta -def delete_courses_in_week(timetable, year, week, today): + +def delete_courses_in_week(source, year, week, today):      start, end = get_week(year, week)      Course.objects.filter(begin__gte=max(start, today), begin__lt=end, -                          timetable=timetable).delete() - -def get_from_db_or_create(cls, **kwargs): -    obj = cls.objects.all().filter(**kwargs) - -    obj = obj.first() -    if obj is None: -        obj = cls(**kwargs) -        obj.save() +                          source=source).delete() -    return obj -def get_event(timetable, event, event_week, today): +def get_event(source, event, event_week, today):      """Renvoie une classe Course à partir d’un événement récupéré par BS4"""      # On récupère la date de l’évènement à partir de la semaine      # et de la semaine référencée, puis l’heure de début et de fin @@ -58,17 +51,17 @@ def get_event(timetable, event, event_week, today):          return      # Création de l’objet cours -    course = Course.objects.create(timetable=timetable, begin=begin, end=end) +    course = Course.objects.create(source=source, begin=begin, end=end)      # On récupère les groupes concernés par les cours -    groups = [get_from_db_or_create(Group, timetable=timetable, -                                    celcat_name=item.text) +    groups = [Group.objects.get_or_create(source=source, +                                          celcat_name=item.text)[0]                for item in event.resources.group.find_all("item")]      course.groups.add(*groups)      # On récupère le champ « remarque »      if event.notes is not None: -        course.notes = event.notes.text +        course.notes = "\n".join(event.notes.find_all(text=True))      # On récupère le champ « nom »      if event.resources.module is not None: @@ -90,32 +83,34 @@ def get_event(timetable, event, event_week, today):      # en ait pas… qui sont ils, leurs réseaux, tout ça…), on les insère      # dans la base de données, et on les ajoute dans l’objet cours      if event.resources.room is not None: -        rooms = [get_from_db_or_create(Room, name=item.text) +        rooms = [Room.objects.get_or_create(name=item.text)[0]                   for item in event.resources.room.find_all("item")]          course.rooms.add(*rooms)      return course -def get_events(timetable, soup, weeks_in_soup, today, year=None, week=None): + +def get_events(source, soup, weeks_in_soup, today, year=None, week=None):      """Récupère tous les cours disponibles dans l’emploi du temps Celcat.      Le traîtement se limitera à la semaine indiquée si il y en a une."""      for event in soup.find_all("event"):          event_week = weeks_in_soup[event.rawweeks.text] -        event_week_num = event_week.isocalendar()[1] # Numéro de semaine +        event_week_num = event_week.isocalendar()[1]  # Numéro de semaine          # On passe le traitement si la semaine de l’événement ne correspond pas          # à la semaine passée, ou qu’il ne contient pas de groupe ou n’a pas de          # date de début ou de fin. -        if (event_week_num == week and event_week.year == year or \ +        if (event_week_num == week and event_week.year == year or              year is None or week is None) and \             event.resources.group is not None and \             event.starttime is not None and event.endtime is not None: -            course = get_event(timetable, event, event_week, today) +            course = get_event(source, event, event_week, today)              # On renvoie le cours si il n’est pas nul              if course is not None:                  yield course +  def get_update_date(soup):      # Explication de la regex      # @@ -140,6 +135,7 @@ def get_update_date(soup):      date = datetime.datetime(year, month, day, hour, minute, second)      return timezone.make_aware(date) +  def get_weeks(soup):      # Les semaines sont référencées de manière assez… exotique      # En gros, il y a une liste d’éléments span qui contiennent une sorte d’ID @@ -151,13 +147,14 @@ def get_weeks(soup):      # Un cours contient donc un ID de semaine, puis le nombre de jours après le      # début de cette semaine.      weeks = {} -    for span in soup.find_all("span"): # Liste de toutes les semaines définies +    for span in soup.find_all("span"):  # Liste de toutes les semaines définies          # On parse la date et on la fait correspondre à l’ID          weeks[span.alleventweeks.text] = timezone.make_aware(              datetime.datetime.strptime(span["date"], "%d/%m/%Y"))      return weeks +  def get_xml(url):      user_agent = "celcatsanitizer/" + edt.VERSION      req = requests.get(url, headers={"User-Agent": user_agent}) diff --git a/management/commands/cleancourses.py b/management/commands/cleancourses.py index f6041ef..246cfcc 100644 --- a/management/commands/cleancourses.py +++ b/management/commands/cleancourses.py @@ -15,22 +15,24 @@  from django.core.management.base import BaseCommand  from django.db import transaction -from edt.models import Course, Group + +from ...models import Course, Group  class Command(BaseCommand):      help = "Remove all courses and groups from the database"      def add_arguments(self, parser): -        parser.add_argument("--timetable", type=int, nargs="+") +        parser.add_argument("--source", type=int, nargs="+")      def handle(self, *args, **options):          with transaction.atomic(): -            if options["timetable"] is None: +            if options["source"] is None:                  Course.objects.all().delete()                  Group.objects.all().delete()              else: -                Course.objects.filter(timetable__id__in=options["timetable"]).delete() -                Group.objects.filter(timetable__id__in=options["timetable"]).delete() +                Course.objects.filter(source__id__in=options["source"]) \ +                              .delete() +                Group.objects.filter(source__id__in=options["source"]).delete()          self.stdout.write(self.style.SUCCESS("Done.")) diff --git a/management/commands/listtimetables.py b/management/commands/listtimetables.py index 6df7ba5..bd27e92 100644 --- a/management/commands/listtimetables.py +++ b/management/commands/listtimetables.py @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -14,24 +14,18 @@  #    along with celcatsanitizer.  If not, see <http://www.gnu.org/licenses/>.  from django.core.management.base import BaseCommand -from edt.models import Timetable +from ...models import Source  class Command(BaseCommand):      help = "List timetables in the database" -    def add_arguments(self, parser): -        parser.add_argument("--order-by-id", action="store_true") -      def handle(self, *args, **options): -        timetables = Timetable.objects.all() -        if options["order_by_id"]: -            timetables = timetables.order_by("id") -        else: -            timetables = timetables.order_by("year__name", "name") +        sources = Source.objects.all() -        for timetable in timetables: -            self.stdout.write("{0} (id: {1})".format(timetable, timetable.id)) +        for source in sources: +            self.stdout.write("{0}\t: {1} (id: {2})".format( +                source.formatted_timetables, source, source.id))          self.stdout.write("")          self.stdout.write(self.style.SUCCESS("Done.")) diff --git a/management/commands/reparse.py b/management/commands/reparse.py new file mode 100644 index 0000000..20eb1b4 --- /dev/null +++ b/management/commands/reparse.py @@ -0,0 +1,30 @@ +#    Copyright (C) 2018  Alban Gruin +# +#    celcatsanitizer is free software: you can redistribute it and/or modify +#    it under the terms of the GNU Affero General Public License as published +#    by the Free Software Foundation, either version 3 of the License, or +#    (at your option) any later version. +# +#    celcatsanitizer is distributed in the hope that it will be useful, +#    but WITHOUT ANY WARRANTY; without even the implied warranty of +#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +#    GNU Affero General Public License for more details. +# +#    You should have received a copy of the GNU Affero General Public License +#    along with celcatsanitizer.  If not, see <http://www.gnu.org/licenses/>. + +from django.core.management.base import BaseCommand +from ...models import Group + + +class Command(BaseCommand): +    help = "Reparses all groups in database" + +    def handle(self, *args, **options): +        self.stdout.write("Processing {0} groups…".format( +            Group.objects.count())) + +        for group in Group.objects.all(): +            group.save() + +        self.stdout.write(self.style.SUCCESS("Done.")) diff --git a/management/commands/timetables.py b/management/commands/timetables.py index ff00c8f..f92ad4e 100644 --- a/management/commands/timetables.py +++ b/management/commands/timetables.py @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -20,12 +20,16 @@ from django.core.management.base import BaseCommand  from django.db import transaction  from django.db.models import Min -from edt.models import Course, Timetable -from edt.utils import get_week, tz_now -from ._private import delete_courses_in_week, get_events, get_update_date, get_weeks, get_xml +from ...models import Course, Source +from ...utils import get_week, tz_now + +from ._private import delete_courses_in_week, get_events, get_update_date, \ +    get_weeks, get_xml +  @transaction.atomic -def process_timetable_week(timetable, soup, weeks_in_soup, force, year=None, week=None): +def process_timetable_week(source, soup, weeks_in_soup, force, +                           year=None, week=None):      if year is not None and week is not None:          begin, end = get_week(year, week) @@ -40,75 +44,83 @@ def process_timetable_week(timetable, soup, weeks_in_soup, force, year=None, wee      else:          today = tz_now() -    # On récupère la mise à jour la plus ancienne dans les cours de l’emploi du temps -    last_update_date = Course.objects.filter(timetable=timetable) +    # On récupère la mise à jour la plus ancienne dans les cours de +    # l’emploi du temps +    last_update_date = Course.objects.filter(source=source)      if today is not None: -        # Cette date concerne les éléments commençant à partir d’aujourd’hui si la valeur -        # n’est pas nulle. +        # Cette date concerne les éléments commençant à partir +        # d’aujourd’hui si la valeur n’est pas nulle.          last_update_date = last_update_date.filter(begin__gte=today)      if year is not None and week is not None: -        # Si jamais on traite une semaine spécifique, on limite les cours sélectionnés -        # à ceux qui commencent entre le début du traitement et la fin de la semaine +        # Si jamais on traite une semaine spécifique, on limite les +        # cours sélectionnés à ceux qui commencent entre le début du +        # traitement et la fin de la semaine          last_update_date = last_update_date.filter(begin__lt=end) -    last_update_date = last_update_date.aggregate(Min("last_update")) \ -                       ["last_update__min"] +    last_update_date = last_update_date.aggregate( +        Min("last_update"))["last_update__min"]      # Date de mise à jour de Celcat, utilisée à des fins de statistiques      new_update_date = get_update_date(soup) -    # On ne fait pas la mise à jour si jamais la dernière date de MàJ est plus récente -    # que celle indiquée par Celcat. -    # Attention, le champ last_update de la classe Course représente l’heure à laquelle -    # le cours a été inséré dans la base de données, et non pas la date indiquée par -    # Celcat. -    if not force and last_update_date is not None and new_update_date is not None and \ -       last_update_date >= new_update_date: +    # On ne fait pas la mise à jour si jamais la dernière date de MàJ +    # est plus récente que celle indiquée par Celcat.  Attention, le +    # champ last_update de la classe Course représente l’heure à +    # laquelle le cours a été inséré dans la base de données, et non +    # pas la date indiquée par Celcat. +    if not force and last_update_date is not None and \ +       new_update_date is not None and last_update_date >= new_update_date:          return      if year is not None and week is not None:          # On efface la semaine à partir de maintenant si jamais          # on demande le traitement d’une seule semaine -        delete_courses_in_week(timetable, year, week, today) +        delete_courses_in_week(source, year, week, today)      else:          # Sinon, on efface tous les cours à partir de maintenant.          # Précisément, on prend la plus grande valeur entre la première semaine          # présente dans Celcat et maintenant.          delete_from = min(weeks_in_soup.values())          if not force: -            # Si jamais on force la MàJ, on efface tout à partir de la première semaine +            # Si jamais on force la MàJ, on efface tout à partir de la +            # première semaine              delete_from = max(delete_from, today) -        Course.objects.filter(timetable=timetable, begin__gte=delete_from).delete() +        Course.objects.filter(source=source, begin__gte=delete_from).delete()      # Tous les cours commençant sur la période traitée      # sont parsés, puis enregistrés dans la base de données. -    for course in get_events(timetable, soup, weeks_in_soup, today, year, week): +    for course in get_events(source, soup, weeks_in_soup, today, year, week):          course.save()      # On renseigne la date de mise à jour de Celcat, à des fins de statistiques -    timetable.last_update_date = new_update_date -    timetable.save() +    source.last_update_date = new_update_date +    source.save() + -def process_timetable(timetable, force, year=None, weeks=None): -    soup = get_xml(timetable.url) +def process_timetable(source, force, year=None, weeks=None): +    soup = get_xml(source.url)      weeks_in_soup = get_weeks(soup)      if year is not None and weeks is not None:          for week in weeks: -            process_timetable_week(timetable, soup, weeks_in_soup, force, year, week) +            process_timetable_week(source, soup, weeks_in_soup, force, +                                   year, week)      else: -        process_timetable_week(timetable, soup, weeks_in_soup, force) +        process_timetable_week(source, soup, weeks_in_soup, force)  class Command(BaseCommand):      help = "Fetches registered celcat timetables"      def add_arguments(self, parser): -        parser.add_argument("--all", const=True, default=False, action="store_const") -        parser.add_argument("--force", const=True, default=False, action="store_const") -        parser.add_argument("--week", type=int, choices=range(1, 54), nargs="+") +        parser.add_argument("--all", const=True, default=False, +                            action="store_const") +        parser.add_argument("--force", const=True, default=False, +                            action="store_const") +        parser.add_argument("--week", type=int, choices=range(1, 54), +                            nargs="+")          parser.add_argument("--year", type=int, nargs=1)      def handle(self, *args, **options): @@ -120,7 +132,8 @@ class Command(BaseCommand):          elif options["week"] is None:              _, week, day = tz_now().isocalendar()              if day >= 6: -                year, week, _ = (tz_now() + datetime.timedelta(weeks=1)).isocalendar() +                year, week, _ = (tz_now() + datetime.timedelta(weeks=1)) \ +                                                            .isocalendar()              weeks = [week]          else:              weeks = options["week"] @@ -131,16 +144,18 @@ class Command(BaseCommand):              elif year is None:                  year = options["year"][0] -        for timetable in Timetable.objects.all(): -            self.stdout.write("Processing {0}".format(timetable)) +        for source in Source.objects.all(): +            self.stdout.write("Processing {0}".format( +                source.formatted_timetables))              try: -                process_timetable(timetable, options["force"], year, weeks) +                process_timetable(source, options["force"], year, weeks)              except KeyboardInterrupt:                  break              except Exception:                  self.stderr.write( -                    self.style.ERROR("Failed to process {0}:".format(timetable)) +                    self.style.ERROR("Failed to process {0}:".format( +                        source.formatted_timetables))                  )                  self.stderr.write(self.style.ERROR(traceback.format_exc()))                  errcount += 1 @@ -148,4 +163,5 @@ class Command(BaseCommand):          if errcount == 0:              self.stdout.write(self.style.SUCCESS("Done."))          else: -            self.stdout.write(self.style.ERROR("Done with {0} errors.".format(errcount))) +            self.stdout.write(self.style.ERROR("Done with {0} errors.".format( +                errcount))) @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -16,7 +16,7 @@  from functools import reduce  from django.db import models -from django.db.models import Count, Manager, Q +from django.db.models import Count, Manager, OuterRef, Q, Subquery  from django.db.models.functions import ExtractWeek, ExtractYear  from django.utils import timezone  from django.utils.text import slugify @@ -25,12 +25,11 @@ from .utils import parse_group  class SlugModel(models.Model): -    def save(self): +    def save(self, *args, **kwargs):          if not self.slug:              self.slug = slugify(self.name) -        super(SlugModel, self).save() - +        super(SlugModel, self).save(*args, **kwargs)      class Meta:          abstract = True @@ -43,26 +42,49 @@ class Year(SlugModel):      def __str__(self):          return self.name -      class Meta:          verbose_name = "année"          verbose_name_plural = "années" +class Source(models.Model): +    url = models.URLField(max_length=255, verbose_name="URL", unique=True) +    last_update_date = models.DateTimeField(null=True, blank=True, +                                            verbose_name="dernière mise à jour" +                                            " Celcat") + +    def __str__(self): +        return self.url + +    @property +    def formatted_timetables(self): +        return ", ".join([str(timetable) for timetable in +                          self.timetables.all()]) + +    class Meta: +        verbose_name = "source d’emploi du temps" +        verbose_name_plural = "sources d’emploi du temps" + + +class TimetableManager(Manager): +    def get_queryset(self): +        return super(Manager, self).get_queryset().select_related("year") + +  class Timetable(SlugModel): +    objects = TimetableManager() +      year = models.ForeignKey(Year, on_delete=models.CASCADE,                               verbose_name="année")      name = models.CharField(max_length=64, verbose_name="nom") -    url = models.URLField(max_length=255, verbose_name="URL")      slug = models.SlugField(max_length=64, default="") - -    last_update_date = models.DateTimeField(verbose_name="dernière mise à jour Celcat", -                                            null=True, blank=True) +    source = models.ForeignKey(Source, on_delete=models.CASCADE, +                               verbose_name="source", +                               related_name="timetables")      def __str__(self):          return self.year.name + " " + self.name -      class Meta:          unique_together = (("year", "name"), ("year", "slug"),)          verbose_name = "emploi du temps" @@ -71,31 +93,40 @@ class Timetable(SlugModel):  class GroupManager(Manager):      def get_parents(self, group): -        groups_criteria = Q(subgroup="") - -        if len(group.subgroup) != 0: -            groups_criteria |= reduce(lambda x, y: x | y, -                                      [Q(subgroup=group.subgroup[:i]) -                                       for i in range(1, len(group.subgroup) + 1)]) +        groups_criteria = reduce(lambda x, y: x | y, +                                 [Q(subgroup=group.subgroup[:i]) +                                  for i in range(1, len(group.subgroup) + 1)], +                                 Q(subgroup=""))          return self.get_queryset().filter(groups_criteria, -                                          Q(semester=None) | Q(semester=group.semester), +                                          Q(semester=None) | +                                          Q(semester=group.semester),                                            mention=group.mention, -                                          timetable=group.timetable) +                                          source=group.source) +    def get_relevant_groups(self, start, **criteria): +        courses = Course.objects.filter(groups=OuterRef("pk"), +                                        begin__gte=start) \ +                                .only("pk")[:1] +        return self.get_queryset() \ +                   .annotate(c=Subquery(courses, +                                        output_field=models.IntegerField())) \ +                   .filter(c__isnull=False, **criteria).order_by("name") -class Group(models.Model): + +class Group(SlugModel):      objects = GroupManager()      name = models.CharField(max_length=255, verbose_name="nom")      celcat_name = models.CharField(max_length=255,                                     verbose_name="nom dans Celcat") -    timetable = models.ForeignKey(Timetable, on_delete=models.CASCADE, -                                  verbose_name="emploi du temps") +    source = models.ForeignKey(Source, on_delete=models.CASCADE, +                               verbose_name="source d’emploi du temps")      mention = models.CharField(max_length=128)      semester = models.IntegerField(verbose_name="semestre", null=True) -    subgroup = models.CharField(max_length=16, verbose_name="sous-groupe", default="") +    subgroup = models.CharField(max_length=16, verbose_name="sous-groupe", +                                default="")      slug = models.SlugField(max_length=64, default="") @@ -106,10 +137,10 @@ class Group(models.Model):          if self.subgroup is not None and subgroup is not None:              subgroup_corresponds = self.subgroup.startswith(subgroup) -        return (self.mention.startswith(mention) or \ +        return (self.mention.startswith(mention) or                  mention.startswith(self.mention)) and \ -                (self.semester == semester or semester is None) and \ -                subgroup_corresponds +               (self.semester == semester or semester is None) and \ +            subgroup_corresponds      @property      def group_info(self): @@ -121,42 +152,68 @@ class Group(models.Model):      def save(self, *args, **kwargs):          if self.name == "":              self.name = self.celcat_name -            self.slug = slugify(self.name)          self.mention, self.semester, self.subgroup = parse_group(self.name)          if self.subgroup is None:              self.subgroup = "" -        super(Group, self).save() - +        super(Group, self).save(*args, **kwargs)      class Meta:          index_together = ("mention", "semester", "subgroup",) -        unique_together = (("name", "timetable",), -                           ("celcat_name", "timetable",), -                           ("slug", "timetable",),) +        unique_together = (("name", "source",), +                           ("celcat_name", "source",), +                           ("slug", "source",),)          verbose_name = "groupe"          verbose_name_plural = "groupes" -class Room(models.Model): +class RoomManager(Manager): +    def qsjps(self, begin, end): +        # On récupère la liste des cours qui commencent avant la fin +        # de l’intervalle sélectionné et qui terminent après le début +        # de l’intervalle, c’est-à-dire qu’au moins une partie du +        # cours se déroule pendant l’intervalle. On récupère ensuite +        # la liste des salles dans lesquelles se déroulent ces +        # cours. On exclu les cours n’ayant aucune salle assignée. +        courses = Course.objects.filter(begin__lt=end, end__gt=begin, +                                        rooms__isnull=False) \ +                                .values_list("rooms", flat=True) + +        # On sélectionne ensuite les salles qui ne sont pas dans la +        # liste récupérée plus haut, et on les trie par leur nom. +        return self.get_queryset().exclude(pk__in=courses).order_by("name") + + +class Room(SlugModel): +    objects = RoomManager() +      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 = "salle"          verbose_name_plural = "salles"  class CourseManager(Manager): -    def get_courses_for_group(self, group, **criteria): -        return self.get_queryset() \ -                   .filter(groups__in=Group.objects.get_parents(group), **criteria) \ -                   .order_by("begin").prefetch_related("rooms") +    def get_courses(self, obj, **criteria): +        qs = self.get_queryset() +        if isinstance(obj, Group): +            qs = qs.filter( +                groups__in=Group.objects.get_parents(obj), **criteria) \ +                   .prefetch_related("rooms") +        elif isinstance(obj, Room): +            qs = qs.filter(rooms=obj, **criteria) \ +                   .prefetch_related("rooms", "groups") +        else: +            raise(TypeError, "obj must be a Group or a Room") + +        return qs.order_by("begin")      def get_weeks(self, **criteria):          return self.get_queryset() \ @@ -165,17 +222,19 @@ class CourseManager(Manager):                     .annotate(year=ExtractYear("begin"),                               week=ExtractWeek("begin")) \                     .values("groups__mention", "groups__semester", -                           "groups__subgroup", "year", "week") +                           "groups__subgroup", "year", "week") \ +                   .annotate(c=Count("*"))  class Course(models.Model):      objects = CourseManager() -    name = models.CharField(max_length=255, verbose_name="nom", default="Sans nom") +    name = models.CharField(max_length=255, verbose_name="nom", +                            default="Sans nom")      type_ = models.CharField(name="type", max_length=255,                               verbose_name="type de cours", null=True) -    timetable = models.ForeignKey(Timetable, on_delete=models.CASCADE, -                                  verbose_name="emploi du temps") +    source = models.ForeignKey(Source, on_delete=models.CASCADE, +                               verbose_name="emploi du temps")      notes = models.TextField(verbose_name="remarques", blank=True, null=True)      groups = models.ManyToManyField(Group, verbose_name="groupes") @@ -199,7 +258,6 @@ class Course(models.Model):          super(Course, self).save(*args, **kwargs) -      class Meta:          verbose_name = "cours"          verbose_name_plural = "cours" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc4ccf8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +beautifulsoup4==4.6.0 +Django==2.0.4 +gunicorn==19.7.1 +icalendar==4.0.1 +psycopg2-binary==2.7.4 +requests==2.18.4 diff --git a/static/celcatsanitizer/style.css b/static/celcatsanitizer/style.css index 825d140..5d05f9e 100644 --- a/static/celcatsanitizer/style.css +++ b/static/celcatsanitizer/style.css @@ -36,6 +36,19 @@ li.course {      margin-bottom: 20px;  } +th { +    text-align: right; +    font-weight: normal; +    vertical-align: top; +} + +@media screen and (max-width: 439px) { +    th, td { +        display: block; +        text-align: left; +    } +} +  @media print {      body, .content {          max-width: none; diff --git a/templates/calendars.html b/templates/calendars.html index d97ea78..e6fcd9f 100644 --- a/templates/calendars.html +++ b/templates/calendars.html @@ -4,10 +4,15 @@  {% block body %}        <h2>ICS disponibles pour le groupe {{ group }}</h2> +      <p> +        Le format ICS (ou iCalendar) permet d’importer un calendrier +        dans un agenda électronique.<br /> +        <a href="https://fr.wikipedia.org/wiki/ICalendar">En savoir plus</a> +      </p>        <ul> -        <li><a href="{% url "ics" group.timetable.year.slug group.timetable.slug group.slug %}">Un seul ICS pour tous les cours</a></li> +        <li><a href="{% url "ics" timetable.year.slug timetable.slug group.slug %}">Un seul ICS pour tous les cours</a></li>  {% for group in groups %} -        <li><a href="{% url "ics-group" group.timetable.year.slug group.timetable.slug group.slug %}">ICS des cours du groupe {{ group }} uniquement</a></li> +        <li><a href="{% url "ics-group" timetable.year.slug timetable.slug group.slug %}">ICS des cours du groupe {{ group }} uniquement</a></li>  {% endfor %}        </ul>  {% endblock %} diff --git a/templates/group_list.html b/templates/group_list.html index f21b2b1..d88f144 100644 --- a/templates/group_list.html +++ b/templates/group_list.html @@ -2,12 +2,15 @@  {% load dt_week %}  {% block title %}{{ timetable }} – {% endblock %} +{% block pagetitle %}<a href="{{ timetable.source.url }}">{{ timetable }}</a>{% endblock %} -{% block body %} -      <h3><a href="{{ timetable.url }}">{{ timetable }}</a></h3> -      <ul> -        {% for group in groups %} -        <li><a class="text"{% if group.weeks is not None %} href="{% url "timetable" timetable.year.slug timetable.slug group.slug %}"{% endif %}>{{ group }}</a> — {% for week in group.weeks %}<a href="{% url "timetable" timetable.year.slug timetable.slug group.slug week.year week|dt_week %}">{{ week|dt_prettyprint }}</a> {% if not forloop.last %}– {% endif %}{% empty %}<em>aucun cours dans le mois à venir</em>{% endfor %}</li> -        {% endfor %} -      </ul> +{% block lelement %}<a class="text"{% if element.weeks is not None %} href="{% block gurl %}{% url "timetable" timetable.year.slug timetable.slug element.slug %}{% endblock %}"{% endif %}>{{ element }}</a> — +{% for week in element.weeks %} +  <a href="{% block wurl %}{% url "timetable" timetable.year.slug timetable.slug element.slug week.year week|dt_week %}{% endblock %}">{{ week|date:"Y/m/d" }}</a> {% if not forloop.last %}– {% endif %} +{% empty %} +  <em>aucun cours dans le mois à venir</em> +{% endfor %}  {% endblock %} + +{% block navigation %}<a href="{% url "mentions" timetable.year.slug %}">Retour à la liste des mentions</a> – +<a href="{% url "groups-all" timetable.year.slug timetable.slug %}">Tous les groupes</a>{% endblock %} diff --git a/templates/group_weeks_list.html b/templates/group_weeks_list.html new file mode 100644 index 0000000..baf312e --- /dev/null +++ b/templates/group_weeks_list.html @@ -0,0 +1,8 @@ +{% extends "index.html" %} +{% load dt_week %} + +{% block title %}Semaines du groupe {{ group }} – {% endblock %} +{% block pagetitle %}Semaines du groupe {{ group }}{% endblock %} +{% block url %}{% url "timetable" timetable.year.slug timetable.slug group.slug element.year element|dt_week %}{% endblock %} +{% block element %}{{ element|date:"Y/m/d" }} (semaine {{ element|dt_week }}){% endblock %} +{% block navigation %}<a href="{% url "groups" timetable.year.slug timetable.slug %}">Liste des groupes</a>{% endblock %} diff --git a/templates/groups_all_list.html b/templates/groups_all_list.html new file mode 100644 index 0000000..36bc447 --- /dev/null +++ b/templates/groups_all_list.html @@ -0,0 +1,6 @@ +{% extends "index.html" %} + +{% block title %}Liste des groupes de {{ timetable }} – {% endblock %} +{% block pagetitle %}Liste des groupes de {{ timetable }}{% endblock %} +{% block url %}{% url "group-weeks" timetable.year.slug timetable.slug element.slug %}{% endblock %} +{% block navigation %}<a href="{% url "groups" timetable.year.slug timetable.slug %}">Retour à la liste réduite</a>{% endblock %} diff --git a/templates/index.html b/templates/index.html index 71665bc..e86038e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,7 +4,7 @@      <meta charset="utf-8" />      <meta name="viewport" content="width=device-width, initial-scale=1" />  {% block head %}{% endblock %} -    <title>{% block title %}{% if year %}{{ year }} – {% endif %}{% endblock %}celcatsanitizer</title> +    <title>{% block title %}{% endblock %}celcatsanitizer</title>      <link rel="stylesheet" href="{% static "celcatsanitizer/style.css" %}">    </head>    <body> @@ -13,18 +13,19 @@      </header>      <div class="content">        {% block body %} -      <h3>{% if year %}{{ year }} – Choisissez votre mention{% else %}Choisissez votre année{% endif %}</h3> -      <ul> -        {% for element in elements %} -        <li><a href="{% if year %}{% url "groups" year.slug element.slug %}{% else %}{% url "mentions" element.slug %}{% endif %}">{{ element }}</a></li> -        {% empty %} -        <p><em>Aucun emploi du temps à afficher</em></p> -        {% endfor %} -      </ul> +        <h3>{% block pagetitle %}{% endblock %}</h3> +        <ul> +          {% for element in elements %} +            <li>{% block lelement %}<a href="{% block url %}{% endblock %}">{% block element %}{{ element }}{% endblock %}</a>{% endblock %}</li> +          {% empty %} +            <p><em>{% block empty %}Aucun emploi du temps à afficher{% endblock %}</em></p> +          {% endfor %} +        </ul> +        {% block navigation %}{% endblock %}        {% endblock %}      </div>      <footer> -      <p>(c) 2017 – 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) 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 />        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 new file mode 100644 index 0000000..3b9d8e8 --- /dev/null +++ b/templates/mention_list.html @@ -0,0 +1,6 @@ +{% extends "index.html" %} + +{% block title %}{{ year }} – {% endblock %} +{% block pagetitle %}{{ year }} – Choississez 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/templates/qsjps.html b/templates/qsjps.html new file mode 100644 index 0000000..9fbdf8a --- /dev/null +++ b/templates/qsjps.html @@ -0,0 +1,8 @@ +{% extends "index.html" %} +{% load dt_week %} + +{% block title %}Trouver une salle – {% endblock %} +{% block pagetitle %}Trouver une salle entre {{ form.begin.value }} et {{ form.end.value }}</h3>{% endblock %} +{% block url %}{% url "room-timetable" element.slug form.cleaned_data.day.year form.cleaned_data.day|dt_week %}{% endblock %} +{% block navigation %}<a href="{% url "rooms" %}">Retour à la liste des salles</a> – +<a href="{% url "qsjps" %}">Nouvelle recherche</a>{% endblock %} diff --git a/templates/qsjps_form.html b/templates/qsjps_form.html new file mode 100644 index 0000000..280a7ad --- /dev/null +++ b/templates/qsjps_form.html @@ -0,0 +1,19 @@ +{% extends "index.html" %} + +{% block title %}Trouver une salle – {% endblock %} + +{% block body %} +      <h3>Trouver une salle</h3> +      <form action="{% url "qsjps" %}" method="post"> +        <table> +          {% for field in form.visible_fields %} +            <tr> +              <th>{{ field.label_tag }}</th> +              <td>{{ field }}{% if field.errors %}<br /><small>{{ field.errors|join:" " }}</small>{% endif %}</td> +            </tr> +          {% endfor %} +          <tr><th></th><td><input type="submit" value="Trouver une salle" /></td></tr> +        </table> +      </form> +      <a href="{% url "rooms" %}">Retour à la liste des salles</a> +{% endblock %} diff --git a/templates/room_list.html b/templates/room_list.html new file mode 100644 index 0000000..1b8497d --- /dev/null +++ b/templates/room_list.html @@ -0,0 +1,9 @@ +{% extends "group_list.html" %} +{% load dt_week %} + +{% block title %}Emploi du temps des salles – {% endblock %} +{% block pagetitle %}Emploi du temps des salles{% endblock %} +{% block gurl %}{% url "room-timetable" element.slug %}{% endblock %} +{% block wurl %}{% url "room-timetable" element.slug week.year week|dt_week %}{% endblock %} +{% block navigation %}<a href="{% url "index" %}">Retour à la liste des années</a> – +<a href="{% url "qsjps" %}">Trouver une salle libre</a>{% endblock %} diff --git a/templates/room_weeks_list.html b/templates/room_weeks_list.html new file mode 100644 index 0000000..6a4c3d9 --- /dev/null +++ b/templates/room_weeks_list.html @@ -0,0 +1,7 @@ +{% extends "group_weeks_list.html" %} +{% load dt_week %} + +{% block title %}Semaines de la salle {{ room }} – {% endblock %} +{% block pagetitle %}Semaines de la salle {{ room }}{% endblock %} +{% block url %}{% url "room-timetable" room.slug element.year element|dt_week %}{% endblock %} +{% block navigation %}<a href="{% url "rooms" %}">Liste des salles</a>{% endblock %} diff --git a/templates/timetable.html b/templates/timetable.html index fc2065f..a6dc1ab 100644 --- a/templates/timetable.html +++ b/templates/timetable.html @@ -1,21 +1,54 @@  {% extends "index.html" %} +{% load dt_week %} -{% block head %} +{% block head %}{% if group_mode %}      <meta name="description" content="Emploi du temps du groupe {{ group }} – Semaine {{ week }}" /> -    <link rel="alternate" type="application/atom+xml" title="Emploi du temps du groupe {{ group }} (Atom)" href="{% url "atom" group.timetable.year.slug group.timetable.slug group.slug %}" /> -    <link rel="alternate" type="application/rss+xml" title="Emploi du temps du groupe {{ group }} (RSS)" href="{% url "rss" group.timetable.year.slug group.timetable.slug group.slug %}" /> -    <link rel="alternate" type="text/calendar" title="Emploi du temps du groupe {{ group }} (iCalendar)" href="{% url "ics" group.timetable.year.slug group.timetable.slug group.slug %}"> -{% endblock %} +    <link rel="alternate" type="application/atom+xml" title="Emploi du temps du groupe {{ group }} (Atom)" href="{% url "atom" timetable.year.slug timetable.slug group.slug %}" /> +    <link rel="alternate" type="application/rss+xml" title="Emploi du temps du groupe {{ group }} (RSS)" href="{% url "rss" timetable.year.slug timetable.slug group.slug %}" /> +    <link rel="alternate" type="text/calendar" title="Emploi du temps du groupe {{ group }} (iCalendar)" href="{% url "ics" timetable.year.slug timetable.slug group.slug %}"> +{% endif %}{% endblock %} -{% block title %}{{ group.timetable }} – {{ group }} – Semaine {{ week }} – {% endblock %} +{% block title %}{% if group_mode %}{{ timetable }} –{% else %}Salle{% endif %} {{ group }} – Semaine {{ week }} – {% endblock %}  {% block body %} -      <h2>{{ group.timetable }} – {{ group }} – Semaine {{ week }}</h2> +      <h2>{% if group_mode %}{{ timetable }} –{% else %}Salle{% endif %} {{ group }} – Semaine {{ week }}</h2>        <p>          {% if is_old_timetable %} -        <b><a href="{% url "timetable" group.timetable.year.slug group.timetable.slug group.slug %}">Accéder à l’emploi du temps de cette semaine.</b></a><br /> +          <b><a href="{% if group_mode %}{% url "timetable" timetable.year.slug timetable.slug group.slug %}{% else %}{% url "room-timetable" group.slug %}{% endif %}"> +              Accéder à l’emploi du temps de cette semaine. +          </b></a><br />          {% endif %}          {% if last_update %}Dernière mise à jour le {{ last_update|date:"l j F o" }} à {{ last_update|date:"H:i" }}{% endif %}        </p>        {% include "timetable_common.html" %} -      <p class="subscribe"><a href="{% url "calendars" group.timetable.year.slug group.timetable.slug group.slug %}">ICS</a> – <a href="{% url "rss" group.timetable.year.slug group.timetable.slug group.slug %}">RSS</a> – <a href="{% url "atom" group.timetable.year.slug group.timetable.slug group.slug %}">Atom</a></p>{% endblock %} +      <p class="subscribe"> +        {% if group_mode %} +          <a href="{% url "groups" timetable.year.slug timetable.slug %}">Retour à la liste des groupes</a> +        {% else %} +          <a href="{% url "rooms" %}">Retour à la liste des salles</a> +        {% endif %} – +        {% if last_week is not None %} +          <a href="{% if group_mode %}{% url "timetable" timetable.year.slug timetable.slug group.slug last_week.year last_week|dt_week %}{% else %}{% url "room-timetable" group.slug last_week.year last_week|dt_week %}{% endif %}"> +            Semaine {{ last_week|dt_week }} +          </a> +          – +        {% endif %} +        {% if next_week is not None %} +          <a href="{% if group_mode %}{% url "timetable" timetable.year.slug timetable.slug group.slug next_week.year next_week|dt_week %}{% else %}{% url "room-timetable" group.slug next_week.year next_week|dt_week %}{% endif %}"> +            Semaine {{ next_week|dt_week }} +          </a> +          – +        {% endif %} +        {% if group_mode %} +          <a href="{% url "group-weeks" timetable.year.slug timetable.slug group.slug %}">Liste des semaines</a> +        {% else %} +          <a href="{% url "room-weeks" group.slug %}">Liste des semaines</a> +        {% endif %} +        <br /> +        {% if group_mode %} +          <a href="{% url "calendars" timetable.year.slug timetable.slug group.slug %}">ICS</a> – +          <a href="{% url "rss" timetable.year.slug timetable.slug group.slug %}">RSS</a> – +          <a href="{% url "atom" timetable.year.slug timetable.slug group.slug %}">Atom</a> +        {% endif %} +      </p> +{% endblock %} diff --git a/templates/timetable_common.html b/templates/timetable_common.html index 62b1d71..6e59322 100644 --- a/templates/timetable_common.html +++ b/templates/timetable_common.html @@ -4,9 +4,9 @@          <h3>{% filter title %}{{ day.0.begin|date:"l j F o" }}{% endfilter %} – de {{ day.0.begin|date:"H:i" }} à {% with day|last as last %}{{ last.end|date:"H:i" }}{% endwith %}</h3>          <ul>{% for course in day %}            <li class="course"> -            <b>{{ course }}</b>{% if course.type %} ({{ course.type }}){% endif %}, de {{ course.begin|date:"H:i" }} à {{ course.end|date:"H:i" }}{% if course.rooms.all|length > 0 %}<br /> -            <em>{{ course.rooms.all|format_rooms }}</em>{% endif %}{% if course.notes %}<br /> -            <small>Remarques : {{ course.notes }}</small>{% endif %} +            <b>{{ course }}</b>{% if course.type %} ({{ course.type }}){% endif %}, de {{ course.begin|date:"H:i" }} à {{ course.end|date:"H:i" }}{% if group_mode and course.rooms.all|length > 0 or not group_mode and course.groups.all|length > 0 %}<br /> +            <em>{% if group_mode %}{{ course.rooms.all|format_rooms }}{% else %}{{ course.groups.all|join:", " }}{% endif %}</em>{% endif %}{% if course.notes %}<br /> +            <small>Remarques : {{ course.notes|linebreaksbr }}</small>{% endif %}            </li>{% endfor %}          </ul>        </section>{% empty %} diff --git a/templates/year_list.html b/templates/year_list.html new file mode 100644 index 0000000..4256ec1 --- /dev/null +++ b/templates/year_list.html @@ -0,0 +1,5 @@ +{% extends "index.html" %} + +{% block pagetitle %}Choisissez votre année{% endblock %} +{% block url %}{% url "mentions" element.slug %}{% endblock %} +{% block navigation %}<a href="{% url "rooms" %}">Emploi du temps des salles</a>{% endblock %} diff --git a/templatetags/dt_week.py b/templatetags/dt_week.py index e8d13ac..2d27481 100644 --- a/templatetags/dt_week.py +++ b/templatetags/dt_week.py @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -17,10 +17,7 @@ from django import template  register = template.Library() +  @register.filter  def dt_week(dt):      return dt.isocalendar()[1] - -@register.filter -def dt_prettyprint(dt): -    return "{0}/{1:02d}/{2:02d}".format(dt.year, dt.month, dt.day) diff --git a/templatetags/rooms.py b/templatetags/rooms.py index 5108c92..f0e1b2e 100644 --- a/templatetags/rooms.py +++ b/templatetags/rooms.py @@ -17,10 +17,12 @@ from django import template  register = template.Library() +  @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")] +    room_list = [room.name for room in rooms +                 if not room.name.startswith("Amphi")]      amphis = ", ".join(amphi_list)      joined = ", ".join(room_list) @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -14,9 +14,13 @@  #    along with celcatsanitizer.  If not, see <http://www.gnu.org/licenses/>.  from django.test import TestCase -from .models import Course, Group, Timetable, Year +from django.utils import timezone + +from .models import Course, Group, Room, Source, Timetable, Year  from .utils import tz_now +import datetime +  class CourseTestCase(TestCase):      def setUp(self): @@ -25,29 +29,44 @@ class CourseTestCase(TestCase):          self.year = Year(name="L2", slug="l2")          self.year.save() -        self.timetable = Timetable(year=self.year, name="Test timetable 2", url="http://example.org/", slug="test-timetable2") +        source = Source(url="http://example.org/") +        source.save() + +        self.timetable = Timetable(year=self.year, name="Test timetable 2", +                                   source=source, slug="test-timetable2")          self.timetable.save() -        cma = Group.objects.create(celcat_name="L1 info s2 CMA", timetable=self.timetable) -        tda2 = Group.objects.create(celcat_name="L1 info s2 TDA2", timetable=self.timetable) -        self.tpa21 = Group.objects.create(celcat_name="L1 info s2 TPA21", timetable=self.timetable) +        cma = Group.objects.create(celcat_name="L1 info s2 CMA", source=source) +        tda2 = Group.objects.create(celcat_name="L1 info s2 TDA2", +                                    source=source) +        self.tpa21 = Group.objects.create(celcat_name="L1 info s2 TPA21", +                                          source=source) -        cmb = Group.objects.create(celcat_name="L1 info s2 CMB", timetable=self.timetable) -        tdb2 = Group.objects.create(celcat_name="L1 info s2 TDB2", timetable=self.timetable) -        self.tpb21 = Group.objects.create(celcat_name="L1 info s2 TPB21", timetable=self.timetable) +        cmb = Group.objects.create(celcat_name="L1 info s2 CMB", source=source) +        tdb2 = Group.objects.create(celcat_name="L1 info s2 TDB2", +                                    source=source) +        self.tpb21 = Group.objects.create(celcat_name="L1 info s2 TPB21", +                                          source=source)          for group in (cma, tda2, self.tpa21, cmb, tdb2, self.tpb21,): -            course = Course.objects.create(name="{0} course".format(group.name), type="cours", timetable=self.timetable, begin=dt, end=dt) +            course = Course.objects.create( +                name="{0} course".format(group.name), type="cours", +                source=source, begin=dt, end=dt)              course.groups.add(group)      def test_get_courses_for_group(self): -        tpa21_courses = Course.objects.get_courses_for_group(self.tpa21) -        tpb21_courses = Course.objects.get_courses_for_group(self.tpb21) - -        tpa21_course_names = ["L1 info s2 CMA course", "L1 info s2 TDA2 course", "L1 info s2 TPA21 course"] -        tpb21_course_names = ["L1 info s2 CMB course", "L1 info s2 TDB2 course", "L1 info s2 TPB21 course"] - -        for courses, names in ((tpa21_courses, tpa21_course_names,), (tpb21_courses, tpb21_course_names,),): +        tpa21_courses = Course.objects.get_courses(self.tpa21) +        tpb21_courses = Course.objects.get_courses(self.tpb21) + +        tpa21_course_names = ["L1 info s2 CMA course", +                              "L1 info s2 TDA2 course", +                              "L1 info s2 TPA21 course"] +        tpb21_course_names = ["L1 info s2 CMB course", +                              "L1 info s2 TDB2 course", +                              "L1 info s2 TPB21 course"] + +        for courses, names in ((tpa21_courses, tpa21_course_names,), +                               (tpb21_courses, tpb21_course_names,),):              for course in courses:                  self.assertIn(course.name, names)                  names.remove(course.name) @@ -58,29 +77,59 @@ class GroupTestCase(TestCase):          self.year = Year(name="L1", slug="l1")          self.year.save() -        self.timetable = Timetable(year=self.year, name="Test timetable", url="http://example.com/", slug="test-timetable") +        self.source = Source(url="http://example.org/") +        self.source.save() + +        self.timetable = Timetable(year=self.year, name="Test timetable", +                                   source=self.source, slug="test-timetable")          self.timetable.save() -        Group.objects.create(celcat_name="L1 info s2 CMA", timetable=self.timetable) -        Group.objects.create(celcat_name="L1 info s2 TDA2", timetable=self.timetable) -        Group.objects.create(celcat_name="L1 info s2 TPA21", timetable=self.timetable) +        Group.objects.create(celcat_name="L1 info s2 CMA", source=self.source) +        Group.objects.create(celcat_name="L1 info s2 TDA2", source=self.source) +        Group.objects.create(celcat_name="L1 info s2 TPA21", +                             source=self.source) -        Group.objects.create(celcat_name="L1 info s2 CMB", timetable=self.timetable) -        Group.objects.create(celcat_name="L1 info s2 TDB2", timetable=self.timetable) -        Group.objects.create(celcat_name="L1 info s2 TPB21", timetable=self.timetable) +        Group.objects.create(celcat_name="L1 info s2 CMB", source=self.source) +        Group.objects.create(celcat_name="L1 info s2 TDB2", source=self.source) +        Group.objects.create(celcat_name="L1 info s2 TPB21", +                             source=self.source) -        Group.objects.create(celcat_name="L1 info (toutes sections et semestres confondus)", timetable=self.timetable) +        Group.objects.create(celcat_name="L1 info (toutes sections et " +                             "semestres confondus)", source=self.source) -    def test_corresponds(self): -        cma = Group.objects.get(celcat_name="L1 info s2 CMA", timetable=self.timetable) -        tda2 = Group.objects.get(celcat_name="L1 info s2 TDA2", timetable=self.timetable) -        tpa21 = Group.objects.get(celcat_name="L1 info s2 TPA21", timetable=self.timetable) +        # Cas spéciaux de groupes sans semestre. Normalement un groupe +        # sans semestre ne possède pas de sous-groupe non plus, mais +        # certains cas font foirer la regex. Voici un exemple trouvé +        # dans la base de données de production. +        Group.objects.create(celcat_name="M1 GC (toutes sections et semestres " +                             "confondus)", source=self.source) + +        # Doit appartenir au groupe au-dessus. +        Group.objects.create(celcat_name="M1 GC s2 GA111", source=self.source) -        cmb = Group.objects.get(celcat_name="L1 info s2 CMB", timetable=self.timetable) -        tdb2 = Group.objects.get(celcat_name="L1 info s2 TDB2", timetable=self.timetable) -        tpb21 = Group.objects.get(celcat_name="L1 info s2 TPB21", timetable=self.timetable) +        # Cas spécial avec les parenthèses +        Group.objects.create(celcat_name="M1 CHI-TCCM (EM) (toutes sections et" +                             " semestres confondus)", source=self.source) +        Group.objects.create(celcat_name="M1 CHI-TCCM (EM) s2 TPA12", +                             source=self.source) -        general = Group.objects.get(celcat_name="L1 info (toutes sections et semestres confondus)", timetable=self.timetable) +    def test_corresponds(self): +        cma = Group.objects.get(celcat_name="L1 info s2 CMA", +                                source=self.source) +        tda2 = Group.objects.get(celcat_name="L1 info s2 TDA2", +                                 source=self.source) +        tpa21 = Group.objects.get(celcat_name="L1 info s2 TPA21", +                                  source=self.source) + +        cmb = Group.objects.get(celcat_name="L1 info s2 CMB", +                                source=self.source) +        tdb2 = Group.objects.get(celcat_name="L1 info s2 TDB2", +                                 source=self.source) +        tpb21 = Group.objects.get(celcat_name="L1 info s2 TPB21", +                                  source=self.source) + +        general = Group.objects.get(celcat_name="L1 info (toutes sections et " +                                    "semestres confondus)", source=self.source)          self.assertFalse(cma.corresponds_to(*tda2.group_info))          self.assertFalse(cma.corresponds_to(*tpa21.group_info)) @@ -120,16 +169,34 @@ class GroupTestCase(TestCase):          self.assertTrue(tpa21.corresponds_to(*general.group_info))          self.assertTrue(tpb21.corresponds_to(*general.group_info)) +    def test_corresponds_no_semester(self): +        general = Group.objects.get(celcat_name="M1 GC (toutes sections et " +                                    "semestres confondus)", source=self.source) +        ga111 = Group.objects.get(celcat_name="M1 GC s2 GA111", +                                  source=self.source) + +        self.assertTrue(ga111.corresponds_to(*general.group_info)) +        self.assertFalse(general.corresponds_to(*ga111.group_info)) + +    def test_correspond_parenthesis(self): +        general = Group.objects.get(celcat_name="M1 CHI-TCCM (EM) (toutes" +                                    " sections et semestres confondus)") +        a12 = Group.objects.get(celcat_name="M1 CHI-TCCM (EM) s2 TPA12") + +        self.assertTrue(a12.corresponds_to(*general.group_info)) +        self.assertFalse(general.corresponds_to(*a12.group_info)) +      def test_get(self): -        cma = Group.objects.get(name="L1 info s2 CMA", timetable=self.timetable) -        tda2 = Group.objects.get(name="L1 info s2 TDA2", timetable=self.timetable) -        tpa21 = Group.objects.get(name="L1 info s2 TPA21", timetable=self.timetable) +        cma = Group.objects.get(name="L1 info s2 CMA", source=self.source) +        tda2 = Group.objects.get(name="L1 info s2 TDA2", source=self.source) +        tpa21 = Group.objects.get(name="L1 info s2 TPA21", source=self.source) -        cmb = Group.objects.get(name="L1 info s2 CMB", timetable=self.timetable) -        tdb2 = Group.objects.get(name="L1 info s2 TDB2", timetable=self.timetable) -        tpb21 = Group.objects.get(name="L1 info s2 TPB21", timetable=self.timetable) +        cmb = Group.objects.get(name="L1 info s2 CMB", source=self.source) +        tdb2 = Group.objects.get(name="L1 info s2 TDB2", source=self.source) +        tpb21 = Group.objects.get(name="L1 info s2 TPB21", source=self.source) -        general = Group.objects.get(celcat_name="L1 info (toutes sections et semestres confondus)", timetable=self.timetable) +        general = Group.objects.get(celcat_name="L1 info (toutes sections et " +                                    "semestres confondus)", source=self.source)          self.assertEqual(cma.celcat_name, "L1 info s2 CMA")          self.assertEqual(tda2.celcat_name, "L1 info s2 TDA2") @@ -139,18 +206,26 @@ class GroupTestCase(TestCase):          self.assertEqual(tdb2.celcat_name, "L1 info s2 TDB2")          self.assertEqual(tpb21.celcat_name, "L1 info s2 TPB21") -        self.assertEqual(general.celcat_name, "L1 info (toutes sections et semestres confondus)") +        self.assertEqual(general.celcat_name, "L1 info (toutes sections et " +                         "semestres confondus)")      def test_parse(self): -        cma = Group.objects.get(celcat_name="L1 info s2 CMA", timetable=self.timetable) -        tda2 = Group.objects.get(celcat_name="L1 info s2 TDA2", timetable=self.timetable) -        tpa21 = Group.objects.get(celcat_name="L1 info s2 TPA21", timetable=self.timetable) - -        cmb = Group.objects.get(celcat_name="L1 info s2 CMB", timetable=self.timetable) -        tdb2 = Group.objects.get(celcat_name="L1 info s2 TDB2", timetable=self.timetable) -        tpb21 = Group.objects.get(celcat_name="L1 info s2 TPB21", timetable=self.timetable) - -        general = Group.objects.get(celcat_name="L1 info (toutes sections et semestres confondus)", timetable=self.timetable) +        cma = Group.objects.get(celcat_name="L1 info s2 CMA", +                                source=self.source) +        tda2 = Group.objects.get(celcat_name="L1 info s2 TDA2", +                                 source=self.source) +        tpa21 = Group.objects.get(celcat_name="L1 info s2 TPA21", +                                  source=self.source) + +        cmb = Group.objects.get(celcat_name="L1 info s2 CMB", +                                source=self.source) +        tdb2 = Group.objects.get(celcat_name="L1 info s2 TDB2", +                                 source=self.source) +        tpb21 = Group.objects.get(celcat_name="L1 info s2 TPB21", +                                  source=self.source) + +        general = Group.objects.get(celcat_name="L1 info (toutes sections et " +                                    "semestres confondus)", source=self.source)          self.assertEqual(cma.group_info, ("L1 info", 2, "A"))          self.assertEqual(tda2.group_info, ("L1 info", 2, "A2")) @@ -161,3 +236,79 @@ class GroupTestCase(TestCase):          self.assertEqual(tpb21.group_info, ("L1 info", 2, "B21"))          self.assertEqual(general.group_info, ("L1 info", None, "")) + +    def test_parse_no_semester(self): +        general = Group.objects.get(celcat_name="M1 GC (toutes sections et " +                                    "semestres confondus)", source=self.source) +        ga111 = Group.objects.get(celcat_name="M1 GC s2 GA111", +                                  source=self.source) + +        self.assertEqual(general.group_info, ("M1 GC", None, "")) +        self.assertEqual(ga111.group_info, ("M1 GC", 2, "A111")) + +    def test_parse_parenthesis(self): +        general = Group.objects.get(celcat_name="M1 CHI-TCCM (EM) (toutes" +                                    " sections et semestres confondus)") +        a12 = Group.objects.get(celcat_name="M1 CHI-TCCM (EM) s2 TPA12") + +        self.assertEqual(general.group_info, ("M1 CHI-TCCM (EM)", None, "")) +        self.assertEqual(a12.group_info, ("M1 CHI-TCCM (EM)", 2, "A12")) + + +class RoomTestCase(TestCase): +    def setUp(self): +        self.day = datetime.datetime(year=2018, month=1, day=27) + +        self.year = Year.objects.create(name="L1") +        self.source = Source.objects.create(url="http://example.org/") + +        # Pas besoin de créer plus de groupes que ça, ni de le rendre +        # global +        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")] + +        hours = [({"begin": datetime.time(hour=14, minute=0)},), +                 ({"begin": datetime.time(hour=16, minute=0)},), +                 ({"begin": datetime.time(hour=13, minute=30)}, +                  {"begin": datetime.time(hour=16, minute=0)}), +                 ({"begin": datetime.time(hour=14, minute=0), "duration": 4},), +                 ({"begin": datetime.time(hour=15, minute=30), +                   "duration": 1},), +                 ({"begin": datetime.time(hour=13, minute=0)}, +                  {"begin": datetime.time(hour=17, minute=0)}), +                 ()] + +        for i, room in enumerate(self.rooms): +            for rn in hours[i]: +                begin = timezone.make_aware( +                    datetime.datetime.combine(self.day, rn["begin"])) +                end = begin + datetime.timedelta(hours=rn.get("duration", 2)) + +                course = Course.objects.create(source=self.source, +                                               begin=begin, end=end) +                course.groups.add(group) +                course.rooms.add(room) + +    def test_qsjps(self): +        begin = timezone.make_aware(datetime.datetime.combine( +            self.day, datetime.time(hour=15, minute=0))) +        end = begin + datetime.timedelta(hours=2) + +        rooms = Room.objects.qsjps(begin, end) +        self.assertEqual(rooms.count(), 2) + +        self.assertNotIn(self.rooms[0], rooms) +        self.assertNotIn(self.rooms[1], rooms) +        self.assertNotIn(self.rooms[2], rooms) +        self.assertNotIn(self.rooms[3], rooms) +        self.assertNotIn(self.rooms[4], rooms) +        self.assertIn(self.rooms[5], rooms) +        self.assertIn(self.rooms[6], rooms) @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -13,19 +13,26 @@  #    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.urls import include, url +from django.urls import include, path  from . import feeds, views  urlpatterns = [ -    url(r"^$", views.index, name="index"), -    url(r"^pages/", include("django.contrib.flatpages.urls")), -    url(r"^(?P<year_slug>[-\w]+)/$", views.mention_list, name="mentions"), -    url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/$", views.group_list, name="groups"), -    url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/$", views.timetable, name="timetable"), -    url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/calendars", views.calendars, name="calendars"), -    url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/calendar.ics$", feeds.IcalFeed(), name="ics"), -    url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/calendar-group.ics$", feeds.IcalOnlyOneFeed(), name="ics-group"), -    url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/feed.atom$", feeds.AtomFeed(), name="atom"), -    url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/feed.rss$", feeds.RSSFeed(), name="rss"), -    url(r"^(?P<year_slug>[-\w]+)/(?P<timetable_slug>[-\w]+)/(?P<group_slug>[-\w]+)/(?P<year>[0-9]{4})/(?P<week>[0-4]?[0-9]|5[0-3])/$", views.timetable, name="timetable"), +    path("", views.index, name="index"), +    path("pages/", include("django.contrib.flatpages.urls")), +    path("salles/", views.rooms, name="rooms"), +    path("salles/qsjps", views.qsjps, name="qsjps"), +    path("salles/<slug:room_slug>/", views.room_timetable, name="room-timetable"), +    path("salles/<slug:room_slug>/semaines", views.room_weeks, name="room-weeks"), +    path("salles/<slug:room_slug>/<int:year>/<int:week>", views.room_timetable, name="room-timetable"), +    path("<slug:year_slug>/", views.mention_list, name="mentions"), +    path("<slug:year_slug>/<slug:timetable_slug>/", views.group_list, name="groups"), +    path("<slug:year_slug>/<slug:timetable_slug>/tous", views.groups_all, name="groups-all"), +    path("<slug:year_slug>/<slug:timetable_slug>/<slug:group_slug>/", views.timetable, name="timetable"), +    path("<slug:year_slug>/<slug:timetable_slug>/<slug:group_slug>/calendars", views.calendars, name="calendars"), +    path("<slug:year_slug>/<slug:timetable_slug>/<slug:group_slug>/calendar.ics", feeds.IcalFeed(), name="ics"), +    path("<slug:year_slug>/<slug:timetable_slug>/<slug:group_slug>/calendar-group.ics", feeds.IcalOnlyOneFeed(), name="ics-group"), +    path("<slug:year_slug>/<slug:timetable_slug>/<slug:group_slug>/feed.atom", feeds.AtomFeed(), name="atom"), +    path("<slug:year_slug>/<slug:timetable_slug>/<slug:group_slug>/feed.rss", feeds.RSSFeed(), name="rss"), +    path("<slug:year_slug>/<slug:timetable_slug>/<slug:group_slug>/semaines", views.group_weeks, name="group-weeks"), +    path("<slug:year_slug>/<slug:timetable_slug>/<slug:group_slug>/<int:year>/<int:week>/", views.timetable, name="timetable"),  ] @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -18,9 +18,11 @@ import re  from django.utils import timezone +  def get_current_week():      return tz_now().isocalendar()[:2] +  def get_current_or_next_week():      year, week, day = tz_now().isocalendar()      if day >= 6: @@ -28,6 +30,7 @@ def get_current_or_next_week():      return year, week +  def get_week(year, week):      start = timezone.make_aware(datetime.datetime.strptime(          "{0}-W{1}-1".format(year, week), "%Y-W%W-%w")) @@ -35,6 +38,7 @@ def get_week(year, week):      return start, end +  def group_courses(courses):      grouped_courses = []      for i, course in enumerate(courses): @@ -45,35 +49,40 @@ def group_courses(courses):      return grouped_courses +  def parse_group(name):      # Explication de la regex      # -    # ^(.+?)\s*(s(\d)\s+)?((CM|TD|TP|G)(\w\d{0,3}))?(\s+\(.+\))?$ -    # ^                                                           début de la ligne -    #  (.+?)                                                      correspond à au moins un caractère -    #       \s*                                                   éventuellement un ou plusieurs espaces -    #          (s(\d)\s+)?                                        éventuellement un s suivi d’un nombre et d’un ou plusieurs espaces -    #                     ((CM|TD|TP|G)                           « CM » ou « TD » ou « TP » ou « G » -    #                                  (\w\d{0,3})                suivi d’un caractère puis entre 0 et 3 chiffres -    #                                             )?              groupe optionnel -    #                                               (\s+          un ou plusieurs espaces -    #                                                   \(.+\))?  un ou pliseurs caractères entre parenthèses -    #                                                           $ fin de la ligne -    group_regex = re.compile(r"^(.+?)\s*(s(\d)\s+)?((CM|TD|TP|G)(\w\d{0,3}))?(\s+\(.+\))?$") +    # ^(.+?)\s*(s(\d)\s+(CM|TD|TP|G)(\w\d{0,3}))?(\s+\([^\(\)]+\))?$ +    # ^                                                              début de la ligne +    #  (.+?)                                                         correspond à au moins un caractère +    #       \s*                                                      éventuellement un ou plusieurs espaces +    #          (s(\d)\s+                                             un s suivi d’un nombre et d’un ou plusieurs espaces +    #                   (CM|TD|TP|G)                                 « CM » ou « TD » ou « TP » ou « G » +    #                               (\w\d{0,3})                      suivi d’un caractère puis entre 0 et 3 chiffres +    #                                          )?                    groupe optionnel +    #                                            (\s+                un ou plusieurs espaces +    #                                                \([^\(\)]+\))?  un ou plusieurs caractères (exceptés des espaces) entre parenthèses +    #                                                            $   fin de la ligne +    group_regex = re.compile( +        r"^(.+?)\s*(s(\d)\s+(CM|TD|TP|G)(\w\d{0,3}))?(\s+\([^\(\)]+\))?$") +      search = group_regex.search(name)      if search is None:          return name, None, None      parts = search.groups() -    # On retourne la section (parts[0]), le semestre (parts[2]) et le groupe (parts[5]) +    # On retourne la section (parts[0]), le semestre (parts[2]) et le +    # groupe (parts[4])      if parts[2] is not None: -        return parts[0], int(parts[2]), parts[5] +        return parts[0], int(parts[2]), parts[4]      else:          # Si jamais le semestre n’est pas présent dans la chaine parsée,          # parts[2] sera à None et sa conversion vers un int va provoquer -        # une erreur. -        return parts[0], None, parts[5] +        # une erreur.  parts[4] devrait être une chaîne vide ici. +        return parts[0], None, parts[4] +  def tz_now():      """Retourne la date et l’heure avec le bon fuseau horaire""" @@ -1,4 +1,4 @@ -#    Copyright (C) 2017  Alban Gruin +#    Copyright (C) 2017-2018  Alban Gruin  #  #    celcatsanitizer is free software: you can redistribute it and/or modify  #    it under the terms of the GNU Affero General Public License as published @@ -15,34 +15,50 @@  import datetime -from django.db.models import Max -from django.db.models.functions import Length +from django.db import connection +from django.db.models import Count, Max +from django.db.models.functions import ExtractWeek, ExtractYear, Length  from django.http import Http404  from django.shortcuts import get_object_or_404, render +from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt -from .models import Timetable, Group, Course, Year -from .utils import get_current_week, get_current_or_next_week, get_week, group_courses +from .forms import QSJPSForm +from .models import Course, Group, Room, Timetable, Year +from .utils import get_current_week, get_current_or_next_week, get_week, \ +    group_courses  import edt +if connection.vendor == "postgresql": +    from django.contrib.postgres.aggregates import ArrayAgg +    from django.db.models.expressions import RawSQL + +  def index(request):      years = Year.objects.order_by("name") -    return render(request, "index.html", {"elements": years}) +    return render(request, "year_list.html", {"elements": years}) +  def mention_list(request, year_slug):      year = get_object_or_404(Year, slug=year_slug) -    timetables = Timetable.objects.order_by("name").filter(year=year).select_related("year") +    timetables = Timetable.objects.order_by("name").filter(year=year) + +    return render(request, "mention_list.html", +                  {"year": year, "elements": timetables}) -    return render(request, "index.html", {"year": year, "elements": timetables})  def group_list(request, year_slug, timetable_slug): -    timetable = get_object_or_404(Timetable, year__slug=year_slug, slug=timetable_slug) -    groups = Group.objects.filter(timetable=timetable, hidden=False).order_by("name") +    timetable = get_object_or_404(Timetable, year__slug=year_slug, +                                  slug=timetable_slug)      start, _ = get_week(*get_current_week())      end = start + datetime.timedelta(weeks=4) -    groups_weeks = Course.objects.get_weeks(begin__gte=start, begin__lt=end, groups__in=groups) +    groups = Group.objects.get_relevant_groups(start, source=timetable.source, +                                               hidden=False) +    groups_weeks = Course.objects.get_weeks(begin__gte=start, begin__lt=end, +                                            groups__in=groups)      for group in groups:          for group_week in groups_weeks: @@ -59,9 +75,48 @@ def group_list(request, year_slug, timetable_slug):          if hasattr(group, "weeks"):              group.weeks.sort() -    return render(request, "group_list.html", {"timetable": timetable, "groups": groups}) +    return render(request, "group_list.html", +                  {"timetable": timetable, "elements": groups}) + + +def groups_all(request, year_slug, timetable_slug): +    # Récupération de l’emploi du temps et du groupe +    timetable = get_object_or_404(Timetable, year__slug=year_slug, +                                  slug=timetable_slug) +    groups = Group.objects.filter(source=timetable.source).order_by("name") + +    # Rendu de la page +    return render(request, "groups_all_list.html", +                  {"timetable": timetable, "elements": groups}) + + +def group_weeks(request, year_slug, timetable_slug, group_slug): +    # Récupération de l’emploi du temps et des groupes +    timetable = get_object_or_404(Timetable, year__slug=year_slug, +                                  slug=timetable_slug) +    group = get_object_or_404(Group, slug=group_slug, source=timetable.source) + +    # Groupes parents +    groups = Group.objects.get_parents(group) + +    # Récupération de toutes les semaines avec des cours, sans doublons +    courses = Course.objects.filter(groups__in=groups) \ +                            .order_by("year", "week") \ +                            .annotate(year=ExtractYear("begin"), +                                      week=ExtractWeek("begin")) \ +                            .values("year", "week") \ +                            .annotate(c=Count("*")) + +    # Conversion des semaines de cours en dates +    weeks = [get_week(course["year"], course["week"])[0] for course in courses] -def timetable(request, year_slug, timetable_slug, group_slug, year=None, week=None): +    # Rendu +    return render(request, "group_weeks_list.html", +                  {"timetable": timetable, "group": group, +                   "elements": weeks}) + + +def timetable_common(request, obj, year=None, week=None, timetable=None):      current_year, current_week = get_current_or_next_week()      is_old_timetable, provided_week = False, True @@ -71,30 +126,165 @@ def timetable(request, year_slug, timetable_slug, group_slug, year=None, week=No      elif (int(year), int(week)) < (current_year, current_week):          is_old_timetable = True -    start, end = get_week(year, week) - -    timetable = get_object_or_404(Timetable, year__slug=year_slug, slug=timetable_slug) -    group = get_object_or_404(Group, slug=group_slug, timetable=timetable) +    try: +        start, end = get_week(year, week) +    except ValueError: +        raise Http404 -    courses = Course.objects.get_courses_for_group(group, begin__gte=start, begin__lt=end) +    courses = Course.objects.get_courses(obj, begin__gte=start, begin__lt=end)      if not courses.exists() and provided_week:          raise Http404 +    # Récupération des semaines suivantes et précédentes pour les +    # afficher proprement dans l’emploi du temps +    last_course = Course.objects.get_courses(obj, begin__lt=start).last() +    last_week = getattr(last_course, "begin", None) + +    next_course = Course.objects.get_courses(obj, begin__gte=end).first() +    next_week = getattr(next_course, "begin", None) +      last_update = courses.aggregate(Max("last_update"))["last_update__max"]      grouped_courses = group_courses(courses) -    return render(request, "timetable.html", {"group": group, "courses": grouped_courses, -                                              "last_update": last_update, -                                              "year": year, "week": int(week), -                                              "is_old_timetable": is_old_timetable}) +    return render(request, "timetable.html", +                  {"group": obj, "courses": grouped_courses, +                   "last_update": last_update, +                   "year": year, "week": int(week), +                   "last_week": last_week, +                   "next_week": next_week, +                   "is_old_timetable": is_old_timetable, +                   "group_mode": isinstance(obj, Group), +                   "timetable": timetable}) + + +def timetable(request, year_slug, timetable_slug, group_slug, +              year=None, week=None): +    timetable = get_object_or_404(Timetable, year__slug=year_slug, +                                  slug=timetable_slug) +    group = get_object_or_404(Group, slug=group_slug, source=timetable.source) + +    return timetable_common(request, group, year, week, timetable) +  def calendars(request, year_slug, timetable_slug, group_slug): -    group = get_object_or_404(Group, timetable__year__slug=year_slug, -                              timetable__slug=timetable_slug, slug=group_slug) -    groups = Group.objects.get_parents(group).annotate(length=Length("subgroup")) \ -                                             .order_by("length") +    timetable = get_object_or_404(Timetable, year__slug=year_slug, +                                  slug=timetable_slug) +    group = get_object_or_404(Group, source=timetable.source, slug=group_slug) +    groups = Group.objects.get_parents(group) \ +                          .annotate(length=Length("subgroup")) \ +                          .order_by("length") + +    return render(request, "calendars.html", +                  {"timetable": timetable, "group": group, "groups": groups}) + + +def rooms(request): +    # On récupère les dates allant de cette semaine à dans un mois +    start, _ = get_week(*get_current_week()) +    end = start + datetime.timedelta(weeks=4) + +    if connection.vendor == "postgresql": +        # Si le SGBD est PostgreSQL, on utilise une requête à base de +        # ArrayAgg. Elle présente l’avantage d’être plus rapide que la +        # requête « généraliste » et de ne pas nécessiter de +        # traitement après.  On récupère chaque salle ayant un cours +        # dans le mois à venir. Pour chacun de ses cours, on ne +        # récupère que le premier jour de la semaine, et si jamais ce +        # jour n’est pas déjà dans la liste des semaines de cours +        # (« weeks »), on l’y rajoute. +        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)) + +        return render(request, "room_list.html", {"elements": rooms}) + +    # Récupération des salles et de toutes les semaines où elles sont +    # concernées. +    # Cette requête associe chaque salle à toutes les semaines où un +    # cours s’y déroule. Le résultat est trié par le nom de la salle +    # et par semaine. +    # TODO optimiser cette requête, elle me semble un peu lente +    rooms = Room.objects.filter(course__begin__gte=start, +                                course__begin__lt=end) \ +                        .order_by("name") \ +                        .annotate(year=ExtractYear("course__begin"), +                                  week=ExtractWeek("course__begin"), +                                  c=Count("*")) + +    # Regroupement des semaines dans une liste de chaque objet salle +    rooms_weeks = [] +    for room in rooms: +        # Si on a pas traité de salle ou que la salle courante +        # dans le résultat de la requête est différente de la dernière +        # dans la liste des salles traitées +        if len(rooms_weeks) == 0 or rooms_weeks[-1].id != room.id: +            # On lui affecte un tableau et on l’ajoute dans +            # la liste des salles à traiter +            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) + +    # Rendu de la page. +    return render(request, "room_list.html", {"elements": rooms_weeks}) + + +def room_weeks(request, room_slug): +    room = get_object_or_404(Room, slug=room_slug) + +    # Récupération des semaines de cours +    courses = Course.objects.filter(rooms=room) \ +                            .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 render(request, "room_weeks_list.html", +                  {"room": room, "elements": weeks}) + + +def room_timetable(request, room_slug, year=None, week=None): +    room = get_object_or_404(Room, slug=room_slug) +    return timetable_common(request, room, year, week) + + +@csrf_exempt +def qsjps(request): +    if request.method == "POST": +        # Si on traite un formulaire, on le valide +        form = QSJPSForm(request.POST) +        if form.is_valid(): +            # Formulaire validé +            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) +            return render(request, "qsjps.html", +                          {"elements": rooms, "form": form}) + +        # Si le formulaire est invalide, on ré-affiche le formulaire +        # avec les erreurs +    else: +        # Si le formulaire n’a pas été soumis, on en instancie un +        # nouveau +        form = QSJPSForm() + +    return render(request, "qsjps_form.html", {"form": form}) -    return render(request, "calendars.html", {"group": group, "groups": groups})  def ctx_processor(request):      return {"celcatsanitizer_version": edt.VERSION} | 
