aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlban Gruin2017-10-03 09:28:49 +0200
committerAlban Gruin2017-10-03 09:28:49 +0200
commitae3358c1296a02352409910a9ffcc2307d5ea87a (patch)
treeb47df3b99f639acc24d751cc0a6a73937c10069b
parent9641fc9bb6ef2897c0d70e6d5d5ed3f8e295dbec (diff)
parent175fcd46f56d8c1c5d10a50b401c09c25cfcdf82 (diff)
Merge branch 'stable/0.10.z' into prod/pa1ch/0.y.zv0.10.0-pa1ch
-rw-r--r--.gitignore2
-rw-r--r--README.md2
-rw-r--r--admin.py24
-rw-r--r--feeds.py77
-rw-r--r--management/commands/_private.py150
-rw-r--r--management/commands/cleancourses.py4
-rw-r--r--management/commands/timetables.py80
-rw-r--r--models.py93
-rw-r--r--templates/group_list.html6
-rw-r--r--templates/index.html14
-rw-r--r--templates/mention_list.html14
-rw-r--r--templates/timetable.html7
-rw-r--r--utils.py39
-rw-r--r--views.py55
14 files changed, 342 insertions, 225 deletions
diff --git a/.gitignore b/.gitignore
index 9377785..0975a47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
__pycache__/
migrations/
+.dir-locals.el
+TAGS
diff --git a/README.md b/README.md
index bc50388..82e6053 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
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 j'en avait ma claque de consulter un emploi du temps mal formaté. Parce que j'en avait ma claque de voir mon Firefox se figer pour pouvoir vérifier mes horaires de la journée. Parce que j'en avait ma claque d'avoir de devoir retrouver une pépite d'information dans un océan de texte. (et parce que j'avais un peu trop de temps libre aussi)
+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 :
diff --git a/admin.py b/admin.py
index 16e34f3..e17948a 100644
--- a/admin.py
+++ b/admin.py
@@ -14,7 +14,15 @@
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
from django.contrib import admin
-from .models import Timetable, LastUpdate, Group, Room, Course, Year
+from .models import Timetable, Group, Room, Course, 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_visible.short_description = "Afficher les groupes sélectionnés"
@admin.register(Year)
@@ -32,21 +40,17 @@ class TimetableAdmin(admin.ModelAdmin):
ordering = ("year", "name",)
-@admin.register(LastUpdate)
-class LastUpdateAdmin(admin.ModelAdmin):
- list_display = ("timetable", "week", "year", "date", "updated_at",)
- list_filter = ("timetable__name",)
-
-
@admin.register(Group)
class GroupAdmin(admin.ModelAdmin):
fieldsets = (
- (None, {"fields": ("name", "celcat_name", "timetable",)}),
+ (None, {"fields": ("name", "celcat_name", "timetable", "hidden",)}),
("Groupes", {"fields": ("mention", "subgroup", "td", "tp", "parent",)}),)
- list_display = ("name", "timetable",)
+ list_display = ("name", "timetable", "hidden",)
+ list_editable = ("hidden",)
list_filter = ("timetable",)
ordering = ("timetable",)
readonly_fields = ("celcat_name", "mention", "subgroup", "td", "tp",)
+ actions = (make_hidden, make_visible,)
@admin.register(Room)
@@ -57,7 +61,7 @@ class RoomAdmin(admin.ModelAdmin):
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
fieldsets = (
- (None, {"fields": ("name", "type", "timetable", "groups", "rooms",)}),
+ (None, {"fields": ("name", "type", "timetable", "groups", "rooms", "last_update",)}),
("Horaires", {"fields": ("begin", "end",)}),
("Remarques", {"fields": ("notes",)}),)
list_display = ("name", "type", "timetable", "begin", "end",)
diff --git a/feeds.py b/feeds.py
index 2dd2479..564b285 100644
--- a/feeds.py
+++ b/feeds.py
@@ -16,18 +16,20 @@
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
from django.contrib.syndication.views import Feed
-from django.db.models import Q
+from django.db.models import Count, Max
+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 icalendar import Calendar, Event
-from .models import Course, Group, LastUpdate
+from .models import Course, Group
from .templatetags.rooms import format_rooms
from .utils import get_current_or_next_week, get_week, group_courses
-ICAL_NAMES = ["summary", "description", "location", "start", "dtstart", "dtend"]
+ICAL_NAMES = ["uid", "summary", "description", "location",
+ "start", "dtstart", "dtend", "dtstamp"]
class IcalFeedGenerator(SyndicationFeed):
@@ -35,6 +37,7 @@ class IcalFeedGenerator(SyndicationFeed):
def write(self, outfile, encoding):
calendar = Calendar()
+ calendar.add("prodid", "-//celcatsanitizer//NONSGML v1.0//EN")
calendar.add("version", "2.0")
self.write_events(calendar)
@@ -55,7 +58,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, slug=group_slug)
+ group = Group.objects.get(timetable__year__slug=year_slug,
+ timetable__slug=timetable_slug,
+ slug=group_slug)
except:
raise ObjectDoesNotExist
else:
@@ -68,55 +73,87 @@ class IcalFeed(Feed):
return ""
def items(self, obj):
- return Course.objects.get_courses_for_group(obj).order_by("begin")
+ return Course.objects.get_courses_for_group(obj)
def item_extra_kwargs(self, item):
- return {"dtstart": item.begin,
+ return {"uid": "{0}@celcatsanitizer".format(item.id),
+ "dtstart": item.begin,
"dtend": item.end,
- "summary": item.name,
+ "dtstamp": item.last_update,
+ "summary": item.name + " (" + item.type + ")",
"location": format_rooms(item.rooms.all())}
class RSSFeed(Feed):
def get_object(self, request, year_slug, timetable_slug, group_slug):
year, week = get_current_or_next_week()
+ _, end = get_week(year, week)
+
try:
- group = Group.objects.get(timetable__year__slug=year_slug, timetable__slug=timetable_slug, slug=group_slug)
- updates = LastUpdate.objects.filter(Q(year=year, week__lte=week) | Q(year__lt=year), timetable__year__slug=year_slug, timetable__slug=timetable_slug).order_by("-year", "-week")[:5]
+ group = Group.objects.get(timetable__year__slug=year_slug,
+ timetable__slug=timetable_slug,
+ slug=group_slug)
except:
raise ObjectDoesNotExist
else:
+ updates = Course.objects.get_courses_for_group(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
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})
+ link = reverse("timetable",
+ kwargs={"year_slug": group.timetable.year.slug,
+ "timetable_slug": group.timetable.slug,
+ "group_slug": group.slug})
return link
def title(self, obj):
return "Emploi du temps du groupe {0}".format(obj[0])
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, "year": item.year, "week": item.week})
+ group = item["group"]
+ return reverse("timetable",
+ kwargs={"year_slug": group.timetable.year.slug,
+ "timetable_slug": group.timetable.slug,
+ "group_slug": group.slug,
+ "year": item["year"],
+ "week": item["week"]})
def item_description(self, item):
- return item.description
+ return item["description"]
+
+ def item_title(self, item):
+ return "{0}, semaine {1} de {2}".format(item["group"],
+ item["week"],
+ item["year"])
def item_updateddate(self, item):
- return item.date
+ return item["last_update__max"]
def items(self, obj):
template = loader.get_template("timetable_common.html")
group = obj[0]
for update in obj[1]:
- 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), "last_update": update, "year": update.year, "week": update.week}
-
- update.group = group
- update.description = template.render(context)
+ 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),
+ "last_update": update["last_update__max"],
+ "year": update["year"],
+ "week": update["week"]}
+
+ update["group"] = group
+ update["description"] = template.render(context)
return obj[1]
diff --git a/management/commands/_private.py b/management/commands/_private.py
index c140f51..8f195a1 100644
--- a/management/commands/_private.py
+++ b/management/commands/_private.py
@@ -13,33 +13,20 @@
# You should have received a copy of the GNU Affero General Public License
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
+import datetime
+import re
+
from bs4 import BeautifulSoup
from django.utils import timezone
from edt.models import Group, Room, Course
from edt.utils import get_week
-import datetime
-import re
-
import requests
-
-class Week:
- def __init__(self, number, start):
- self.number = number
- self.start = timezone.make_aware(
- datetime.datetime.strptime(start, "%d/%m/%Y"))
-
- def get_day(self, id):
- return self.start + datetime.timedelta(id)
-
- @property
- def year(self):
- return self.start.year
-
def add_time(date, time):
- delta = datetime.timedelta(hours=time.hour, minutes=time.minute)
+ ptime = datetime.datetime.strptime(time, "%H:%M")
+ delta = datetime.timedelta(hours=ptime.hour, minutes=ptime.minute)
return date + delta
def consolidate_group(group):
@@ -75,7 +62,7 @@ def consolidate_group(group):
def consolidate_groups(groups):
for group in groups:
- if group.parent == None:
+ if group.parent is None:
consolidate_group(group)
def delete_courses_in_week(timetable, year, week):
@@ -93,50 +80,69 @@ def get_from_db_or_create(cls, **kwargs):
return obj
-def get_events(timetable, year, week, soup, weeks_in_soup):
+def get_event(timetable, event, event_week):
+ """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
+ date = event_week + datetime.timedelta(int(event.day.text))
+ begin = add_time(date, event.starttime.text)
+ end = add_time(date, event.endtime.text)
+
+ # 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.
+ 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
+ if event.resources.module is not None:
+ course.name = event.resources.module.item.text
+ else:
+ # 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
+
+ # Récupération du type de cours
+ if event.category is not None:
+ course.type = event.category.text
+
+ # Si un cours a une salle attribuée (oui, il est possible qu’il n’y
+ # 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)
+ for item in event.resources.room.find_all("item")]
+ course.rooms.add(*rooms)
+
+ return course
+
+def get_events(timetable, soup, weeks_in_soup, 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"):
- title = None
- type_ = None
- groups = None
- rooms = None
- notes = None
-
- if weeks_in_soup[event.rawweeks.text].number == week and \
- weeks_in_soup[event.rawweeks.text].year == year and \
+ event_week = weeks_in_soup[event.rawweeks.text]
+ 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 \
+ 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:
- date = weeks_in_soup[event.rawweeks.text].get_day(int(
- event.day.text))
-
- begin = add_time(date, datetime.datetime.strptime(
- event.starttime.text, "%H:%M"))
- end = add_time(date, datetime.datetime.strptime(
- event.endtime.text, "%H:%M"))
-
- 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)
-
- if event.notes is not None:
- notes = event.notes.text
-
- if event.resources.module is not None:
- title = event.resources.module.item.text
- elif notes is not None:
- title = notes
- notes = None
- else:
- title = "Aucune information"
-
- if event.category is not None:
- type_ = event.category.text
-
- if event.resources.room is not None:
- rooms = [get_from_db_or_create(Room, name=item.text)
- for item in event.resources.room.find_all("item")]
-
- yield title, type_, groups, rooms, notes, begin, end
+ yield get_event(timetable, event, event_week)
def get_update_date(soup):
# Explication de la regex
@@ -153,7 +159,7 @@ def get_update_date(soup):
# (\d+) au moins un nombre
# : un deux-points
# (\d+) au moins un nombre
- datetime_regex = re.compile("(\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)")
+ datetime_regex = re.compile(r"(\d+)/(\d+)/(\d+)\s+(\d+):(\d+):(\d+)")
search = datetime_regex.search(soup.footer.text)
if search is None:
return None
@@ -163,16 +169,26 @@ def get_update_date(soup):
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
+ # de la semaine, formaté de la manière suivante :
+ # NNNNNNNNNNNNNNNNNNNYNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN
+ # Tous sont de la même longueur, contiennent 51 N et un seul Y.
+ # Allez savoir pourquoi. Il se trouve dans la balise « alleventweeks ».
+ # Un paramètre du span (« date ») représente la date de début.
+ # 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"):
- weeks[span.alleventweeks.text] = Week(int(span.title.text),
- span["date"])
+ 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):
- r = requests.get(url)
- r.encoding = "utf8"
+ req = requests.get(url)
+ req.encoding = "utf8"
- soup = BeautifulSoup(r.content, "html.parser")
+ soup = BeautifulSoup(req.content, "html.parser")
return soup
diff --git a/management/commands/cleancourses.py b/management/commands/cleancourses.py
index ca2ef94..f6041ef 100644
--- a/management/commands/cleancourses.py
+++ b/management/commands/cleancourses.py
@@ -15,7 +15,7 @@
from django.core.management.base import BaseCommand
from django.db import transaction
-from edt.models import Course, Group, LastUpdate
+from edt.models import Course, Group
class Command(BaseCommand):
@@ -29,10 +29,8 @@ class Command(BaseCommand):
if options["timetable"] is None:
Course.objects.all().delete()
Group.objects.all().delete()
- LastUpdate.objects.all().delete()
else:
Course.objects.filter(timetable__id__in=options["timetable"]).delete()
Group.objects.filter(timetable__id__in=options["timetable"]).delete()
- LastUpdate.objects.filter(timetable__id__in=options["timetable"]).delete()
self.stdout.write(self.style.SUCCESS("Done."))
diff --git a/management/commands/timetables.py b/management/commands/timetables.py
index c82b0e4..76f0a7c 100644
--- a/management/commands/timetables.py
+++ b/management/commands/timetables.py
@@ -13,59 +13,63 @@
# You should have received a copy of the GNU Affero General Public License
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
+import datetime
+
from django.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 Timetable, LastUpdate, Course
+from edt.models import Course, Timetable
+from edt.utils import get_week
from ._private import delete_courses_in_week, get_events, get_update_date, get_weeks, get_xml
-import datetime
-
@transaction.atomic
-def process_timetable_week(timetable, year, week, soup, weeks_in_soup):
- last_update_date = None
+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"]
new_update_date = get_update_date(soup)
- try:
- last_update = LastUpdate.objects.get(timetable=timetable, year=year, week=week)
- last_update_date = last_update.updated_at
- except:
- last_update = LastUpdate(timetable=timetable, year=year, week=week)
- if last_update_date is not None and new_update_date is not None and \
+ 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
- delete_courses_in_week(timetable, year, week)
- for name, type_, groups, rooms, notes, begin, end in \
- get_events(timetable, year, week, soup, weeks_in_soup):
- course = Course.objects.create(timetable=timetable, begin=begin, end=end)
- course.name = name
- course.type = type_
- course.notes = notes
-
- course.groups.add(*groups)
- if rooms is not None:
- course.rooms.add(*rooms)
+ if year is not None and week is not None:
+ delete_courses_in_week(timetable, year, week)
+ 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):
course.save()
- last_update.date = timezone.make_aware(datetime.datetime.now())
- last_update.updated_at = new_update_date
- last_update.save()
+ timetable.last_update_date = new_update_date
+ timetable.save()
-def process_timetable(timetable, year, weeks):
+def process_timetable(timetable, force, year=None, weeks=None):
soup = get_xml(timetable.url)
weeks_in_soup = get_weeks(soup)
- for week in weeks:
- process_timetable_week(timetable, year, week, soup, weeks_in_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)
+ else:
+ process_timetable_week(timetable, 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("--year", type=int, nargs=1)
@@ -73,7 +77,9 @@ class Command(BaseCommand):
year = None
errcount = 0
- if options["week"] is None:
+ if options["all"]:
+ weeks = None
+ elif options["week"] is None:
_, week, day = timezone.now().isocalendar()
if day >= 6:
year, week, _ = (timezone.now() + datetime.timedelta(weeks=1)).isocalendar()
@@ -81,18 +87,20 @@ class Command(BaseCommand):
else:
weeks = options["week"]
- if options["year"] is None and year is None:
- year = timezone.now().year
- elif year is None:
- year = options["year"][0]
+ if not options["all"]:
+ if options["year"] is None and year is None:
+ year = timezone.now().year
+ elif year is None:
+ year = options["year"][0]
for timetable in Timetable.objects.all():
self.stdout.write("Processing {0}".format(timetable))
try:
- process_timetable(timetable, year, weeks)
- except Exception as e:
- self.stderr.write(self.style.ERROR("Failed to process {0}: {1}".format(timetable, e)))
+ process_timetable(timetable, options["force"], year, weeks)
+ except Exception as exc:
+ self.stderr.write(
+ self.style.ERROR("Failed to process {0}: {1}".format(timetable, exc)))
errcount += 1
if errcount == 0:
diff --git a/models.py b/models.py
index f223317..ec141a2 100644
--- a/models.py
+++ b/models.py
@@ -16,6 +16,7 @@
from django.db import models
from django.db.models import Count, Manager, Q
from django.db.models.functions import ExtractWeek, ExtractYear
+from django.utils import timezone
from django.utils.text import slugify
from .utils import parse_group
@@ -47,11 +48,14 @@ class Year(SlugModel):
class Timetable(SlugModel):
- year = models.ForeignKey(Year, on_delete=models.CASCADE, verbose_name="année")
+ 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)
+
def __str__(self):
return self.year.name + " " + self.name
@@ -62,46 +66,47 @@ class Timetable(SlugModel):
verbose_name_plural = "emplois du temps"
-class LastUpdate(models.Model):
- timetable = models.ForeignKey(Timetable, on_delete=models.CASCADE, verbose_name="emploi du temps")
- week = models.IntegerField(verbose_name="semaine")
- year = models.IntegerField(verbose_name="année")
- date = models.DateTimeField(verbose_name="date de mise à jour")
-
- updated_at = models.DateTimeField(verbose_name="date de publication", null=True)
-
- def __str__(self):
- return "{0}, semaine {1} de {2}".format(self.timetable, self.week, self.year)
-
-
- class Meta:
- unique_together = ("timetable", "week", "year",)
- verbose_name = "dernière mise à jour"
- verbose_name_plural = "dernières mises à jour"
-
-
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_relevant_groups(self, *args, **criteria):
- return self.get_queryset().filter(*args, **criteria).annotate(children_count=Count("children")).filter(children_count=0)
+ return self.get_queryset().filter(*args, **criteria) \
+ .annotate(children_count=Count("children")) \
+ .filter(children_count=0, hidden=False)
class Group(models.Model):
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")
+ 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")
mention = models.CharField(max_length=128)
- subgroup = models.CharField(max_length=1, verbose_name="sous-groupe", null=True)
+ subgroup = models.CharField(max_length=1, 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")
+ 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):
- 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)
+ 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)
@property
def group_info(self):
@@ -121,7 +126,9 @@ class Group(models.Model):
class Meta:
index_together = ("mention", "subgroup", "td", "tp",)
- unique_together = (("name", "timetable",), ("celcat_name", "timetable",), ("slug", "timetable",),)
+ unique_together = (("name", "timetable",),
+ ("celcat_name", "timetable",),
+ ("slug", "timetable",),)
verbose_name = "groupe"
verbose_name_plural = "groupes"
@@ -141,18 +148,39 @@ class Room(models.Model):
class CourseManager(Manager):
def get_courses_for_group(self, group, **criteria):
- return self.get_queryset().filter(Q(groups__td__isnull=True) | Q(groups__td=group.td), Q(groups__tp__isnull=True) | Q(groups__tp=group.tp), Q(groups__subgroup__isnull=True) | Q(groups__subgroup=group.subgroup), groups__mention=group.mention, timetable=group.timetable, **criteria).order_by("begin")
+ 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) \
+ .order_by("begin")
def get_weeks(self, **criteria):
- return self.get_queryset().filter(**criteria).order_by("groups__name", "year", "week").annotate(_=Count(("groups", "year", "week", "begin")), year=ExtractYear("begin"), week=ExtractWeek("begin"))
+ return self.get_queryset() \
+ .filter(**criteria) \
+ .order_by("groups__name", "year", "week") \
+ .annotate(_=Count(("groups", "year", "week", "begin")),
+ year=ExtractYear("begin"),
+ week=ExtractWeek("begin"))
class Course(models.Model):
objects = CourseManager()
- name = models.CharField(max_length=255, verbose_name="nom", null=True)
- 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")
+ name = models.CharField(max_length=255, verbose_name="nom", default="Sans nom",
+ null=True)
+ 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")
notes = models.TextField(verbose_name="remarques", blank=True, null=True)
groups = models.ManyToManyField(Group, verbose_name="groupes")
@@ -161,6 +189,9 @@ class Course(models.Model):
begin = models.DateTimeField(verbose_name="début du cours", db_index=True)
end = models.DateTimeField(verbose_name="fin du cours")
+ last_update = models.DateTimeField(verbose_name="dernière mise à jour",
+ default=timezone.now)
+
def __str__(self):
return self.name
diff --git a/templates/group_list.html b/templates/group_list.html
index 2530865..aebb0db 100644
--- a/templates/group_list.html
+++ b/templates/group_list.html
@@ -1,11 +1,13 @@
{% extends "index.html" %}
{% load dt_week %}
+{% block title %}{{ timetable }} &ndash; {% endblock %}
+
{% block body %}
<h3><a href="{{ timetable.url }}">{{ timetable }}</a></h3>
<ul>
- {% for group in groups %}
+ {% 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> &ndash; {% 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> {% empty %}<em>aucun cours</em>{% endfor %}</li>
- {% endfor %}
+ {% endfor %}
</ul>
{% endblock %}
diff --git a/templates/index.html b/templates/index.html
index 6597158..43c567e 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
- <title>{% block title %}{% endblock %}celcatsanitizer</title>
+ <title>{% block title %}{% if year %}{{ year }} &ndash; {% endif %}{% endblock %}celcatsanitizer</title>
<link rel="stylesheet" href="{% static "celcatsanitizer/style.css" %}">
</head>
<body>
@@ -12,13 +12,13 @@
</header>
<div class="content">
{% block body %}
- <h3>Choisissez votre année</h3>
+ <h3>{% if year %}{{ year }} &ndash; Choisissez votre mention{% else %}Choisissez votre année{% endif %}</h3>
<ul>
- {% for year in years %}
- <li><a href="{% url "mentions" year.slug %}">{{ year }}</a></li>
- {% empty %}
- <p><em>Aucun emploi du temps à afficher</em></p>
- {% endfor %}
+ {% 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>
{% endblock %}
</div>
diff --git a/templates/mention_list.html b/templates/mention_list.html
deleted file mode 100644
index 71cfaac..0000000
--- a/templates/mention_list.html
+++ /dev/null
@@ -1,14 +0,0 @@
-{% extends "index.html" %}
-
-{% block title %}{{ year }} &ndash; {% endblock %}
-
-{% block body %}
- <h3>{{ year }} &ndash; Choisissez votre mention</h3>
- <ul>
-{% for timetable in timetables %}
- <li><a href="{% url "groups" year.slug timetable.slug %}">{{ timetable }}</a></li>
-{% empty %}
- <p>Aucun emploi du temps à afficher</p>
-{% endfor %}
- </ul>
-{% endblock %}
diff --git a/templates/timetable.html b/templates/timetable.html
index 7a30595..9143c2f 100644
--- a/templates/timetable.html
+++ b/templates/timetable.html
@@ -4,6 +4,11 @@
{% block body %}
<h2>{{ group.timetable }} &ndash; {{ group }} &ndash; Semaine {{ week }}</h2>
- <p>Dernière mise à jour le {{ last_update|date:"l j F o" }} à {{ last_update|date:"H:i" }}</p>
+ <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 />
+ {% endif %}
+ Dernière mise à jour le {{ last_update|date:"l j F o" }} à {{ last_update|date:"H:i" }}
+ </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 %}
diff --git a/utils.py b/utils.py
index d343def..bd337e4 100644
--- a/utils.py
+++ b/utils.py
@@ -13,11 +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 django.utils import timezone
-
import datetime
import re
+from django.utils import timezone
+
def get_current_week():
return timezone.now().isocalendar()[:2]
@@ -48,26 +48,29 @@ def group_courses(courses):
def parse_group(name):
# Explication de la regex
#
- # ^([\w ]+?)(\s*(((CM)(\w))|((TD)(\w)(\d))|((TP)(\w)(\d)(\d))))?$
+ # ^(.+?)\s+((CM(\w))|(TD(\w)(\d))|(TP(\w)(\d)(\d)))?(\s+\(.+\))?$
# ^ début de la ligne
- # ([\w ]+?) correspond à au moins un caractère
- # (\s* zéro, 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 optionel
+ # (.+?) 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("^([\w ]+?)(\s*(((CM)(\w))|((TD)(\w)(\d))|((TP)(\w)(\d)(\d))))?$")
+ group_regex = re.compile(r"^(.+?)\s+((CM(\w))|(TD(\w)(\d))|(TP(\w)(\d)(\d)))?(\s+\(.+\))?$")
search = group_regex.search(name)
if search is None:
return name, None, None, None
- parts = search.groups(0)
- if parts[1] == 0:
+ parts = search.groups()
+ if parts[1] is None: # Pas de groupe précis indiqué
return parts[0], None, None, None
- elif parts[4] == "CM":
- return parts[0], parts[5], None, None
- elif parts[7] == "TD":
- return parts[0], parts[8], parts[9], None
- elif parts[11] == "TP":
- return parts[0], parts[12], parts[13], parts[14]
+ 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]
diff --git a/views.py b/views.py
index 302b375..bd93712 100644
--- a/views.py
+++ b/views.py
@@ -13,33 +13,39 @@
# You should have received a copy of the GNU Affero General Public License
# along with celcatsanitizer. If not, see <http://www.gnu.org/licenses/>.
+import datetime
+
from django.conf import settings
+from django.db.models import Max
+from django.http import Http404
from django.shortcuts import get_object_or_404, render
-from .models import Timetable, LastUpdate, Group, Course, Year
-from .utils import get_current_week, get_week, group_courses
+from .models import Timetable, Group, Course, Year
+from .utils import get_current_week, get_current_or_next_week, get_week, group_courses
def index(request):
years = Year.objects.order_by("name")
- return render(request, "index.html", {"years": years})
+ 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, "mention_list.html", {"year": year, "timetables": 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.get_relevant_groups(timetable=timetable).order_by("name")
+def group_list_common(request, timetable, groups):
+ start, _ = get_week(*get_current_week())
+ end = start + datetime.timedelta(weeks=4)
- year, week = get_current_week()
- start, _ = get_week(year, week)
- groups_weeks = Course.objects.get_weeks(begin__gte=start, timetable=timetable).values("groups__mention", "groups__subgroup", "groups__td", "groups__tp", "year", "week")
+ 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")
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"]):
+ if group.corresponds_to(timetable.id, group_week["groups__mention"],
+ group_week["groups__subgroup"], group_week["groups__td"],
+ group_week["groups__tp"]):
if not hasattr(group, "weeks"):
group.weeks = []
@@ -52,20 +58,39 @@ def group_list(request, year_slug, timetable_slug):
return render(request, "group_list.html", {"timetable": timetable, "groups": 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")
+ 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
+
if year is None or week is None:
- year, week = get_current_week()
+ year, week = current_year, current_week
+ elif (int(year), int(week)) < (current_year, current_week):
+ is_old_timetable = True
start, end = get_week(int(year), int(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)
- last_update = get_object_or_404(LastUpdate, timetable=timetable, week=week, year=year)
- courses = Course.objects.get_courses_for_group(group, begin__gte=start, begin__lt=end)
+
+ if group.children.count():
+ return group_list_common(request, timetable, Group.objects.get_relevant_children(group))
+
+ courses = Course.objects.get_courses_for_group(group, begin__gte=start, begin__lt=end) \
+ .annotate(Max("last_update"))
+ if courses.count() == 0:
+ raise Http404
grouped_courses = group_courses(courses)
- return render(request, "timetable.html", {"group": group, "courses": grouped_courses, "last_update": last_update.date, "year": year, "week": int(week)})
+ return render(request, "timetable.html", {"group": group, "courses": grouped_courses,
+ "last_update": courses.first().last_update__max,
+ "year": year, "week": int(week),
+ "is_old_timetable": is_old_timetable})
def contact(request):
return render(request, "contact.html", {"email": settings.ADMINS[0][1]})