From b710188221d0cdc13afb5e0065c72aa3950a604a Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Fri, 12 Apr 2024 21:49:22 +0200 Subject: [PATCH 01/12] Improve and restructure mini timetables, include mini timetable for teachers 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 + } +} --- .../TimetableManagement.vue | 8 +-- .../timetables/MiniTimeTable.vue | 16 +++++- .../timetables/RoomTimeTable.vue | 7 ++- .../timetables/TeacherTimeTable.vue | 7 ++- .../timetables/timetables.graphql | 55 +++++++++++++++++++ 5 files changed, 82 insertions(+), 11 deletions(-) rename aleksis/apps/lesrooster/frontend/components/{timetable_management => }/timetables/MiniTimeTable.vue (91%) rename aleksis/apps/lesrooster/frontend/components/{timetable_management => }/timetables/RoomTimeTable.vue (84%) rename aleksis/apps/lesrooster/frontend/components/{timetable_management => }/timetables/TeacherTimeTable.vue (84%) rename aleksis/apps/lesrooster/frontend/components/{timetable_management => }/timetables/timetables.graphql (67%) diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue index 12b149fb..81b14747 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"; @@ -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/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 f32e518e..a4d71574 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 a50154bb..a667de14 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 5ed32111..f8d89a4b 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 67% rename from aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql rename to aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql index 9bbcc2eb..8e8a735a 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql +++ b/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql @@ -111,3 +111,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 + } +} -- GitLab From 726e66febab8bb2fe16d9600526056b69ae9dbbf Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Fri, 12 Apr 2024 21:49:55 +0200 Subject: [PATCH 02/12] Support displaying dates of validity ranges in TimeGridField --- .../validity_range/TimeGridField.vue | 30 +++++++++++++------ .../apps/lesrooster/frontend/messages/en.json | 6 ++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue index 73134aa4..87e3ee93 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/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json index 9a73841f..1789802d 100644 --- a/aleksis/apps/lesrooster/frontend/messages/en.json +++ b/aleksis/apps/lesrooster/frontend/messages/en.json @@ -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}" } } }, -- GitLab From 242449766dd06189d6757022da3ab53a7be001fe Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Fri, 12 Apr 2024 21:50:41 +0200 Subject: [PATCH 03/12] Introduce view for regular timetables --- .../components/timetables/GroupTimeTable.vue | 39 +++++++ .../components/timetables/Timetable.vue | 102 ++++++++++++++++++ aleksis/apps/lesrooster/frontend/index.js | 36 ++++++- .../apps/lesrooster/frontend/messages/en.json | 3 + 4 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue create mode 100644 aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue 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 00000000..1705dcb3 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue @@ -0,0 +1,39 @@ +<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() { + console.log(this.timeGrid, "TIMEGRID"); + return { + timeGrid: this.timeGrid.id, + group: this.id, + }; + }, + skip() { + return this.timeGrid === null; + }, + }, + }, +}); +</script> 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 00000000..6694e29f --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue @@ -0,0 +1,102 @@ +<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 }"> + <v-card class="mb-2"> + <v-card-text> + <time-grid-field + outlined + filled + label="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 #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/index.js b/aleksis/apps/lesrooster/frontend/index.js index e9aa58e8..0484a740 100644 --- a/aleksis/apps/lesrooster/frontend/index.js +++ b/aleksis/apps/lesrooster/frontend/index.js @@ -10,6 +10,40 @@ 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: "validity_ranges/", component: () => import("./components/validity_range/ValidityRange.vue"), @@ -49,7 +83,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/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json index 1789802d..336f8d26 100644 --- a/aleksis/apps/lesrooster/frontend/messages/en.json +++ b/aleksis/apps/lesrooster/frontend/messages/en.json @@ -127,6 +127,9 @@ } } }, + "timetable": { + "menu_title": "Timetables" + }, "supervision": { "menu_title": "Supervisions", "title": "Supervision", -- GitLab From 604247f6c036135d2345e036cd111b74f8ca90d6 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Fri, 19 Apr 2024 18:49:50 +0200 Subject: [PATCH 04/12] Fix mobile view for validity range select in timetables --- .../frontend/components/timetables/Timetable.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue index 6694e29f..5e1ad4fd 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue +++ b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue @@ -67,8 +67,11 @@ export default { <template> <timetable-wrapper :on-selected="onSelected"> - <template #additionalSelect="{ selected }"> - <v-card class="mb-2"> + <template #additionalSelect="{ selected, mobile }"> + <v-card + :class="{ 'mb-2': !mobile, 'mx-2 mt-2': mobile }" + :outlined="mobile" + > <v-card-text> <time-grid-field outlined -- GitLab From 502836d4d9d07884d5aae171a1deb150a3ddb9c2 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Fri, 19 Apr 2024 18:50:50 +0200 Subject: [PATCH 05/12] Add print view for regular timetables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- aleksis/apps/lesrooster/managers.py | 137 +++++++++++++++++- aleksis/apps/lesrooster/models.py | 45 +++++- .../static/css/lesrooster/timetable_print.css | 63 ++++++++ .../lesrooster/partials/elements.html | 7 + .../templates/lesrooster/partials/group.html | 1 + .../templates/lesrooster/partials/groups.html | 5 + .../lesrooster/partials/groups_part.html | 7 + .../templates/lesrooster/partials/lesson.html | 25 ++++ .../templates/lesrooster/partials/rooms.html | 3 + .../lesrooster/partials/slot_time.html | 18 +++ .../lesrooster/partials/subject.html | 3 + .../lesrooster/partials/subject_colour.html | 6 + .../lesrooster/partials/supervision.html | 15 ++ .../lesrooster/partials/teachers.html | 3 + .../templates/lesrooster/timetable_print.html | 56 +++++++ aleksis/apps/lesrooster/urls.py | 11 ++ aleksis/apps/lesrooster/util/build.py | 116 +++++++++++++++ aleksis/apps/lesrooster/views.py | 41 ++++++ 18 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 aleksis/apps/lesrooster/static/css/lesrooster/timetable_print.css create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/elements.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/group.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/groups.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/groups_part.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/lesson.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/rooms.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/slot_time.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/subject.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/subject_colour.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/supervision.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/partials/teachers.html create mode 100644 aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html create mode 100644 aleksis/apps/lesrooster/urls.py create mode 100644 aleksis/apps/lesrooster/util/build.py create mode 100644 aleksis/apps/lesrooster/views.py diff --git a/aleksis/apps/lesrooster/managers.py b/aleksis/apps/lesrooster/managers.py index a5ad9d11..0daa7448 100644 --- a/aleksis/apps/lesrooster/managers.py +++ b/aleksis/apps/lesrooster/managers.py @@ -1,8 +1,18 @@ -from typing import Optional +from datetime import time +from typing import Iterable, Optional, Union -from django.db.models import QuerySet +from django.db.models import Max, Min, Q, QuerySet +from django.db.models.functions import Coalesce -from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations, DateRangeQuerySetMixin +from polymorphic.query import PolymorphicQuerySet + +from aleksis.apps.chronos.managers import TimetableType +from aleksis.core.managers import ( + AlekSISBaseManagerWithoutMigrations, + DateRangeQuerySetMixin, + PolymorphicBaseManager, +) +from aleksis.core.models import Group, Person, Room class TeacherPropertiesMixin: @@ -53,3 +63,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 7fb31117..03ba7cb8 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() @@ -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, 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 00000000..9bed3576 --- /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 00000000..00646c20 --- /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 00000000..aba12c5d --- /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 00000000..6ffcaec7 --- /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 00000000..97a9f049 --- /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 00000000..969f71fb --- /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 00000000..081540ee --- /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 00000000..b190c2ec --- /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 00000000..1b565a2f --- /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 00000000..4cead551 --- /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 00000000..6abd44eb --- /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 00000000..73afb6e4 --- /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 00000000..fd4de7a9 --- /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 00000000..5794d921 --- /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 00000000..217d7ec4 --- /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 00000000..d6bd3032 --- /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) -- GitLab From a0e2fe1e074b0d91dbe893a9ba1b534c7ee7f50a Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Mon, 22 Apr 2024 19:54:37 +0200 Subject: [PATCH 06/12] Add print button to timetable view --- .../components/timetables/Timetable.vue | 22 +++++++++++++++++++ aleksis/apps/lesrooster/frontend/index.js | 9 +++++++- .../apps/lesrooster/frontend/messages/en.json | 3 ++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue index 5e1ad4fd..b0a1cbe8 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue +++ b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue @@ -87,6 +87,28 @@ export default { </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'" diff --git a/aleksis/apps/lesrooster/frontend/index.js b/aleksis/apps/lesrooster/frontend/index.js index 0484a740..df8ebf22 100644 --- a/aleksis/apps/lesrooster/frontend/index.js +++ b/aleksis/apps/lesrooster/frontend/index.js @@ -43,7 +43,14 @@ export default { }, ], }, - + { + 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"), diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json index 336f8d26..939fd310 100644 --- a/aleksis/apps/lesrooster/frontend/messages/en.json +++ b/aleksis/apps/lesrooster/frontend/messages/en.json @@ -128,7 +128,8 @@ } }, "timetable": { - "menu_title": "Timetables" + "menu_title": "Timetables", + "print": "Print" }, "supervision": { "menu_title": "Supervisions", -- GitLab From 668c111b195f36942e718f13ae741719b5db6807 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Fri, 26 Apr 2024 10:19:05 +0200 Subject: [PATCH 07/12] Remove debug prints --- .../frontend/components/timetables/GroupTimeTable.vue | 1 - aleksis/apps/lesrooster/util/build.py | 10 ---------- 2 files changed, 11 deletions(-) diff --git a/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue index 1705dcb3..473c4742 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue +++ b/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue @@ -24,7 +24,6 @@ export default defineComponent({ lessonsGroup: { query: lessonsGroup, variables() { - console.log(this.timeGrid, "TIMEGRID"); return { timeGrid: this.timeGrid.id, group: this.id, diff --git a/aleksis/apps/lesrooster/util/build.py b/aleksis/apps/lesrooster/util/build.py index 217d7ec4..37c2f3dc 100644 --- a/aleksis/apps/lesrooster/util/build.py +++ b/aleksis/apps/lesrooster/util/build.py @@ -41,15 +41,6 @@ def build_timetable( # 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, @@ -97,7 +88,6 @@ def build_timetable( col = {"type": "break", "col": supervisions_per_slot.get(actual_slot)} else: - print(lesson_periods_per_slot[actual_slot]) col = { "type": "period", "col": ( -- GitLab From fc1a38264fb5eb9f634fd025d7f66f1dc1ccd803 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Tue, 12 Nov 2024 23:47:04 +0100 Subject: [PATCH 08/12] Fix PDF export --- aleksis/apps/lesrooster/managers.py | 12 ++++---- aleksis/apps/lesrooster/util/build.py | 43 +++++---------------------- 2 files changed, 14 insertions(+), 41 deletions(-) diff --git a/aleksis/apps/lesrooster/managers.py b/aleksis/apps/lesrooster/managers.py index 0daa7448..aa9877de 100644 --- a/aleksis/apps/lesrooster/managers.py +++ b/aleksis/apps/lesrooster/managers.py @@ -127,7 +127,7 @@ class LessonQuerySet(QuerySet): self, type_: TimetableType, obj: Union[Person, Group, Room, int], - ) -> Optional["LessonQuerySet"]: + ) -> "LessonQuerySet": """Filter lessons for a group, teacher or room by provided type.""" if type_ == TimetableType.GROUP: return self.filter_group(obj) @@ -136,9 +136,9 @@ class LessonQuerySet(QuerySet): elif type_ == TimetableType.ROOM: return self.filter_room(obj) else: - return None + return self.none() - def filter_from_person(self, person: Person) -> Optional["LessonQuerySet"]: + def filter_from_person(self, person: Person) -> "LessonQuerySet": """Filter lessons for a person.""" type_ = person.timetable_type @@ -147,7 +147,7 @@ class LessonQuerySet(QuerySet): elif type_ == TimetableType.GROUP: return self.filter_participant(person) else: - return None + return self.none() class LessonManager(AlekSISBaseManagerWithoutMigrations): @@ -167,14 +167,14 @@ class SupervisionQuerySet(QuerySet): self, type_: TimetableType, obj: Union[Person, Group, Room, int], - ) -> Optional["SupervisionQuerySet"]: + ) -> "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 + return self.none() def filter_from_person(self, person: Person) -> Optional["SupervisionQuerySet"]: """Filter supervisions for a person.""" diff --git a/aleksis/apps/lesrooster/util/build.py b/aleksis/apps/lesrooster/util/build.py index 37c2f3dc..41ac058a 100644 --- a/aleksis/apps/lesrooster/util/build.py +++ b/aleksis/apps/lesrooster/util/build.py @@ -8,18 +8,10 @@ from aleksis.core.models import Group, Person, Room def build_timetable( time_grid: TimeGrid, - type_: Union[TimetableType, str], + type_: TimetableType, 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() @@ -33,37 +25,19 @@ def build_timetable( 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) - ) + lessons = Lesson.objects.filter(slot_start__time_grid=time_grid).filter_from_type(type_, obj) # Sort lesson periods in a dict for lesson in lessons: - 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) + lesson_periods_per_slot[lesson.slot_start].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 = Supervision.objects.filter(break_slot__time_grid=time_grid).filter_from_type( + type_, obj + ) - supervisions_per_slot[supervision.break_slot] = supervision + for supervision in supervisions: + supervisions_per_slot[supervision.break_slot] = supervision rows = [] for slot_idx in range(max_slots): # period is period after break @@ -100,7 +74,6 @@ def build_timetable( cols.append(col) row["cols"] = cols - rows.append(row) return rows -- GitLab From c9c874cefe0f2c3068341e0e4b32e4d4fe8b0133 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Sun, 29 Dec 2024 13:13:33 +0100 Subject: [PATCH 09/12] Fix and update translation (keys) --- .../components/lesson_raster/LessonRaster.vue | 4 +-- .../components/lesson_raster/SlotCard.vue | 2 +- .../components/supervision/Supervision.vue | 2 +- .../TimeboundCourseConfigRaster.vue | 2 +- .../TimetableManagement.vue | 4 +-- .../components/timetables/Timetable.vue | 2 +- .../components/timetables/timetables.graphql | 24 +++++++++-------- .../validity_range/CopyFromTimeGridMenu.vue | 6 ++--- .../apps/lesrooster/frontend/messages/de.json | 26 +++++++++++-------- .../apps/lesrooster/frontend/messages/en.json | 24 ++++++++--------- 10 files changed, 51 insertions(+), 45 deletions(-) diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue index bc0205fe..0aa8dfa2 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 f274dcee..e44147e8 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 2a25319d..6ff5b309 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/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue index 8ef85cd8..b7154db6 100644 --- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue +++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue @@ -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" /> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue index 81b14747..954b2076 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.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 /> diff --git a/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue index b0a1cbe8..3a7c8a82 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue +++ b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue @@ -76,7 +76,7 @@ export default { <time-grid-field outlined filled - label="Select Validity Range" + :label="$t('lesrooster.labels.select_validity_range')" hide-details with-dates :enable-create="false" diff --git a/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql b/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql index 8e8a735a..fa3c73e1 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql +++ b/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql @@ -113,17 +113,20 @@ query lessonsRoom($room: ID!, $timeGrid: ID!) { } query lessonsGroup($group: ID!, $timeGrid: ID!) { - lessonsGroup: lessonObjectsForGroup(group: $group, timeGrid: $timeGrid) { + lessonsGroup: lessonsForGroup(group: $group, timeGrid: $timeGrid) { id - slotStart { - id - period - weekday - } - slotEnd { - id - period - weekday + bundle { + slotStart { + id + period + weekday + } + slotEnd { + id + period + weekday + } + recurrence } subject { id @@ -161,7 +164,6 @@ query lessonsGroup($group: ID!, $timeGrid: ID!) { shortName } } - recurrence 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 1004f59b..6e9d71ab 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/messages/de.json b/aleksis/apps/lesrooster/frontend/messages/de.json index da44980d..f64ff4b7 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 939fd310..6684a637 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", @@ -128,7 +128,7 @@ } }, "timetable": { - "menu_title": "Timetables", + "menu_title": "Regular Timetables", "print": "Print" }, "supervision": { @@ -140,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" } } -- GitLab From de33dc2d784a2cfd072b28be6c4a90cd023c1906 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Sun, 29 Dec 2024 13:13:50 +0100 Subject: [PATCH 10/12] Update timetable printing for lesson bundles --- aleksis/apps/lesrooster/managers.py | 3 ++- aleksis/apps/lesrooster/util/build.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/aleksis/apps/lesrooster/managers.py b/aleksis/apps/lesrooster/managers.py index aa9877de..a4971e95 100644 --- a/aleksis/apps/lesrooster/managers.py +++ b/aleksis/apps/lesrooster/managers.py @@ -1,5 +1,6 @@ +from collections.abc import Iterable from datetime import time -from typing import Iterable, Optional, Union +from typing import Optional, Union from django.db.models import Max, Min, Q, QuerySet from django.db.models.functions import Coalesce diff --git a/aleksis/apps/lesrooster/util/build.py b/aleksis/apps/lesrooster/util/build.py index 41ac058a..c7fe29fb 100644 --- a/aleksis/apps/lesrooster/util/build.py +++ b/aleksis/apps/lesrooster/util/build.py @@ -25,11 +25,13 @@ def build_timetable( max_slots = len(max_slots) # Get matching lessons - lessons = Lesson.objects.filter(slot_start__time_grid=time_grid).filter_from_type(type_, obj) + 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.slot_start].append(lesson) + 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( @@ -65,9 +67,7 @@ def build_timetable( col = { "type": "period", "col": ( - lesson_periods_per_slot[actual_slot] - if actual_slot in lesson_periods_per_slot - else [] + lesson_periods_per_slot.get(actual_slot, []) ), } -- GitLab From 4dc81d217f6d56cb3f5752f8ea06aa199178c22c Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Sun, 29 Dec 2024 13:16:23 +0100 Subject: [PATCH 11/12] Update changelog --- CHANGELOG.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aff862f7..6406f380 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 -------------------------- -- GitLab From f523066acdf981844ee66d09b24acef11df2cd38 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Sat, 12 Apr 2025 16:14:12 +0200 Subject: [PATCH 12/12] Fix some bugs with regular/print timetable --- aleksis/apps/lesrooster/schema/__init__.py | 2 +- .../apps/lesrooster/templates/lesrooster/timetable_print.html | 2 +- aleksis/apps/lesrooster/util/build.py | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py index f67ad826..b36ddcd0 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/templates/lesrooster/timetable_print.html b/aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html index fd4de7a9..531c595f 100644 --- a/aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html +++ b/aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html @@ -7,7 +7,7 @@ {% endblock %} {% block page_title %} - {% trans "Timetable" %} <i>{{ el.short_name }}</i> + {% trans "Timetable" %} <i>{{ el.short_name|default:el.name }}</i> {% endblock %} {% block content %} diff --git a/aleksis/apps/lesrooster/util/build.py b/aleksis/apps/lesrooster/util/build.py index c7fe29fb..7d31cbdb 100644 --- a/aleksis/apps/lesrooster/util/build.py +++ b/aleksis/apps/lesrooster/util/build.py @@ -66,9 +66,7 @@ def build_timetable( else: col = { "type": "period", - "col": ( - lesson_periods_per_slot.get(actual_slot, []) - ), + "col": (lesson_periods_per_slot.get(actual_slot, [])), } cols.append(col) -- GitLab