Skip to content
Snippets Groups Projects
Verified Commit 502836d4 authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Add print view for regular timetables

diff --git a/aleksis/apps/lesrooster/managers.py b/aleksis/apps/lesrooster/managers.py
index 10703b0..634dfb2 100644
--- a/aleksis/apps/lesrooster/managers.py
+++ b/aleksis/apps/lesrooster/managers.py
@@ -1,6 +1,18 @@
-from django.db.models import QuerySet
+from datetime import time
+from typing import Iterable, Optional, Union

-from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations, DateRangeQuerySetMixin
+from django.db.models import Max, Min, Q, QuerySet
+from django.db.models.functions import Coalesce
+
+from polymorphic.query import PolymorphicQuerySet
+
+from aleksis.apps.chronos.managers import TimetableType
+from aleksis.core.managers import (
+    AlekSISBaseManagerWithoutMigrations,
+    DateRangeQuerySetMixin,
+    PolymorphicBaseManager,
+)
+from aleksis.core.models import Group, Person, Room

 class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin):
@@ -9,3 +21,124 @@ class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin):

 class ValidityRangeManager(AlekSISBaseManagerWithoutMigrations):
     """Manager for validity ranges."""
+
+
+class SlotQuerySet(PolymorphicQuerySet):
+    def get_period_min(self) -> int:
+        """Get minimum period."""
+        return self.aggregate(period__min=Coalesce(Min("period"), 1)).get("period__min")
+
+    def get_period_max(self) -> int:
+        """Get maximum period."""
+        return self.aggregate(period__max=Coalesce(Max("period"), 7)).get("period__max")
+
+    def get_time_min(self) -> time | None:
+        """Get minimum time."""
+        return self.aggregate(Min("time_start")).get("time_start__min")
+
+    def get_time_max(self) -> time | None:
+        """Get maximum time."""
+        return self.aggregate(Max("time_end")).get("time_end__max")
+
+    def get_weekday_min(self) -> int:
+        """Get minimum weekday."""
+        return self.aggregate(weekday__min=Coalesce(Min("weekday"), 0)).get("weekday__min")
+
+    def get_weekday_max(self) -> int:
+        """Get maximum weekday."""
+        return self.aggregate(weekday__max=Coalesce(Max("weekday"), 6)).get("weekday__max")
+
+
+class SlotManager(PolymorphicBaseManager):
+    pass
+
+
+class LessonQuerySet(QuerySet):
+    def filter_participant(self, person: Union[Person, int]) -> "LessonQuerySet":
+        """Filter for all lessons a participant (student) attends."""
+        return self.filter(course__groups__members=person)
+
+    def filter_group(self, group: Union[Group, int]) -> "LessonQuerySet":
+        """Filter for all lessons a group (class) regularly attends."""
+        if isinstance(group, int):
+            group = Group.objects.get(pk=group)
+
+        return self.filter(
+            Q(course__groups=group) | Q(course__groups__parent_groups=group)
+        ).distinct()
+
+    def filter_groups(self, groups: Iterable[Group]) -> "LessonQuerySet":
+        """Filter for all lessons one of the groups regularly attends."""
+        return self.filter(
+            Q(course__groups__in=groups) | Q(course__groups__parent_groups__in=groups)
+        )
+
+    def filter_teacher(self, teacher: Union[Person, int]) -> "LessonQuerySet":
+        """Filter for all lessons given by a certain teacher."""
+        return self.filter(teachers=teacher)
+
+    def filter_room(self, room: Union[Room, int]) -> "LessonQuerySet":
+        """Filter for all lessons taking part in a certain room."""
+        return self.filter(rooms=room)
+
+    def filter_from_type(
+        self,
+        type_: TimetableType,
+        obj: Union[Person, Group, Room, int],
+    ) -> Optional["LessonQuerySet"]:
+        """Filter lessons for a group, teacher or room by provided type."""
+        if type_ == TimetableType.GROUP:
+            return self.filter_group(obj)
+        elif type_ == TimetableType.TEACHER:
+            return self.filter_teacher(obj)
+        elif type_ == TimetableType.ROOM:
+            return self.filter_room(obj)
+        else:
+            return None
+
+    def filter_from_person(self, person: Person) -> Optional["LessonQuerySet"]:
+        """Filter lessons for a person."""
+        type_ = person.timetable_type
+
+        if type_ == TimetableType.TEACHER:
+            return self.filter_teacher(person)
+        elif type_ == TimetableType.GROUP:
+            return self.filter_participant(person)
+        else:
+            return None
+
+
+class LessonManager(AlekSISBaseManagerWithoutMigrations):
+    pass
+
+
+class SupervisionQuerySet(QuerySet):
+    def filter_teacher(self, teacher: Union[Person, int]) -> "SupervisionQuerySet":
+        """Filter for all supervisions done by a certain teacher."""
+        return self.filter(teachers=teacher)
+
+    def filter_room(self, room: Union[Room, int]) -> "SupervisionQuerySet":
+        """Filter for all supervisions taking part in a certain room."""
+        return self.filter(rooms=room)
+
+    def filter_from_type(
+        self,
+        type_: TimetableType,
+        obj: Union[Person, Group, Room, int],
+    ) -> Optional["SupervisionQuerySet"]:
+        """Filter supervisions for a eacher or room by provided type."""
+        if type_ == TimetableType.TEACHER:
+            return self.filter_teacher(obj)
+        elif type_ == TimetableType.ROOM:
+            return self.filter_room(obj)
+        else:
+            return None
+
+    def filter_from_person(self, person: Person) -> Optional["SupervisionQuerySet"]:
+        """Filter supervisions for a person."""
+
+        return self.filter_teacher(person)
+
+
+class SupervisionManager(AlekSISBaseManagerWithoutMigrations):
+    pass
diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 17c2dc6..73a1e1a 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -1,5 +1,5 @@
 import logging
-from datetime import date, datetime, timedelta
+from datetime import date, datetime, timedelta, time
 from typing import Optional, Union

 from django.core.exceptions import ValidationError
@@ -25,7 +25,16 @@ from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel, Glo
 from aleksis.core.models import Group, Holiday, Person, Room, SchoolTerm
 from aleksis.core.util.celery_progress import ProgressRecorder, render_progress_page

-from .managers import ValidityRangeManager, ValidityRangeQuerySet
+from .managers import (
+    LessonManager,
+    LessonQuerySet,
+    SlotManager,
+    SlotQuerySet,
+    SupervisionManager,
+    SupervisionQuerySet,
+    ValidityRangeManager,
+    ValidityRangeQuerySet,
+)

 class ValidityRangeStatus(models.TextChoices):
@@ -222,6 +231,37 @@ class TimeGrid(ExtensibleModel):
         null=True,
     )

+    @property
+    def times_dict(self) -> dict[int, tuple[datetime, datetime]]:
+        slots = {}
+        for slot in self.slots.all():
+            slots[slot.period] = (slot.time_start, slot.time_end)
+        return slots
+
+    @property
+    def period_min(self) -> int:
+        return self.slots.get_period_min()
+
+    @property
+    def period_max(self) -> int:
+        return self.slots.get_period_max()
+
+    @property
+    def time_min(self) -> time | None:
+        return self.slots.get_time_min()
+
+    @property
+    def time_max(self) -> time | None:
+        return self.slots.get_time_max()
+
+    @property
+    def weekday_min(self) -> int:
+        return self.slots.get_weekday_min()
+
+    @property
+    def weekday_max(self) -> int:
+        return self.slots.get_weekday_max()
+
     def __str__(self):
         if self.group:
             return f"{self.validity_range}: {self.group}"
@@ -243,6 +283,8 @@ class TimeGrid(ExtensibleModel):
 class Slot(ExtensiblePolymorphicModel):
     """A slot is a time period in which a lesson can take place."""

+    objects = SlotManager.from_queryset(SlotQuerySet)()
+
     WEEKDAY_CHOICES = i18n_day_name_choices_lazy()
     WEEKDAY_CHOICES_SHORT = i18n_day_abbr_choices_lazy()

@@ -346,6 +388,8 @@ class Slot(ExtensiblePolymorphicModel):
 class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
     """A lesson represents a single teaching event."""

+    objects = LessonManager.from_queryset(LessonQuerySet)()
+
     lesson_event = models.OneToOneField(
         LessonEvent,
         on_delete=models.SET_NULL,
@@ -501,6 +545,8 @@ class BreakSlot(Slot):
 class Supervision(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
     """A supervision is a time period in which a teacher supervises a room."""

+    objects = SupervisionManager.from_queryset(SupervisionQuerySet)()
+
     supervision_event = models.OneToOneField(
         SupervisionEvent,
         on_delete=models.SET_NULL,
diff --git a/aleksis/apps/lesrooster/static/css/lesrooster/timetable_print.css b/aleksis/apps/lesrooster/static/css/lesrooster/timetable_print.css
new file mode 100644
index 0000000..9bed357
--- /dev/null
+++ b/aleksis/apps/lesrooster/static/css/lesrooster/timetable_print.css
@@ -0,0 +1,63 @@
+.timetable-plan .row,
+.timetable-plan .col {
+  display: flex;
+  padding: 0;
+}
+
+.timetable-plan .row {
+  margin-bottom: 0;
+}
+
+.lesson-card,
+.timetable-title-card {
+  display: flex;
+  flex-grow: 1;
+  min-height: 40px;
+  box-shadow: none;
+  border: 1px solid black;
+  margin: -1px -1px 0 0;
+  border-radius: 0;
+  font-size: 11px;
+}
+
+.timetable-title-card .card-title {
+  margin-bottom: 0 !important;
+}
+
+.supervision-card {
+  min-height: 10px;
+  border-left: none;
+  border-right: none;
+}
+
+.lesson-card .card-content {
+  padding: 0;
+  text-align: center;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.lesson-card .card-content > div {
+  padding: 0;
+  flex: auto;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.timetable-title-card .card-content {
+  padding: 7px;
+  text-align: center;
+  width: 100%;
+}
+
+.lesson-card a {
+  color: inherit;
+}
+
+.card .card-title {
+  font-size: 18px;
+}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/elements.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/elements.html
new file mode 100644
index 0000000..00646c2
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/elements.html
@@ -0,0 +1,7 @@
+<div class="card lesson-card">
+  <div class="card-content">
+    {% for element in elements %}
+        {% include "lesrooster/partials/lesson.html" with lesson=element %}
+    {% endfor %}
+  </div>
+</div>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/group.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/group.html
new file mode 100644
index 0000000..aba12c5
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/group.html
@@ -0,0 +1 @@
+  {{ item.short_name }}{% if not forloop.last %},{% endif %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/groups.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/groups.html
new file mode 100644
index 0000000..6ffcaec
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/groups.html
@@ -0,0 +1,5 @@
+{% if groups.count == 1 and groups.0.parent_groups.all and request.site.preferences.lesrooster__use_parent_groups %}
+  {% include "lesrooster/partials/groups_part.html" with groups=groups.0.parent_groups.all no_collapsible=no_collapsible %}
+{% else %}
+  {% include "lesrooster/partials/groups_part.html" with groups=groups no_collapsible=no_collapsible %}
+{% endif %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/groups_part.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/groups_part.html
new file mode 100644
index 0000000..97a9f04
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/groups_part.html
@@ -0,0 +1,7 @@
+{% if groups.count > request.site.preferences.lesrooster__shorten_groups_limit and request.user.person.preferences.lesrooster__shorten_groups and not no_collapsible %}
+  {% include "components/text_collapsible.html" with template="lesrooster/partials/group.html" qs=groups %}
+{% else %}
+  {% for group in groups %}
+    {% include "lesrooster/partials/group.html" with item=group %}
+  {% endfor %}
+{% endif %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/lesson.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/lesson.html
new file mode 100644
index 0000000..969f71f
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/lesson.html
@@ -0,0 +1,25 @@
+{% load i18n %}
+
+<div style="{% include "lesrooster/partials/subject_colour.html" with subject=lesson.subject %}">
+  <p>
+      {# Teacher or room > Display classes #}
+      {% if type.value == "teacher" or type.value == "room" %}
+        {% if lesson.course.groups %}
+          {% include "lesrooster/partials/groups.html" with groups=lesson.course.groups.all %}
+        {% endif %}
+      {% endif %}
+
+      {# Class or room > Display teacher #}
+      {% if type.value == "room" or type.value == "group" %}
+        {% include "lesrooster/partials/teachers.html" with teachers=lesson.teachers.all %}
+      {% endif %}
+
+      {# Display subject #}
+      {% include "lesrooster/partials/subject.html" with subject=lesson.subject %}
+
+      {# Teacher or class > Display room #}
+      {% if type.value == "teacher" or type.value == "group" %}
+        {% include "lesrooster/partials/rooms.html" with rooms=lesson.rooms.all %}
+      {% endif %}
+  </p>
+</div>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/rooms.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/rooms.html
new file mode 100644
index 0000000..081540e
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/rooms.html
@@ -0,0 +1,3 @@
+{% for room in rooms %}
+  {{ room.short_name }}{% if not forloop.last %},{% endif %}
+{% endfor %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/slot_time.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/slot_time.html
new file mode 100644
index 0000000..b190c2e
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/slot_time.html
@@ -0,0 +1,18 @@
+{% load data_helpers %}
+
+<div class="card timetable-title-card">
+  <div class="card-content">
+
+    {# Lesson number #}
+    <div class="card-title left">
+      {{ slot.period }}.
+    </div>
+
+    {# Time dimension of lesson #}
+    <div class="right timetable-time grey-text text-darken-2">
+        <span>{{ slot.time_start|time }}</span>
+        <br/>
+        <span>{{ slot.time_end|time }}</span>
+    </div>
+  </div>
+</div>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/subject.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/subject.html
new file mode 100644
index 0000000..1b565a2
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/subject.html
@@ -0,0 +1,3 @@
+<strong>
+  {{ subject.short_name }}
+</strong>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/subject_colour.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/subject_colour.html
new file mode 100644
index 0000000..4cead55
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/subject_colour.html
@@ -0,0 +1,6 @@
+{% if subject.colour_fg %}
+  color: {{ subject.colour_fg }};
+{% endif %}
+{% if subject.colour_bg and subject.colour_bg != subject.colour_fg %}
+  background-color: {{ subject.colour_bg }};
+{% endif %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/supervision.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/supervision.html
new file mode 100644
index 0000000..6abd44e
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/supervision.html
@@ -0,0 +1,15 @@
+{% load i18n %}
+
+<div class="card lesson-card supervision-card">
+  <div class="card-content">
+    {% if supervision %}
+      <div>
+        <p>
+          <strong>{% trans "Supervision" %}</strong>
+          {% include "lesrooster/partials/rooms.html" with rooms=supervision.rooms.all %}
+          {% include "lesrooster/partials/teachers.html" with teachers=supervision.teachers.all %}
+        </p>
+      </div>
+    {% endif %}
+  </div>
+</div>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/teachers.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/teachers.html
new file mode 100644
index 0000000..73afb6e
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/teachers.html
@@ -0,0 +1,3 @@
+{% for teacher in teachers %}
+          {{ teacher.short_name }}{% if not forloop.last %},{% endif %}
+{% endfor %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html b/aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html
new file mode 100644
index 0000000..fd4de7a
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html
@@ -0,0 +1,56 @@
+{% extends 'core/base_print.html' %}
+
+{% load data_helpers static i18n %}
+
+{% block extra_head %}
+  <link rel="stylesheet" href="{% static 'css/lesrooster/timetable_print.css' %}">
+{% endblock %}
+
+{% block page_title %}
+  {% trans "Timetable" %} <i>{{ el.short_name }}</i>
+{% endblock %}
+{% block content %}
+
+  <div class="timetable-plan">
+    {#  Week days #}
+    <div class="row">
+      <div class="col s2">
+
+      </div>
+      {% for weekday in weekdays %}
+        <div class="col s2">
+          <div class="card timetable-title-card">
+            <div class="card-content">
+              <span class="card-title">
+                {{ weekday.1 }}
+              </span>
+            </div>
+          </div>
+        </div>
+      {% endfor %}
+    </div>
+
+    {% for row in timetable %}
+      <div class="row">
+        <div class="col s2">
+          {% if row.type == "period" %}
+            {% include "lesrooster/partials/slot_time.html" with slot=row.slot %}
+          {% endif %}
+        </div>
+
+        {% for col in row.cols %}
+          {# A lesson #}
+          <div class="col s2">
+            {% if col.type == "period" %}
+              {% include "lesrooster/partials/elements.html" with elements=col.col %}
+            {% else %}
+              {% include "lesrooster/partials/supervision.html" with supervision=col.col %}
+            {% endif %}
+          </div>
+        {% endfor %}
+      </div>
+    {% endfor %}
+  </div>
+
+  <small>{% trans "Validity range" %}: {{ time_grid.validity_range.date_start }}–{{ time_grid.validity_range.date_end }}</small>
+{% endblock %}
diff --git a/aleksis/apps/lesrooster/urls.py b/aleksis/apps/lesrooster/urls.py
new file mode 100644
index 0000000..5794d92
--- /dev/null
+++ b/aleksis/apps/lesrooster/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+    path(
+        "timetable/<int:time_grid>/<str:type_>/<int:pk>/print/",
+        views.print_timetable,
+        name="timetable_print",
+    ),
+]
diff --git a/aleksis/apps/lesrooster/util/build.py b/aleksis/apps/lesrooster/util/build.py
new file mode 100644
index 0000000..217d7ec
--- /dev/null
+++ b/aleksis/apps/lesrooster/util/build.py
@@ -0,0 +1,116 @@
+from collections import OrderedDict
+from typing import Union
+
+from aleksis.apps.chronos.managers import TimetableType
+from aleksis.apps.lesrooster.models import BreakSlot, Lesson, Slot, Supervision, TimeGrid
+from aleksis.core.models import Group, Person, Room
+
+
+def build_timetable(
+    time_grid: TimeGrid,
+    type_: Union[TimetableType, str],
+    obj: Union[Group, Room, Person],
+) -> list | None:
+    """Build regular timetable for the given time grid."""
+    is_person = False
+    if type_ == "person":
+        is_person = True
+        type_ = obj.timetable_type
+
+    if type_ is None:
+        return None
+
+    slots = Slot.objects.filter(time_grid=time_grid).order_by("weekday", "time_start")
+    lesson_periods_per_slot = OrderedDict()
+    supervisions_per_slot = OrderedDict()
+    slot_map = OrderedDict()
+    for slot in slots:
+        lesson_periods_per_slot[slot] = []
+        supervisions_per_slot[slot] = []
+        slot_map.setdefault(slot.weekday, []).append(slot)
+
+    max_slots_weekday, max_slots = max(slot_map.items(), key=lambda x: len(x[1]))
+    max_slots = len(max_slots)
+
+    # Get matching lessons
+    lessons = Lesson.objects.filter(slot_start__time_grid=time_grid)
+
+    lessons = (
+        lessons.filter_from_person(obj) if is_person else lessons.filter_from_group(type_, obj)
+    )
+
+    # Sort lesson periods in a dict
+    for lesson in lessons:
+        print(
+            lesson.subject,
+            Slot.objects.filter(
+                time_grid=time_grid,
+                weekday=lesson.slot_start.weekday,
+                time_start__gte=lesson.slot_start.time_start,
+                time_end__lte=lesson.slot_end.time_end,
+            ).not_instance_of(BreakSlot),
+        )
+        for slot in Slot.objects.filter(
+            time_grid=time_grid,
+            weekday=lesson.slot_start.weekday,
+            time_start__gte=lesson.slot_start.time_start,
+            time_end__lte=lesson.slot_end.time_end,
+        ).not_instance_of(BreakSlot):
+            lesson_periods_per_slot[slot].append(lesson)
+
+    # Get matching supervisions
+    needed_break_slots = []
+
+    supervisions = Supervision.objects.filter(break_slot__time_grid=time_grid).all()
+    if is_person:
+        supervisions = supervisions.filter_from_person(obj)
+    else:
+        supervisions = supervisions.filter_from_type(type_, obj)
+
+    if supervisions:
+        for supervision in supervisions:
+            if supervision.break_slot not in needed_break_slots:
+                needed_break_slots.append(supervision.break_slot)
+
+            supervisions_per_slot[supervision.break_slot] = supervision
+
+    rows = []
+    for slot_idx in range(max_slots):  # period is period after break
+        left_slot = slot_map[max_slots_weekday][slot_idx]
+
+        if isinstance(left_slot, BreakSlot):
+            row = {"type": "break", "slot": left_slot}
+        else:
+            row = {
+                "type": "period",
+                "slot": left_slot,
+            }
+
+        cols = []
+
+        for weekday in range(time_grid.weekday_min, time_grid.weekday_max + 1):
+            if slot_idx > len(slot_map[weekday]) - 1:
+                continue
+            actual_slot = slot_map[weekday][slot_idx]
+
+            if isinstance(actual_slot, BreakSlot):
+                col = {"type": "break", "col": supervisions_per_slot.get(actual_slot)}
+
+            else:
+                print(lesson_periods_per_slot[actual_slot])
+                col = {
+                    "type": "period",
+                    "col": (
+                        lesson_periods_per_slot[actual_slot]
+                        if actual_slot in lesson_periods_per_slot
+                        else []
+                    ),
+                }
+
+            cols.append(col)
+
+        row["cols"] = cols
+
+        rows.append(row)
+
+    return rows
diff --git a/aleksis/apps/lesrooster/views.py b/aleksis/apps/lesrooster/views.py
new file mode 100644
index 0000000..d6bd303
--- /dev/null
+++ b/aleksis/apps/lesrooster/views.py
@@ -0,0 +1,41 @@
+from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
+from django.shortcuts import get_object_or_404
+
+from rules.contrib.views import permission_required
+
+from aleksis.apps.chronos.managers import TimetableType
+from aleksis.apps.chronos.util.chronos_helpers import get_el_by_pk
+from aleksis.apps.lesrooster.models import Slot, TimeGrid
+from aleksis.apps.lesrooster.util.build import build_timetable
+from aleksis.core.util.pdf import render_pdf
+
+
+@permission_required("chronos.view_timetable_rule", fn=get_el_by_pk)
+def print_timetable(
+    request: HttpRequest,
+    time_grid: int,
+    type_: str,
+    pk: int,
+) -> HttpResponse:
+    """View a selected timetable for a person, group or room."""
+    context = {}
+
+    time_grid = get_object_or_404(TimeGrid, pk=time_grid)
+    el = get_el_by_pk(request, type_, pk, prefetch=True)
+
+    if isinstance(el, HttpResponseNotFound):
+        return HttpResponseNotFound()
+
+    type_ = TimetableType.from_string(type_)
+
+    timetable = build_timetable(time_grid, type_, el)
+    context["timetable"] = timetable
+
+    context["weekdays"] = Slot.WEEKDAY_CHOICES[time_grid.weekday_min : time_grid.weekday_max + 1]
+
+    context["time_grid"] = time_grid
+    context["type"] = type_
+    context["pk"] = pk
+    context["el"] = el
+
+    return render_pdf(request, "lesrooster/timetable_print.html", context)
parent 604247f6
No related branches found
No related tags found
1 merge request!36Draft: Resolve "[New data model] PDF export for "regular" timetable"
Showing
with 558 additions and 4 deletions
from typing import Optional from datetime import time
from typing import Iterable, Optional, Union
from django.db.models import QuerySet from django.db.models import Max, Min, Q, QuerySet
from django.db.models.functions import Coalesce
from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations, DateRangeQuerySetMixin from polymorphic.query import PolymorphicQuerySet
from aleksis.apps.chronos.managers import TimetableType
from aleksis.core.managers import (
AlekSISBaseManagerWithoutMigrations,
DateRangeQuerySetMixin,
PolymorphicBaseManager,
)
from aleksis.core.models import Group, Person, Room
class TeacherPropertiesMixin: class TeacherPropertiesMixin:
...@@ -53,3 +63,124 @@ class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin): ...@@ -53,3 +63,124 @@ class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin):
class ValidityRangeManager(AlekSISBaseManagerWithoutMigrations): class ValidityRangeManager(AlekSISBaseManagerWithoutMigrations):
"""Manager for validity ranges.""" """Manager for validity ranges."""
class SlotQuerySet(PolymorphicQuerySet):
def get_period_min(self) -> int:
"""Get minimum period."""
return self.aggregate(period__min=Coalesce(Min("period"), 1)).get("period__min")
def get_period_max(self) -> int:
"""Get maximum period."""
return self.aggregate(period__max=Coalesce(Max("period"), 7)).get("period__max")
def get_time_min(self) -> time | None:
"""Get minimum time."""
return self.aggregate(Min("time_start")).get("time_start__min")
def get_time_max(self) -> time | None:
"""Get maximum time."""
return self.aggregate(Max("time_end")).get("time_end__max")
def get_weekday_min(self) -> int:
"""Get minimum weekday."""
return self.aggregate(weekday__min=Coalesce(Min("weekday"), 0)).get("weekday__min")
def get_weekday_max(self) -> int:
"""Get maximum weekday."""
return self.aggregate(weekday__max=Coalesce(Max("weekday"), 6)).get("weekday__max")
class SlotManager(PolymorphicBaseManager):
pass
class LessonQuerySet(QuerySet):
def filter_participant(self, person: Union[Person, int]) -> "LessonQuerySet":
"""Filter for all lessons a participant (student) attends."""
return self.filter(course__groups__members=person)
def filter_group(self, group: Union[Group, int]) -> "LessonQuerySet":
"""Filter for all lessons a group (class) regularly attends."""
if isinstance(group, int):
group = Group.objects.get(pk=group)
return self.filter(
Q(course__groups=group) | Q(course__groups__parent_groups=group)
).distinct()
def filter_groups(self, groups: Iterable[Group]) -> "LessonQuerySet":
"""Filter for all lessons one of the groups regularly attends."""
return self.filter(
Q(course__groups__in=groups) | Q(course__groups__parent_groups__in=groups)
)
def filter_teacher(self, teacher: Union[Person, int]) -> "LessonQuerySet":
"""Filter for all lessons given by a certain teacher."""
return self.filter(teachers=teacher)
def filter_room(self, room: Union[Room, int]) -> "LessonQuerySet":
"""Filter for all lessons taking part in a certain room."""
return self.filter(rooms=room)
def filter_from_type(
self,
type_: TimetableType,
obj: Union[Person, Group, Room, int],
) -> Optional["LessonQuerySet"]:
"""Filter lessons for a group, teacher or room by provided type."""
if type_ == TimetableType.GROUP:
return self.filter_group(obj)
elif type_ == TimetableType.TEACHER:
return self.filter_teacher(obj)
elif type_ == TimetableType.ROOM:
return self.filter_room(obj)
else:
return None
def filter_from_person(self, person: Person) -> Optional["LessonQuerySet"]:
"""Filter lessons for a person."""
type_ = person.timetable_type
if type_ == TimetableType.TEACHER:
return self.filter_teacher(person)
elif type_ == TimetableType.GROUP:
return self.filter_participant(person)
else:
return None
class LessonManager(AlekSISBaseManagerWithoutMigrations):
pass
class SupervisionQuerySet(QuerySet):
def filter_teacher(self, teacher: Union[Person, int]) -> "SupervisionQuerySet":
"""Filter for all supervisions done by a certain teacher."""
return self.filter(teachers=teacher)
def filter_room(self, room: Union[Room, int]) -> "SupervisionQuerySet":
"""Filter for all supervisions taking part in a certain room."""
return self.filter(rooms=room)
def filter_from_type(
self,
type_: TimetableType,
obj: Union[Person, Group, Room, int],
) -> Optional["SupervisionQuerySet"]:
"""Filter supervisions for a eacher or room by provided type."""
if type_ == TimetableType.TEACHER:
return self.filter_teacher(obj)
elif type_ == TimetableType.ROOM:
return self.filter_room(obj)
else:
return None
def filter_from_person(self, person: Person) -> Optional["SupervisionQuerySet"]:
"""Filter supervisions for a person."""
return self.filter_teacher(person)
class SupervisionManager(AlekSISBaseManagerWithoutMigrations):
pass
import logging import logging
from collections.abc import Sequence from collections.abc import Sequence
from datetime import date, datetime, timedelta from datetime import date, datetime, time, timedelta
from typing import Optional, Union from typing import Optional, Union
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
...@@ -26,7 +26,13 @@ from aleksis.core.models import Group, Holiday, Person, Room, SchoolTerm ...@@ -26,7 +26,13 @@ from aleksis.core.models import Group, Holiday, Person, Room, SchoolTerm
from aleksis.core.util.celery_progress import ProgressRecorder, render_progress_page from aleksis.core.util.celery_progress import ProgressRecorder, render_progress_page
from .managers import ( from .managers import (
LessonManager,
LessonQuerySet,
RoomPropertiesMixin, RoomPropertiesMixin,
SlotManager,
SlotQuerySet,
SupervisionManager,
SupervisionQuerySet,
TeacherPropertiesMixin, TeacherPropertiesMixin,
ValidityRangeManager, ValidityRangeManager,
ValidityRangeQuerySet, ValidityRangeQuerySet,
...@@ -238,6 +244,37 @@ class TimeGrid(ExtensibleModel): ...@@ -238,6 +244,37 @@ class TimeGrid(ExtensibleModel):
null=True, null=True,
) )
@property
def times_dict(self) -> dict[int, tuple[datetime, datetime]]:
slots = {}
for slot in self.slots.all():
slots[slot.period] = (slot.time_start, slot.time_end)
return slots
@property
def period_min(self) -> int:
return self.slots.get_period_min()
@property
def period_max(self) -> int:
return self.slots.get_period_max()
@property
def time_min(self) -> time | None:
return self.slots.get_time_min()
@property
def time_max(self) -> time | None:
return self.slots.get_time_max()
@property
def weekday_min(self) -> int:
return self.slots.get_weekday_min()
@property
def weekday_max(self) -> int:
return self.slots.get_weekday_max()
def __str__(self): def __str__(self):
if self.group: if self.group:
return f"{self.validity_range}: {self.group}" return f"{self.validity_range}: {self.group}"
...@@ -259,6 +296,8 @@ class TimeGrid(ExtensibleModel): ...@@ -259,6 +296,8 @@ class TimeGrid(ExtensibleModel):
class Slot(ExtensiblePolymorphicModel): class Slot(ExtensiblePolymorphicModel):
"""A slot is a time period in which a lesson can take place.""" """A slot is a time period in which a lesson can take place."""
objects = SlotManager.from_queryset(SlotQuerySet)()
WEEKDAY_CHOICES = i18n_day_name_choices_lazy() WEEKDAY_CHOICES = i18n_day_name_choices_lazy()
WEEKDAY_CHOICES_SHORT = i18n_day_abbr_choices_lazy() WEEKDAY_CHOICES_SHORT = i18n_day_abbr_choices_lazy()
...@@ -501,6 +540,8 @@ class LessonBundle(ExtensibleModel): ...@@ -501,6 +540,8 @@ class LessonBundle(ExtensibleModel):
class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
"""A lesson represents a single teaching event.""" """A lesson represents a single teaching event."""
objects = LessonManager.from_queryset(LessonQuerySet)()
lesson_event = models.OneToOneField( lesson_event = models.OneToOneField(
LessonEvent, LessonEvent,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
...@@ -586,6 +627,8 @@ class BreakSlot(Slot): ...@@ -586,6 +627,8 @@ class BreakSlot(Slot):
class Supervision(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): class Supervision(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
"""A supervision is a time period in which a teacher supervises a room.""" """A supervision is a time period in which a teacher supervises a room."""
objects = SupervisionManager.from_queryset(SupervisionQuerySet)()
supervision_event = models.OneToOneField( supervision_event = models.OneToOneField(
SupervisionEvent, SupervisionEvent,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
......
.timetable-plan .row,
.timetable-plan .col {
display: flex;
padding: 0;
}
.timetable-plan .row {
margin-bottom: 0;
}
.lesson-card,
.timetable-title-card {
display: flex;
flex-grow: 1;
min-height: 40px;
box-shadow: none;
border: 1px solid black;
margin: -1px -1px 0 0;
border-radius: 0;
font-size: 11px;
}
.timetable-title-card .card-title {
margin-bottom: 0 !important;
}
.supervision-card {
min-height: 10px;
border-left: none;
border-right: none;
}
.lesson-card .card-content {
padding: 0;
text-align: center;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.lesson-card .card-content > div {
padding: 0;
flex: auto;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.timetable-title-card .card-content {
padding: 7px;
text-align: center;
width: 100%;
}
.lesson-card a {
color: inherit;
}
.card .card-title {
font-size: 18px;
}
<div class="card lesson-card">
<div class="card-content">
{% for element in elements %}
{% include "lesrooster/partials/lesson.html" with lesson=element %}
{% endfor %}
</div>
</div>
{{ item.short_name }}{% if not forloop.last %},{% endif %}
{% if groups.count == 1 and groups.0.parent_groups.all and request.site.preferences.lesrooster__use_parent_groups %}
{% include "lesrooster/partials/groups_part.html" with groups=groups.0.parent_groups.all no_collapsible=no_collapsible %}
{% else %}
{% include "lesrooster/partials/groups_part.html" with groups=groups no_collapsible=no_collapsible %}
{% endif %}
{% if groups.count > request.site.preferences.lesrooster__shorten_groups_limit and request.user.person.preferences.lesrooster__shorten_groups and not no_collapsible %}
{% include "components/text_collapsible.html" with template="lesrooster/partials/group.html" qs=groups %}
{% else %}
{% for group in groups %}
{% include "lesrooster/partials/group.html" with item=group %}
{% endfor %}
{% endif %}
{% load i18n %}
<div style="{% include "lesrooster/partials/subject_colour.html" with subject=lesson.subject %}">
<p>
{# Teacher or room > Display classes #}
{% if type.value == "teacher" or type.value == "room" %}
{% if lesson.course.groups %}
{% include "lesrooster/partials/groups.html" with groups=lesson.course.groups.all %}
{% endif %}
{% endif %}
{# Class or room > Display teacher #}
{% if type.value == "room" or type.value == "group" %}
{% include "lesrooster/partials/teachers.html" with teachers=lesson.teachers.all %}
{% endif %}
{# Display subject #}
{% include "lesrooster/partials/subject.html" with subject=lesson.subject %}
{# Teacher or class > Display room #}
{% if type.value == "teacher" or type.value == "group" %}
{% include "lesrooster/partials/rooms.html" with rooms=lesson.rooms.all %}
{% endif %}
</p>
</div>
{% for room in rooms %}
{{ room.short_name }}{% if not forloop.last %},{% endif %}
{% endfor %}
{% load data_helpers %}
<div class="card timetable-title-card">
<div class="card-content">
{# Lesson number #}
<div class="card-title left">
{{ slot.period }}.
</div>
{# Time dimension of lesson #}
<div class="right timetable-time grey-text text-darken-2">
<span>{{ slot.time_start|time }}</span>
<br/>
<span>{{ slot.time_end|time }}</span>
</div>
</div>
</div>
<strong>
{{ subject.short_name }}
</strong>
{% if subject.colour_fg %}
color: {{ subject.colour_fg }};
{% endif %}
{% if subject.colour_bg and subject.colour_bg != subject.colour_fg %}
background-color: {{ subject.colour_bg }};
{% endif %}
{% load i18n %}
<div class="card lesson-card supervision-card">
<div class="card-content">
{% if supervision %}
<div>
<p>
<strong>{% trans "Supervision" %}</strong>
{% include "lesrooster/partials/rooms.html" with rooms=supervision.rooms.all %}
{% include "lesrooster/partials/teachers.html" with teachers=supervision.teachers.all %}
</p>
</div>
{% endif %}
</div>
</div>
{% for teacher in teachers %}
{{ teacher.short_name }}{% if not forloop.last %},{% endif %}
{% endfor %}
{% extends 'core/base_print.html' %}
{% load data_helpers static i18n %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/lesrooster/timetable_print.css' %}">
{% endblock %}
{% block page_title %}
{% trans "Timetable" %} <i>{{ el.short_name }}</i>
{% endblock %}
{% block content %}
<div class="timetable-plan">
{# Week days #}
<div class="row">
<div class="col s2">
</div>
{% for weekday in weekdays %}
<div class="col s2">
<div class="card timetable-title-card">
<div class="card-content">
<span class="card-title">
{{ weekday.1 }}
</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% for row in timetable %}
<div class="row">
<div class="col s2">
{% if row.type == "period" %}
{% include "lesrooster/partials/slot_time.html" with slot=row.slot %}
{% endif %}
</div>
{% for col in row.cols %}
{# A lesson #}
<div class="col s2">
{% if col.type == "period" %}
{% include "lesrooster/partials/elements.html" with elements=col.col %}
{% else %}
{% include "lesrooster/partials/supervision.html" with supervision=col.col %}
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<small>{% trans "Validity range" %}: {{ time_grid.validity_range.date_start }}–{{ time_grid.validity_range.date_end }}</small>
{% endblock %}
from django.urls import path
from . import views
urlpatterns = [
path(
"timetable/<int:time_grid>/<str:type_>/<int:pk>/print/",
views.print_timetable,
name="timetable_print",
),
]
from collections import OrderedDict
from typing import Union
from aleksis.apps.chronos.managers import TimetableType
from aleksis.apps.lesrooster.models import BreakSlot, Lesson, Slot, Supervision, TimeGrid
from aleksis.core.models import Group, Person, Room
def build_timetable(
time_grid: TimeGrid,
type_: Union[TimetableType, str],
obj: Union[Group, Room, Person],
) -> list | None:
"""Build regular timetable for the given time grid."""
is_person = False
if type_ == "person":
is_person = True
type_ = obj.timetable_type
if type_ is None:
return None
slots = Slot.objects.filter(time_grid=time_grid).order_by("weekday", "time_start")
lesson_periods_per_slot = OrderedDict()
supervisions_per_slot = OrderedDict()
slot_map = OrderedDict()
for slot in slots:
lesson_periods_per_slot[slot] = []
supervisions_per_slot[slot] = []
slot_map.setdefault(slot.weekday, []).append(slot)
max_slots_weekday, max_slots = max(slot_map.items(), key=lambda x: len(x[1]))
max_slots = len(max_slots)
# Get matching lessons
lessons = Lesson.objects.filter(slot_start__time_grid=time_grid)
lessons = (
lessons.filter_from_person(obj) if is_person else lessons.filter_from_group(type_, obj)
)
# Sort lesson periods in a dict
for lesson in lessons:
print(
lesson.subject,
Slot.objects.filter(
time_grid=time_grid,
weekday=lesson.slot_start.weekday,
time_start__gte=lesson.slot_start.time_start,
time_end__lte=lesson.slot_end.time_end,
).not_instance_of(BreakSlot),
)
for slot in Slot.objects.filter(
time_grid=time_grid,
weekday=lesson.slot_start.weekday,
time_start__gte=lesson.slot_start.time_start,
time_end__lte=lesson.slot_end.time_end,
).not_instance_of(BreakSlot):
lesson_periods_per_slot[slot].append(lesson)
# Get matching supervisions
needed_break_slots = []
supervisions = Supervision.objects.filter(break_slot__time_grid=time_grid).all()
if is_person:
supervisions = supervisions.filter_from_person(obj)
else:
supervisions = supervisions.filter_from_type(type_, obj)
if supervisions:
for supervision in supervisions:
if supervision.break_slot not in needed_break_slots:
needed_break_slots.append(supervision.break_slot)
supervisions_per_slot[supervision.break_slot] = supervision
rows = []
for slot_idx in range(max_slots): # period is period after break
left_slot = slot_map[max_slots_weekday][slot_idx]
if isinstance(left_slot, BreakSlot):
row = {"type": "break", "slot": left_slot}
else:
row = {
"type": "period",
"slot": left_slot,
}
cols = []
for weekday in range(time_grid.weekday_min, time_grid.weekday_max + 1):
if slot_idx > len(slot_map[weekday]) - 1:
continue
actual_slot = slot_map[weekday][slot_idx]
if isinstance(actual_slot, BreakSlot):
col = {"type": "break", "col": supervisions_per_slot.get(actual_slot)}
else:
print(lesson_periods_per_slot[actual_slot])
col = {
"type": "period",
"col": (
lesson_periods_per_slot[actual_slot]
if actual_slot in lesson_periods_per_slot
else []
),
}
cols.append(col)
row["cols"] = cols
rows.append(row)
return rows
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
from django.shortcuts import get_object_or_404
from rules.contrib.views import permission_required
from aleksis.apps.chronos.managers import TimetableType
from aleksis.apps.chronos.util.chronos_helpers import get_el_by_pk
from aleksis.apps.lesrooster.models import Slot, TimeGrid
from aleksis.apps.lesrooster.util.build import build_timetable
from aleksis.core.util.pdf import render_pdf
@permission_required("chronos.view_timetable_rule", fn=get_el_by_pk)
def print_timetable(
request: HttpRequest,
time_grid: int,
type_: str,
pk: int,
) -> HttpResponse:
"""View a selected timetable for a person, group or room."""
context = {}
time_grid = get_object_or_404(TimeGrid, pk=time_grid)
el = get_el_by_pk(request, type_, pk, prefetch=True)
if isinstance(el, HttpResponseNotFound):
return HttpResponseNotFound()
type_ = TimetableType.from_string(type_)
timetable = build_timetable(time_grid, type_, el)
context["timetable"] = timetable
context["weekdays"] = Slot.WEEKDAY_CHOICES[time_grid.weekday_min : time_grid.weekday_max + 1]
context["time_grid"] = time_grid
context["type"] = type_
context["pk"] = pk
context["el"] = el
return render_pdf(request, "lesrooster/timetable_print.html", context)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment