diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aff862f7fc5171982107cbe1755bbe0bc3f70ffb..6406f3808fb9d9c1414718080f35d27bd06f2ddd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 -------------------------- diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue index bc0205fe045c8ba045a7b13be511c7975158588d..0aa8dfa2b34b9071d08654f45639c88b288b0e87 100644 --- a/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue +++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue @@ -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> diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue index f274dcee31400ae30b58e7c20966fbd422f885dd..e44147e8055fc7879229f70aa127f4f6149d7af3 100644 --- a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue +++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue @@ -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> diff --git a/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue b/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue index 2a25319dab6e75a3540aa26b9b1e94517871c2fd..6ff5b3090fd92eefa625170cd5e440ac25014462 100644 --- a/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue +++ b/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue @@ -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>--> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue index 12b149fb9a710d30361a16b0cf27b742666f0944..954b2076706a0373b1a10f409ae6e2f849c4f6a0 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 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" /> diff --git a/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..473c47427ac2805baa1fb115c50214b998335861 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue @@ -0,0 +1,38 @@ +<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> 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 f32e518e2d6a4c714640ab3e3eec95410a0fb000..a4d71574a7222e2b791648ae2758ebeedb488a41 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 a50154bbbf49f681223283176652128fa08f4c8e..a667de14404263982534c728e3777ca192edea59 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 5ed3211176f9bb0ad66c654a5fe1c36747ff13b0..f8d89a4b564ac00126e72db72e29a36637b5423c 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/timetables/Timetable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue new file mode 100644 index 0000000000000000000000000000000000000000..3a7c8a8253039cde74e64f50b2d4d90af7273eed --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue @@ -0,0 +1,127 @@ +<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> 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 9bbcc2eb5a3aeaea2c5210acc1b3a59f85bad9ba..fa3c73e1e03ee94f6382d36ba000bce00059cd2f 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql +++ b/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql @@ -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 + } +} diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue index 1004f59bf8ae61e3ce52d6d2d7e9e52213f1b81a..6e9d71abd6933bbdb1dea7d9bbe4559f449f135c 100644 --- a/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue @@ -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> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue index 73134aa416360dfdec9e2c07a906ed945cb0400b..87e3ee9339a5a0a636120cfd50fe98725f9f5d23 100644 --- a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue @@ -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); }, }, }); diff --git a/aleksis/apps/lesrooster/frontend/index.js b/aleksis/apps/lesrooster/frontend/index.js index e9aa58e8041552f6add54b9bda69dc5854201f9a..df8ebf22621c5fc98ef5c4517c8e5f263931f04d 100644 --- a/aleksis/apps/lesrooster/frontend/index.js +++ b/aleksis/apps/lesrooster/frontend/index.js @@ -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", diff --git a/aleksis/apps/lesrooster/frontend/messages/de.json b/aleksis/apps/lesrooster/frontend/messages/de.json index da44980d0e3f51e4ceccff6fb30c04ee4cb04835..f64ff4b7ec7fac98867dbad94a81adad08525e7f 100644 --- a/aleksis/apps/lesrooster/frontend/messages/de.json +++ b/aleksis/apps/lesrooster/frontend/messages/de.json @@ -1,15 +1,15 @@ { - "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", diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json index 72325fa572621c2e33dc878d6b4d10cd7025a154..1a619771a72d227630e5e50decf7e770daed28c6 100644 --- a/aleksis/apps/lesrooster/frontend/messages/en.json +++ b/aleksis/apps/lesrooster/frontend/messages/en.json @@ -1,6 +1,6 @@ { "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}" } } }, @@ -128,6 +130,10 @@ } } }, + "timetable": { + "menu_title": "Regular Timetables", + "print": "Print" + }, "supervision": { "menu_title": "Supervisions", "title": "Supervision", @@ -137,16 +143,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" } } diff --git a/aleksis/apps/lesrooster/managers.py b/aleksis/apps/lesrooster/managers.py index a5ad9d114a621ed6cfcd39b4020a90a0d33dc15b..a4971e95e6b6d1b6caf735e681093130bf1d2c8e 100644 --- a/aleksis/apps/lesrooster/managers.py +++ b/aleksis/apps/lesrooster/managers.py @@ -1,8 +1,19 @@ -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 diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py index 012b9ec38afe13421a651c7ee1fe337fe0e27ec4..7b222a23af2d6bb3cfe3ca3e2a96c9b10d676163 100644 --- a/aleksis/apps/lesrooster/models.py +++ b/aleksis/apps/lesrooster/models.py @@ -1,6 +1,6 @@ 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() @@ -504,6 +543,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, @@ -587,6 +628,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/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py index f67ad826acaa4fab36c41b7b1be0cb8de700f4c1..b36ddcd0ed798319d4bc71a56203b6bede0bece7 100644 --- a/aleksis/apps/lesrooster/schema/__init__.py +++ b/aleksis/apps/lesrooster/schema/__init__.py @@ -184,7 +184,7 @@ class Query(graphene.ObjectType): def resolve_time_grids(root, info): qs = filter_active_school_term( info.context, TimeGrid.objects.all(), "validity_range__school_term" - ) + ).order_by("-validity_range__date_start") if not info.context.user.has_perm("lesrooster.view_timegrid_rule"): return graphene_django_optimizer.query( get_objects_for_user(info.context.user, "lesrooster.view_timegrid", qs), info 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 0000000000000000000000000000000000000000..9bed3576bd2494db75fa93c8f192622612a2a80a --- /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 0000000000000000000000000000000000000000..00646c2017926e6f0a253d9b248c8217ccb1fee3 --- /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 0000000000000000000000000000000000000000..aba12c5d13f747c01b79e3545f3fd0be5d348f1f --- /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 0000000000000000000000000000000000000000..6ffcaec7cc2f673bb40878e0ff176ffbce346f8f --- /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 0000000000000000000000000000000000000000..97a9f049e981c27e68c4340c1f242cbbfb49973a --- /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 0000000000000000000000000000000000000000..969f71fb299a8524f272dcbc4b5b5ac78ae9a7f1 --- /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 0000000000000000000000000000000000000000..081540eefc6fbcca417e5dd25113986103a0de08 --- /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 0000000000000000000000000000000000000000..b190c2eca0a172d23b36aa146ecbe098fdaec84b --- /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 0000000000000000000000000000000000000000..1b565a2f0825e2d9dff9d31699e18fdf390b1f6b --- /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 0000000000000000000000000000000000000000..4cead55119b2c4b799c13018dc175c2fe414f059 --- /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 0000000000000000000000000000000000000000..6abd44eb98459de4d3283e5fe2a02c1794201854 --- /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 0000000000000000000000000000000000000000..73afb6e43670fc8cdba1f2e0e9c68c39153d0676 --- /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 0000000000000000000000000000000000000000..531c595f91fd3e6a2af078b9801cd9e286ae1de9 --- /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|default:el.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 0000000000000000000000000000000000000000..5794d92154aaa9fde3222dfb89533aedd36abe8e --- /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 0000000000000000000000000000000000000000..7d31cbdbe3ac84686b3cb578c62761d6c63070ff --- /dev/null +++ b/aleksis/apps/lesrooster/util/build.py @@ -0,0 +1,77 @@ +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_: TimetableType, + obj: Union[Group, Room, Person], +) -> list | None: + """Build regular timetable for the given time grid.""" + 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(bundle__slot_start__time_grid=time_grid).filter_from_type( + type_, obj + ) + + # Sort lesson periods in a dict + for lesson in lessons: + lesson_periods_per_slot[lesson.bundle.all()[0].slot_start].append(lesson) + + # Get matching supervisions + supervisions = Supervision.objects.filter(break_slot__time_grid=time_grid).filter_from_type( + type_, obj + ) + + for supervision in supervisions: + 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: + col = { + "type": "period", + "col": (lesson_periods_per_slot.get(actual_slot, [])), + } + + 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 0000000000000000000000000000000000000000..d6bd30320cf5e4301ccded5bae19c5b99a5ec862 --- /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)