diff options
| -rw-r--r-- | admin.py | 11 | ||||
| -rw-r--r-- | feeds.py | 9 | ||||
| -rw-r--r-- | forms.py | 9 | ||||
| -rw-r--r-- | management/commands/_private.py | 31 | ||||
| -rw-r--r-- | management/commands/cleancourses.py | 6 | ||||
| -rw-r--r-- | management/commands/listtimetables.py | 8 | ||||
| -rw-r--r-- | management/commands/reparse.py | 30 | ||||
| -rw-r--r-- | management/commands/timetables.py | 72 | ||||
| -rw-r--r-- | models.py | 65 | ||||
| -rw-r--r-- | templates/index.html | 2 | ||||
| -rw-r--r-- | templatetags/dt_week.py | 2 | ||||
| -rw-r--r-- | templatetags/rooms.py | 4 | ||||
| -rw-r--r-- | tests.py | 211 | ||||
| -rw-r--r-- | utils.py | 43 | ||||
| -rw-r--r-- | views.py | 49 | 
15 files changed, 410 insertions, 142 deletions
@@ -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",) @@ -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): @@ -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))) @@ -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 – Alban Gruin – <a href="{% url "django.contrib.flatpages.views.flatpage" url="contact/" %}">contacter</a> – celcatsanitizer {{ celcatsanitizer_version }} – <a href="{% url "django.contrib.flatpages.views.flatpage" url="a-propos/" %}">à propos</a><br /> +      <p>(c) 2018 – Alban Gruin – <a href="{% url "django.contrib.flatpages.views.flatpage" url="contact/" %}">contacter</a> – celcatsanitizer {{ celcatsanitizer_version }} – <a href="{% url "django.contrib.flatpages.views.flatpage" url="a-propos/" %}">à propos</a><br />        Design inspiré par <a href="https://bestmotherfucking.website/">https://bestmotherfucking.website/</a></p>      </footer>    </body> diff --git a/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) @@ -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) @@ -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""" @@ -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}  | 
