Skip to content
Snippets Groups Projects
  1. Dec 29, 2024
    • Jonathan Weth's avatar
      Verified
      de33dc2d
    • Jonathan Weth's avatar
      Fix PDF export · fc1a3826
      Jonathan Weth authored
      Verified
      fc1a3826
    • Jonathan Weth's avatar
      Remove debug prints · 668c111b
      Jonathan Weth authored
      Verified
      668c111b
    • Jonathan Weth's avatar
      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
Loading