Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • AlekSIS/onboarding/AlekSIS-App-Lesrooster
  • elumKtrolte/AlekSIS-App-Lesrooster
  • 3caifricarha/AlekSIS-App-Lesrooster
  • 0randpenFtiabo/AlekSIS-App-Lesrooster
  • disclaMcremze/AlekSIS-App-Lesrooster
  • mogamuboun/AlekSIS-App-Lesrooster
  • 1lidisPtheoto/AlekSIS-App-Lesrooster
  • 8exitQconsko/AlekSIS-App-Lesrooster
  • 9scelasOyghe/AlekSIS-App-Lesrooster
  • misraMcaryo/AlekSIS-App-Lesrooster
  • 3mirummocgu/AlekSIS-App-Lesrooster
11 results
Show changes
Commits on Source (11)
  • Jonathan Weth's avatar
    Improve and restructure mini timetables, include mini timetable for teachers · b7101882
    Jonathan Weth authored
    diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue
    index 931306c..5370144 100644
    --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue
    +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue
    @@ -22,8 +22,8 @@ import SubjectField from "aleksis.apps.cursus/components/SubjectField.vue";
     import LessonCard from "./LessonCard.vue";
    
     import { RRule } from "rrule";
    -import TeacherTimeTable from "./timetables/TeacherTimeTable.vue";
    -import RoomTimeTable from "./timetables/RoomTimeTable.vue";
    +import TeacherTimeTable from "../timetables/TeacherTimeTable.vue";
    +import RoomTimeTable from "../timetables/RoomTimeTable.vue";
     import LessonRatioChip from "./LessonRatioChip.vue";
     import TimeGridField from "../validity_range/TimeGridField.vue";
     import BlockingCard from "./BlockingCard.vue";
    @@ -1138,13 +1138,13 @@ export default defineComponent({
                 >
                   <teacher-time-table
                     v-if="internalTimeGrid && selectedObjectType === 'teacher'"
    -                :teacher-id="selectedObject"
    +                :id="selectedObject"
                     :time-grid="timeGrid"
                     class="fill-height"
                   />
                   <room-time-table
                     v-if="internalTimeGrid && selectedObjectType === 'room'"
    -                :room-id="selectedObject"
    +                :id="selectedObject"
                     :time-grid="timeGrid"
                     class="fill-height"
                   />
    diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/MiniTimeTable.vue
    similarity index 91%
    rename from aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue
    rename to aleksis/apps/lesrooster/frontend/components/timetables/MiniTimeTable.vue
    index 988a9b8..e69b9b6 100644
    --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue
    +++ b/aleksis/apps/lesrooster/frontend/components/timetables/MiniTimeTable.vue
    @@ -1,7 +1,7 @@
     <script>
     import { defineComponent } from "vue";
    -import { slots } from "../../breaks_and_slots/slot.graphql";
    -import LessonCard from "../LessonCard.vue";
    +import { slots } from "../breaks_and_slots/slot.graphql";
    +import LessonCard from "../timetable_management/LessonCard.vue";
     import MessageBox from "aleksis.core/components/generic/MessageBox.vue";
    
     export default defineComponent({
    @@ -93,6 +93,9 @@ export default defineComponent({
    
           return weekdayPeriodSlots;
         },
    +    loading() {
    +      return false;
    +    },
       },
       methods: {
         styleForWeekdayAndPeriod(weekday, period) {
    @@ -105,7 +108,14 @@ export default defineComponent({
     </script>
    
     <template>
    -  <div class="timetable">
    +  <div v-if="loading" class="d-flex justify-center pa-10">
    +    <v-progress-circular
    +      indeterminate
    +      color="primary"
    +      :size="50"
    +    ></v-progress-circular>
    +  </div>
    +  <div v-else class="timetable">
         <!-- Empty div to fill top-left corner -->
         <div></div>
         <v-card
    diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/RoomTimeTable.vue
    similarity index 84%
    rename from aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue
    rename to aleksis/apps/lesrooster/frontend/components/timetables/RoomTimeTable.vue
    index a50154b..a667de1 100644
    --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue
    +++ b/aleksis/apps/lesrooster/frontend/components/timetables/RoomTimeTable.vue
    @@ -7,7 +7,7 @@ export default defineComponent({
       name: "RoomTimeTable",
       extends: MiniTimeTable,
       props: {
    -    roomId: {
    +    id: {
           type: String,
           required: true,
         },
    @@ -16,6 +16,9 @@ export default defineComponent({
         lessons() {
           return this.lessonsRoom;
         },
    +    loading() {
    +      return this.$apollo.queries.lessonsRoom.loading;
    +    },
       },
       apollo: {
         lessonsRoom: {
    @@ -23,7 +26,7 @@ export default defineComponent({
           variables() {
             return {
               timeGrid: this.timeGrid.id,
    -          room: this.roomId,
    +          room: this.id,
             };
           },
           skip() {
    diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/TeacherTimeTable.vue
    similarity index 84%
    rename from aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue
    rename to aleksis/apps/lesrooster/frontend/components/timetables/TeacherTimeTable.vue
    index 5ed3211..f8d89a4 100644
    --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue
    +++ b/aleksis/apps/lesrooster/frontend/components/timetables/TeacherTimeTable.vue
    @@ -7,7 +7,7 @@ export default defineComponent({
       name: "TeacherTimeTable",
       extends: MiniTimeTable,
       props: {
    -    teacherId: {
    +    id: {
           type: String,
           required: true,
         },
    @@ -16,6 +16,9 @@ export default defineComponent({
         lessons() {
           return this.lessonsTeacher;
         },
    +    loading() {
    +      return this.$apollo.queries.lessonsTeacher.loading;
    +    },
       },
       apollo: {
         lessonsTeacher: {
    @@ -23,7 +26,7 @@ export default defineComponent({
           variables() {
             return {
               timeGrid: this.timeGrid.id,
    -          teacher: this.teacherId,
    +          teacher: this.id,
             };
           },
           skip() {
    diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql b/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql
    similarity index 66%
    rename from aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql
    rename to aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql
    index 3bd1897..e72e857 100644
    --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql
    +++ b/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql
    @@ -110,3 +110,58 @@ query lessonsRoom($room: ID!, $timeGrid: ID!) {
         canDelete
       }
     }
    +
    +query lessonsGroup($group: ID!, $timeGrid: ID!) {
    +  lessonsGroup: lessonObjectsForGroup(group: $group, timeGrid: $timeGrid) {
    +    id
    +    slotStart {
    +      id
    +      period
    +      weekday
    +    }
    +    slotEnd {
    +      id
    +      period
    +      weekday
    +    }
    +    subject {
    +      id
    +      name
    +      colourFg
    +      colourBg
    +    }
    +    teachers {
    +      id
    +      fullName
    +      shortName
    +    }
    +    rooms {
    +      id
    +      name
    +      shortName
    +    }
    +    course {
    +      id
    +      name
    +      subject {
    +        id
    +        name
    +        colourFg
    +        colourBg
    +      }
    +      teachers {
    +        id
    +        fullName
    +        shortName
    +      }
    +      groups {
    +        id
    +        name
    +        shortName
    +      }
    +    }
    +    recurrence
    +    canEdit
    +    canDelete
    +  }
    +}
    b7101882
  • Jonathan Weth's avatar
  • Jonathan Weth's avatar
    Introduce view for regular timetables · 24244976
    Jonathan Weth authored
    24244976
  • Jonathan Weth's avatar
    604247f6
  • 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)
    502836d4
  • Jonathan Weth's avatar
    Add print button to timetable view · a0e2fe1e
    Jonathan Weth authored
    a0e2fe1e
  • Jonathan Weth's avatar
    Remove debug prints · 668c111b
    Jonathan Weth authored
    668c111b
  • Jonathan Weth's avatar
    Fix PDF export · fc1a3826
    Jonathan Weth authored
    fc1a3826
  • Jonathan Weth's avatar
    Fix and update translation (keys) · c9c874ce
    Jonathan Weth authored
    c9c874ce
  • Jonathan Weth's avatar
    de33dc2d
  • Jonathan Weth's avatar
    Update changelog · 4dc81d21
    Jonathan Weth authored
    4dc81d21
Showing
with 606 additions and 59 deletions
......@@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file.
The format is based on `Keep a Changelog`_,
and this project adheres to `Semantic Versioning`_.
Unreleased
----------
Added
~~~~~
* Read-only view for showing and printing the planned timetables.
`0.1.0.dev6`_ - 2024-12-24
--------------------------
......
......@@ -61,7 +61,7 @@
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ $t("actions.copy_last_configuration") }}
{{ $t("lesrooster.actions.copy_last_configuration") }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
......@@ -114,7 +114,7 @@
<v-icon>mdi-application-export</v-icon>
</v-btn>
</template>
<span v-t="'actions.copy_to_day'"></span>
<span v-t="'lesrooster.actions.copy_to_day'"></span>
</v-tooltip>
</template>
<v-list>
......
......@@ -85,7 +85,7 @@ export default defineComponent({
<v-icon>mdi-application-export</v-icon>
</v-btn>
</template>
<span v-t="'actions.copy_to_day'"></span>
<span v-t="'lesrooster.actions.copy_to_day'"></span>
</v-tooltip>
</template>
<v-list>
......
......@@ -139,7 +139,7 @@ import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue";
<!-- filled-->
<!-- v-bind="attrs('break_slot__time_grid__exact')"-->
<!-- v-on="on('break_slot__time_grid__exact')"-->
<!-- :label="$t('labels.select_validity_range')"-->
<!-- :label="$t('lesrooster.labels.select_validity_range')"-->
<!-- hide-details-->
<!-- />-->
<!--</template>-->
......
......@@ -63,7 +63,7 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
class="d-flex justify-space-between flex-wrap align-center"
>
<secondary-action-button
i18n-key="actions.copy_last_configuration"
i18n-key="lesrooster.actions.copy_last_configuration"
block
class="mr-4"
/>
......
......@@ -22,8 +22,8 @@ import SubjectField from "aleksis.apps.cursus/components/SubjectField.vue";
import BundleCard from "./BundleCard.vue";
import { RRule } from "rrule";
import TeacherTimeTable from "./timetables/TeacherTimeTable.vue";
import RoomTimeTable from "./timetables/RoomTimeTable.vue";
import TeacherTimeTable from "../timetables/TeacherTimeTable.vue";
import RoomTimeTable from "../timetables/RoomTimeTable.vue";
import LessonRatioChip from "./LessonRatioChip.vue";
import TimeGridField from "../validity_range/TimeGridField.vue";
import BlockingCard from "./BlockingCard.vue";
......@@ -950,7 +950,7 @@ export default defineComponent({
class="d-flex justify-space-between flex-wrap align-center"
>
<secondary-action-button
i18n-key="actions.copy_last_configuration"
i18n-key="lesrooster.actions.copy_last_configuration"
block
disabled
/>
......@@ -1112,7 +1112,7 @@ export default defineComponent({
rounded
v-model="courseSearch"
clearable
:label="$t('actions.search_courses')"
:label="$t('lesrooster.actions.search_courses')"
:hint="totalLessonRatio"
persistent-hint
/>
......@@ -1200,13 +1200,13 @@ export default defineComponent({
>
<teacher-time-table
v-if="internalTimeGrid && selectedObjectType === 'teacher'"
:teacher-id="selectedObject"
:id="selectedObject"
:time-grid="timeGrid"
class="fill-height"
/>
<room-time-table
v-if="internalTimeGrid && selectedObjectType === 'room'"
:room-id="selectedObject"
:id="selectedObject"
:time-grid="timeGrid"
class="fill-height"
/>
......
<script>
import { defineComponent } from "vue";
import { lessonsGroup } from "./timetables.graphql";
import MiniTimeTable from "./MiniTimeTable.vue";
export default defineComponent({
name: "GroupTimeTable",
extends: MiniTimeTable,
props: {
id: {
type: String,
required: true,
},
},
computed: {
lessons() {
return this.lessonsGroup;
},
loading() {
return this.$apollo.queries.lessonsGroup.loading;
},
},
apollo: {
lessonsGroup: {
query: lessonsGroup,
variables() {
return {
timeGrid: this.timeGrid.id,
group: this.id,
};
},
skip() {
return this.timeGrid === null;
},
},
},
});
</script>
<script>
import { defineComponent } from "vue";
import { slots } from "../../breaks_and_slots/slot.graphql";
import LessonCard from "../LessonCard.vue";
import { slots } from "../breaks_and_slots/slot.graphql";
import LessonCard from "../timetable_management/LessonCard.vue";
import MessageBox from "aleksis.core/components/generic/MessageBox.vue";
export default defineComponent({
......@@ -93,6 +93,9 @@ export default defineComponent({
return weekdayPeriodSlots;
},
loading() {
return false;
},
},
methods: {
styleForWeekdayAndPeriod(weekday, period) {
......@@ -105,7 +108,14 @@ export default defineComponent({
</script>
<template>
<div class="timetable">
<div v-if="loading" class="d-flex justify-center pa-10">
<v-progress-circular
indeterminate
color="primary"
:size="50"
></v-progress-circular>
</div>
<div v-else class="timetable">
<!-- Empty div to fill top-left corner -->
<div></div>
<v-card
......
......@@ -7,7 +7,7 @@ export default defineComponent({
name: "RoomTimeTable",
extends: MiniTimeTable,
props: {
roomId: {
id: {
type: String,
required: true,
},
......@@ -16,6 +16,9 @@ export default defineComponent({
lessons() {
return this.lessonsRoom;
},
loading() {
return this.$apollo.queries.lessonsRoom.loading;
},
},
apollo: {
lessonsRoom: {
......@@ -23,7 +26,7 @@ export default defineComponent({
variables() {
return {
timeGrid: this.timeGrid.id,
room: this.roomId,
room: this.id,
};
},
skip() {
......
......@@ -7,7 +7,7 @@ export default defineComponent({
name: "TeacherTimeTable",
extends: MiniTimeTable,
props: {
teacherId: {
id: {
type: String,
required: true,
},
......@@ -16,6 +16,9 @@ export default defineComponent({
lessons() {
return this.lessonsTeacher;
},
loading() {
return this.$apollo.queries.lessonsTeacher.loading;
},
},
apollo: {
lessonsTeacher: {
......@@ -23,7 +26,7 @@ export default defineComponent({
variables() {
return {
timeGrid: this.timeGrid.id,
teacher: this.teacherId,
teacher: this.id,
};
},
skip() {
......
<script setup>
import TimetableWrapper from "aleksis.apps.chronos/components/TimetableWrapper.vue";
import TimeGridField from "../validity_range/TimeGridField.vue";
import RoomTimeTable from "./RoomTimeTable.vue";
import GroupTimeTable from "./GroupTimeTable.vue";
import TeacherTimeTable from "./TeacherTimeTable.vue";
</script>
<script>
export default {
name: "Timetable",
data() {
return {
timeGrid: null,
selected: null,
};
},
watch: {
timeGrid(newTimeGrid) {
this.onSelected(this.selected);
},
},
computed: {
timetableAttrs() {
return {
id: this.$route.params.id,
timeGrid: this.timeGrid,
};
},
},
methods: {
onSelected(selected) {
this.selected = selected;
if (!selected && this.timeGrid) {
this.$router.push({
name: "lesrooster.timetableWithTimeGrid",
params: { timeGrid: this.timeGrid.id },
});
} else if (!selected && !this.timeGrid) {
this.$router.push({ name: "lesrooster.timetable" });
} else if (
selected.objId !== this.$route.params.id ||
selected.type.toLowerCase() !== this.$route.params.type ||
this.timeGrid.id !== this.$route.params.timeGrid
) {
this.$router.push({
name: "lesrooster.timetableWithId",
params: {
timeGrid: this.timeGrid.id,
type: selected.type.toLowerCase(),
id: selected.objId,
},
});
}
},
setInitialTimeGrid(timeGrids) {
if (!this.timeGrid) {
this.timeGrid = timeGrids.find(
this.$route.params.timeGrid
? (timeGrid) => timeGrid.id === this.$route.params.timeGrid
: (timeGrid) => timeGrid.validityRange.isCurrent && !timeGrid.group,
);
}
},
},
};
</script>
<template>
<timetable-wrapper :on-selected="onSelected">
<template #additionalSelect="{ selected, mobile }">
<v-card
:class="{ 'mb-2': !mobile, 'mx-2 mt-2': mobile }"
:outlined="mobile"
>
<v-card-text>
<time-grid-field
outlined
filled
:label="$t('lesrooster.labels.select_validity_range')"
hide-details
with-dates
:enable-create="false"
v-model="timeGrid"
@items="setInitialTimeGrid"
>
</time-grid-field>
</v-card-text>
</v-card>
</template>
<template #additionalButton="{ selected, mobile }">
<div :class="{ 'full-width': mobile, 'd-flex': true }" v-if="selected">
<v-btn
outlined
color="secondary"
small
:class="{ 'mx-3 flex-grow-1': true, 'mb-3': mobile }"
:to="{
name: 'lesrooster.timetablePrint',
params: {
timeGrid: timeGrid.id,
type: selected.type.toLowerCase(),
id: selected.objId,
},
}"
target="_blank"
>
<v-icon left>mdi-printer-outline</v-icon>
{{ $t("lesrooster.timetable.print") }}
</v-btn>
</div>
</template>
<template #default="{ selected }">
<group-time-table
v-if="$route.params.type === 'group'"
v-bind="timetableAttrs"
/>
<teacher-time-table
v-else-if="$route.params.type === 'teacher'"
v-bind="timetableAttrs"
/>
<room-time-table
v-else-if="$route.params.type === 'room'"
v-bind="timetableAttrs"
/>
</template>
</timetable-wrapper>
</template>
......@@ -111,3 +111,60 @@ query lessonsRoom($room: ID!, $timeGrid: ID!) {
canDelete
}
}
query lessonsGroup($group: ID!, $timeGrid: ID!) {
lessonsGroup: lessonsForGroup(group: $group, timeGrid: $timeGrid) {
id
bundle {
slotStart {
id
period
weekday
}
slotEnd {
id
period
weekday
}
recurrence
}
subject {
id
name
colourFg
colourBg
}
teachers {
id
fullName
shortName
}
rooms {
id
name
shortName
}
course {
id
name
subject {
id
name
colourFg
colourBg
}
teachers {
id
fullName
shortName
}
groups {
id
name
shortName
}
}
canEdit
canDelete
}
}
......@@ -82,7 +82,7 @@ export default defineComponent({
<template #activator="{ attrs, on }">
<slot name="activator" :attrs="attrs" :on="on">
<primary-action-button
i18n-key="actions.copy_last_configuration"
i18n-key="lesrooster.actions.copy_last_configuration"
icon="mdi-content-copy"
/>
</slot>
......@@ -100,10 +100,10 @@ export default defineComponent({
<confirm-dialog v-model="dialog" @confirm="confirm" @cancel="cancel">
<template #title>
{{ $t("actions.confirm_copy_last_configuration") }}
{{ $t("lesrooster.actions.confirm_copy_last_configuration") }}
</template>
<template #text>
{{ $t("actions.confirm_copy_last_configuration_message") }}
{{ $t("lesrooster.actions.confirm_copy_last_configuration_message") }}
</template>
</confirm-dialog>
</div>
......
......@@ -47,6 +47,13 @@ export default defineComponent({
required: [(value) => !!value || this.$t("forms.errors.required")],
};
},
props: {
withDates: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
getCreateData(item) {
return {
......@@ -80,16 +87,21 @@ export default defineComponent({
);
},
formatItem(item) {
if (item.group === null) {
return this.$t(
"lesrooster.validity_range.time_grid.repr.generic",
item.validityRange,
);
const data = {
name: item.validityRange.name,
group: item.group ? item.group.name : "",
start: this.$d(this.$parseISODate(item.validityRange.dateStart)),
end: this.$d(this.$parseISODate(item.validityRange.dateEnd)),
};
let key = "generic";
if (item.group !== null) {
key = "group";
}
if (this.withDates) {
key = "dates_" + key;
}
return this.$t("lesrooster.validity_range.time_grid.repr.default", [
item.validityRange.name,
item.group.name,
]);
return this.$t(`lesrooster.validity_range.time_grid.repr.${key}`, data);
},
},
});
......
......@@ -10,6 +10,47 @@ export default {
permission: "lesrooster.view_lesrooster_menu_rule",
},
children: [
{
path: "timetable/",
component: () => import("./components/timetables/Timetable.vue"),
name: "lesrooster.timetable",
meta: {
inMenu: true,
titleKey: "lesrooster.timetable.menu_title",
toolbarTitle: "lesrooster.timetable.menu_title",
icon: "mdi-grid",
permission: "chronos.view_timetable_overview_rule",
fullWidth: true,
},
children: [
{
path: ":timeGrid(\\d+)/",
component: () => import("./components/timetables/Timetable.vue"),
name: "lesrooster.timetableWithTimeGrid",
meta: {
permission: "chronos.view_timetable_overview_rule",
fullWidth: true,
},
},
{
path: ":timeGrid(\\d+)/:type(\\w+)/:id(\\d+)/",
component: () => import("./components/timetables/Timetable.vue"),
name: "lesrooster.timetableWithId",
meta: {
permission: "chronos.view_timetable_overview_rule",
fullWidth: true,
},
},
],
},
{
path: "timetable/:timeGrid(\\d+)/:type(\\w+)/:id(\\d+)/print/",
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
name: "lesrooster.timetablePrint",
props: {
byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
},
},
{
path: "validity_ranges/",
component: () => import("./components/validity_range/ValidityRange.vue"),
......@@ -49,7 +90,7 @@ export default {
},
},
{
path: "timetable/",
path: "timetable_management/",
component: () =>
import("./components/timetable_management/TimetableManagement.vue"),
name: "lesrooster.timetable_management_select",
......
{
"actions": {
"confirm_copy_last_configuration": "Soll wirklich eine andere Konfiguration in diesen Zeitraum übernommen werden?",
"confirm_copy_last_configuration_message": "Diese Aktion wird alle bestehenden Daten in diesem Zeitraum löschen. Diese Aktion kann nicht rückgängig gemacht werden.",
"copy_last_configuration": "Aus anderem Zeitraum übernehmen",
"copy_to_day": "Zu anderem Tag übernehmen",
"search_courses": "Kurse durchsuchen"
},
"labels": {
"select_validity_range": "Gültigkeitszeitraum auswählen"
},
"lesrooster": {
"actions": {
"confirm_copy_last_configuration": "Soll wirklich eine andere Konfiguration in diesen Zeitraum übernommen werden?",
"confirm_copy_last_configuration_message": "Diese Aktion wird alle bestehenden Daten in diesem Zeitraum löschen. Diese Aktion kann nicht rückgängig gemacht werden.",
"copy_last_configuration": "Aus anderem Zeitraum übernehmen",
"copy_to_day": "Zu anderem Tag übernehmen",
"search_courses": "Kurse durchsuchen"
},
"labels": {
"select_validity_range": "Gültigkeitszeitraum auswählen"
},
"break": {
"create_item": "Pause erstellen",
"create_items": "Pausen erstellen",
......@@ -26,7 +26,7 @@
"lesson_raster": {
"menu_title": "Stundenraster"
},
"menu_title": "Unterrichtsmanagement",
"menu_title": "Stundenplanung",
"slot": {
"confirm_delete_multiple_slots": "Wollen Sie wirklich alle Zeitfenster am {day} löschen?",
"create_items": "Zeitfenster erstellen",
......@@ -46,6 +46,10 @@
"weekday": "Wochentag",
"weekdays": "Wochentage"
},
"timetable": {
"menu_title": "Reguläre Stundenpläne",
"print": "Drucken"
},
"supervision": {
"break_slot": "Pause",
"create_supervision": "Aufsicht erstellen",
......
{
"lesrooster": {
"menu_title": "Lesson Management",
"menu_title": "Lesson Planning",
"validity_range": {
"menu_title": "Validity Ranges",
"title": "Validity Range",
......@@ -36,8 +36,10 @@
},
"confirm_delete_body": "If you remove this group from the validity range, all connected data, like slots and lessons are deleted.",
"repr": {
"default": "{0} ({1})",
"generic": "{name} (generic/catch-all)"
"default": "{name} ({group})",
"generic": "{name} (generic/catch-all)",
"dates_default": "{start}–{end} ({group})",
"dates_generic": "{start}–{end}"
}
}
},
......@@ -125,6 +127,10 @@
}
}
},
"timetable": {
"menu_title": "Regular Timetables",
"print": "Print"
},
"supervision": {
"menu_title": "Supervisions",
"title": "Supervision",
......@@ -134,16 +140,16 @@
"rooms": "Rooms",
"teachers": "Teachers",
"subject": "Subject"
},
"actions": {
"copy_to_day": "Copy to another day",
"search_courses": "Search Courses",
"copy_last_configuration": "Copy from different range",
"confirm_copy_last_configuration": "Do you really want to copy another configuration to this range?",
"confirm_copy_last_configuration_message": "This will overwrite all existing data in this range. This action cannot be undone."
},
"labels": {
"select_validity_range": "Select Validity Range"
}
},
"actions": {
"copy_to_day": "Copy to another day",
"search_courses": "Search Courses",
"copy_last_configuration": "Copy from different range",
"confirm_copy_last_configuration": "Do you really want to copy another configuration to this range?",
"confirm_copy_last_configuration_message": "This will overwrite all existing data in this range. This action cannot be undone."
},
"labels": {
"select_validity_range": "Select Validity Range"
}
}
from typing import Optional
from collections.abc import Iterable
from datetime import time
from typing import 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:
......@@ -53,3 +64,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],
) -> "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 self.none()
def filter_from_person(self, person: Person) -> "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 self.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],
) -> "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 self.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
from collections.abc import Sequence
from datetime import date, datetime, timedelta
from datetime import date, datetime, time, timedelta
from typing import Optional, Union
from django.core.exceptions import ValidationError
......@@ -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 .managers import (
LessonManager,
LessonQuerySet,
RoomPropertiesMixin,
SlotManager,
SlotQuerySet,
SupervisionManager,
SupervisionQuerySet,
TeacherPropertiesMixin,
ValidityRangeManager,
ValidityRangeQuerySet,
......@@ -238,6 +244,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}"
......@@ -259,6 +296,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()
......@@ -501,6 +540,8 @@ class LessonBundle(ExtensibleModel):
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,
......@@ -586,6 +627,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,
......
.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;
}