Commit ffffd984 authored by Alban Gruin's avatar Alban Gruin
Browse files

Merge branch 'stable/0.11.z' into prod/pa1ch/0.y.z

parents ae3358c1 04ca9906
Showing with 286 additions and 226 deletions
+286 -226
# 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 ?
## 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.
## Comment faire tourner celcatsanitizer chez moi ?
celcatsanitizer est écrit en Python 3. Il dépend des bibliothèques suivantes :
## Comment faire tourner celcatsanitizer chez moi ?
celcatsanitizer est écrit en Python 3. Il dépend des bibliothèques suivantes :
* Django 1.11
* requests
* BeautifulSoup4
* icalendar
Pour installer celcatsanitizer, il est possible d'utiliser git.
Pour installer celcatsanitizer, il est possible dutiliser git.
Pour tester celcatsanitizer, il est recommandé d'utiliser SQLite ou PostgreSQL.
Pour tester celcatsanitizer, il est recommandé dutiliser SQLite ou PostgreSQL.
Pour la production, il est recommandé d'utiliser PostgreSQL (avec le driver psycopg2) et de mettre le tout dans un environnement virtuel.
Pour la production, il est recommandé dutiliser PostgreSQL (avec le driver psycopg2) 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.
Aucun autre SGBD na été testé, mais depuis la version 0.8.0, celcatsanitizer nutilise 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.
Il est préférable dutiliser un environnement virtuel, mais ce nest pas obligatoire. Si vous ne souhaitez pas utiliser un environnement virtuel, passez directement à linstallation des dépendances.
#### Création de l'environnement virtuel
Déplacez-vous dans le répertoire souhaité, installez l'environnement virtuel, et activez-le :
#### Création de lenvironnement virtuel
Déplacez-vous dans le répertoire souhaité, installez lenvironnement virtuel, et activez-le :
> $ virtualenv -p python3 celcatsanitizer
......@@ -31,21 +31,19 @@ Déplacez-vous dans le répertoire souhaité, installez l'environnement virtuel,
> $ source bin/activate
Il est possible que votre version de pip soit ancienne. Si vous le souhaitez, mettez ce programme à jour :
Il est possible que votre version de pip soit ancienne. Si vous le souhaitez, mettez ce programme à jour :
> $ pip install --upgrade pip
Notez que cette étape n'est pas obligatoire
#### Installation des dépendances
> $ pip install requests django beautifulsoup4 icalendar
Si vous utilisez PostgreSQL, vous allez avoir besoin du driver psycopg2 :
Si vous utilisez PostgreSQL, vous allez avoir besoin du driver psycopg2 :
> $ pip install psycopg2
SQLite n'a pas besoin de driver.
SQLite na pas besoin de driver.
#### Création du répertoire Django
......@@ -57,7 +55,7 @@ SQLite n'a pas besoin de driver.
> $ 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.
Pour la production, il est recommandé dutiliser une version stable, accessibles à travers les tags git.
#### Configuration de Django
Dans le fichier celcatsanitizer/settings.py, vous devrez renseigner plusieurs informations.
......@@ -67,8 +65,8 @@ Dans le fichier celcatsanitizer/settings.py, vous devrez renseigner plusieurs in
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 linternationalisation
Ce passage nest pas obligatoire. [Vous pouvez retrouver la documentation de linternationalisation 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)
......@@ -76,30 +74,54 @@ Ce passage n'est pas obligatoire. [Vous pouvez retrouver la documentation de l'i
##### Configuration du mode de Django
Si jamais vous utiliser Django en production, vous **devez** mettre la variable DEBUG à False.
##### Configuration personnalisée nécessaire à celcatsanitizer
celcatsanitizer a besoin d'une variable DEFAULT_DOMAIN qui contient l'URL de base de l'instance.
##### Ajout de celcatsanitizer dans la liste des applications Django
Ajoutez la chaine de caractère "edt" à la fin de la liste INSTALLED_APPS
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
Vous avez besoin de générer les migrations de celcatsanitizer, puis appliquez-les :
Générer les migrations de celcatsanitizer, puis appliquez-les :
> $ ./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 :
Si vous êtes en production, vous devez renseigner lemplacement 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 :
> $ ./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ébuggage, lancez le serveur de cette manière :
Si vous êtes en mode de déboguage, lancez le serveur de cette manière :
> $ ./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.
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 :
> $ ./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.
......@@ -13,4 +13,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
VERSION = "0.11.0-pa1ch"
__version__ = VERSION
default_app_config = "edt.apps.EdtConfig"
......@@ -44,12 +44,12 @@ class TimetableAdmin(admin.ModelAdmin):
class GroupAdmin(admin.ModelAdmin):
fieldsets = (
(None, {"fields": ("name", "celcat_name", "timetable", "hidden",)}),
("Groupes", {"fields": ("mention", "subgroup", "td", "tp", "parent",)}),)
("Groupes", {"fields": ("mention", "subgroup",)}),)
list_display = ("name", "timetable", "hidden",)
list_editable = ("hidden",)
list_filter = ("timetable",)
ordering = ("timetable",)
readonly_fields = ("celcat_name", "mention", "subgroup", "td", "tp",)
readonly_fields = ("celcat_name", "mention",)
actions = (make_hidden, make_visible,)
......
......@@ -29,7 +29,7 @@
from .utils import get_current_or_next_week, get_week, group_courses
ICAL_NAMES = ["uid", "summary", "description", "location",
"start", "dtstart", "dtend", "dtstamp"]
"start", "dtstart", "dtend", "dtstamp", "categories"]
class IcalFeedGenerator(SyndicationFeed):
......@@ -66,12 +66,20 @@ def get_object(self, request, year_slug, timetable_slug, group_slug):
else:
return group
def item_categories(self, item):
return (item.type,)
def item_description(self, item):
return item.notes
def item_link(self, item):
return ""
def item_summary(self, item):
if item.type is not None:
return item.name + " (" + item.type + ")"
return item.name
def items(self, obj):
return Course.objects.get_courses_for_group(obj)
......@@ -80,10 +88,15 @@ def item_extra_kwargs(self, item):
"dtstart": item.begin,
"dtend": item.end,
"dtstamp": item.last_update,
"summary": item.name + " (" + item.type + ")",
"summary": self.item_summary(item),
"location": format_rooms(item.rooms.all())}
class IcalOnlyOneFeed(IcalFeed):
def items(self, obj):
return Course.objects.filter(groups=obj).order_by("begin")
class RSSFeed(Feed):
def get_object(self, request, year_slug, timetable_slug, group_slug):
year, week = get_current_or_next_week()
......
......@@ -23,51 +23,16 @@
from edt.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 consolidate_group(group):
group_content_key = ("mention", "subgroup", "td", "tp")
group_content_list = group.group_info[1:]
if group.subgroup is not None:
group_content = dict(zip(group_content_key, group_content_list))
for i in range(len(group_content_list))[::-1]:
del group_content[group_content_key[i]]
group_content[group_content_key[i] + "__isnull"] = True
if group_content_list[i] is not None:
break
group.parent = Group.objects.filter(timetable=group.timetable,
**group_content).first()
group.save()
if group.tp is None:
group_content = dict(zip(group_content_key, group_content_list))
last_is_none = False
for i, key in enumerate(group_content_key):
if group_content_list[i] is None or last_is_none:
del group_content[key]
group_content[key + "__isnull"] = last_is_none
last_is_none = True
Group.objects.filter(timetable=group.timetable, parent__isnull=True,
**group_content).update(parent=group)
def consolidate_groups(groups):
for group in groups:
if group.parent is None:
consolidate_group(group)
def delete_courses_in_week(timetable, year, week):
def delete_courses_in_week(timetable, year, week, today):
start, end = get_week(year, week)
Course.objects.filter(begin__gte=start, begin__lt=end,
Course.objects.filter(begin__gte=max(start, today), begin__lt=end,
timetable=timetable).delete()
def get_from_db_or_create(cls, **kwargs):
......@@ -80,7 +45,7 @@ def get_from_db_or_create(cls, **kwargs):
return obj
def get_event(timetable, event, event_week):
def get_event(timetable, 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
......@@ -88,31 +53,34 @@ def get_event(timetable, event, event_week):
begin = add_time(date, event.starttime.text)
end = add_time(date, event.endtime.text)
# On ne traite pas le cours si il commence après le moment du traitement
if begin < today:
return
# Création de l’objet cours
course = Course.objects.create(timetable=timetable, begin=begin, end=end)
# On récupère les groupes concernés par les cours, on les
# « consolide », puis on les insère dans l’objet cours.
# On récupère les groupes concernés par les cours
groups = [get_from_db_or_create(Group, timetable=timetable,
celcat_name=item.text)
for item in event.resources.group.find_all("item")]
consolidate_groups(groups)
course.groups.add(*groups)
# On récupère le champ « remarque »
if event.notes is not None:
course.notes = event.notes.text
# On récupère le nom du cours
# On récupère le champ « nom »
if event.resources.module is not None:
course.name = event.resources.module.item.text
else:
elif event.category is not None:
# Il est possible qu’un cours n’ait pas de nom. Oui oui.
# Qui sont les concepteurs de ce système ? Quels sont leurs
# réseaux ?
# Bref, dans ce cas, on déplace le champ « remarque » de
# l’objet dans le champ « nom ».
course.name, course.notes = course.notes, None
# Bref, dans ce cas, si le cours a un type, il devient son nom.
course.type = event.category.text
# Si il n’a pas de type (mais je ne pense pas que ça soit possible…),
# il obtiendra une valeur par défaut définie à l’avance.
# Récupération du type de cours
if event.category is not None:
......@@ -128,7 +96,7 @@ def get_event(timetable, event, event_week):
return course
def get_events(timetable, soup, weeks_in_soup, year=None, week=None):
def get_events(timetable, 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"):
......@@ -142,7 +110,11 @@ def get_events(timetable, soup, weeks_in_soup, year=None, week=None):
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:
yield get_event(timetable, event, event_week)
course = get_event(timetable, 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
......@@ -187,7 +159,8 @@ def get_weeks(soup):
return weeks
def get_xml(url):
req = requests.get(url)
user_agent = "celcatsanitizer/" + edt.VERSION
req = requests.get(url, headers={"User-Agent": user_agent})
req.encoding = "utf8"
soup = BeautifulSoup(req.content, "html.parser")
......
......@@ -14,42 +14,80 @@
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
import datetime
import traceback
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Min
from django.utils import timezone
from edt.models import Course, Timetable
from edt.utils import get_week
from edt.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):
criteria = {}
if year is not None and week is not None:
begin, end = get_week(year, week)
criteria["begin__gte"] = begin
criteria["begin__lt"] = end
last_update_date = Course.objects.filter(timetable=timetable, **criteria) \
.aggregate(Min("last_update")) \
["last_update__min"]
# Si on force la mise à jour, on définit de moment
# de la mise à jour au début de la semaine
if force and year is not None and week is not None:
today = begin
elif force:
# Si la mise à jour est faite sur tout l’emploi du temps,
# alors la date de début est indéfinie.
today = None
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)
if today is not None:
# 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
last_update_date = last_update_date.filter(begin__lt=end)
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:
return
if year is not None and week is not None:
delete_courses_in_week(timetable, year, week)
# 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)
else:
Course.objects.filter(timetable=timetable,
begin__gte=min(weeks_in_soup.values())).delete()
for course in get_events(timetable, soup, weeks_in_soup, year, week):
# 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
delete_from = max(delete_from, today)
Course.objects.filter(timetable=timetable, 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):
course.save()
# On renseigne la date de mise à jour de Celcat, à des fins de statistiques
timetable.last_update_date = new_update_date
timetable.save()
......@@ -80,16 +118,16 @@ def handle(self, *args, **options):
if options["all"]:
weeks = None
elif options["week"] is None:
_, week, day = timezone.now().isocalendar()
_, week, day = tz_now().isocalendar()
if day >= 6:
year, week, _ = (timezone.now() + datetime.timedelta(weeks=1)).isocalendar()
year, week, _ = (tz_now() + datetime.timedelta(weeks=1)).isocalendar()
weeks = [week]
else:
weeks = options["week"]
if not options["all"]:
if options["year"] is None and year is None:
year = timezone.now().year
year = tz_now().year
elif year is None:
year = options["year"][0]
......@@ -98,9 +136,13 @@ def handle(self, *args, **options):
try:
process_timetable(timetable, options["force"], year, weeks)
except Exception as exc:
except KeyboardInterrupt:
break
except Exception:
self.stderr.write(
self.style.ERROR("Failed to process {0}: {1}".format(timetable, exc)))
self.style.ERROR("Failed to process {0}:".format(timetable))
)
self.stderr.write(self.style.ERROR(traceback.format_exc()))
errcount += 1
if errcount == 0:
......
......@@ -13,8 +13,11 @@
# 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 functools import reduce
from django.db import models
from django.db.models import Count, Manager, Q
from django.db.models import Count, Manager, Q, Subquery
from django.db.models.expressions import OuterRef
from django.db.models.functions import ExtractWeek, ExtractYear
from django.utils import timezone
from django.utils.text import slugify
......@@ -67,17 +70,23 @@ class Meta:
class GroupManager(Manager):
def get_relevant_children(self, group):
parent_in = self.get_queryset().filter(parent=group)
return self.get_queryset().filter(Q(parent=group) | Q(parent__in=parent_in)) \
.annotate(children_count=Count("children")) \
.filter(children_count=0, hidden=False) \
.order_by("name")
def get_parents(self, group):
groups_criteria = Q(subgroup__isnull=True) | Q(subgroup__startswith=group.subgroup) | \
reduce(lambda x, y: x | y,
[Q(subgroup=group.subgroup[:i])
for i in range(1, len(group.subgroup) + 1)])
return self.get_queryset().filter(groups_criteria, mention=group.mention,
timetable=group.timetable)
def get_relevant_groups(self, timetable, *args, **criteria):
sub = self.get_queryset().filter(timetable=timetable, mention=OuterRef("mention"),
subgroup__startswith=OuterRef("subgroup")) \
.order_by().values("mention").annotate(c=Count("*")).values("c")
def get_relevant_groups(self, *args, **criteria):
return self.get_queryset().filter(*args, **criteria) \
.annotate(children_count=Count("children")) \
.filter(children_count=0, hidden=False)
return self.get_queryset().filter(*args, timetable=timetable, hidden=False, **criteria) \
.annotate(nbsub=Subquery(sub, output_field=models.IntegerField())) \
.filter(Q(nbsub=1) | Q(nbsub__isnull=True)).order_by("name")
class Group(models.Model):
......@@ -90,27 +99,26 @@ class Group(models.Model):
verbose_name="emploi du temps")
mention = models.CharField(max_length=128)
subgroup = models.CharField(max_length=1, verbose_name="sous-groupe",
subgroup = models.CharField(max_length=16, verbose_name="sous-groupe",
null=True)
td = models.IntegerField(verbose_name="groupe de TD", null=True)
tp = models.IntegerField(verbose_name="groupe de TP", null=True)
parent = models.ForeignKey("self", verbose_name="groupe parent", null=True,
default=None, related_name="children")
slug = models.SlugField(max_length=64, default="")
hidden = models.BooleanField(verbose_name="caché", default=False)
def corresponds_to(self, timetable_id, mention, subgroup, td, tp):
def corresponds_to(self, timetable_id, mention, subgroup):
subgroup_corresponds = True
if self.subgroup is not None and subgroup is not None:
subgroup_corresponds = subgroup.startswith(self.subgroup) or \
self.subgroup.startswith(subgroup)
return self.timetable.id == timetable_id and \
self.mention.startswith(mention) and \
(self.subgroup == subgroup or self.subgroup is None) and \
(self.td == td or self.td is None or td is None) and \
(self.tp == tp or self.tp is None or tp is None)
subgroup_corresponds
@property
def group_info(self):
return self.timetable.id, self.mention, self.subgroup, self.td, self.tp
return self.timetable.id, self.mention, self.subgroup
def __str__(self):
return self.name
......@@ -120,12 +128,12 @@ def save(self, *args, **kwargs):
self.name = self.celcat_name
self.slug = slugify(self.name)
self.mention, self.subgroup, self.td, self.tp = parse_group(self.name)
self.mention, self.subgroup = parse_group(self.name)
super(Group, self).save()
class Meta:
index_together = ("mention", "subgroup", "td", "tp",)
index_together = ("mention", "subgroup",)
unique_together = (("name", "timetable",),
("celcat_name", "timetable",),
("slug", "timetable",),)
......@@ -148,19 +156,8 @@ class Meta:
class CourseManager(Manager):
def get_courses_for_group(self, group, **criteria):
groups_criteria = []
if group.subgroup is not None:
groups_criteria.append(Q(groups__subgroup__isnull=True) | \
Q(groups__subgroup=group.subgroup))
if group.td is not None:
groups_criteria.append(Q(groups__td__isnull=True) | Q(groups__td=group.td))
if group.tp is not None:
groups_criteria.append(Q(groups__tp__isnull=True) | Q(groups__tp=group.tp))
return self.get_queryset() \
.filter(*groups_criteria,
groups__mention=group.mention,
timetable=group.timetable, **criteria) \
.filter(groups__in=Group.objects.get_parents(group), **criteria) \
.order_by("begin")
def get_weeks(self, **criteria):
......@@ -175,8 +172,7 @@ def get_weeks(self, **criteria):
class Course(models.Model):
objects = CourseManager()
name = models.CharField(max_length=255, verbose_name="nom", default="Sans nom",
null=True)
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,
......
{% extends "index.html" %}
{% block title %}ICS disponibles pour le groupe {{ group }} &ndash; {% endblock %}
{% block body %}
<h2>ICS disponibles pour le groupe {{ group }}</h2>
<ul>
<li><a href="{% url "ics" group.timetable.year.slug group.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>
{% endfor %}
</ul>
{% endblock %}
{% extends "index.html" %}
{% load email %}
{% block title %}Contacter &ndash; {% endblock %}
{% block body %}
<h3>Contacter</h3>
<p>Pour contacter l’administrateur du service, envoyez un mail à l’adresse suivante :<br/>{{ email|format_email }}.</p>
{% endblock %}
{% extends "flatpages/default.html" %}
{% block body %}
<h3>{{ flatpage.title }} &ndash; celcatsanitizer {{ celcatsanitizer_version }}</h3>
{{ flatpage.content }}
{% endblock %}
{% extends "index.html" %}
{% block title %}{{ flatpage.title }} &ndash; {% endblock %}
{% block body %}
<h3>{{ flatpage.title }}</h3>
{{ flatpage.content }}
{% endblock %}
......@@ -3,6 +3,7 @@
<head>
<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>
<link rel="stylesheet" href="{% static "celcatsanitizer/style.css" %}">
</head>
......@@ -23,10 +24,8 @@ <h3>{% if year %}{{ year }} &ndash; Choisissez votre mention{% else %}Choisissez
{% endblock %}
</div>
<footer>
<p>(c) 2017 &ndash; Alban Gruin &ndash; <a href="{% url "contact" %}">contacter</a><br />
Design inspiré par <a href="https://bestmotherfucking.website/">https://bestmotherfucking.website/</a><br />
Les informations affichées sur ce site sont actualisées tout les jours à minuit CET.
</p>
<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 />
Design inspiré par <a href="https://bestmotherfucking.website/">https://bestmotherfucking.website/</a></p>
</footer>
</body>
</html>
{% extends "index.html" %}
{% block head %}
<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 %}
{% block title %}{{ group.timetable }} &ndash; {{ group }} &ndash; Semaine {{ week }} &ndash; {% endblock %}
{% block body %}
......@@ -8,7 +15,7 @@ <h2>{{ group.timetable }} &ndash; {{ group }} &ndash; Semaine {{ week }}</h2>
{% 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 />
{% endif %}
Dernière mise à jour le {{ last_update|date:"l j F o" }} à {{ last_update|date:"H:i" }}
{% 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 "ics" 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"><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 %}
......@@ -4,9 +4,10 @@
<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> ({{ course.type }}), de {{ course.begin|date:"H:i" }} à {{ course.end|date:"H:i" }}{% if course.rooms.all|length > 0 %}<br />
<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 %}
</li>{% endfor %}
</ul>
</section>{% endfor %}
</section>{% empty %}
<p>Aucun cours cette semaine.</p>{% endfor %}
# Copyright (C) 2017 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 import template
register = template.Library()
@register.filter
def format_email(address):
return address.replace("+", " [plus] ") \
.replace("@", " [arobase] ") \
.replace(".", " [point] ")
......@@ -14,14 +14,13 @@
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
from django.test import TestCase
from django.utils import timezone
from .models import Course, Group, Timetable, Year
from .utils import tz_now
class CourseTestCase(TestCase):
def setUp(self):
dt = timezone.now()
dt = tz_now()
self.year = Year(name="L2", slug="l2")
self.year.save()
......@@ -129,10 +128,10 @@ def test_parse(self):
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)
self.assertEqual(cma.group_info, (self.timetable.id, "L1 info s2", "A", None, None))
self.assertEqual(tda2.group_info, (self.timetable.id, "L1 info s2", "A", 2, None))
self.assertEqual(tpa21.group_info, (self.timetable.id, "L1 info s2", "A", 2, 1))
self.assertEqual(cma.group_info, (self.timetable.id, "L1 info", "A"))
self.assertEqual(tda2.group_info, (self.timetable.id, "L1 info", "A2"))
self.assertEqual(tpa21.group_info, (self.timetable.id, "L1 info", "A21"))
self.assertEqual(cmb.group_info, (self.timetable.id, "L1 info s2", "B", None, None))
self.assertEqual(tdb2.group_info, (self.timetable.id, "L1 info s2", "B", 2, None))
self.assertEqual(tpb21.group_info, (self.timetable.id, "L1 info s2", "B", 2, 1))
self.assertEqual(cmb.group_info, (self.timetable.id, "L1 info", "B"))
self.assertEqual(tdb2.group_info, (self.timetable.id, "L1 info", "B2"))
self.assertEqual(tpb21.group_info, (self.timetable.id, "L1 info", "B21"))
......@@ -13,16 +13,18 @@
# 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 url
from django.conf.urls import include, url
from . import feeds, views
urlpatterns = [
url(r"^$", views.index, name="index"),
url(r"^contact$", views.contact, name="contact"),
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"),
......
......@@ -19,12 +19,12 @@
from django.utils import timezone
def get_current_week():
return timezone.now().isocalendar()[:2]
return tz_now().isocalendar()[:2]
def get_current_or_next_week():
year, week, day = timezone.now().isocalendar()
year, week, day = tz_now().isocalendar()
if day >= 6:
year, week, _ = (timezone.now() + datetime.timedelta(weeks=1)).isocalendar()
year, week, _ = (tz_now() + datetime.timedelta(weeks=1)).isocalendar()
return year, week
......@@ -48,29 +48,25 @@ def group_courses(courses):
def parse_group(name):
# Explication de la regex
#
# ^(.+?)\s+((CM(\w))|(TD(\w)(\d))|(TP(\w)(\d)(\d)))?(\s+\(.+\))?$
# ^ début de la ligne
# (.+?) correspond à au moins un caractère
# \s+ un ou plusieurs espaces
# ((CM(\w))| correspond à CM suivi d'une lettre ou…
# (TD(\w)(\d))| … à TD suivi d’une lettre et d'un chiffre ou…
# (TP(\w)(\d)(\d)) … à TP suivi d’une lettre et de deux chiffres
# )? groupe optionnel
# (\s+ un ou plusieurs espaces
# \(.+\)) un ou plusieurs caractères quelconques entre parenthèses
# ? groupe optionnel
# $ fin de la ligne
group_regex = re.compile(r"^(.+?)\s+((CM(\w))|(TD(\w)(\d))|(TP(\w)(\d)(\d)))?(\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+)? é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+\(.+\))?$")
search = group_regex.search(name)
if search is None:
return name, None, None, None
return name, None
parts = search.groups()
if parts[1] is None: # Pas de groupe précis indiqué
return parts[0], None, None, None
elif parts[2] is not None: # Groupe de CM
return parts[0], parts[3], None, None
elif parts[4] is not None: # Groupe de TD
return parts[0], parts[5], parts[6], None
elif parts[7] is not None: # Groupe de TP
return parts[0], parts[8], parts[9], parts[10]
return parts[0], parts[4]
def tz_now():
"""Retourne la date et l’heure avec le bon fuseau horaire"""
return timezone.make_aware(datetime.datetime.now())
......@@ -15,14 +15,16 @@
import datetime
from django.conf import settings
from django.db.models import Max
from django.db.models.functions import Length
from django.http import Http404
from django.shortcuts import get_object_or_404, render
from .models import Timetable, Group, Course, Year
from .utils import get_current_week, get_current_or_next_week, get_week, group_courses
import edt
def index(request):
years = Year.objects.order_by("name")
return render(request, "index.html", {"elements": years})
......@@ -39,13 +41,12 @@ def group_list_common(request, timetable, groups):
groups_weeks = Course.objects.get_weeks(begin__gte=start, begin__lt=end, timetable=timetable) \
.values("groups__mention", "groups__subgroup",
"groups__td", "groups__tp", "year", "week")
"year", "week")
for group in groups:
for group_week in groups_weeks:
if group.corresponds_to(timetable.id, group_week["groups__mention"],
group_week["groups__subgroup"], group_week["groups__td"],
group_week["groups__tp"]):
group_week["groups__subgroup"]):
if not hasattr(group, "weeks"):
group.weeks = []
......@@ -60,15 +61,16 @@ def group_list_common(request, timetable, groups):
def group_list(request, year_slug, timetable_slug):
timetable = get_object_or_404(Timetable, year__slug=year_slug, slug=timetable_slug)
groups = Group.objects.get_relevant_groups(timetable=timetable).order_by("name")
groups = Group.objects.get_relevant_groups(timetable)
return group_list_common(request, timetable, groups)
def timetable(request, year_slug, timetable_slug, group_slug, year=None, week=None):
current_year, current_week = get_current_or_next_week()
is_old_timetable = False
is_old_timetable, provided_week = False, True
if year is None or week is None:
year, week = current_year, current_week
provided_week = False
elif (int(year), int(week)) < (current_year, current_week):
is_old_timetable = True
......@@ -77,20 +79,31 @@ def timetable(request, year_slug, timetable_slug, group_slug, year=None, week=No
timetable = get_object_or_404(Timetable, year__slug=year_slug, slug=timetable_slug)
group = get_object_or_404(Group, slug=group_slug, timetable=timetable)
if group.children.count():
return group_list_common(request, timetable, Group.objects.get_relevant_children(group))
if Group.objects.filter(timetable=timetable, mention=group.mention,
subgroup__startswith=group.subgroup).count() > 1:
subgroups = Group.objects.get_relevant_groups(timetable, mention=group.mention,
subgroup__startswith=group.subgroup)
return group_list_common(request, timetable, subgroups)
courses = Course.objects.get_courses_for_group(group, begin__gte=start, begin__lt=end) \
.annotate(Max("last_update"))
if courses.count() == 0:
courses = Course.objects.get_courses_for_group(group, begin__gte=start, begin__lt=end)
if courses.count() == 0 and provided_week:
raise Http404
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": courses.first().last_update__max,
"last_update": last_update,
"year": year, "week": int(week),
"is_old_timetable": is_old_timetable})
def contact(request):
return render(request, "contact.html", {"email": settings.ADMINS[0][1]})
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")
return render(request, "calendars.html", {"group": group, "groups": groups})
def ctx_processor(request):
return {"celcatsanitizer_version": edt.VERSION}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment