aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlban Gruin2018-04-23 16:20:38 +0200
committerAlban Gruin2018-04-23 16:20:38 +0200
commit63ceb7ff232cd4015ae148bec31c6d72d9239c19 (patch)
tree9aee056480b5fae0c97f5e1c8be4ab1358602926
parentaf055dbf703af0eca03862194de1a0f34ac1fcc5 (diff)
parent9853d375d9e482c23f5fdbed9a3cf041029a92cb (diff)
Merge branch 'qsjps' into futur
-rw-r--r--.gitattributes5
-rw-r--r--Documentation/.gitignore1
-rw-r--r--Documentation/Makefile20
-rw-r--r--Documentation/conf.py93
-rw-r--r--Documentation/dev/contribute.rst55
-rw-r--r--Documentation/dev/roadmap.rst20
-rw-r--r--Documentation/dev/xml.rst147
-rw-r--r--Documentation/index.rst76
-rw-r--r--Documentation/usage/commands/cleancourses.rst20
-rw-r--r--Documentation/usage/commands/listtimetables.rst26
-rw-r--r--Documentation/usage/commands/reparse.rst11
-rw-r--r--Documentation/usage/commands/timetables.rst44
-rw-r--r--Documentation/usage/installation.rst266
-rw-r--r--Documentation/usage/versions.rst35
-rw-r--r--README.md212
-rw-r--r--admin.py34
-rw-r--r--feeds.py65
-rw-r--r--forms.py58
-rw-r--r--management/commands/_private.py47
-rw-r--r--management/commands/cleancourses.py12
-rw-r--r--management/commands/listtimetables.py18
-rw-r--r--management/commands/reparse.py30
-rw-r--r--management/commands/timetables.py94
-rw-r--r--models.py144
-rw-r--r--static/celcatsanitizer/style.css13
-rw-r--r--templates/calendars.html9
-rw-r--r--templates/group_list.html12
-rw-r--r--templates/group_weeks_list.html8
-rw-r--r--templates/groups_all_list.html6
-rw-r--r--templates/index.html21
-rw-r--r--templates/mention_list.html6
-rw-r--r--templates/qsjps.html17
-rw-r--r--templates/qsjps_form.html19
-rw-r--r--templates/room_weeks_list.html7
-rw-r--r--templates/timetable.html51
-rw-r--r--templates/timetable_common.html6
-rw-r--r--templates/year_list.html5
-rw-r--r--templatetags/dt_week.py2
-rw-r--r--templatetags/rooms.py4
-rw-r--r--tests.py251
-rw-r--r--urls.py33
-rw-r--r--utils.py43
-rw-r--r--views.py244
43 files changed, 1784 insertions, 506 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..69020ab
--- /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'dev'
+release = u'dev'
+
+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..795d4a9
--- /dev/null
+++ b/Documentation/usage/installation.rst
@@ -0,0 +1,266 @@
+===============================
+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
+----------------------------
+
+.. code:: shell
+
+ $ pip install django beautifulsoup4 icalendar requests
+
+Si vous utilisez PostgreSQL, vous devez installer le module
+psycopg2 :
+
+.. code:: shell
+
+ $ pip install psycopg2
+
+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/
+
+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``
diff --git a/README.md b/README.md
index c0a5f3c..0778ec6 100644
--- a/README.md
+++ b/README.md
@@ -1,209 +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
-
-Vous pouvez demander à pip d’installer les dépendances à partir du
-fichier requirements.txt présent dans le dépôt :
-
-```bash
-$ pip install -r requirements.txt
-```
-
-pip installera Django, BeautifulSoup, icalendar, requests, gunicorn,
-psycopg2 ainsi que toutes leurs dépendances. C’est la manière
-recommandée de l’installer dans un environnement de production.
-
-Alternativement, si vous vous montez un environnement de
-développement dans le but de mettre à jour les dépendances, il est
-possible d’installer les dépendances à la main :
-
-```bash
-$ pip install requests django beautifulsoup4 icalendar psycopg2 gunicorn
-```
-
-Il est aussi possible d’installer
-[django-debug-toolbar](https://django-debug-toolbar.readthedocs.io/en/stable/)
-(pour le développement seulement) :
-
-```bash
-$ pip install django-debug-toolbar
-```
-
-Réferez-vous à la documentation de django-debug-toolbar pour
-l’installer et le configurer sur votre environnement.
-
-#### 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/admin.py b/admin.py
index 7739a95..def84f0 100644
--- a/admin.py
+++ b/admin.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,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",)
+ 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",)
diff --git a/feeds.py b/feeds.py
index aced5e6..2f6c586 100644
--- a/feeds.py
+++ b/feeds.py
@@ -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)))
diff --git a/models.py b/models.py
index 035bd6d..e56d33d 100644
--- a/models.py
+++ b/models.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
@@ -16,7 +16,7 @@
from functools import reduce
from django.db import models
-from django.db.models import 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/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..9c9a7fc 100644
--- a/templates/group_list.html
+++ b/templates/group_list.html
@@ -1,13 +1,19 @@
{% extends "index.html" %}
{% load dt_week %}
-{% block title %}{{ timetable }} &ndash; {% endblock %}
+{% block title %}{% if timetable %}{{ timetable }}{% else %}Emploi du temps des salles{% endif %} &ndash; {% endblock %}
{% block body %}
- <h3><a href="{{ timetable.url }}">{{ timetable }}</a></h3>
+ <h3>{% if timetable %}<a href="{{ timetable.source.url }}">{{ timetable }}</a>{% else %}Emploi du temps des salles{% endif %}</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> &mdash; {% 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 %}&ndash; {% endif %}{% empty %}<em>aucun cours dans le mois à venir</em>{% endfor %}</li>
+ <li><a class="text"{% if group.weeks is not None %} href="{% if timetable %}{% url "timetable" timetable.year.slug timetable.slug group.slug %}{% else %}{% url "room-timetable" group.slug %}{% endif %}"{% endif %}>{{ group }}</a> &mdash; {% for week in group.weeks %}<a href="{% if timetable %}{% url "timetable" timetable.year.slug timetable.slug group.slug week.year week|dt_week %}{% else %}{% url "room-timetable" group.slug week.year week|dt_week %}{% endif %}">{{ week|dt_prettyprint }}</a> {% if not forloop.last %}&ndash; {% endif %}{% empty %}<em>aucun cours dans le mois à venir</em>{% endfor %}</li>
{% endfor %}
</ul>
+ {% if timetable %}
+ <a href="{% url "mentions" timetable.year.slug %}">Retour à la liste des mentions</a> &ndash;
+ <a href="{% url "groups-all" timetable.year.slug timetable.slug %}">Tous les groupes</a>
+ {% else %}
+ <a href="{% url "index" %}">Retour à la liste des années</a>
+ {% endif %}
{% endblock %}
diff --git a/templates/group_weeks_list.html b/templates/group_weeks_list.html
new file mode 100644
index 0000000..c348d5d
--- /dev/null
+++ b/templates/group_weeks_list.html
@@ -0,0 +1,8 @@
+{% extends "index.html" %}
+{% load dt_week %}
+
+{% block title %}Semaines du groupe {{ group }} &ndash; {% 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|dt_prettyprint }} (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 }} &ndash; {% 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..cdab591 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 }} &ndash; {% 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 }} &ndash; 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><a href="{% block url %}{% endblock %}">{% block element %}{{ element }}{% endblock %}</a></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 &ndash; Alban Gruin &ndash; <a href="{% url "django.contrib.flatpages.views.flatpage" url="contact/" %}">contacter</a> &ndash; celcatsanitizer {{ celcatsanitizer_version }} &ndash; <a href="{% url "django.contrib.flatpages.views.flatpage" url="a-propos/" %}">à propos</a><br />
+ <p>(c) 2018 &ndash; Alban Gruin &ndash; <a href="{% url "django.contrib.flatpages.views.flatpage" url="contact/" %}">contacter</a> &ndash; celcatsanitizer {{ celcatsanitizer_version }} &ndash; <a href="{% url "django.contrib.flatpages.views.flatpage" url="a-propos/" %}">à propos</a><br />
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 }} &ndash; {% endblock %}
+{% block pagetitle %}{{ year }} &ndash; 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..a166dbd
--- /dev/null
+++ b/templates/qsjps.html
@@ -0,0 +1,17 @@
+{% extends "index.html" %}
+{% load dt_week %}
+
+{% block title %}Trouver une salle &ndash; {% endblock %}
+
+{% block body %}
+ <h3>Trouver une salle entre {{ form.begin.value }} et {{ form.end.value }}</h3>
+ <ul>
+ {% with year=form.cleaned_data.day.year week=form.cleaned_data.day|dt_week %}
+ {% for room in rooms %}
+ <li><a href="{% url "room-timetable" room.slug year week %}">{{ room }}</li>
+ {% empty %}
+ <p>Aucune salle trouvée</p>
+ {% endfor %}
+ {% endwith %}
+ </ul>
+{% 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 &ndash; {% 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_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 }} &ndash; {% 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 }} &ndash; 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 }} &ndash; {{ group }} &ndash; Semaine {{ week }} &ndash; {% endblock %}
+{% block title %}{% if group_mode %}{{ timetable }} &ndash;{% else %}Salle{% endif %} {{ group }} &ndash; Semaine {{ week }} &ndash; {% endblock %}
{% block body %}
- <h2>{{ group.timetable }} &ndash; {{ group }} &ndash; Semaine {{ week }}</h2>
+ <h2>{% if group_mode %}{{ timetable }} &ndash;{% else %}Salle{% endif %} {{ group }} &ndash; 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> &ndash; <a href="{% url "rss" group.timetable.year.slug group.timetable.slug group.slug %}">RSS</a> &ndash; <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 %} &ndash;
+ {% 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>
+ &ndash;
+ {% 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>
+ &ndash;
+ {% 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> &ndash;
+ <a href="{% url "rss" timetable.year.slug timetable.slug group.slug %}">RSS</a> &ndash;
+ <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 %} &ndash; 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..de0db08 100644
--- a/templatetags/dt_week.py
+++ b/templatetags/dt_week.py
@@ -17,10 +17,12 @@ 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)
diff --git a/tests.py b/tests.py
index 1425a84..c3d34fd 100644
--- a/tests.py
+++ b/tests.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,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)
diff --git a/urls.py b/urls.py
index 487994e..977ac8d 100644
--- a/urls.py
+++ b/urls.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017 Alban Gruin
+# Copyright (C) 2017-2018 Alban Gruin
#
# celcatsanitizer is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
@@ -13,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"),
]
diff --git a/utils.py b/utils.py
index 9f0a6b5..cd7f1f8 100644
--- a/utils.py
+++ b/utils.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
@@ -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"""
diff --git a/views.py b/views.py
index c647fac..aabda59 100644
--- a/views.py
+++ b/views.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
@@ -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, "groups": 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, "group_list.html", {"groups": 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, "group_list.html", {"groups": 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",
+ {"rooms": 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}