Skip to content
Snippets Groups Projects
  • Jonathan Weth's avatar
    502836d4
    Add print view for regular timetables · 502836d4
    Jonathan Weth authored
    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)
    Verified
    502836d4
    History
    Add print view for regular timetables
    Jonathan Weth authored
    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)