aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--admin.py11
-rw-r--r--feeds.py9
-rw-r--r--forms.py9
-rw-r--r--management/commands/_private.py31
-rw-r--r--management/commands/cleancourses.py6
-rw-r--r--management/commands/listtimetables.py8
-rw-r--r--management/commands/reparse.py30
-rw-r--r--management/commands/timetables.py72
-rw-r--r--models.py65
-rw-r--r--templates/index.html2
-rw-r--r--templatetags/dt_week.py2
-rw-r--r--templatetags/rooms.py4
-rw-r--r--tests.py211
-rw-r--r--utils.py43
-rw-r--r--views.py49
15 files changed, 410 insertions, 142 deletions
diff --git a/admin.py b/admin.py
index 5af6cb1..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
@@ -16,12 +16,16 @@
from django.contrib import admin
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"
@@ -66,7 +70,8 @@ class RoomAdmin(admin.ModelAdmin):
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
fieldsets = (
- (None, {"fields": ("name", "type", "source", "groups", "rooms", "last_update",)}),
+ (None, {"fields": ("name", "type", "source", "groups", "rooms",
+ "last_update",)}),
("Horaires", {"fields": ("begin", "end",)}),
("Remarques", {"fields": ("notes",)}),)
list_display = ("name", "type", "source", "begin", "end",)
diff --git a/feeds.py b/feeds.py
index dde051b..2f6c586 100644
--- a/feeds.py
+++ b/feeds.py
@@ -21,6 +21,7 @@ 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
@@ -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())
@@ -92,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):
diff --git a/forms.py b/forms.py
index 5e76bc1..00dbf9e 100644
--- a/forms.py
+++ b/forms.py
@@ -22,7 +22,8 @@ from .utils import tz_now
class QSJPSForm(forms.Form):
- day = forms.DateField(label="Jour", widget=DateInput(attrs={"type": "date"}))
+ 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",),
@@ -38,7 +39,8 @@ class QSJPSForm(forms.Form):
# 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")
+ self.fields["end"].initial = (tz_now() + timedelta(hours=1)) \
+ .strftime("%H:%M")
def clean(self):
form_data = self.cleaned_data
@@ -51,5 +53,6 @@ class QSJPSForm(forms.Form):
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.")
+ 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 e78c3c2..94c1918 100644
--- a/management/commands/_private.py
+++ b/management/commands/_private.py
@@ -19,31 +19,24 @@ import re
from bs4 import BeautifulSoup
from django.utils import timezone
-from edt.models import Course, Group, Room
-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(source, year, week, today):
start, end = get_week(year, week)
Course.objects.filter(begin__gte=max(start, today), begin__lt=end,
source=source).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()
-
- return obj
def get_event(source, event, event_week, today):
"""Renvoie une classe Course à partir d’un événement récupéré par BS4"""
@@ -61,8 +54,8 @@ def get_event(source, event, event_week, today):
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, source=source,
- 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)
@@ -90,23 +83,24 @@ def get_event(source, 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(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:
@@ -116,6 +110,7 @@ def get_events(source, soup, weeks_in_soup, today, year=None, week=None):
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 310c843..246cfcc 100644
--- a/management/commands/cleancourses.py
+++ b/management/commands/cleancourses.py
@@ -15,7 +15,8 @@
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):
@@ -30,7 +31,8 @@ class Command(BaseCommand):
Course.objects.all().delete()
Group.objects.all().delete()
else:
- Course.objects.filter(source__id__in=options["source"]).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 25f641b..d17399a 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,7 +14,7 @@
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand
-from edt.models import Source
+from ...models import Source
class Command(BaseCommand):
@@ -29,8 +29,8 @@ class Command(BaseCommand):
sources = sources.order_by("id")
for source in sources:
- self.stdout.write("{0}\t: {1} (id: {2})".format(source.formatted_timetables,
- source, source.id))
+ 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 86f389e..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, Source
-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(source, 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,32 +44,34 @@ def process_timetable_week(source, soup, weeks_in_soup, force, year=None, week=N
else:
today = tz_now()
- # On récupère la mise à jour la plus ancienne dans les cours de l’emploi du temps
+ # 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:
@@ -78,7 +84,8 @@ def process_timetable_week(source, soup, weeks_in_soup, force, year=None, week=N
# 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(source=source, begin__gte=delete_from).delete()
@@ -91,13 +98,15 @@ def process_timetable_week(source, soup, weeks_in_soup, force, year=None, week=N
source.last_update_date = new_update_date
source.save()
+
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(source, soup, weeks_in_soup, force, year, week)
+ process_timetable_week(source, soup, weeks_in_soup, force,
+ year, week)
else:
process_timetable_week(source, soup, weeks_in_soup, force)
@@ -106,9 +115,12 @@ 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"]
@@ -132,7 +145,8 @@ class Command(BaseCommand):
year = options["year"][0]
for source in Source.objects.all():
- self.stdout.write("Processing {0}".format(source.formatted_timetables))
+ self.stdout.write("Processing {0}".format(
+ source.formatted_timetables))
try:
process_timetable(source, options["force"], year, weeks)
@@ -140,7 +154,8 @@ class Command(BaseCommand):
break
except Exception:
self.stderr.write(
- self.style.ERROR("Failed to process {0}:".format(source.formatted_timetables))
+ 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 b809a53..f7450dc 100644
--- a/models.py
+++ b/models.py
@@ -25,11 +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:
@@ -43,7 +43,6 @@ class Year(SlugModel):
def __str__(self):
return self.name
-
class Meta:
verbose_name = "année"
verbose_name_plural = "années"
@@ -51,16 +50,17 @@ class Year(SlugModel):
class Source(models.Model):
url = models.URLField(max_length=255, verbose_name="URL", unique=True)
- last_update_date = models.DateTimeField(verbose_name="dernière mise à jour Celcat",
- null=True, blank=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.iterator()])
-
+ return ", ".join([str(timetable) for timetable in
+ self.timetables.all()])
class Meta:
verbose_name = "source d’emploi du temps"
@@ -80,12 +80,12 @@ class Timetable(SlugModel):
name = models.CharField(max_length=64, verbose_name="nom")
slug = models.SlugField(max_length=64, default="")
source = models.ForeignKey(Source, on_delete=models.CASCADE,
- verbose_name="source", related_name="timetables")
+ 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"
@@ -96,11 +96,12 @@ class GroupManager(Manager):
def get_parents(self, group):
groups_criteria = reduce(lambda x, y: x | y,
[Q(subgroup=group.subgroup[:i])
- for i in range(1, len(group.subgroup) + 1)],
+ 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,
source=group.source)
@@ -123,7 +124,8 @@ class Group(SlugModel):
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="")
@@ -134,10 +136,10 @@ class Group(SlugModel):
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):
@@ -154,8 +156,7 @@ class Group(SlugModel):
if self.subgroup is None:
self.subgroup = ""
- super(Group, self).save()
-
+ super(Group, self).save(*args, **kwargs)
class Meta:
index_together = ("mention", "semester", "subgroup",)
@@ -167,7 +168,26 @@ class Group(SlugModel):
verbose_name_plural = "groupes"
+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)
@@ -183,11 +203,12 @@ class CourseManager(Manager):
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) \
+ qs = qs.filter(
+ groups__in=Group.objects.get_parents(obj), **criteria) \
.prefetch_related("rooms")
elif isinstance(obj, Room):
- qs = qs.filter(rooms__in=(obj,), **criteria) \
- .prefetch_related("groups")
+ qs = qs.filter(rooms=obj, **criteria) \
+ .prefetch_related("rooms", "groups")
else:
raise(TypeError, "obj must be a Group or a Room")
@@ -207,7 +228,8 @@ class CourseManager(Manager):
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)
source = models.ForeignKey(Source, on_delete=models.CASCADE,
@@ -235,7 +257,6 @@ class Course(models.Model):
super(Course, self).save(*args, **kwargs)
-
class Meta:
verbose_name = "cours"
verbose_name_plural = "cours"
diff --git a/templates/index.html b/templates/index.html
index 5779b3e..007ab35 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -25,7 +25,7 @@
{% 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/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 8a21a3f..c3d34fd 100644
--- a/tests.py
+++ b/tests.py
@@ -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, Source, 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):
@@ -28,29 +32,41 @@ class CourseTestCase(TestCase):
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 = 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", 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)
+ 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", 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)
+ 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", source=source, 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(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"]
+ 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 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)
@@ -64,29 +80,56 @@ class GroupTestCase(TestCase):
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 = 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", 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 TPA21",
+ source=self.source)
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 s2 TPB21",
+ source=self.source)
- Group.objects.create(celcat_name="L1 info (toutes sections et semestres confondus)", source=self.source)
+ 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", 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)
+ # 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)
- 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)
+ # Doit appartenir au groupe au-dessus.
+ Group.objects.create(celcat_name="M1 GC s2 GA111", source=self.source)
- general = Group.objects.get(celcat_name="L1 info (toutes sections et semestres confondus)", source=self.source)
+ # 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)
+
+ 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))
@@ -126,6 +169,23 @@ 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", source=self.source)
tda2 = Group.objects.get(name="L1 info s2 TDA2", source=self.source)
@@ -135,7 +195,8 @@ class GroupTestCase(TestCase):
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)", source=self.source)
+ 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")
@@ -145,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", 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)
+ 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"))
@@ -167,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/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 2a500c1..bdbbdc2 100644
--- a/views.py
+++ b/views.py
@@ -20,11 +20,13 @@ 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 .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
+from .utils import get_current_week, get_current_or_next_week, get_week, \
+ group_courses
import edt
@@ -36,11 +38,14 @@ def index(request):
years = Year.objects.order_by("name")
return render(request, "index.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)
- return render(request, "index.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)
@@ -66,7 +71,9 @@ 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 timetable_common(request, obj, year=None, week=None, timetable=None):
current_year, current_week = get_current_or_next_week()
@@ -101,21 +108,26 @@ def timetable_common(request, obj, year=None, week=None, timetable=None):
"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)
+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):
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")
+ groups = Group.objects.get_parents(group) \
+ .annotate(length=Length("subgroup")) \
+ .order_by("length")
+
+ return render(request, "calendars.html",
+ {"timetable": timetable, "group": group, "groups": groups})
- 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
@@ -146,7 +158,8 @@ def rooms(request):
# 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) \
+ rooms = Room.objects.filter(course__begin__gte=start,
+ course__begin__lt=end) \
.order_by("name") \
.annotate(year=ExtractYear("course__begin"),
week=ExtractWeek("course__begin"),
@@ -172,10 +185,12 @@ def rooms(request):
# Rendu de la page.
return render(request, "group_list.html", {"groups": rooms_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":
@@ -183,7 +198,18 @@ def qsjps(request):
form = QSJPSForm(request.POST)
if form.is_valid():
# Formulaire validé
- return render(request, "qsjps.html", {"rooms": [], "form": form})
+ 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:
@@ -193,5 +219,6 @@ def qsjps(request):
return render(request, "qsjps_form.html", {"form": form})
+
def ctx_processor(request):
return {"celcatsanitizer_version": edt.VERSION}