diff --git a/.dev-js/package.json b/.dev-js/package.json index f1a3b8b143115ffb8f0fee2d452ec2830cd888a3..99b0989e1dde9b0f71fdc229bac3aef501c06398 100644 --- a/.dev-js/package.json +++ b/.dev-js/package.json @@ -6,7 +6,7 @@ "eslint": "^8.26.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-vue": "^9.7.0", - "prettier": "^3.0.0", + "prettier": "^3.4.0", "stylelint": "^15.0.0", "stylelint-config-prettier": "^9.0.3", "stylelint-config-standard": "^34.0.0" diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9eb7c441010861edaaddc42983004f0548e6e677..aff862f7fc5171982107cbe1755bbe0bc3f70ffb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,18 +6,42 @@ 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`_. +`0.1.0.dev6`_ - 2024-12-24 +-------------------------- -`0.1`_ ------- +Added +~~~~~ + +* Lesson bundles for simple planning of connected courses (e. g. for language tracks). + +`0.1.0.dev5`_ - 2024-10-17 +-------------------------- + +Upgrade notice +~~~~~~~~~~~~~~ + +This app's functionality was mainly covered by AlekSIS-App-Chronos +in the past. Lesrooster contains a migration path for migrating +3.x versions of AlekSIS-App-Chronos and AlekSIS-App-Alsijil. +If you want to migrate from these versions, please be sure that the +apps are installed. Then run migrations as usual. Please be aware +that depending on the amount of data the migration might take a while. +On really large installations, several hours are to be expected. +In case you run into memory issues, please contact us for support. Added ~~~~~ -* Initial release. +* Management of validity ranges and lesson rasters (slots and breaks). +* Planning of courses per validity range/school term using interactive + planning tool. +* Creation of timetables using digital magnetic board with assistance + functions. .. _Keep a Changelog: https://keepachangelog.com/en/1.0.0/ .. _Semantic Versioning: https://semver.org/spec/v2.0.0.html -.. _0.1: https://edugit.org/AlekSIS/onboarding//AlekSIS-App-Lesrooster/-/tags/0.1 +.. _0.1.0.dev5: https://edugit.org/AlekSIS/onboarding/AlekSIS-App-Lesrooster/-/tags/0.1.0.dev5 +.. _0.1.0.dev6: https://edugit.org/AlekSIS/onboarding/AlekSIS-App-Lesrooster/-/tags/0.1.0.dev6 diff --git a/README.rst b/README.rst index 6e4c646ff977dd48c7f01dd2bbce56d6889d8e5a..46df134f49fca5b2f60987ca839e72dfb65e537a 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -AlekSIS (School Information System) — App Lesrooster (Manage lessons in a timetable schema) -================================================================================================== +AlekSIS (School Information System) — App Lesrooster (Timetable Management Using Time Grids) +============================================================================================ AlekSIS ------- @@ -9,14 +9,22 @@ This is an application for use with the `AlekSIS®`_ platform. Features -------- -The author of this app did not describe it yet. +* Manage validity ranges for timetables. +* Manage different timetable grids for different validity ranges and groups (with slots and breaks). +* Do lesson distribution using course planning raster. +* Plan timetables using magnet board. +* Manage supervisions for breaks. Licence ------- :: - Copyright © 2023 Jonathan Weth <dev@jonathanweth.de> + Copyright © 2023, 2024 Julian Leucker <julian.leucker@teckids.org> + Copyright © 2023, 2024 Jonathan Weth <jonathan.weth@teckids.org> + Copyright © 2023, 2024 Hangzhi Yu <hangzhi.yu@teckids.org> + Copyright © 2024 magicfelix <felix@felix-zauberer.de> + Copyright © 2024 Michael Bauer <michael-bauer@posteo.de> Licenced under the EUPL, version 1.2 or later diff --git a/aleksis/apps/lesrooster/apps.py b/aleksis/apps/lesrooster/apps.py index 17c326c24fbc1acc18f91da24f1bde8e3e51eb4d..0873da3f443df096552adab924ede63894cad15d 100644 --- a/aleksis/apps/lesrooster/apps.py +++ b/aleksis/apps/lesrooster/apps.py @@ -1,57 +1,20 @@ -from django.db.models import signals - from aleksis.core.util.apps import AppConfig -from .util.signal_handlers import ( - create_time_grid_for_new_validity_range, - m2m_changed_handler, - post_save_handler, - pre_delete_handler, - publish_validity_range, -) - class DefaultConfig(AppConfig): name = "aleksis.apps.lesrooster" - verbose_name = "AlekSIS — Lesrooster" + verbose_name = "AlekSIS — Lesrooster (Timetable Management Using Time Grids)" dist_name = "AlekSIS-App-Lesrooster" urls = { - "Repository": "https://edugit.org/AlekSIS/onboarding//AlekSIS-App-Lesrooster", + "Repository": "https://edugit.org/AlekSIS/onboarding/AlekSIS-App-Lesrooster", } licence = "EUPL-1.2+" - copyright_info = (([2023], "Jonathan Weth", "dev@jonathanweth.de"),) - - def ready(self): - # Configure change tracking for models to sync changes with LessonEvent in Chronos - from .models import ( - Lesson, - Supervision, - ValidityRange, - ) - - models = [Lesson, Supervision] - - for model in models: - signals.post_save.connect( - post_save_handler, - sender=model, - ) - signals.m2m_changed.connect( - m2m_changed_handler, - sender=model.teachers.through, - ) - signals.pre_delete.connect(pre_delete_handler, sender=model) - - signals.m2m_changed.connect( - m2m_changed_handler, - sender=Lesson.rooms.through, - ) - signals.m2m_changed.connect( - m2m_changed_handler, - sender=Supervision.rooms.through, - ) - - signals.post_save.connect(create_time_grid_for_new_validity_range, sender=ValidityRange) - signals.post_save.connect(publish_validity_range, sender=ValidityRange) + copyright_info = ( + ([2023, 2024], "Julian Leucker", "julian.leucker@teckids.org"), + ([2023, 2024], "Jonathan Weth", "jonathan.weth@teckids.org"), + ([2023, 2024], "Hangzhi Yu", "hangzhi.yu@teckids.org"), + ([2024], "magicfelix", "felix@felix-zauberer.de"), + ([2024], "Michael Bauer", "michael-bauer@posteo.de"), + ) diff --git a/aleksis/apps/lesrooster/frontend/components/common/ColoredShortNameChip.vue b/aleksis/apps/lesrooster/frontend/components/common/ColoredShortNameChip.vue index d154fe7586381e511429d7a522e3c605ac9858a2..dd8fa1ffd02e75de89b464742580210791e574f6 100644 --- a/aleksis/apps/lesrooster/frontend/components/common/ColoredShortNameChip.vue +++ b/aleksis/apps/lesrooster/frontend/components/common/ColoredShortNameChip.vue @@ -61,6 +61,11 @@ export default { }, }, computed: { + color() { + return this.overrideColor + ? this.defaultColor + : colors[((this.hash % colors.length) + colors.length) % colors.length]; + }, // Returns an integer hash based on the shortName of the supplied item hash() { return this.item.shortName @@ -82,11 +87,6 @@ export default { // As color[1] can be a boolean or a color return this.color[1] === true; }, - color() { - return this.overrideColor - ? this.defaultColor - : colors[this.hash % colors.length]; - }, }, }; </script> diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue index f567120ee7a3b57756bd56896da930d48dd287f8..63f17f218ac8b1eccb6ff278889f75e6178d03fb 100644 --- a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue +++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue @@ -25,8 +25,8 @@ export default defineComponent({ slots: { weekdays: [], period: null, - timeStart: "8:00", - timeEnd: "9:00", + timeStart: "08:00", + timeEnd: "09:00", }, required: [(value) => !!value || this.$t("forms.errors.required")], }; 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 3e1a63f179719b58d6fabf759d48fe417148289a..7cb125a6deec0df3ca829d6b8209a0c4383ab7b1 100644 --- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue +++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue @@ -282,6 +282,7 @@ export default { subject: subject.id, groups: JSON.parse(header.value), schoolTerm: this.internalValidityRange.schoolTerm.id, + name: `${header.text}-${subject.name}`, ...newValue, }); } else { diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/BundleCard.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/BundleCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..3f0254895b0c9241f197f3bb501e13088b14f0cc --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/BundleCard.vue @@ -0,0 +1,198 @@ +<script> +import { defineComponent } from "vue"; +import ColoredShortNameChip from "../common/ColoredShortNameChip.vue"; + +import bundleAccessorsMixin from "../../mixins/bundleAccessorsMixin.js"; + +export default defineComponent({ + name: "BundleCard", + components: { ColoredShortNameChip }, + mixins: [bundleAccessorsMixin], + extends: "v-card", + props: { + /** + * Bundle to show + * @values CourseBundle, LessonBundle + */ + bundle: { + type: Object, + required: true, + }, + highlightedTeachers: { + type: Array, + required: false, + default: () => [], + }, + highlightedRooms: { + type: Array, + required: false, + default: () => [], + }, + oneLine: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + children() { + return this.bundleChildren(this.bundle); + }, + subjects() { + return this.bundleSubjects(this.bundle).map( + (subject) => + subject || { + name: "", + colourFg: "#000000", + colourBg: "#e6e6e6", + }, + ); + }, + teachers() { + return this.bundleTeachers(this.bundle); + }, + rooms() { + return this.bundleRooms(this.bundle); + }, + outlined() { + return this.subjects.length > 1; + }, + colorFg() { + if (this.subjects.length === 1) { + return this.subjects[0].colourFg; + } else { + return this.$vuetify.theme.currentTheme.primary; + } + }, + colorBg() { + if (this.subjects.length === 1) { + return this.subjects[0].colourBg; + } else { + return null; + } + }, + loading() { + return ( + this.bundle.isOptimistic || + this.bundle.id.toString().startsWith("temporary") + ); + }, + }, +}); +</script> + +<template> + <v-card + :color="colorBg" + :outlined="outlined" + :disabled="loading" + :style="{ 'border-color': colorFg }" + class="color no-select h-100 fill-height d-flex align-center justify-center pa-0 width-title" + v-bind="$attrs" + v-on="$listeners" + > + <div + v-if="!loading" + :class="{ 'd-flex align-center my-1': true, 'flex-column': !oneLine }" + > + <component + :is="oneLine ? 'div' : 'v-card-title'" + class="color d-flex justify-center flex-wrap px-3 py-0 ma-0" + > + <!-- Show subject shortname with full course name as tooltip. --> + <v-tooltip v-for="child in children" :key="child.id" bottom tag="span"> + <template #activator="{ on, attrs }"> + <span v-bind="attrs" v-on="on" class="separator-after-first"> + {{ child.subject.shortName }} + </span> + </template> + <span>{{ child.name || child.course.name }}</span> + </v-tooltip> + + <!-- Show full course name as subtitle if enough space and bundle of one. --> + <v-card-subtitle + v-if="children.length === 1" + class="caption px-3 py-0 ma-0 text-center hidden-when-small" + > + {{ children[0].name || children[0].course.name }} + </v-card-subtitle> + </component> + + <!-- Show teachers --> + <v-card-subtitle + class="color pa-0 ma-0 d-flex flex-wrap justify-center small-gap" + > + <span v-for="teacher in teachers" :key="'teacher-' + teacher.id"> + <v-tooltip bottom> + <template #activator="{ on, attrs }"> + <colored-short-name-chip + :default-color="[colorFg, colorBg, true]" + :override-color="!highlightedTeachers.includes(teacher.id)" + v-bind="attrs" + v-on="on" + @click="$emit('click:teacher', teacher)" + :item="teacher" + :elevation="0" + /> + </template> + <span>{{ teacher.fullName }}</span> + </v-tooltip> + </span> + <!-- Show rooms --> + <span v-for="room in rooms" :key="'room-' + room.id"> + <v-tooltip bottom> + <template #activator="{ on, attrs }"> + <colored-short-name-chip + :default-color="[colorFg, colorBg, true]" + :override-color="!highlightedRooms.includes(room.id)" + v-bind="attrs" + v-on="on" + @click="$emit('click:room', room)" + :item="room" + :elevation="0" + /> + </template> + <span>{{ room.name }}</span> + </v-tooltip> + </span> + </v-card-subtitle> + <slot /> + </div> + <div v-if="loading" class="text-center"> + <v-progress-circular :color="colorFg" indeterminate /> + </div> + </v-card> +</template> + +<style scoped> +.width-title { + container: title/inline-size; +} + +.hidden-when-small { + display: none; +} + +@container title (width > 150px) { + .hidden-when-small { + display: inline; + } +} + +.color { + color: v-bind(colorFg); +} + +.no-select { + user-select: none; +} + +.small-gap { + gap: 0.25rem; +} + +.separator-after-first:not(:first-of-type)::before { + content: "·"; + padding-left: 0.25em; +} +</style> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue index 931306ccf8eae8d3c6c65add4b09ad20c9ac1912..12b149fb9a710d30361a16b0cf27b742666f0944 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue @@ -1,13 +1,13 @@ <script> import { defineComponent } from "vue"; import { - courses, - createLessons, - deleteLessons, + courseBundles, + createLessonBundles, + deleteLessonBundles, gqlGroups, - lessonObjects, - moveLesson, - overlayLessons, + lessonBundles, + moveLessonBundles, + overlayBundles, updateLessons, } from "./timetableManagement.graphql"; import { gqlTeachers } from "../helper.graphql"; @@ -19,7 +19,7 @@ import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.v import DialogObjectForm from "aleksis.core/components/generic/dialogs/DialogObjectForm.vue"; import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue"; import SubjectField from "aleksis.apps.cursus/components/SubjectField.vue"; -import LessonCard from "./LessonCard.vue"; +import BundleCard from "./BundleCard.vue"; import { RRule } from "rrule"; import TeacherTimeTable from "./timetables/TeacherTimeTable.vue"; @@ -30,6 +30,8 @@ import BlockingCard from "./BlockingCard.vue"; import PeriodCard from "./PeriodCard.vue"; import TimetableOverlayCard from "./TimetableOverlayCard.vue"; +import bundleAccessorsMixin from "../../mixins/bundleAccessorsMixin.js"; + export default defineComponent({ name: "TimetableManagement", components: { @@ -44,9 +46,10 @@ export default defineComponent({ RoomTimeTable, TeacherTimeTable, DeleteDialog, - LessonCard, + BundleCard, SecondaryActionButton, }, + mixins: [bundleAccessorsMixin], data() { return { weekdays: [], @@ -56,7 +59,7 @@ export default defineComponent({ courseSearch: null, lessonsUsed: {}, lessonQuotaTotal: 0, - deleteMutation: deleteLessons, + deleteMutation: deleteLessonBundles, deleteDialog: false, itemsToDelete: [], selectedObject: null, @@ -94,7 +97,7 @@ export default defineComponent({ mutation: updateLessons, }, draggedItem: null, - overlayLessons: [], + overlayBundles: [], }; }, apollo: { @@ -166,12 +169,12 @@ export default defineComponent({ return !this.internalTimeGrid; }, }, - courses: { - query: courses, + courseBundles: { + query: courseBundles, variables() { return { - timeGrid: this.internalTimeGrid.id, group: this.selectedGroup.id, + validityRange: this.internalTimeGrid.validityRange.id, }; }, skip() { @@ -179,55 +182,45 @@ export default defineComponent({ }, result({ data }) { this.lessonQuotaTotal = - data && data.courses - ? data.courses.reduce( + data && data.courseBundles + ? data.courseBundles.reduce( (accumulator, course) => accumulator + course.lessonQuota, 0, ) : 0; }, }, - lessonObjects: { - query: lessonObjects, + lessonBundles: { + query: lessonBundles, variables() { return { - timeGrid: this.internalTimeGrid.id, group: this.selectedGroup.id, + timeGrid: this.internalTimeGrid.id, }; }, skip() { return !this.readyForQueries; }, - result( - { - data: { lessonObjects = [] } = { - lessonObjects: [], - }, - } = { - data: { - lessonObjects: [], - }, - }, - ) { + result({ data }) { this.lessonsUsed = {}; - lessonObjects.forEach((lesson) => { + data.lessonBundles.forEach((lessonBundle) => { let increment = - this.periods.indexOf(lesson.slotEnd.period) - - this.periods.indexOf(lesson.slotStart.period) + + this.periods.indexOf(lessonBundle.slotEnd.period) - + this.periods.indexOf(lessonBundle.slotStart.period) + 1; - this.lessonsUsed[lesson.course.id] = - this.lessonsUsed[lesson.course.id] + increment || increment; + this.lessonsUsed[lessonBundle.courseBundle.id] = + this.lessonsUsed[lessonBundle.courseBundle.id] + increment || + increment; }); }, }, - overlayLessons: { - query: overlayLessons, + overlayBundles: { + query: overlayBundles, variables() { return { timeGrid: this.internalTimeGrid.id, - rooms: this.draggedItem?.data.rooms?.map((room) => room.id) || [], - teachers: - this.draggedItem?.data.teachers?.map((teacher) => teacher.id) || [], + rooms: this.draggedRooms, + teachers: this.draggedTeachers, }; }, skip() { @@ -251,51 +244,60 @@ export default defineComponent({ this.selectedGroup.id != null ); }, - lessons() { - return this.lessonObjects - ? this.lessonObjects.map((lesson) => ({ - x: this.weekdays.indexOf(lesson.slotStart.weekday) + 1, - y: this.periods.indexOf(lesson.slotStart.period) + 1, + griddedLessonBundles() { + return this.lessonBundles + ? this.lessonBundles.map((lessonBundle) => ({ + x: this.weekdays.indexOf(lessonBundle.slotStart.weekday) + 1, + y: this.periods.indexOf(lessonBundle.slotStart.period) + 1, w: - this.weekdays.indexOf(lesson.slotEnd.weekday) - - this.weekdays.indexOf(lesson.slotStart.weekday) + + this.weekdays.indexOf(lessonBundle.slotEnd.weekday) - + this.weekdays.indexOf(lessonBundle.slotStart.weekday) + 1, h: - this.periods.indexOf(lesson.slotEnd.period) - - this.periods.indexOf(lesson.slotStart.period) + + this.periods.indexOf(lessonBundle.slotEnd.period) - + this.periods.indexOf(lessonBundle.slotStart.period) + 1, - key: "lesson-" + lesson.id, - disabled: !lesson.canEdit, - data: lesson, + key: "lesson-bundle-" + lessonBundle.id, + disabled: !lessonBundle.canEdit, + data: lessonBundle, })) : []; }, gridItems() { // As we may want to display more in the future - return this.lessons; + return this.griddedLessonBundles; }, gridLoading() { return ( this.$apollo.queries.slots.loading || - this.$apollo.queries.lessonObjects.loading || + this.$apollo.queries.lessonBundles.loading || this.$apollo.queries.groups.loading ); }, - selectableCourses() { - return this.courses - ? this.courses.map((course) => ({ - x: "0", - y: "0", - w: 1, - h: 1, - key: "course-" + course.courseId, - data: { - ...course, - lessonsUsed: this.lessonsUsed[course.courseId] || 0, - lessonRatio: - (this.lessonsUsed[course.courseId] || 0) / course.lessonQuota, - }, - })) + griddedCourseBundles() { + return this.courseBundles + ? this.courseBundles.map((bundle) => { + const lessonQuota = + bundle.lessonQuota || + bundle.courses.reduce( + (min, course) => Math.min(min, course.lessonQuota), + Number.MAX_SAFE_INTEGER, + ); + return { + x: "0", + y: "0", + w: 1, + h: 1, + key: "course-bundle-" + bundle.id, + data: { + // TODO: A uniform interface between courseBundles and lessonBundles + ...bundle, + lessonQuota: lessonQuota, + lessonsUsed: this.lessonsUsed[bundle.id] || 0, + lessonRatio: (this.lessonsUsed[bundle.id] || 0) / lessonQuota, + }, + }; + }) : []; }, disabledSlots() { @@ -332,24 +334,18 @@ export default defineComponent({ ); }, draggedTeachers() { - if ( - !this.draggedItem || - !this.draggedItem.data || - !this.draggedItem.data.teachers - ) { - return []; + const bundle = this.draggedItem?.data; + if (bundle) { + return this.bundleTeachers(bundle).map((teacher) => teacher.id); } - return this.draggedItem?.data.teachers.map((t) => t.id) || []; + return []; }, draggedRooms() { - if ( - !this.draggedItem || - !this.draggedItem.data || - !this.draggedItem.data.rooms - ) { - return []; + const bundle = this.draggedItem?.data; + if (bundle) { + return this.bundleRooms(bundle).map((room) => room.id); } - return this.draggedItem?.data.rooms.map((t) => t.id) || []; + return []; }, }, watch: { @@ -373,8 +369,8 @@ export default defineComponent({ group: this.selectedGroup.name, }), ); - this.$apollo.queries.courses.refetch(); - this.$apollo.queries.lessonObjects.refetch(); + this.$apollo.queries.courseBundles.refetch(); + this.$apollo.queries.lessonBundles.refetch(); }, internalTimeGrid(newTimeGrid, oldTimeGrid) { if (!oldTimeGrid) return; @@ -415,11 +411,11 @@ export default defineComponent({ throw new Error("Multiple slots matched"); } - if (eventData.originGridId === "lessons") { + if (eventData.originGridId === "lessonBundles") { let that = this; this.$apollo .mutate({ - mutation: moveLesson, + mutation: moveLessonBundles, variables: { input: { id: eventData.data.id, @@ -428,8 +424,8 @@ export default defineComponent({ }, }, optimisticResponse: { - updateLessons: { - lessons: [ + updateLessonBundles: { + lessonBundles: [ { ...eventData.data, slotStart: newStartSlot, @@ -437,21 +433,21 @@ export default defineComponent({ isOptimistic: true, }, ], - __typename: "LessonBatchPatchMutation", + __typename: "LessonBundleBatchPatchMutation", }, }, update( store, { data: { - updateLessons: { lessons }, + updateLessonBundles: { lessonBundles }, }, }, ) { let query = { - ...that.$apollo.queries.lessonObjects.options, + ...that.$apollo.queries.lessonBundles.options, variables: JSON.parse( - that.$apollo.queries.lessonObjects.previousVariablesJson, + that.$apollo.queries.lessonBundles.previousVariablesJson, ), }; // Read the data from cache for query @@ -462,12 +458,14 @@ export default defineComponent({ return; } - lessons.forEach((lesson) => { - const index = storedData.lessonObjects.findIndex( - (lessonObject) => lessonObject.id === lesson.id, + lessonBundles.forEach((lessonBundle) => { + const index = storedData.lessonBundles.findIndex( + (lessonBundleObject) => + lessonBundleObject.id === lessonBundle.id, ); - storedData.lessonObjects[index].slotStart = lesson.slotStart; - storedData.lessonObjects[index].slotEnd = lesson.slotEnd; + storedData.lessonBundles[index].slotStart = + lessonBundle.slotStart; + storedData.lessonBundles[index].slotEnd = lessonBundle.slotEnd; // Write data back to the cache store.writeQuery({ ...query, data: storedData }); @@ -476,15 +474,19 @@ export default defineComponent({ }) .then(() => { this.$toastSuccess( - "lesrooster.timetable_management.snacks.lesson_move.success", + this.$t( + "lesrooster.timetable_management.snacks.lesson_move.success", + ), ); }) .catch(() => { this.$toastError( - "lesrooster.timetable_management.snacks.lesson_move.error", + this.$t( + "lesrooster.timetable_management.snacks.lesson_move.error", + ), ); }); - } else if (eventData.originGridId === "courses") { + } else if (eventData.originGridId === "courseBundles") { let that = this; const rule = new RRule({ freq: RRule.WEEKLY, // TODO: Make this configurable @@ -494,53 +496,55 @@ export default defineComponent({ const recurrenceString = rule.toString(); this.$apollo .mutate({ - mutation: createLessons, + mutation: createLessonBundles, variables: { input: { slotStart: newStartSlotId, slotEnd: newEndSlotId, - course: eventData.data.courseId, - subject: eventData.data.subject?.id, - teachers: eventData.data.teachers.map((t) => t.id), - rooms: [], - // rooms: eventData.data.rooms.map((r) => r.id), + courseBundle: eventData.data.id, recurrence: recurrenceString, }, }, optimisticResponse: { - createLessons: { + createLessonBundles: { items: [ { - id: "temporary-lesson-id-" + crypto.randomUUID(), + id: "temporary-lesson-bundle-id-" + crypto.randomUUID(), slotStart: newStartSlot, slotEnd: newEndSlot, - subject: eventData.data.subject, - teachers: eventData.data.teachers, - // rooms: eventData.data.rooms, - rooms: [], - course: eventData.data, + recurrence: recurrenceString, + courseBundle: eventData.data, + lessons: eventData.data.courses.map((course) => { + return { + id: "temporary-lesson-id-" + crypto.randomUUID(), + course: course, + rooms: [course.defaultRoom], + teachers: course.teachers, + subject: course.subject, + __typename: "LessonType", + }; + }), isOptimistic: true, canEdit: true, canDelete: true, - recurrence: recurrenceString, - __typename: "LessonType", + __typename: "LessonBundleType", }, ], - __typename: "LessonBatchCreateMutation", + __typename: "LessonBundleBatchCreateMutation", }, }, update( store, { data: { - createLessons: { items }, + createLessonBundles: { items }, }, }, ) { let query = { - ...that.$apollo.queries.lessonObjects.options, + ...that.$apollo.queries.lessonBundles.options, variables: JSON.parse( - that.$apollo.queries.lessonObjects.previousVariablesJson, + that.$apollo.queries.lessonBundles.previousVariablesJson, ), }; // Read the data from cache for query @@ -551,7 +555,9 @@ export default defineComponent({ return; } - items.forEach((lesson) => storedData.lessonObjects.push(lesson)); + items.forEach((lessonBundle) => + storedData.lessonBundles.push(lessonBundle), + ); // Write data back to the cache store.writeQuery({ ...query, data: storedData }); @@ -559,32 +565,36 @@ export default defineComponent({ }) .then(() => { this.$toastSuccess( - "lesrooster.timetable_management.snacks.lesson_create.success", + this.$t( + "lesrooster.timetable_management.snacks.lesson_create.success", + ), ); }) .catch(() => { this.$toastError( - "lesrooster.timetable_management.snacks.lesson_create.error", + this.$t( + "lesrooster.timetable_management.snacks.lesson_create.error", + ), ); }); } }, itemMovedToCourses(eventData) { - if (eventData.originGridId === "lessons") { + if (eventData.originGridId === "lessonBundles") { // TODO: remove lessons from plan? // Maybe not needed, due to delete button in menu } }, - canShortenLesson(lesson) { - // Only allow shortening a lesson if it is longer than 1 slot - return lesson.slotEnd.id !== lesson.slotStart.id; + canShortenLessonBundle(lessonBundle) { + // Only allow shortening a lessonBundle if it is longer than 1 slot + return lessonBundle.slotEnd.id !== lessonBundle.slotStart.id; }, - canProlongLesson(lesson) { + canProlongLessonBundle(lessonBundle) { const nextSlot = this.slots .filter( (slot) => - slot.weekday === lesson.slotEnd.weekday && - slot.period > lesson.slotEnd.period, + slot.weekday === lessonBundle.slotEnd.weekday && + slot.period > lessonBundle.slotEnd.period, ) .reduce( (prev, current) => @@ -594,43 +604,43 @@ export default defineComponent({ return !!nextSlot; }, - changeLessonSlots(lesson, slotStart, slotEnd) { + changeLessonBundleSlots(lessonBundle, slotStart, slotEnd) { let that = this; this.$apollo .mutate({ - mutation: moveLesson, + mutation: moveLessonBundles, variables: { input: { - id: lesson.id, + id: lessonBundle.id, slotStart: slotStart.id, slotEnd: slotEnd.id, }, }, optimisticResponse: { - updateLessons: { - lessons: [ + updateLessonBundles: { + lessonBundles: [ { - ...lesson, + ...lessonBundle, slotStart: slotStart, slotEnd: slotEnd, isOptimistic: true, }, ], - __typename: "LessonBatchPatchMutation", + __typename: "LessonBundleBatchPatchMutation", }, }, update( store, { data: { - updateLessons: { lessons }, + updateLessonBundles: { lessonBundles }, }, }, ) { let query = { - ...that.$apollo.queries.lessonObjects.options, + ...that.$apollo.queries.lessonBundles.options, variables: JSON.parse( - that.$apollo.queries.lessonObjects.previousVariablesJson, + that.$apollo.queries.lessonBundles.previousVariablesJson, ), }; // Read the data from cache for query @@ -641,12 +651,14 @@ export default defineComponent({ return; } - lessons.forEach((lesson) => { - const index = storedData.lessonObjects.findIndex( - (lessonObject) => lessonObject.id === lesson.id, + lessonBundles.forEach((lessonBundle) => { + const index = storedData.lessonBundles.findIndex( + (lessonBundleObject) => + lessonBundleObject.id === lessonBundle.id, ); - storedData.lessonObjects[index].slotStart = lesson.slotStart; - storedData.lessonObjects[index].slotEnd = lesson.slotEnd; + storedData.lessonBundles[index].slotStart = + lessonBundle.slotStart; + storedData.lessonBundles[index].slotEnd = lessonBundle.slotEnd; // Write data back to the cache store.writeQuery({ ...query, data: storedData }); @@ -655,45 +667,57 @@ export default defineComponent({ }) .then(() => { this.$toastSuccess( - "lesrooster.timetable_management.snacks.lesson_change_length.success", + this.$t( + "lesrooster.timetable_management.snacks.lesson_change_length.success", + ), ); }) .catch(() => { this.$toastError( - "lesrooster.timetable_management.snacks.lesson_change_length.error", + this.$t( + "lesrooster.timetable_management.snacks.lesson_change_length.error", + ), ); }); }, - prolongLesson(lesson) { + prolongLessonBundle(lessonBundle) { // Find next slot on the same day const slotEnd = this.slots .filter( (slot) => - slot.weekday === lesson.slotEnd.weekday && - slot.period > lesson.slotEnd.period, + slot.weekday === lessonBundle.slotEnd.weekday && + slot.period > lessonBundle.slotEnd.period, ) .reduce((prev, current) => prev.period < current.period ? prev : current, ); - this.changeLessonSlots(lesson, lesson.slotStart, slotEnd); + this.changeLessonBundleSlots( + lessonBundle, + lessonBundle.slotStart, + slotEnd, + ); }, - shortenLesson(lesson) { + shortenLessonBundle(lessonBundle) { // Find previous slot on the same day const slotEnd = this.slots .filter( (slot) => - slot.weekday === lesson.slotEnd.weekday && - slot.period < lesson.slotEnd.period, + slot.weekday === lessonBundle.slotEnd.weekday && + slot.period < lessonBundle.slotEnd.period, ) .reduce((prev, current) => prev.period > current.period ? prev : current, ); - this.changeLessonSlots(lesson, lesson.slotStart, slotEnd); + this.changeLessonBundleSlots( + lessonBundle, + lessonBundle.slotStart, + slotEnd, + ); }, - deleteLesson(lesson) { - this.itemsToDelete = [lesson]; + deleteLessonBundle(lessonBundle) { + this.itemsToDelete = [lessonBundle]; this.deleteDialog = true; }, teacherClick(teacher) { @@ -735,9 +759,9 @@ export default defineComponent({ }, handleLessonEditUpdate(store, lesson) { const query = { - ...this.$apollo.queries.lessonObjects.options, + ...this.$apollo.queries.lessonBundles.options, variables: JSON.parse( - this.$apollo.queries.lessonObjects.previousVariablesJson, + this.$apollo.queries.lessonBundles.previousVariablesJson, ), }; // Read the data from cache for query @@ -748,29 +772,43 @@ export default defineComponent({ return; } - const index = storedData.lessonObjects.findIndex( - (lessonObject) => lessonObject.id === lesson.id, - ); + let lessonIndex = -1; - if (index === -1) { + const bundleIndex = storedData.lessonBundles.findIndex((lessonBundle) => { + const index = lessonBundle.lessons.findIndex( + (lessonObject) => lessonObject.id === lesson.id, + ); + + if (index < 0) { + return false; + } + + lessonIndex = index; + return true; + }); + + if (bundleIndex === -1 || lessonIndex === -1) { return; } - storedData.lessonObjects[index].subject = lesson.subject; - storedData.lessonObjects[index].teachers = lesson.teachers; - storedData.lessonObjects[index].rooms = lesson.rooms; + storedData.lessonBundles[bundleIndex].lessons[lessonIndex].subject = + lesson.subject; + storedData.lessonBundles[bundleIndex].lessons[lessonIndex].teachers = + lesson.teachers; + storedData.lessonBundles[bundleIndex].lessons[lessonIndex].rooms = + lesson.rooms; // Write data back to the cache store.writeQuery({ ...query, data: storedData }); }, handleLessonEditSave() { this.$toastSuccess( - "lesrooster.timetable_management.snacks.lesson_edit.success", + this.$t("lesrooster.timetable_management.snacks.lesson_edit.success"), ); }, handleLessonEditError() { this.$toastError( - "lesrooster.timetable_management.snacks.lesson_edit.error", + this.$t("lesrooster.timetable_management.snacks.lesson_edit.error"), ); }, lessonEditGetPatchData(lesson) { @@ -953,7 +991,7 @@ export default defineComponent({ context="timetable" :disabled-fields="disabledSlots" @itemChanged="itemMovedToLessons" - grid-id="lessons" + grid-id="lessonBundles" id="timetable" multiple-items-y @containerDragStart="handleContainerDrag($event, 'start')" @@ -964,14 +1002,14 @@ export default defineComponent({ open-on-hover offset-y :open-on-click="false" - rounded="pill" + :rounded="item.data.lessons.length === 1 ? 'pill' : 'xl'" bottom min-width="max-content" nudge-right="40%" > <template #activator="{ attrs, on }"> - <lesson-card - :lesson="item.data" + <bundle-card + :bundle="item.data" rounded="lg" class="d-flex" v-bind="attrs" @@ -983,31 +1021,55 @@ export default defineComponent({ /> </template> - <v-card rounded="pill" style="width: max-content"> - <v-btn - icon - :disabled="!item.data.canDelete" - @click="deleteLesson(item.data)" - > - <v-icon>$deleteContent</v-icon> - </v-btn> - <v-btn - icon - :disabled="!canShortenLesson(item.data)" - @click="shortenLesson(item.data)" - > - <v-icon>mdi-minus</v-icon> - </v-btn> - <v-btn - icon - :disabled="!canProlongLesson(item.data)" - @click="prolongLesson(item.data)" - > - <v-icon>mdi-plus</v-icon> - </v-btn> - <v-btn icon @click="editLessonClick(item.data)"> - <v-icon>$edit</v-icon> - </v-btn> + <v-card + style="width: max-content" + class="d-flex flex-column align-center" + > + <div> + <v-btn + icon + :disabled="!item.data.canDelete" + @click="deleteLessonBundle(item.data)" + > + <v-icon>$deleteContent</v-icon> + </v-btn> + <v-btn + icon + :disabled="!canShortenLessonBundle(item.data)" + @click="shortenLessonBundle(item.data)" + > + <v-icon>mdi-minus</v-icon> + </v-btn> + <v-btn + icon + :disabled="!canProlongLessonBundle(item.data)" + @click="prolongLessonBundle(item.data)" + > + <v-icon>mdi-plus</v-icon> + </v-btn> + <v-btn + v-if="item.data.lessons.length === 1" + icon + @click="editLessonClick(item.data.lessons[0])" + > + <v-icon>$edit</v-icon> + </v-btn> + </div> + <template v-if="item.data.lessons.length > 1"> + <v-list-item + v-for="lesson in item.data.lessons" + :key="lesson.id" + dense + @click="editLessonClick(lesson)" + > + <v-list-item-icon> + <v-icon>$edit</v-icon> + </v-list-item-icon> + <v-list-item-title> + {{ lesson.subject.name }} + </v-list-item-title> + </v-list-item> + </template> </v-card> </v-menu> </template> @@ -1027,15 +1089,15 @@ export default defineComponent({ <blocking-card v-show="isDraggedOver" /> </v-fade-transition> </template> - <template v-for="overlayLesson in overlayLessons"> + <template v-for="overlayBundle in overlayBundles"> <timetable-overlay-card v-if="draggedItem" - v-show="draggedItem?.data.id !== overlayLesson.id" + v-show="draggedItem?.data.id !== overlayBundle.id" :dragged-item="draggedItem?.data" :periods="periods" :weekdays="weekdays" - :lesson="overlayLesson" - :key="'overlay-' + overlayLesson.id" + :bundle="overlayBundle" + :key="'overlay-' + overlayBundle.id" /> </template> </drag-grid> @@ -1055,7 +1117,7 @@ export default defineComponent({ persistent-hint /> <v-data-iterator - :items="selectableCourses" + :items="griddedCourseBundles" item-key="key" :items-per-page="-1" single-expand @@ -1068,17 +1130,17 @@ export default defineComponent({ :cols="3" :rows="4" :value="items" - :loading="$apollo.queries.courses.loading" + :loading="$apollo.queries.courseBundles.loading" no-highlight context="timetable" @itemChanged="itemMovedToCourses" - grid-id="courses" + grid-id="courseBundles" @containerDragStart="handleContainerDrag($event, 'start')" @containerDragEnd="handleContainerDrag($event, 'end')" > <template #item="item"> - <lesson-card - :lesson="item.data" + <bundle-card + :bundle="item.data" rounded="lg" :highlighted-rooms="draggedRooms" :highlighted-teachers="draggedTeachers" @@ -1086,7 +1148,7 @@ export default defineComponent({ @click:room="roomClick" > <lesson-ratio-chip :course="item.data" /> - </lesson-card> + </bundle-card> </template> <template #loader> <v-skeleton-loader type="image" /> @@ -1156,14 +1218,20 @@ export default defineComponent({ <delete-dialog :gql-delete-mutation="deleteMutation" - :affected-query="$apollo.queries.lessonObjects" + :affected-query="$apollo.queries.lessonBundles" + gql-data-key="lessonBundles" v-model="deleteDialog" :items="itemsToDelete" > <template #body> <ul class="text-body-1"> <li v-for="item in itemsToDelete" :key="'delete-' + item.id"> - {{ item.subject?.name || item.course.subject.name }} + <span + v-for="lesson in item.lessons" + :key="'delete-' + lesson.subject.id" + > + {{ lesson.subject.name }} + </span> </li> </ul> </template> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableOverlayCard.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableOverlayCard.vue index 92de1150203ed4deeca5a68324b22b53953cd96d..21588381071ab35528b915cccff09c5c21499e4b 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableOverlayCard.vue +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableOverlayCard.vue @@ -2,11 +2,14 @@ import BlockingCard from "./BlockingCard.vue"; import ColoredShortNameChip from "../common/ColoredShortNameChip.vue"; +import bundleAccessorsMixin from "../../mixins/bundleAccessorsMixin.js"; + export default { name: "TimetableOverlayCard", components: { ColoredShortNameChip, BlockingCard }, + mixins: [bundleAccessorsMixin], props: { - lesson: { + bundle: { type: Object, required: true, }, @@ -25,38 +28,39 @@ export default { }, computed: { x() { - return this.weekdays.indexOf(this.lesson.slotStart.weekday) + 1; + return this.weekdays.indexOf(this.bundle.slotStart.weekday) + 1; }, y() { - return this.periods.indexOf(this.lesson.slotStart.period) + 1; + return this.periods.indexOf(this.bundle.slotStart.period) + 1; }, w() { return ( - this.weekdays.indexOf(this.lesson.slotEnd.weekday) - - this.weekdays.indexOf(this.lesson.slotStart.weekday) + + this.weekdays.indexOf(this.bundle.slotEnd.weekday) - + this.weekdays.indexOf(this.bundle.slotStart.weekday) + 1 ); }, h() { return ( - this.periods.indexOf(this.lesson.slotEnd.period) - - this.periods.indexOf(this.lesson.slotStart.period) + + this.periods.indexOf(this.bundle.slotEnd.period) - + this.periods.indexOf(this.bundle.slotStart.period) + 1 ); }, rooms() { - // dragged item may be a course which doesn't have a field rooms - return this.lesson.rooms?.filter( - (lessonRoom) => - !!this.draggedItem.rooms?.find((room) => room.id === lessonRoom.id), + const selectedRooms = this.bundleRooms(this.draggedItem).map( + (room) => room.id, + ); + return this.bundleRooms(this.bundle)?.filter((room) => + selectedRooms.includes(room.id), ); }, teachers() { - return this.lesson.teachers.filter( - (lessonTeacher) => - !!this.draggedItem.teachers.find( - (teacher) => teacher.id === lessonTeacher.id, - ), + const selectedTeachers = this.bundleTeachers(this.draggedItem).map( + (teacher) => teacher.id, + ); + return this.bundleTeachers(this.bundle)?.filter((teacher) => + selectedTeachers.includes(teacher.id), ); }, }, diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql index 8f29aa75315e02433be28051c860bed2990cdcdd..54f97b8eccddc539765773f6491806207764cf23 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql @@ -12,39 +12,46 @@ query gqlGroups($timeGrid: ID!) { } } -query courses($group: ID!, $timeGrid: ID!) { - courses: courseObjectsForGroup(group: $group, timeGrid: $timeGrid) { +query courseBundles($group: ID!, $validityRange: ID!) { + courseBundles: courseBundlesForGroup( + group: $group + validityRange: $validityRange + ) { id - courseId name - subject { + lessonQuota + courses { id name - shortName - colourFg - colourBg + lessonQuota + subject { + id + name + shortName + colourFg + colourBg + } teachers { id fullName shortName } + defaultRoom { + id + name + shortName + } + groups { + id + name + shortName + } } - teachers { - id - fullName - shortName - } - groups { - id - name - shortName - } - lessonQuota } } -query lessonObjects($group: ID!, $timeGrid: ID!) { - lessonObjects: lessonObjectsForGroup(group: $group, timeGrid: $timeGrid) { +query lessonBundles($group: ID!, $timeGrid: ID!) { + lessonBundles: lessonBundlesForGroup(group: $group, timeGrid: $timeGrid) { id slotStart { id @@ -56,62 +63,42 @@ query lessonObjects($group: ID!, $timeGrid: ID!) { period weekday } - subject { - id - name - shortName - colourFg - colourBg - teachers { - id - fullName - shortName - } - } - teachers { - id - fullName - shortName - } - rooms { + recurrence + courseBundle { id - name - shortName } - course { + lessons { id - name subject { id name + shortName colourFg colourBg - teachers { - id - fullName - shortName - } } teachers { id fullName shortName } - groups { + rooms { id name shortName } + course { + id + name + } } isOptimistic - recurrence canEdit canDelete } } -query overlayLessons($rooms: [ID]!, $teachers: [ID]!, $timeGrid: ID!) { - overlayLessons: lessonsObjectsForRoomsOrTeachers( +query overlayBundles($rooms: [ID]!, $teachers: [ID]!, $timeGrid: ID!) { + overlayBundles: lessonBundlesForRoomsOrTeachers( rooms: $rooms teachers: $teachers timeGrid: $timeGrid @@ -127,20 +114,23 @@ query overlayLessons($rooms: [ID]!, $teachers: [ID]!, $timeGrid: ID!) { period weekday } - rooms { - id - shortName - } - teachers { + lessons { id - shortName + teachers { + id + shortName + } + rooms { + id + shortName + } } } } -mutation createLessons($input: [BatchCreateLessonInput]!) { - createLessons(input: $input) { - items: lessons { +mutation createLessonBundles($input: [BatchCreateLessonBundleInput]!) { + createLessonBundles(input: $input) { + items: lessonBundles { id slotStart { id @@ -152,26 +142,12 @@ mutation createLessons($input: [BatchCreateLessonInput]!) { period weekday } - subject { - id - name - shortName - colourFg - colourBg - } - teachers { + recurrence + courseBundle { id - fullName - shortName } - rooms { + lessons { id - name - shortName - } - course { - id - name subject { id name @@ -184,23 +160,26 @@ mutation createLessons($input: [BatchCreateLessonInput]!) { fullName shortName } - groups { + rooms { id name shortName } + course { + id + name + } } isOptimistic - recurrence canEdit canDelete } } } -mutation moveLesson($input: [BatchPatchLessonInput]!) { - updateLessons(input: $input) { - lessons { +mutation moveLessonBundles($input: [BatchPatchLessonBundleInput]!) { + updateLessonBundles(input: $input) { + lessonBundles { id slotStart { id @@ -245,8 +224,8 @@ mutation updateLessons($input: [BatchPatchLessonInput]!) { } } -mutation deleteLessons($ids: [ID]!) { - deleteLessons(ids: $ids) { +mutation deleteLessonBundles($ids: [ID]!) { + deleteLessonBundles(ids: $ids) { deletionCount } } diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue index 988a9b836070a2520c0515e7b359226419624e79..f32e518e2d6a4c714640ab3e3eec95410a0fb000 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue @@ -73,10 +73,10 @@ export default defineComponent({ ); this.lessons?.forEach((lesson) => { - const weekdayStart = lesson.slotStart.weekday; - const weekdayEnd = lesson.slotEnd.weekday; - const periodStart = lesson.slotStart.period; - const periodEnd = lesson.slotEnd.period; + const weekdayStart = lesson.bundle[0].slotStart.weekday; + const weekdayEnd = lesson.bundle[0].slotEnd.weekday; + const periodStart = lesson.bundle[0].slotStart.period; + const periodEnd = lesson.bundle[0].slotEnd.period; // If lesson start and end is on the same day, just add it in between the periods if (weekdayStart === weekdayEnd) { diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql index 3bd1897b5674ad1baf196e8e820a57e5c4afacf2..9bbcc2eb5a3aeaea2c5210acc1b3a59f85bad9ba 100644 --- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql @@ -1,18 +1,18 @@ query lessonsTeacher($teacher: ID!, $timeGrid: ID!) { - lessonsTeacher: lessonObjectsForTeacher( - teacher: $teacher - timeGrid: $timeGrid - ) { + lessonsTeacher: lessonsForTeacher(teacher: $teacher, timeGrid: $timeGrid) { id - slotStart { - id - period - weekday - } - slotEnd { - id - period - weekday + bundle { + slotStart { + id + period + weekday + } + slotEnd { + id + period + weekday + } + recurrence } subject { id @@ -50,24 +50,26 @@ query lessonsTeacher($teacher: ID!, $timeGrid: ID!) { shortName } } - recurrence canEdit canDelete } } query lessonsRoom($room: ID!, $timeGrid: ID!) { - lessonsRoom: lessonObjectsForRoom(room: $room, timeGrid: $timeGrid) { + lessonsRoom: lessonsForRoom(room: $room, timeGrid: $timeGrid) { id - slotStart { - id - period - weekday - } - slotEnd { - id - period - weekday + bundle { + slotStart { + id + period + weekday + } + slotEnd { + id + period + weekday + } + recurrence } subject { id @@ -105,7 +107,6 @@ query lessonsRoom($room: ID!, $timeGrid: ID!) { shortName } } - recurrence canEdit canDelete } diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue new file mode 100644 index 0000000000000000000000000000000000000000..b7a4430cc5bdd753812b55288205ed13e1c81e67 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue @@ -0,0 +1,59 @@ +<template> + <ApolloMutation + v-if="item.status !== 'PUBLISHED'" + :mutation="publishValidityRange" + :variables="{ id: item.id }" + @done="onDone" + @error="handleMutationError" + tag="span" + > + <template #default="{ mutate, loading, error }"> + <confirm-dialog v-model="confirmDialog" @confirm="mutate()"> + <template #title> + {{ $t("lesrooster.validity_range.publish.confirm_title", item) }} + </template> + <template #text> + {{ + $t("lesrooster.validity_range.publish.confirm_explanation", item) + }} + </template> + <template #confirm> + {{ $t("lesrooster.validity_range.publish.confirm_button") }} + </template> + </confirm-dialog> + <secondary-action-button + icon-text="mdi-publish" + i18n-key="lesrooster.validity_range.publish.button" + @click="confirmDialog = true" + :loading="loading" + ></secondary-action-button> + </template> + </ApolloMutation> +</template> + +<script setup> +import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue"; +import ConfirmDialog from "aleksis.core/components/generic/dialogs/ConfirmDialog.vue"; +</script> +<script> +import { publishValidityRange } from "./validityRange.graphql"; +export default { + name: "PublishValidityRange", + data() { + return { + confirmDialog: false, + }; + }, + methods: { + onDone() { + this.$activateFrequentCeleryPolling(); + }, + }, + props: { + item: { + type: Object, + required: true, + }, + }, +}; +</script> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue index 69246ca805cd43a8995de9088ef29454d2ed4e3e..73134aa416360dfdec9e2c07a906ed945cb0400b 100644 --- a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue @@ -111,10 +111,6 @@ export default defineComponent({ return-object ref="field" > - <template #item="{ item }"> - {{ formatItem(item) }} - </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> <template #validityRange.field="{ attrs, on }"> <div aria-required="true"> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue index 52a8156217b86c1f6bae0e71449681e4125c3693..1530a99803b381e54d3a670b81fb283c4d0802a8 100644 --- a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue @@ -1,7 +1,6 @@ <script setup> -import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; +import CRUDList from "aleksis.core/components/generic/CRUDList.vue"; import DateField from "aleksis.core/components/generic/forms/DateField.vue"; -import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField.vue"; import TimeGridChip from "./TimeGridChip.vue"; import MessageBox from "aleksis.core/components/generic/MessageBox.vue"; import CreateButton from "aleksis.core/components/generic/buttons/CreateButton.vue"; @@ -9,11 +8,12 @@ import DialogObjectForm from "aleksis.core/components/generic/dialogs/DialogObje import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue"; import ValidityRangeStatusField from "./ValidityRangeStatusField.vue"; import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; +import PublishValidityRange from "./PublishValidityRange.vue"; </script> <template> <div> - <inline-c-r-u-d-list + <c-r-u-d-list :headers="headers" :i18n-key="i18nKey" create-item-i18n-key="lesrooster.validity_range.create_validity_range" @@ -24,40 +24,14 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; :default-item="defaultItem" :get-create-data="getCreateData" :get-patch-data="getPatchData" - filter + :enable-filter="true" show-expand + :enable-edit="true" ref="crudList" > <template #status="{ item }"> <validity-range-status-chip :value="item.status" /> </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #status.field="{ attrs, on }"> - <div aria-required="true"> - <validity-range-status-field - v-bind="attrs" - v-on="on" - required - :rules="required" - /> - </div> - </template> - - <template #schoolTerm="{ item }"> - {{ item.schoolTerm.name }} - </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #schoolTerm.field="{ attrs, on }"> - <div aria-required="true"> - <school-term-field - v-bind="attrs" - v-on="on" - return-object - required - :rules="required" - /> - </div> - </template> <template #dateStart="{ item }"> {{ $d(new Date(item.dateStart), "short") }} @@ -92,6 +66,12 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; </template> <template #filters="{ attrs, on }"> + <validity-range-status-field + v-bind="attrs('status__iexact')" + v-on="on('status__iexact')" + :label="$t('lesrooster.validity_range.status_label')" + /> + <date-field v-bind="attrs('date_end__gte')" v-on="on('date_end__gte')" @@ -105,6 +85,10 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; /> </template> + <template #actions="{ item }"> + <publish-validity-range :item="item" /> + </template> + <template #expanded-item="{ item }"> <v-sheet class="my-4"> <message-box type="error" v-if="item.timeGrids.length === 0"> @@ -155,7 +139,7 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; /> </v-sheet> </template> - </inline-c-r-u-d-list> + </c-r-u-d-list> <dialog-object-form is-create @@ -234,11 +218,7 @@ export default { { text: this.$t("lesrooster.validity_range.status_label"), value: "status", - }, - { - text: this.$t("school_term.title"), - value: "schoolTerm", - orderKey: "school_term__date_start", + disableEdit: true, }, { text: this.$t("lesrooster.validity_range.date_start"), @@ -258,7 +238,6 @@ export default { name: "", dateStart: "", dateEnd: "", - schoolTerm: "", }, required: [(value) => !!value || this.$t("forms.errors.required")], timeGrids: { @@ -309,20 +288,12 @@ export default { }, }, methods: { - getCreateData(item) { - return { - ...item, - schoolTerm: item.schoolTerm?.id, - }; - }, getPatchData(item) { item = { id: item.id, name: item.name, dateStart: item.dateStart, dateEnd: item.dateEnd, - schoolTerm: item.schoolTerm?.id, - status: item.status?.toLowerCase(), }; return Object.fromEntries( Object.entries(item).filter(([key, value]) => value !== undefined), diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue index 72ee632639fb6293d583e88d6297cc8896aebcd5..5be88f33b042d90d7cb241819a7df2c69f0e8936 100644 --- a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue @@ -1,7 +1,6 @@ <script setup> import ForeignKeyField from "aleksis.core/components/generic/forms/ForeignKeyField.vue"; import DateField from "aleksis.core/components/generic/forms/DateField.vue"; -import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField.vue"; </script> <template> @@ -14,23 +13,9 @@ import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField :gql-create-mutation="gqlCreateMutation" :gql-patch-mutation="{}" :default-item="defaultItem" - :get-create-data="getCreateData" :get-patch-data="getPatchData" return-object > - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #schoolTerm.field="{ attrs, on }"> - <div aria-required="true"> - <school-term-field - v-bind="attrs" - v-on="on" - return-object - :rules="required" - required - /> - </div> - </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> <template #dateStart.field="{ attrs, on, item }"> <div aria-required="true"> @@ -70,10 +55,6 @@ export default { text: this.$t("lesrooster.validity_range.name"), value: "name", }, - { - text: this.$t("school_term.title"), - value: "schoolTerm", - }, { text: this.$t("lesrooster.validity_range.date_start"), value: "dateStart", @@ -90,25 +71,17 @@ export default { name: "", dateStart: "", dateEnd: "", - schoolTerm: "", }, required: [(value) => !!value || this.$t("forms.errors.required")], }; }, methods: { - getCreateData(item) { - return { - ...item, - schoolTerm: item.schoolTerm?.id, - }; - }, getPatchData(item) { item = { id: item.id, name: item.name, dateStart: item.dateStart, dateEnd: item.dateEnd, - schoolTerm: item.schoolTerm.id, }; return Object.fromEntries( Object.entries(item).filter(([key, value]) => value !== undefined), diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql index b6ec43791ed725fa9ddfed3c564ac28c6a602e56..e33b42b7d41126b1de7ff32af34946995b238bf9 100644 --- a/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql @@ -3,10 +3,6 @@ query validityRanges($orderBy: [String], $filters: JSONString) { id name status - schoolTerm { - id - name - } timeGrids { id group { @@ -28,10 +24,6 @@ mutation createValidityRanges($input: [BatchCreateValidityRangeInput]!) { id name status - schoolTerm { - id - name - } timeGrids { id group { @@ -60,10 +52,28 @@ mutation updateValidityRanges($input: [BatchPatchValidityRangeInput]!) { id name status - schoolTerm { + timeGrids { id - name + group { + id + name + shortName + } } + dateStart + dateEnd + canEdit + canDelete + } + } +} + +mutation publishValidityRange($id: ID!) { + publishValidityRange(id: $id) { + validityRange { + id + name + status timeGrids { id group { @@ -86,9 +96,6 @@ query currentValidityRange { name dateStart dateEnd - schoolTerm { - id - } } } diff --git a/aleksis/apps/lesrooster/frontend/messages/de.json b/aleksis/apps/lesrooster/frontend/messages/de.json index 7f151145f424740ff0475eaaf2b34794baee5c2c..da44980d0e3f51e4ceccff6fb30c04ee4cb04835 100644 --- a/aleksis/apps/lesrooster/frontend/messages/de.json +++ b/aleksis/apps/lesrooster/frontend/messages/de.json @@ -1,143 +1,148 @@ { + "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": { - "menu_title": "Unterrichtsmanagement", - "validity_range": { - "menu_title": "Gültigkeitszeiträume", - "title": "Gültigkeitszeitraum", - "title_plural": "Gültigkeitszeitraum", - "name": "Name", - "date_start": "Startdatum", - "date_end": "Enddatum", - "create_validity_range": "Gültigkeitszeitraum erstellen", - "status_label": "Status", - "status": { - "draft": "Entwurf", - "published": "Veröffentlicht" + "break": { + "create_item": "Pause erstellen", + "create_items": "Pausen erstellen", + "create_items_error": "Fehler beim Erstellen der Pausen.", + "create_items_success": "Pausen erfolgreich erstellt.", + "menu_title": "Pausen", + "repr": { + "default": "Pause von {timeStart} bis {timeEnd}", + "weekday_short": "{weekday}, {timeStart} bis {timeEnd}" }, - "time_grid": { - "generic": "Generisch (Platzhalter für alle Gruppen)", - "explanations": { - "none_created": "Diesem Gültigkeitszeitraum wurden noch keine Gruppen zugeordnet, er kann daher noch nicht verwendet werden.", - "only_one_group": "Alle mit dem Gültigkeitszeitraum verbundenen Daten (wie Unterrichtszeiten) sind nur für die folgende Gruppe gültig.", - "only_generic": "Alle mit dem Gültigkeitszeitraum verbundenen Daten (wie Unterrichtszeiten) sind für alle Gruppen identisch.", - "multiple_set": "Mit dem Gültigkeitszeitraum verbundene Daten (wie Unterrichtszeiten) können sich für folgende Gruppen unterscheiden." - }, - "create": "Gruppe auswählen", - "create_long": "Gruppenspezifischen Gültigkeitszeitraum erstellen", - "fields": { - "is_generic": "Ist generisch", - "group": "Gruppe" - }, - "confirm_delete_body": "Wenn diese Gruppe von diesem Gültigkeitszeitraum entfernt wird, werden alle zugehörigen Daten wie Unterrichtszeiten oder Stundenpläne gelöscht.", - "repr": { - "default": "{0} ({1})", - "generic": "{name} (generisch/gültig für alle)" - } - } + "title": "Pause", + "title_plural": "Pausen" + }, + "lesson_raster": { + "menu_title": "Stundenraster" }, + "menu_title": "Unterrichtsmanagement", "slot": { + "confirm_delete_multiple_slots": "Wollen Sie wirklich alle Zeitfenster am {day} löschen?", + "create_items": "Zeitfenster erstellen", + "create_items_error": "Fehler beim Erstellen der Zeitfenster.", + "create_items_success": "Zeitfenster erfolgreich erstellt.", + "create_slot": "Zeitfenster erstellen", "menu_title": "Zeitfenster", - "title": "Zeitfenster", - "title_plural": "Zeitfenster", "name": "Name", - "weekday": "Wochentag", - "weekdays": "Wochentage", "period": "Stunde", - "period_lte": "Stunde bis", "period_gte": "Stunde ab", - "time_start": "Startzeitpunkt", + "period_lte": "Stunde bis", + "repr": "Zeitfenster in Stunde {period}, von {timeStart} bis {timeEnd}", "time_end": "Endzeitpunkt", - "create_slot": "Zeitfenster erstellen", - "create_items": "Zeitfenster erstellen", - "create_items_success": "Zeitfenster erfolgreich erstellt.", - "create_items_error": "Fehler beim Erstellen der Zeitfenster.", - "confirm_delete_multiple_slots": "Wollen Sie wirklich alle Zeitfenster am {day} löschen?", - "repr": "Zeitfenster in Stunde {period}, von {timeStart} bis {timeEnd}" + "time_start": "Startzeitpunkt", + "title": "Zeitfenster", + "title_plural": "Zeitfenster", + "weekday": "Wochentag", + "weekdays": "Wochentage" }, - "break": { - "menu_title": "Pausen", - "title": "Pause", - "title_plural": "Pausen", - "create_item": "Pause erstellen", - "create_items": "Pausen erstellen", - "create_items_success": "Pausen erfolgreich erstellt.", - "create_items_error": "Fehler beim Erstellen der Pausen.", - "repr": { - "default": "Pause von {timeStart} bis {timeEnd}", - "weekday_short": "{weekday}, {timeStart} bis {timeEnd}" - } + "supervision": { + "break_slot": "Pause", + "create_supervision": "Aufsicht erstellen", + "menu_title": "Aufsichten", + "rooms": "Räume", + "subject": "Fach", + "teachers": "Lehrkräfte", + "title": "Aufsicht", + "title_plural": "Aufsichten" }, "timebound_course_config": { - "crud_table_menu_title": "Kurskonfigurationen", - "raster_menu_title": "Kurse planen", - "title": "Kurskonfiguration", - "title_plural": "Kurskonfigurationen", - "lesson_quota": "Stundenpensum", + "all_teachers": "Alle Lehrkräfte", "course": "Kurs", + "create_timebound_course_config": "Kurskonfiguration erstellen", + "crud_table_menu_title": "Kurskonfigurationen", "groups": "Gruppen", + "lesson_quota": "Stundenpensum", + "no_course_selected": "Kein Kurs ausgewählt", + "raster_menu_title": "Kurse planen", + "subject": "Fach", + "subject_teachers": "Fachlehrkräfte", "teachers": "Lehrkräfte", "teachers_for": "Lehrkräfte für", - "subject_teachers": "Fachlehrkräfte", - "all_teachers": "Alle Lehrkräfte", - "no_course_selected": "Kein Kurs ausgewählt", - "create_timebound_course_config": "Kurskonfiguration erstellen", - "subject": "Fach" - }, - "lesson_raster": { - "menu_title": "Stundenraster" + "title": "Kurskonfiguration", + "title_plural": "Kurskonfigurationen" }, "timetable_management": { - "menu_title": "Stundenplanung", - "for_group": "Stundenplanung für die Gruppe {group}", - "timetable_for": "Stundenplan für {name}", - "no_lessons": "Es gibt noch keine Stunden in diesem Plan", - "no_slots": "Für diesen Plan sind noch keine Unterrichtszeiten definiert", "back": "Zurück zur Schulstruktur", - "lessons_used_ratio": "{lessonsUsed}/{lessonQuota}", - "lessons_used_ratio_total": "{lessonsUsed}/{lessonQuota} Stunden verplant", + "for_group": "Stundenplanung für die Gruppe {group}", "lesson_fields": { - "subject": "Fach", "rooms": "Räume", - "teachers": "Lehrer" + "subject": "Fach", + "teachers": "Lehrkräfte" }, + "lessons_used_ratio": "{lessonsUsed}/{lessonQuota}", + "lessons_used_ratio_total": "{lessonsUsed}/{lessonQuota} Stunden verplant", + "menu_title": "Stundenplanung", + "no_lessons": "Es gibt noch keine Stunden in diesem Plan", + "no_slots": "Für diesen Plan sind noch keine Unterrichtszeiten definiert", "snacks": { - "lesson_edit": { - "success": "Stunde erfolgreich bearbeitet.", - "error": "Fehler beim Bearbeiten der Stunde." + "lesson_change_length": { + "error": "Fehler beim Verändern der Stundenlänge.", + "success": "Stundenlänge erfolgreich angepasst" }, "lesson_create": { - "success": "Stunde erfolgreich erstellt.", - "error": "Fehler beim Erstellen der Stunde." + "error": "Fehler beim Erstellen der Stunde.", + "success": "Stunde erfolgreich erstellt." }, - "lesson_move": { - "success": "Stunde erfolgreich verschoben.", - "error": "Fehler beim Verschieben der Stunde." + "lesson_edit": { + "error": "Fehler beim Bearbeiten der Stunde.", + "success": "Stunde erfolgreich bearbeitet." }, - "lesson_change_length": { - "success": "Stundenlänge erfolgreich angepasst", - "error": "Fehler beim Verändern der Stundenlänge." + "lesson_move": { + "error": "Fehler beim Verschieben der Stunde.", + "success": "Stunde erfolgreich verschoben." } - } + }, + "timetable_for": "Stundenplan für {name}" }, - "supervision": { - "menu_title": "Aufsichten", - "title": "Aufsicht", - "title_plural": "Aufsichten", - "create_supervision": "Aufsicht erstellen", - "break_slot": "Pause", - "rooms": "Räume", - "teachers": "Lehrkräfte", - "subject": "Fach" + "validity_range": { + "create_validity_range": "Gültigkeitszeitraum erstellen", + "date_end": "Enddatum", + "date_start": "Startdatum", + "menu_title": "Gültigkeitszeiträume", + "name": "Name", + "publish": { + "button": "Veröffentlichen", + "confirm_button": "Veröffentlichen", + "confirm_explanation": "Bitte beachten Sie, dass dies den gesamten Stundenplan veröffentlichen und für alle sichtbar machen wird. Außerdem werden Sie das Zeitraster, die Kurskonfigurationen und den Stundenplan in diesem Gültigkeitsbereich nicht mehr ändern können, nach dem er veröffentlicht wurde." + }, + "status": { + "draft": "Entwurf", + "published": "Veröffentlicht" + }, + "status_label": "Status", + "time_grid": { + "confirm_delete_body": "Wenn diese Gruppe von diesem Gültigkeitszeitraum entfernt wird, werden alle zugehörigen Daten wie Unterrichtszeiten oder Stundenpläne gelöscht.", + "create": "Gruppe auswählen", + "create_long": "Gruppenspezifischen Gültigkeitszeitraum erstellen", + "explanations": { + "multiple_set": "Mit dem Gültigkeitszeitraum verbundene Daten (wie Unterrichtszeiten) können sich für folgende Gruppen unterscheiden.", + "none_created": "Diesem Gültigkeitszeitraum wurden noch keine Gruppen zugeordnet, er kann daher noch nicht verwendet werden.", + "only_generic": "Alle mit dem Gültigkeitszeitraum verbundenen Daten (wie Unterrichtszeiten) sind für alle Gruppen identisch.", + "only_one_group": "Alle mit dem Gültigkeitszeitraum verbundenen Daten (wie Unterrichtszeiten) sind nur für die folgende Gruppe gültig." + }, + "fields": { + "group": "Gruppe", + "is_generic": "Ist generisch" + }, + "generic": "Generisch (Platzhalter für alle Gruppen)", + "repr": { + "default": "{0} ({1})", + "generic": "{name} (generisch/gültig für alle)" + } + }, + "title": "Gültigkeitszeitraum", + "title_plural": "Gültigkeitszeitraum" } - }, - "actions": { - "copy_to_day": "Zu anderem Tag übernehmen", - "search_courses": "Kurse durchsuchen", - "copy_last_configuration": "Aus anderem Zeitraum übernehmen", - "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." - }, - "labels": { - "select_validity_range": "Gültigkeitszeitraum auswählen" } } diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json index 02c440cdda5ed367fadaa9164557cf783c7eaa47..72325fa572621c2e33dc878d6b4d10cd7025a154 100644 --- a/aleksis/apps/lesrooster/frontend/messages/en.json +++ b/aleksis/apps/lesrooster/frontend/messages/en.json @@ -14,6 +14,12 @@ "draft": "Draft", "published": "Published" }, + "publish": { + "button": "Publish", + "confirm_title": "Are you sure that you want to publish the validity range \"{name}\"?", + "confirm_explanation": "Please be aware that this will publish the whole timetable and make it visible for everyone. Additionally, you won't be able to change the time grid, the course configs, and the timetable in this validity range after it's published.", + "confirm_button": "Publish" + }, "time_grid": { "generic": "Generic (catch-all)", "explanations": { diff --git a/aleksis/apps/lesrooster/frontend/messages/ru.json b/aleksis/apps/lesrooster/frontend/messages/ru.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/messages/ru.json @@ -0,0 +1 @@ +{} diff --git a/aleksis/apps/lesrooster/frontend/messages/uk.json b/aleksis/apps/lesrooster/frontend/messages/uk.json new file mode 100644 index 0000000000000000000000000000000000000000..af3ee077d7857d990dfd25c61f8244bf5e82f87e --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/messages/uk.json @@ -0,0 +1,150 @@ +{ + "actions": { + "confirm_copy_last_configuration": "Ви дійÑно хочете Ñкопіювати іншу конфігурацію у цей проміжок?", + "confirm_copy_last_configuration_message": "Це перезапише уÑÑ– наÑвні в цьому проміжку дані. Ð¦Ñ Ð´Ñ–Ñ Ð½ÐµÐ·Ð²Ð¾Ñ€Ð¾Ñ‚Ð½Ð°.", + "copy_last_configuration": "Скопіювати з інших проміжків", + "copy_to_day": "Скопіювати в інший день", + "search_courses": "Пошук курÑів" + }, + "labels": { + "select_validity_range": "Виберіть облаÑть дії" + }, + "lesrooster": { + "break": { + "create_item": "Створити перерву", + "create_items": "Створити перерви", + "create_items_error": "Помилка під Ñ‡Ð°Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð¿ÐµÑ€ÐµÑ€Ð².", + "create_items_success": "Перерви уÑпішно Ñтворені.", + "menu_title": "Перерви", + "repr": { + "default": "Перерва з {timeStart} по {timeEnd}", + "weekday_short": "{weekday}, {timeStart} - {timeEnd}" + }, + "title_0": "Перерва", + "title_1": "Перерви", + "title_2": "Перерв" + }, + "lesson_raster": { + "menu_title": "План уроків" + }, + "menu_title": "ÐšÐµÑ€ÑƒÐ²Ð°Ð½Ð½Ñ ÑƒÑ€Ð¾ÐºÐ°Ð¼Ð¸", + "slot": { + "confirm_delete_multiple_slots": "Ви дійÑно хочете видалити уÑÑ– Ñлоти та перерви Ð´Ð»Ñ {day}?", + "create_items": "Створити Ñлоти", + "create_items_error": "Помилка під Ñ‡Ð°Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ñлотів.", + "create_items_success": "Слоти уÑпішно Ñтворені.", + "create_slot": "Створити Ñлот (чаÑовий інтервал)", + "menu_title": "Слоти", + "name": "Ðазва", + "period": "Урок", + "period_gte": "Урок з", + "period_lte": "Урок до", + "repr": "Слот в інтервалі {period}, з {timeStart} по {timeEnd}", + "time_end": "Ð§Ð°Ñ Ð·Ð°ÐºÑ–Ð½Ñ‡ÐµÐ½Ð½Ñ", + "time_start": "Ð§Ð°Ñ Ð¿Ð¾Ñ‡Ð°Ñ‚ÐºÑƒ", + "title_0": "Слот", + "title_1": "Слоти", + "title_2": "Слотів", + "weekday": "День тижнÑ", + "weekdays": "Дні тижнÑ" + }, + "supervision": { + "break_slot": "Перерва", + "menu_title": "ÐаглÑдачі", + "rooms": "Кімнати", + "subject": "Предмет", + "teachers": "Викладачі" + }, + "timebound_course_config": { + "all_teachers": "УÑÑ– викладачі", + "course": "КурÑ", + "create_timebound_course_config": "Створити конфігурацію курÑу", + "crud_table_menu_title": "Конфігурації курÑу", + "groups": "Групи", + "lesson_quota": "Квота уроків за планом", + "no_course_selected": "ÐšÑƒÑ€Ñ Ð½Ðµ обраний", + "raster_menu_title": "ÐŸÐ»Ð°Ð½ÑƒÐ²Ð°Ð½Ð½Ñ ÐºÑƒÑ€Ñів", + "subject": "Предмет", + "subject_teachers": "Викладачі цього предмета", + "teachers": "Викладачі", + "teachers_for": "Викладачі длÑ", + "title_0": "ÐšÐ¾Ð½Ñ„Ñ–Ð³ÑƒÑ€Ð°Ñ†Ñ–Ñ ÐºÑƒÑ€Ñу", + "title_1": "Конфігурації курÑу", + "title_2": "Конфігурацій курÑу" + }, + "timetable_management": { + "back": "Ðазад до Ñтруктури н/з", + "for_group": "ÐšÐµÑ€ÑƒÐ²Ð°Ð½Ð½Ñ Ñ€Ð¾Ð·ÐºÐ»Ð°Ð´Ð¾Ð¼ Ð´Ð»Ñ Ð³Ñ€ÑƒÐ¿Ð¸ {group}", + "lesson_fields": { + "rooms": "Кімнати", + "subject": "Предмет", + "teachers": "Викладачі" + }, + "lessons_used_ratio": "{lessonsUsed}/{lessonQuota}", + "lessons_used_ratio_total": "Заплановано {lessonsUsed}/{lessonQuota} уроків", + "menu_title": "ÐšÐµÑ€ÑƒÐ²Ð°Ð½Ð½Ñ Ñ€Ð¾Ð·ÐºÐ»Ð°Ð´Ð¾Ð¼", + "no_lessons": "Ð’ цьому плані уроків ще немає", + "no_slots": "Ð”Ð»Ñ Ñ†ÑŒÐ¾Ð³Ð¾ плану чаÑові Ñлоти не визначені", + "snacks": { + "lesson_change_length": { + "error": "Помилка під Ñ‡Ð°Ñ Ð·Ð¼Ñ–Ð½Ð¸ довжини уроку.", + "success": "Довжина уроку уÑпішно змінена." + }, + "lesson_create": { + "error": "Помилка ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ ÑƒÑ€Ð¾ÐºÑƒ.", + "success": "Урок уÑпішно Ñтворений" + }, + "lesson_edit": { + "error": "Помилка під Ñ‡Ð°Ñ Ð¾Ð½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ ÑƒÑ€Ð¾ÐºÑƒ.", + "success": "Урок уÑпішно оновлений." + }, + "lesson_move": { + "error": "Помилка перенеÑÐµÐ½Ð½Ñ ÑƒÑ€Ð¾ÐºÑƒ.", + "success": "Урок уÑпішно перенеÑений." + } + }, + "timetable_for": "Розклад Ð´Ð»Ñ {name}" + }, + "validity_range": { + "create_validity_range": "Створити облаÑть дії", + "date_end": "Дата закінченнÑ", + "date_start": "Дата початку", + "menu_title": "ОблаÑті дії", + "name": "Ðазва", + "publish": { + "button": "Опублікувати", + "confirm_button": "Опублікувати", + "confirm_explanation": "Будь лаÑка, зверніть увагу, що Ñ†Ñ Ð´Ñ–Ñ Ð¾Ð¿ÑƒÐ±Ð»Ñ–ÐºÑƒÑ” веÑÑŒ розклад та зробить його видимим Ð´Ð»Ñ Ð²ÑÑ–Ñ…. Ðа додаток, піÑÐ»Ñ Ð¿ÑƒÐ±Ð»Ñ–ÐºÐ°Ñ†Ñ–Ñ— Ви не зможете змінити у цій облаÑті дій чаÑову Ñітку, Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ ÐºÑƒÑ€Ñів та розклад.", + "confirm_title": "Ви дійÑно хочете опублікувати облаÑть дії \"{name}\"?" + }, + "status": { + "draft": "Чернетка", + "published": "Опубліковано" + }, + "status_label": "Стан", + "time_grid": { + "confirm_delete_body": "Якщо видалити цю групу з облаÑті дії, уÑÑ– поєднані з нею дані, такі Ñк Ñлоти та уроки, будуть також видалені.", + "create": "Обрати групу", + "create_long": "Створити Ð´Ð»Ñ Ð³Ñ€ÑƒÐ¿Ð¸ облаÑть дії", + "explanations": { + "multiple_set": "Дані, поєднані з цією облаÑтю дії (напр. чаÑові Ñлоти Ð´Ð»Ñ ÑƒÑ€Ð¾ÐºÑ–Ð²) Ð´Ð»Ñ Ð²ÐºÐ°Ð·Ð°Ð½Ð¸Ñ… нижче груп можуть відрізнÑтиÑÑ.", + "none_created": "Ви ще не налаштували, Ð´Ð»Ñ Ñких груп працює Ñ†Ñ Ð¾Ð±Ð»Ð°Ñть дії, тож викориÑтати Ñ—Ñ— ще не можна.", + "only_generic": "Дані, поєднані з цією облаÑтю дії (напр. чаÑові Ñлоти Ð´Ð»Ñ ÑƒÑ€Ð¾ÐºÑ–Ð²) однакові Ð´Ð»Ñ Ð²ÑÑ–Ñ… груп.", + "only_one_group": "Дані, поєднані з цією облаÑтю дії (напр. чаÑові Ñлоти Ð´Ð»Ñ ÑƒÑ€Ð¾ÐºÑ–Ð²) дійÑні лише Ð´Ð»Ñ Ð²ÐºÐ°Ð·Ð°Ð½Ð¸Ñ… нижче груп." + }, + "fields": { + "group": "Група", + "is_generic": "Це загальне" + }, + "generic": "Загальний (Ð´Ð»Ñ Ð²ÑÑ–Ñ… груп)", + "repr": { + "default": "{0} ({1})", + "generic": "{name} (загальне/дійÑне Ð´Ð»Ñ Ð²ÑÑ–Ñ…)" + } + }, + "title_0": "ОблаÑть дії", + "title_1": "ОблаÑті дії", + "title_2": "ОблаÑтей дії" + } + } +} diff --git a/aleksis/apps/lesrooster/frontend/mixins/bundleAccessorsMixin.js b/aleksis/apps/lesrooster/frontend/mixins/bundleAccessorsMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..17a89bae697e53cc11b9eff7bf14691e7d37feb7 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/mixins/bundleAccessorsMixin.js @@ -0,0 +1,43 @@ +/** + * This mixin provides accessors for course*lesson bundles + */ +export default { + methods: { + /** + * Removes duplicate objects from an array based on their `id` property. + * + * @param {Array<{id: string|number, [key: string]: any}>} array - An array of objects, each having an `id` property and possibly other attributes. + * @return {Array<{id: string|number, [key: string]: any}>} - A new array with duplicates removed. + */ + removeDuplicatesById(array) { + return array.filter( + (value, index, self) => + index === + self.findIndex( + (t) => t.place === value.place && t.name === value.name, + ), + ); + }, + bundleChildren(bundle) { + return bundle.courses || bundle.lessons; + }, + bundleSubjects(bundle) { + const subjects = this.bundleChildren(bundle).flatMap( + (child) => child.subject, + ); + return this.removeDuplicatesById(subjects); + }, + bundleTeachers(bundle) { + const teachers = this.bundleChildren(bundle).flatMap( + (child) => child.teachers, + ); + return this.removeDuplicatesById(teachers); + }, + bundleRooms(bundle) { + const rooms = bundle.courses + ? bundle.courses.map((course) => course.defaultRoom) + : bundle.lessons.flatMap((lesson) => lesson.rooms); + return this.removeDuplicatesById(rooms.filter((room) => room)); + }, + }, +}; diff --git a/aleksis/apps/lesrooster/locale/ar/LC_MESSAGES/django.po b/aleksis/apps/lesrooster/locale/ar/LC_MESSAGES/django.po new file mode 100644 index 0000000000000000000000000000000000000000..8a61881a855a63b7826ade626c5d9f8a1ea2fa76 --- /dev/null +++ b/aleksis/apps/lesrooster/locale/ar/LC_MESSAGES/django.po @@ -0,0 +1,284 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-18 14:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " +"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" +#: aleksis/apps/lesrooster/model_extensions.py:14 +msgid "Slot by day (1-7) and period" +msgstr "" + +#: aleksis/apps/lesrooster/model_extensions.py:32 +msgid "Weekly recurrence (for lessons, supervisions etc.)" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:34 +msgid "Draft" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:35 +msgid "Published" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:46 +msgid "School term" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:49 aleksis/apps/lesrooster/models.py:256 +msgid "Name" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:51 +msgid "Start date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:52 +msgid "End date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:55 +msgid "Status" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:90 +msgid "You can't unpublish a validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:93 +msgid "The start date must be earlier than the end date." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:99 +msgid "The validity range must be within the school term." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:105 +msgid "The school term of a published validity range can't be changed." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:111 +msgid "You can't change the start date if the validity range is already active." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:114 +msgid "You can't set the start date to a date in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:119 +msgid "You can't change the end date if the validity range is already in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:124 +msgid "To avoid data loss, the validity range can be only shortened until the current day." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:139 +msgid "There is already a published validity range for this time or a part of this time." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:168 +msgid "Publish validity range {}" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:170 +msgid "All lessons and supervisions in the validity range {} are being synced …" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:172 +msgid "The validity range has been published successfully." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:173 +msgid "There was a problem while publishing the validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:191 +msgid "Validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:192 +msgid "Validity ranges" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:213 aleksis/apps/lesrooster/models.py:622 +msgid "Linked validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:218 +msgid "Group" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:231 aleksis/apps/lesrooster/models.py:253 +msgid "Time Grid" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:232 +msgid "Time Grids" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:259 +msgid "Week day" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:262 +msgid "Number of period" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:265 +msgid "Start time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:266 +msgid "End time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:342 +msgid "Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:343 +msgid "Slots" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:353 +msgid "Linked lesson event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:361 aleksis/apps/lesrooster/models.py:616 +msgid "Course" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:369 +msgid "Start slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:375 +msgid "End slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:382 aleksis/apps/lesrooster/models.py:541 +msgid "Recurrence" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:385 +msgid "Leave empty for a single lesson." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:392 aleksis/apps/lesrooster/models.py:515 +msgid "Rooms" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:398 aleksis/apps/lesrooster/models.py:520 +#: aleksis/apps/lesrooster/models.py:634 +msgid "Teachers" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:405 aleksis/apps/lesrooster/models.py:532 +msgid "Subject" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:423 +msgid "The slots must be in the same time grid." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:486 +msgid "Lesson" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:487 +msgid "Lessons" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:497 +msgid "Break" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:498 +msgid "Breaks" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:508 +msgid "Linked supervision event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:526 +msgid "Break Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:544 +msgid "Leave empty for a single supervision." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:557 +msgid "Supervision" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:558 +msgid "Supervisions" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:627 +msgid "Lesson quota" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:628 +msgid "Number of slots this course is scheduled to fill per week" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:644 +msgid "Timebound course config" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:645 +msgid "Timebound course configs" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:652 +msgid "Can manage lesson raster" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:653 +msgid "Can plan timetables" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:9 +msgid "Lesson management" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:17 +msgid "Create course group when planning new courses" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:19 +msgid "If creating a new course with the 'Plan courses' feature, also create a seperate course group with the original group(s) as parent group(s) and link that/these group(s) to the newly created course" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:33 +msgid "Group type for automatically created course groups" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:34 +msgid "If you leave it empty, no group type will be used." +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:45 +msgid "Fill course group with the given courses' members" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:47 +msgid "If creating a new course with the 'Plan courses' feature, fill the seperately created course group with the members of the original group(s)." +msgstr "" diff --git a/aleksis/apps/lesrooster/locale/de_DE/LC_MESSAGES/django.po b/aleksis/apps/lesrooster/locale/de_DE/LC_MESSAGES/django.po new file mode 100644 index 0000000000000000000000000000000000000000..d8bf811f246f92e43d111a165d5d1f441d2d4758 --- /dev/null +++ b/aleksis/apps/lesrooster/locale/de_DE/LC_MESSAGES/django.po @@ -0,0 +1,306 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-18 14:58+0200\n" +"PO-Revision-Date: 2024-08-19 10:52+0000\n" +"Last-Translator: Jonathan Weth <teckids@jonathanweth.de>\n" +"Language-Team: German <https://translate.edugit.org/projects/aleksis/" +"aleksis-app-lesrooster/de/>\n" +"Language: de_DE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.0.2\n" + +#: aleksis/apps/lesrooster/model_extensions.py:14 +msgid "Slot by day (1-7) and period" +msgstr "Zeitfenster nach Tag (1-7) und Stunde" + +#: aleksis/apps/lesrooster/model_extensions.py:32 +msgid "Weekly recurrence (for lessons, supervisions etc.)" +msgstr "Wöchentliche Wiederholung (für Unterrichtsstunden, Aufsichten etc.)" + +#: aleksis/apps/lesrooster/models.py:34 +msgid "Draft" +msgstr "Entwurf" + +#: aleksis/apps/lesrooster/models.py:35 +msgid "Published" +msgstr "Veröffentlicht" + +#: aleksis/apps/lesrooster/models.py:46 +msgid "School term" +msgstr "Schuljahr" + +#: aleksis/apps/lesrooster/models.py:49 aleksis/apps/lesrooster/models.py:256 +msgid "Name" +msgstr "Name" + +#: aleksis/apps/lesrooster/models.py:51 +msgid "Start date" +msgstr "Startdatum" + +#: aleksis/apps/lesrooster/models.py:52 +msgid "End date" +msgstr "Enddatum" + +#: aleksis/apps/lesrooster/models.py:55 +msgid "Status" +msgstr "Status" + +#: aleksis/apps/lesrooster/models.py:90 +msgid "You can't unpublish a validity range." +msgstr "" +"Sie können das Veröffentlichen eines Gültigkeitsbereichs nicht rückgängig " +"machen." + +#: aleksis/apps/lesrooster/models.py:93 +msgid "The start date must be earlier than the end date." +msgstr "Das Startdatum muss vor dem Enddatum liegen." + +#: aleksis/apps/lesrooster/models.py:99 +msgid "The validity range must be within the school term." +msgstr "Der Gültigkeitsbereich muss innerhalb des Schuljahres liegen." + +#: aleksis/apps/lesrooster/models.py:105 +msgid "The school term of a published validity range can't be changed." +msgstr "" +"Das Schuljahr eines veröffentlichten Gültigkeitsbereichs kann nicht geändert " +"werden." + +#: aleksis/apps/lesrooster/models.py:111 +msgid "You can't change the start date if the validity range is already active." +msgstr "" +"Sie können das Startdatum nicht ändern, wenn der Gültigkeitsbereich bereits " +"aktiv ist." + +#: aleksis/apps/lesrooster/models.py:114 +msgid "You can't set the start date to a date in the past." +msgstr "" +"Sie können das Startdatum nicht auf ein Datum in der Vergangenheit setzen." + +#: aleksis/apps/lesrooster/models.py:119 +msgid "You can't change the end date if the validity range is already in the past." +msgstr "" +"Sie können das Enddatum nicht ändern, wenn der Gültigkeitsbereich bereits in " +"der Vergangenheit liegt." + +#: aleksis/apps/lesrooster/models.py:124 +msgid "To avoid data loss, the validity range can be only shortened until the current day." +msgstr "" +"Um Datenverlust zu vermeiden kann der Gültigkeitsbereich nur bis zum " +"heutigen Tag verkürzt werden." + +#: aleksis/apps/lesrooster/models.py:139 +msgid "There is already a published validity range for this time or a part of this time." +msgstr "" +"Es gibt bereits einen veröffentlichten Gültigkeitsbereich für diesen " +"Zeitraum oder einen Teil diesen Zeitraumes." + +#: aleksis/apps/lesrooster/models.py:168 +msgid "Publish validity range {}" +msgstr "Gültigkeitsbereich {} veröffentlichen" + +#: aleksis/apps/lesrooster/models.py:170 +msgid "All lessons and supervisions in the validity range {} are being synced …" +msgstr "" +"Alle Unterrichtsstunden und Aufsichten in dem Gültigkeitsbereich {} werden " +"synchronisiert …" + +#: aleksis/apps/lesrooster/models.py:172 +msgid "The validity range has been published successfully." +msgstr "Der Gültigkeitsbereich wurde erfolgreich veröffentlicht." + +#: aleksis/apps/lesrooster/models.py:173 +msgid "There was a problem while publishing the validity range." +msgstr "Es gab ein Problem beim Veröffentlichen des Gültigkeitsbereichs." + +#: aleksis/apps/lesrooster/models.py:191 +msgid "Validity range" +msgstr "Gültigkeitsbereich" + +#: aleksis/apps/lesrooster/models.py:192 +msgid "Validity ranges" +msgstr "Gültigkeitsbereiche" + +#: aleksis/apps/lesrooster/models.py:213 aleksis/apps/lesrooster/models.py:622 +msgid "Linked validity range" +msgstr "Zugeordneter Gültigkeitsbereich" + +#: aleksis/apps/lesrooster/models.py:218 +msgid "Group" +msgstr "Gruppe" + +#: aleksis/apps/lesrooster/models.py:231 aleksis/apps/lesrooster/models.py:253 +msgid "Time Grid" +msgstr "Zeitraster" + +#: aleksis/apps/lesrooster/models.py:232 +msgid "Time Grids" +msgstr "Zeitraster" + +#: aleksis/apps/lesrooster/models.py:259 +msgid "Week day" +msgstr "Wochentag" + +#: aleksis/apps/lesrooster/models.py:262 +msgid "Number of period" +msgstr "Nummer der Stunde" + +#: aleksis/apps/lesrooster/models.py:265 +msgid "Start time" +msgstr "Startzeit" + +#: aleksis/apps/lesrooster/models.py:266 +msgid "End time" +msgstr "Endzeit" + +#: aleksis/apps/lesrooster/models.py:342 +msgid "Slot" +msgstr "Zeitfenster" + +#: aleksis/apps/lesrooster/models.py:343 +msgid "Slots" +msgstr "Zeitfenster" + +#: aleksis/apps/lesrooster/models.py:353 +msgid "Linked lesson event" +msgstr "Zugeordnete Unterrichtsveranstaltung" + +#: aleksis/apps/lesrooster/models.py:361 aleksis/apps/lesrooster/models.py:616 +msgid "Course" +msgstr "Kurs" + +#: aleksis/apps/lesrooster/models.py:369 +msgid "Start slot" +msgstr "Startzeitfenster" + +#: aleksis/apps/lesrooster/models.py:375 +msgid "End slot" +msgstr "Endzeitfenster" + +#: aleksis/apps/lesrooster/models.py:382 aleksis/apps/lesrooster/models.py:541 +msgid "Recurrence" +msgstr "Wiederholung" + +#: aleksis/apps/lesrooster/models.py:385 +msgid "Leave empty for a single lesson." +msgstr "Leer lassen für eine einzelne Unterrichtsstunde." + +#: aleksis/apps/lesrooster/models.py:392 aleksis/apps/lesrooster/models.py:515 +msgid "Rooms" +msgstr "Räume" + +#: aleksis/apps/lesrooster/models.py:398 aleksis/apps/lesrooster/models.py:520 +#: aleksis/apps/lesrooster/models.py:634 +msgid "Teachers" +msgstr "Lehrkräfte" + +#: aleksis/apps/lesrooster/models.py:405 aleksis/apps/lesrooster/models.py:532 +msgid "Subject" +msgstr "Fach" + +#: aleksis/apps/lesrooster/models.py:423 +msgid "The slots must be in the same time grid." +msgstr "Die Zeitfenster müssen im gleichen Zeitraster sein." + +#: aleksis/apps/lesrooster/models.py:486 +msgid "Lesson" +msgstr "Unterrichtsstunde" + +#: aleksis/apps/lesrooster/models.py:487 +msgid "Lessons" +msgstr "Unterrichtsstunden" + +#: aleksis/apps/lesrooster/models.py:497 +msgid "Break" +msgstr "Pause" + +#: aleksis/apps/lesrooster/models.py:498 +msgid "Breaks" +msgstr "Pausen" + +#: aleksis/apps/lesrooster/models.py:508 +msgid "Linked supervision event" +msgstr "Zugeordnete Aufsicht" + +#: aleksis/apps/lesrooster/models.py:526 +msgid "Break Slot" +msgstr "Pausen-Zeitfenster" + +#: aleksis/apps/lesrooster/models.py:544 +msgid "Leave empty for a single supervision." +msgstr "Leer lassen für eine einzelne Aufsicht." + +#: aleksis/apps/lesrooster/models.py:557 +msgid "Supervision" +msgstr "Aufsicht" + +#: aleksis/apps/lesrooster/models.py:558 +msgid "Supervisions" +msgstr "Aufsichten" + +#: aleksis/apps/lesrooster/models.py:627 +msgid "Lesson quota" +msgstr "Stundenpensum" + +#: aleksis/apps/lesrooster/models.py:628 +msgid "Number of slots this course is scheduled to fill per week" +msgstr "Anzahl der Zeitfenster, die dieser Kurs in der Woche füllen soll" + +#: aleksis/apps/lesrooster/models.py:644 +msgid "Timebound course config" +msgstr "Kurskonfiguration" + +#: aleksis/apps/lesrooster/models.py:645 +msgid "Timebound course configs" +msgstr "Kurskonfigurationen" + +#: aleksis/apps/lesrooster/models.py:652 +msgid "Can manage lesson raster" +msgstr "Kann das Zeitraster verwalten" + +#: aleksis/apps/lesrooster/models.py:653 +msgid "Can plan timetables" +msgstr "Kann Stundenpläne planen" + +#: aleksis/apps/lesrooster/preferences.py:9 +msgid "Lesson management" +msgstr "Unterrichtsmanagement" + +#: aleksis/apps/lesrooster/preferences.py:17 +msgid "Create course group when planning new courses" +msgstr "Kursgruppe beim Planen neuer Kurse erstellen" + +#: aleksis/apps/lesrooster/preferences.py:19 +msgid "If creating a new course with the 'Plan courses' feature, also create a seperate course group with the original group(s) as parent group(s) and link that/these group(s) to the newly created course" +msgstr "" +"Beim Erstellen eines neuen Kurses mit der 'Kurse planen'-Funktion auch eine " +"separate Kursgruppe mit den originalen Gruppe(n) als Elterngruppe(n) " +"erstellen und diese Gruppe(n) mit dem neu erstellen Kurs verknüpfen" + +#: aleksis/apps/lesrooster/preferences.py:33 +msgid "Group type for automatically created course groups" +msgstr "Gruppentyp für automatisch erstellte Kursgruppen" + +#: aleksis/apps/lesrooster/preferences.py:34 +msgid "If you leave it empty, no group type will be used." +msgstr "Wenn Sie es leer lassen, wird kein Gruppentyp benutzt." + +#: aleksis/apps/lesrooster/preferences.py:45 +msgid "Fill course group with the given courses' members" +msgstr "Kursgruppen mit den gegebenen Kursmitgliedern füllen" + +#: aleksis/apps/lesrooster/preferences.py:47 +msgid "If creating a new course with the 'Plan courses' feature, fill the seperately created course group with the members of the original group(s)." +msgstr "" +"Beim Erstellen eines neuen Kurses mit der 'Kurse planen'-Funktion die " +"separat erstellte Kursgruppe mit den Mitgliedern der Originalgruppe(n) " +"füllen." diff --git a/aleksis/apps/lesrooster/locale/fr/LC_MESSAGES/django.po b/aleksis/apps/lesrooster/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000000000000000000000000000000000000..ef5815121dd8dd8ac3b8276b35a769ca05fb1594 --- /dev/null +++ b/aleksis/apps/lesrooster/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,283 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-18 14:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: aleksis/apps/lesrooster/model_extensions.py:14 +msgid "Slot by day (1-7) and period" +msgstr "" + +#: aleksis/apps/lesrooster/model_extensions.py:32 +msgid "Weekly recurrence (for lessons, supervisions etc.)" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:34 +msgid "Draft" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:35 +msgid "Published" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:46 +msgid "School term" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:49 aleksis/apps/lesrooster/models.py:256 +msgid "Name" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:51 +msgid "Start date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:52 +msgid "End date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:55 +msgid "Status" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:90 +msgid "You can't unpublish a validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:93 +msgid "The start date must be earlier than the end date." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:99 +msgid "The validity range must be within the school term." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:105 +msgid "The school term of a published validity range can't be changed." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:111 +msgid "You can't change the start date if the validity range is already active." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:114 +msgid "You can't set the start date to a date in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:119 +msgid "You can't change the end date if the validity range is already in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:124 +msgid "To avoid data loss, the validity range can be only shortened until the current day." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:139 +msgid "There is already a published validity range for this time or a part of this time." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:168 +msgid "Publish validity range {}" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:170 +msgid "All lessons and supervisions in the validity range {} are being synced …" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:172 +msgid "The validity range has been published successfully." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:173 +msgid "There was a problem while publishing the validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:191 +msgid "Validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:192 +msgid "Validity ranges" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:213 aleksis/apps/lesrooster/models.py:622 +msgid "Linked validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:218 +msgid "Group" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:231 aleksis/apps/lesrooster/models.py:253 +msgid "Time Grid" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:232 +msgid "Time Grids" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:259 +msgid "Week day" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:262 +msgid "Number of period" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:265 +msgid "Start time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:266 +msgid "End time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:342 +msgid "Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:343 +msgid "Slots" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:353 +msgid "Linked lesson event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:361 aleksis/apps/lesrooster/models.py:616 +msgid "Course" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:369 +msgid "Start slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:375 +msgid "End slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:382 aleksis/apps/lesrooster/models.py:541 +msgid "Recurrence" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:385 +msgid "Leave empty for a single lesson." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:392 aleksis/apps/lesrooster/models.py:515 +msgid "Rooms" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:398 aleksis/apps/lesrooster/models.py:520 +#: aleksis/apps/lesrooster/models.py:634 +msgid "Teachers" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:405 aleksis/apps/lesrooster/models.py:532 +msgid "Subject" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:423 +msgid "The slots must be in the same time grid." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:486 +msgid "Lesson" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:487 +msgid "Lessons" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:497 +msgid "Break" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:498 +msgid "Breaks" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:508 +msgid "Linked supervision event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:526 +msgid "Break Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:544 +msgid "Leave empty for a single supervision." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:557 +msgid "Supervision" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:558 +msgid "Supervisions" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:627 +msgid "Lesson quota" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:628 +msgid "Number of slots this course is scheduled to fill per week" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:644 +msgid "Timebound course config" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:645 +msgid "Timebound course configs" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:652 +msgid "Can manage lesson raster" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:653 +msgid "Can plan timetables" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:9 +msgid "Lesson management" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:17 +msgid "Create course group when planning new courses" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:19 +msgid "If creating a new course with the 'Plan courses' feature, also create a seperate course group with the original group(s) as parent group(s) and link that/these group(s) to the newly created course" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:33 +msgid "Group type for automatically created course groups" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:34 +msgid "If you leave it empty, no group type will be used." +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:45 +msgid "Fill course group with the given courses' members" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:47 +msgid "If creating a new course with the 'Plan courses' feature, fill the seperately created course group with the members of the original group(s)." +msgstr "" diff --git a/aleksis/apps/lesrooster/locale/la/LC_MESSAGES/django.po b/aleksis/apps/lesrooster/locale/la/LC_MESSAGES/django.po new file mode 100644 index 0000000000000000000000000000000000000000..4496950ab38d24778889767d20a7ff3502970f7e --- /dev/null +++ b/aleksis/apps/lesrooster/locale/la/LC_MESSAGES/django.po @@ -0,0 +1,283 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-18 14:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: aleksis/apps/lesrooster/model_extensions.py:14 +msgid "Slot by day (1-7) and period" +msgstr "" + +#: aleksis/apps/lesrooster/model_extensions.py:32 +msgid "Weekly recurrence (for lessons, supervisions etc.)" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:34 +msgid "Draft" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:35 +msgid "Published" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:46 +msgid "School term" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:49 aleksis/apps/lesrooster/models.py:256 +msgid "Name" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:51 +msgid "Start date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:52 +msgid "End date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:55 +msgid "Status" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:90 +msgid "You can't unpublish a validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:93 +msgid "The start date must be earlier than the end date." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:99 +msgid "The validity range must be within the school term." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:105 +msgid "The school term of a published validity range can't be changed." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:111 +msgid "You can't change the start date if the validity range is already active." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:114 +msgid "You can't set the start date to a date in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:119 +msgid "You can't change the end date if the validity range is already in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:124 +msgid "To avoid data loss, the validity range can be only shortened until the current day." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:139 +msgid "There is already a published validity range for this time or a part of this time." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:168 +msgid "Publish validity range {}" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:170 +msgid "All lessons and supervisions in the validity range {} are being synced …" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:172 +msgid "The validity range has been published successfully." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:173 +msgid "There was a problem while publishing the validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:191 +msgid "Validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:192 +msgid "Validity ranges" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:213 aleksis/apps/lesrooster/models.py:622 +msgid "Linked validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:218 +msgid "Group" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:231 aleksis/apps/lesrooster/models.py:253 +msgid "Time Grid" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:232 +msgid "Time Grids" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:259 +msgid "Week day" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:262 +msgid "Number of period" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:265 +msgid "Start time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:266 +msgid "End time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:342 +msgid "Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:343 +msgid "Slots" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:353 +msgid "Linked lesson event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:361 aleksis/apps/lesrooster/models.py:616 +msgid "Course" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:369 +msgid "Start slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:375 +msgid "End slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:382 aleksis/apps/lesrooster/models.py:541 +msgid "Recurrence" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:385 +msgid "Leave empty for a single lesson." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:392 aleksis/apps/lesrooster/models.py:515 +msgid "Rooms" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:398 aleksis/apps/lesrooster/models.py:520 +#: aleksis/apps/lesrooster/models.py:634 +msgid "Teachers" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:405 aleksis/apps/lesrooster/models.py:532 +msgid "Subject" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:423 +msgid "The slots must be in the same time grid." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:486 +msgid "Lesson" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:487 +msgid "Lessons" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:497 +msgid "Break" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:498 +msgid "Breaks" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:508 +msgid "Linked supervision event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:526 +msgid "Break Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:544 +msgid "Leave empty for a single supervision." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:557 +msgid "Supervision" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:558 +msgid "Supervisions" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:627 +msgid "Lesson quota" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:628 +msgid "Number of slots this course is scheduled to fill per week" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:644 +msgid "Timebound course config" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:645 +msgid "Timebound course configs" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:652 +msgid "Can manage lesson raster" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:653 +msgid "Can plan timetables" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:9 +msgid "Lesson management" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:17 +msgid "Create course group when planning new courses" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:19 +msgid "If creating a new course with the 'Plan courses' feature, also create a seperate course group with the original group(s) as parent group(s) and link that/these group(s) to the newly created course" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:33 +msgid "Group type for automatically created course groups" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:34 +msgid "If you leave it empty, no group type will be used." +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:45 +msgid "Fill course group with the given courses' members" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:47 +msgid "If creating a new course with the 'Plan courses' feature, fill the seperately created course group with the members of the original group(s)." +msgstr "" diff --git a/aleksis/apps/lesrooster/locale/nb_NO/LC_MESSAGES/django.po b/aleksis/apps/lesrooster/locale/nb_NO/LC_MESSAGES/django.po new file mode 100644 index 0000000000000000000000000000000000000000..4496950ab38d24778889767d20a7ff3502970f7e --- /dev/null +++ b/aleksis/apps/lesrooster/locale/nb_NO/LC_MESSAGES/django.po @@ -0,0 +1,283 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-18 14:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: aleksis/apps/lesrooster/model_extensions.py:14 +msgid "Slot by day (1-7) and period" +msgstr "" + +#: aleksis/apps/lesrooster/model_extensions.py:32 +msgid "Weekly recurrence (for lessons, supervisions etc.)" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:34 +msgid "Draft" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:35 +msgid "Published" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:46 +msgid "School term" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:49 aleksis/apps/lesrooster/models.py:256 +msgid "Name" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:51 +msgid "Start date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:52 +msgid "End date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:55 +msgid "Status" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:90 +msgid "You can't unpublish a validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:93 +msgid "The start date must be earlier than the end date." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:99 +msgid "The validity range must be within the school term." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:105 +msgid "The school term of a published validity range can't be changed." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:111 +msgid "You can't change the start date if the validity range is already active." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:114 +msgid "You can't set the start date to a date in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:119 +msgid "You can't change the end date if the validity range is already in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:124 +msgid "To avoid data loss, the validity range can be only shortened until the current day." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:139 +msgid "There is already a published validity range for this time or a part of this time." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:168 +msgid "Publish validity range {}" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:170 +msgid "All lessons and supervisions in the validity range {} are being synced …" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:172 +msgid "The validity range has been published successfully." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:173 +msgid "There was a problem while publishing the validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:191 +msgid "Validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:192 +msgid "Validity ranges" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:213 aleksis/apps/lesrooster/models.py:622 +msgid "Linked validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:218 +msgid "Group" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:231 aleksis/apps/lesrooster/models.py:253 +msgid "Time Grid" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:232 +msgid "Time Grids" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:259 +msgid "Week day" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:262 +msgid "Number of period" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:265 +msgid "Start time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:266 +msgid "End time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:342 +msgid "Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:343 +msgid "Slots" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:353 +msgid "Linked lesson event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:361 aleksis/apps/lesrooster/models.py:616 +msgid "Course" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:369 +msgid "Start slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:375 +msgid "End slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:382 aleksis/apps/lesrooster/models.py:541 +msgid "Recurrence" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:385 +msgid "Leave empty for a single lesson." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:392 aleksis/apps/lesrooster/models.py:515 +msgid "Rooms" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:398 aleksis/apps/lesrooster/models.py:520 +#: aleksis/apps/lesrooster/models.py:634 +msgid "Teachers" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:405 aleksis/apps/lesrooster/models.py:532 +msgid "Subject" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:423 +msgid "The slots must be in the same time grid." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:486 +msgid "Lesson" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:487 +msgid "Lessons" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:497 +msgid "Break" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:498 +msgid "Breaks" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:508 +msgid "Linked supervision event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:526 +msgid "Break Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:544 +msgid "Leave empty for a single supervision." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:557 +msgid "Supervision" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:558 +msgid "Supervisions" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:627 +msgid "Lesson quota" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:628 +msgid "Number of slots this course is scheduled to fill per week" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:644 +msgid "Timebound course config" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:645 +msgid "Timebound course configs" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:652 +msgid "Can manage lesson raster" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:653 +msgid "Can plan timetables" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:9 +msgid "Lesson management" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:17 +msgid "Create course group when planning new courses" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:19 +msgid "If creating a new course with the 'Plan courses' feature, also create a seperate course group with the original group(s) as parent group(s) and link that/these group(s) to the newly created course" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:33 +msgid "Group type for automatically created course groups" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:34 +msgid "If you leave it empty, no group type will be used." +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:45 +msgid "Fill course group with the given courses' members" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:47 +msgid "If creating a new course with the 'Plan courses' feature, fill the seperately created course group with the members of the original group(s)." +msgstr "" diff --git a/aleksis/apps/lesrooster/locale/ru/LC_MESSAGES/django.po b/aleksis/apps/lesrooster/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 0000000000000000000000000000000000000000..5f55d9dbc0b084508d06d8d0448153b3bd9ff816 --- /dev/null +++ b/aleksis/apps/lesrooster/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,285 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-18 14:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " +"(n%100>=11 && n%100<=14)? 2 : 3);\n" +#: aleksis/apps/lesrooster/model_extensions.py:14 +msgid "Slot by day (1-7) and period" +msgstr "" + +#: aleksis/apps/lesrooster/model_extensions.py:32 +msgid "Weekly recurrence (for lessons, supervisions etc.)" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:34 +msgid "Draft" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:35 +msgid "Published" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:46 +msgid "School term" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:49 aleksis/apps/lesrooster/models.py:256 +msgid "Name" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:51 +msgid "Start date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:52 +msgid "End date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:55 +msgid "Status" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:90 +msgid "You can't unpublish a validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:93 +msgid "The start date must be earlier than the end date." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:99 +msgid "The validity range must be within the school term." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:105 +msgid "The school term of a published validity range can't be changed." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:111 +msgid "You can't change the start date if the validity range is already active." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:114 +msgid "You can't set the start date to a date in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:119 +msgid "You can't change the end date if the validity range is already in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:124 +msgid "To avoid data loss, the validity range can be only shortened until the current day." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:139 +msgid "There is already a published validity range for this time or a part of this time." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:168 +msgid "Publish validity range {}" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:170 +msgid "All lessons and supervisions in the validity range {} are being synced …" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:172 +msgid "The validity range has been published successfully." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:173 +msgid "There was a problem while publishing the validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:191 +msgid "Validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:192 +msgid "Validity ranges" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:213 aleksis/apps/lesrooster/models.py:622 +msgid "Linked validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:218 +msgid "Group" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:231 aleksis/apps/lesrooster/models.py:253 +msgid "Time Grid" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:232 +msgid "Time Grids" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:259 +msgid "Week day" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:262 +msgid "Number of period" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:265 +msgid "Start time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:266 +msgid "End time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:342 +msgid "Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:343 +msgid "Slots" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:353 +msgid "Linked lesson event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:361 aleksis/apps/lesrooster/models.py:616 +msgid "Course" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:369 +msgid "Start slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:375 +msgid "End slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:382 aleksis/apps/lesrooster/models.py:541 +msgid "Recurrence" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:385 +msgid "Leave empty for a single lesson." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:392 aleksis/apps/lesrooster/models.py:515 +msgid "Rooms" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:398 aleksis/apps/lesrooster/models.py:520 +#: aleksis/apps/lesrooster/models.py:634 +msgid "Teachers" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:405 aleksis/apps/lesrooster/models.py:532 +msgid "Subject" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:423 +msgid "The slots must be in the same time grid." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:486 +msgid "Lesson" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:487 +msgid "Lessons" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:497 +msgid "Break" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:498 +msgid "Breaks" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:508 +msgid "Linked supervision event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:526 +msgid "Break Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:544 +msgid "Leave empty for a single supervision." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:557 +msgid "Supervision" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:558 +msgid "Supervisions" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:627 +msgid "Lesson quota" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:628 +msgid "Number of slots this course is scheduled to fill per week" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:644 +msgid "Timebound course config" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:645 +msgid "Timebound course configs" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:652 +msgid "Can manage lesson raster" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:653 +msgid "Can plan timetables" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:9 +msgid "Lesson management" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:17 +msgid "Create course group when planning new courses" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:19 +msgid "If creating a new course with the 'Plan courses' feature, also create a seperate course group with the original group(s) as parent group(s) and link that/these group(s) to the newly created course" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:33 +msgid "Group type for automatically created course groups" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:34 +msgid "If you leave it empty, no group type will be used." +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:45 +msgid "Fill course group with the given courses' members" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:47 +msgid "If creating a new course with the 'Plan courses' feature, fill the seperately created course group with the members of the original group(s)." +msgstr "" diff --git a/aleksis/apps/lesrooster/locale/tr_TR/LC_MESSAGES/django.po b/aleksis/apps/lesrooster/locale/tr_TR/LC_MESSAGES/django.po new file mode 100644 index 0000000000000000000000000000000000000000..4496950ab38d24778889767d20a7ff3502970f7e --- /dev/null +++ b/aleksis/apps/lesrooster/locale/tr_TR/LC_MESSAGES/django.po @@ -0,0 +1,283 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-18 14:58+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: aleksis/apps/lesrooster/model_extensions.py:14 +msgid "Slot by day (1-7) and period" +msgstr "" + +#: aleksis/apps/lesrooster/model_extensions.py:32 +msgid "Weekly recurrence (for lessons, supervisions etc.)" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:34 +msgid "Draft" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:35 +msgid "Published" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:46 +msgid "School term" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:49 aleksis/apps/lesrooster/models.py:256 +msgid "Name" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:51 +msgid "Start date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:52 +msgid "End date" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:55 +msgid "Status" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:90 +msgid "You can't unpublish a validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:93 +msgid "The start date must be earlier than the end date." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:99 +msgid "The validity range must be within the school term." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:105 +msgid "The school term of a published validity range can't be changed." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:111 +msgid "You can't change the start date if the validity range is already active." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:114 +msgid "You can't set the start date to a date in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:119 +msgid "You can't change the end date if the validity range is already in the past." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:124 +msgid "To avoid data loss, the validity range can be only shortened until the current day." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:139 +msgid "There is already a published validity range for this time or a part of this time." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:168 +msgid "Publish validity range {}" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:170 +msgid "All lessons and supervisions in the validity range {} are being synced …" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:172 +msgid "The validity range has been published successfully." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:173 +msgid "There was a problem while publishing the validity range." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:191 +msgid "Validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:192 +msgid "Validity ranges" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:213 aleksis/apps/lesrooster/models.py:622 +msgid "Linked validity range" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:218 +msgid "Group" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:231 aleksis/apps/lesrooster/models.py:253 +msgid "Time Grid" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:232 +msgid "Time Grids" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:259 +msgid "Week day" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:262 +msgid "Number of period" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:265 +msgid "Start time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:266 +msgid "End time" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:342 +msgid "Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:343 +msgid "Slots" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:353 +msgid "Linked lesson event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:361 aleksis/apps/lesrooster/models.py:616 +msgid "Course" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:369 +msgid "Start slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:375 +msgid "End slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:382 aleksis/apps/lesrooster/models.py:541 +msgid "Recurrence" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:385 +msgid "Leave empty for a single lesson." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:392 aleksis/apps/lesrooster/models.py:515 +msgid "Rooms" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:398 aleksis/apps/lesrooster/models.py:520 +#: aleksis/apps/lesrooster/models.py:634 +msgid "Teachers" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:405 aleksis/apps/lesrooster/models.py:532 +msgid "Subject" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:423 +msgid "The slots must be in the same time grid." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:486 +msgid "Lesson" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:487 +msgid "Lessons" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:497 +msgid "Break" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:498 +msgid "Breaks" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:508 +msgid "Linked supervision event" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:526 +msgid "Break Slot" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:544 +msgid "Leave empty for a single supervision." +msgstr "" + +#: aleksis/apps/lesrooster/models.py:557 +msgid "Supervision" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:558 +msgid "Supervisions" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:627 +msgid "Lesson quota" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:628 +msgid "Number of slots this course is scheduled to fill per week" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:644 +msgid "Timebound course config" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:645 +msgid "Timebound course configs" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:652 +msgid "Can manage lesson raster" +msgstr "" + +#: aleksis/apps/lesrooster/models.py:653 +msgid "Can plan timetables" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:9 +msgid "Lesson management" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:17 +msgid "Create course group when planning new courses" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:19 +msgid "If creating a new course with the 'Plan courses' feature, also create a seperate course group with the original group(s) as parent group(s) and link that/these group(s) to the newly created course" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:33 +msgid "Group type for automatically created course groups" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:34 +msgid "If you leave it empty, no group type will be used." +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:45 +msgid "Fill course group with the given courses' members" +msgstr "" + +#: aleksis/apps/lesrooster/preferences.py:47 +msgid "If creating a new course with the 'Plan courses' feature, fill the seperately created course group with the members of the original group(s)." +msgstr "" diff --git a/aleksis/apps/lesrooster/locale/uk/LC_MESSAGES/django.po b/aleksis/apps/lesrooster/locale/uk/LC_MESSAGES/django.po new file mode 100644 index 0000000000000000000000000000000000000000..ff5cc2e7f8d3720af9d031cc51ea76a010f12a7c --- /dev/null +++ b/aleksis/apps/lesrooster/locale/uk/LC_MESSAGES/django.po @@ -0,0 +1,296 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-18 14:58+0200\n" +"PO-Revision-Date: 2024-09-02 20:34+0000\n" +"Last-Translator: Serhii Horichenko <m@sgg.im>\n" +"Language-Team: Ukrainian <https://translate.edugit.org/projects/aleksis/" +"aleksis-app-lesrooster/uk/>\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != 11 " +"? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > " +"14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || (n % " +"100 >=11 && n % 100 <=14 )) ? 2: 3);\n" +"X-Generator: Weblate 5.0.2\n" + +#: aleksis/apps/lesrooster/model_extensions.py:14 +msgid "Slot by day (1-7) and period" +msgstr "Слот за днем (1-7) та уроком" + +#: aleksis/apps/lesrooster/model_extensions.py:32 +msgid "Weekly recurrence (for lessons, supervisions etc.)" +msgstr "Щотижневе Ð¿Ð¾Ð²Ñ‚Ð¾Ñ€ÐµÐ½Ð½Ñ (уроків, наглÑду та ін.)" + +#: aleksis/apps/lesrooster/models.py:34 +msgid "Draft" +msgstr "Чернетка" + +#: aleksis/apps/lesrooster/models.py:35 +msgid "Published" +msgstr "Опубліковано" + +#: aleksis/apps/lesrooster/models.py:46 +msgid "School term" +msgstr "Ðавчальний рік" + +#: aleksis/apps/lesrooster/models.py:49 aleksis/apps/lesrooster/models.py:256 +msgid "Name" +msgstr "Повне ім'Ñ" + +#: aleksis/apps/lesrooster/models.py:51 +msgid "Start date" +msgstr "Дата початку" + +#: aleksis/apps/lesrooster/models.py:52 +msgid "End date" +msgstr "Дата закінченнÑ" + +#: aleksis/apps/lesrooster/models.py:55 +msgid "Status" +msgstr "Стан" + +#: aleksis/apps/lesrooster/models.py:90 +msgid "You can't unpublish a validity range." +msgstr "Ви не можете ÑкаÑувати публікацію облаÑті дії." + +#: aleksis/apps/lesrooster/models.py:93 +msgid "The start date must be earlier than the end date." +msgstr "Початкова дата повинна бути раніше кінцевої дати." + +#: aleksis/apps/lesrooster/models.py:99 +msgid "The validity range must be within the school term." +msgstr "ОблаÑть дії повинна бути в межах навчального року." + +#: aleksis/apps/lesrooster/models.py:105 +msgid "The school term of a published validity range can't be changed." +msgstr "Ðавчальний рік опублікованої облаÑті дії змінити не можна." + +#: aleksis/apps/lesrooster/models.py:111 +msgid "You can't change the start date if the validity range is already active." +msgstr "Ви не можете змінити початкову дату, Ñкщо активна облаÑть дії." + +#: aleksis/apps/lesrooster/models.py:114 +msgid "You can't set the start date to a date in the past." +msgstr "Ви не можете вÑтановити початкову дату на дату в минулому." + +#: aleksis/apps/lesrooster/models.py:119 +msgid "You can't change the end date if the validity range is already in the past." +msgstr "Ви не можете змінити кінцеву дату, Ñкщо облаÑті дії вже в минулому." + +#: aleksis/apps/lesrooster/models.py:124 +msgid "To avoid data loss, the validity range can be only shortened until the current day." +msgstr "" +"Щоби не втратити дані, облаÑть дії може бути Ñкорочена лише до поточного днÑ." + +#: aleksis/apps/lesrooster/models.py:139 +msgid "There is already a published validity range for this time or a part of this time." +msgstr "" +"Ð”Ð»Ñ Ð²ÐºÐ°Ð·Ð°Ð½Ð¾Ð³Ð¾ періоду чаÑу або Ð´Ð»Ñ Ð¹Ð¾Ð³Ð¾ чаÑтини облаÑть дії вже опублікована." + +#: aleksis/apps/lesrooster/models.py:168 +msgid "Publish validity range {}" +msgstr "Опублікувати облаÑть дії {}" + +#: aleksis/apps/lesrooster/models.py:170 +msgid "All lessons and supervisions in the validity range {} are being synced …" +msgstr "УÑÑ– уроки та ÑпоÑÑ‚ÐµÑ€ÐµÐ¶ÐµÐ½Ð½Ñ Ð² облаÑті дії {} Ñинхронізовані …" + +#: aleksis/apps/lesrooster/models.py:172 +msgid "The validity range has been published successfully." +msgstr "ОблаÑть дії уÑпішно опублікована." + +#: aleksis/apps/lesrooster/models.py:173 +msgid "There was a problem while publishing the validity range." +msgstr "Під Ñ‡Ð°Ñ Ð¿ÑƒÐ±Ð»Ñ–ÐºÐ°Ñ†Ñ–Ñ— облаÑті дії виникла помилка." + +#: aleksis/apps/lesrooster/models.py:191 +msgid "Validity range" +msgstr "ОблаÑть дії" + +#: aleksis/apps/lesrooster/models.py:192 +msgid "Validity ranges" +msgstr "ОблаÑті дії" + +#: aleksis/apps/lesrooster/models.py:213 aleksis/apps/lesrooster/models.py:622 +msgid "Linked validity range" +msgstr "Пов'Ñзана облаÑть дії" + +#: aleksis/apps/lesrooster/models.py:218 +msgid "Group" +msgstr "Група" + +#: aleksis/apps/lesrooster/models.py:231 aleksis/apps/lesrooster/models.py:253 +msgid "Time Grid" +msgstr "ЧаÑова Ñітка" + +#: aleksis/apps/lesrooster/models.py:232 +msgid "Time Grids" +msgstr "ЧаÑові Ñітки" + +#: aleksis/apps/lesrooster/models.py:259 +msgid "Week day" +msgstr "День тижнÑ" + +#: aleksis/apps/lesrooster/models.py:262 +msgid "Number of period" +msgstr "Ðомер уроку" + +#: aleksis/apps/lesrooster/models.py:265 +msgid "Start time" +msgstr "Ð§Ð°Ñ Ð¿Ð¾Ñ‡Ð°Ñ‚ÐºÑƒ" + +#: aleksis/apps/lesrooster/models.py:266 +msgid "End time" +msgstr "Ð§Ð°Ñ Ð·Ð°ÐºÑ–Ð½Ñ‡ÐµÐ½Ð½Ñ" + +#: aleksis/apps/lesrooster/models.py:342 +msgid "Slot" +msgstr "Слот" + +#: aleksis/apps/lesrooster/models.py:343 +msgid "Slots" +msgstr "Слоти" + +#: aleksis/apps/lesrooster/models.py:353 +msgid "Linked lesson event" +msgstr "Поєднані уроки" + +#: aleksis/apps/lesrooster/models.py:361 aleksis/apps/lesrooster/models.py:616 +msgid "Course" +msgstr "КурÑ" + +#: aleksis/apps/lesrooster/models.py:369 +msgid "Start slot" +msgstr "Початок Ñлота" + +#: aleksis/apps/lesrooster/models.py:375 +msgid "End slot" +msgstr "Ð—Ð°Ð²ÐµÑ€ÑˆÐµÐ½Ð½Ñ Ñлота" + +#: aleksis/apps/lesrooster/models.py:382 aleksis/apps/lesrooster/models.py:541 +msgid "Recurrence" +msgstr "ПовтореннÑ" + +#: aleksis/apps/lesrooster/models.py:385 +msgid "Leave empty for a single lesson." +msgstr "Залиште порожнім Ð´Ð»Ñ Ð¾Ð´Ð½Ð¾Ð³Ð¾ уроку." + +#: aleksis/apps/lesrooster/models.py:392 aleksis/apps/lesrooster/models.py:515 +msgid "Rooms" +msgstr "Кімнати" + +#: aleksis/apps/lesrooster/models.py:398 aleksis/apps/lesrooster/models.py:520 +#: aleksis/apps/lesrooster/models.py:634 +msgid "Teachers" +msgstr "Викладачі" + +#: aleksis/apps/lesrooster/models.py:405 aleksis/apps/lesrooster/models.py:532 +msgid "Subject" +msgstr "Предмет" + +#: aleksis/apps/lesrooster/models.py:423 +msgid "The slots must be in the same time grid." +msgstr "Слоти повинні бути в тій Ñамій чаÑовій Ñітці." + +#: aleksis/apps/lesrooster/models.py:486 +msgid "Lesson" +msgstr "Урок" + +#: aleksis/apps/lesrooster/models.py:487 +msgid "Lessons" +msgstr "Уроки" + +#: aleksis/apps/lesrooster/models.py:497 +msgid "Break" +msgstr "Перерва" + +#: aleksis/apps/lesrooster/models.py:498 +msgid "Breaks" +msgstr "Перерви" + +#: aleksis/apps/lesrooster/models.py:508 +msgid "Linked supervision event" +msgstr "Пов'Ñзані події наглÑду" + +#: aleksis/apps/lesrooster/models.py:526 +msgid "Break Slot" +msgstr "Призупинити Ñлот" + +#: aleksis/apps/lesrooster/models.py:544 +msgid "Leave empty for a single supervision." +msgstr "Залишіть порожнім Ð´Ð»Ñ Ð¾Ð´Ð½Ð¾Ð³Ð¾ контролю." + +#: aleksis/apps/lesrooster/models.py:557 +msgid "Supervision" +msgstr "Контроль" + +#: aleksis/apps/lesrooster/models.py:558 +#, fuzzy +msgid "Supervisions" +msgstr "ÐаглÑди" + +#: aleksis/apps/lesrooster/models.py:627 +msgid "Lesson quota" +msgstr "Погодинне навантаженнÑ" + +#: aleksis/apps/lesrooster/models.py:628 +msgid "Number of slots this course is scheduled to fill per week" +msgstr "КількіÑть Ñлотів цього курÑу Ð´Ð»Ñ Ð¿Ð»Ð°Ð½ÑƒÐ²Ð°Ð½Ð½Ñ Ð½Ð° цей тиждень" + +#: aleksis/apps/lesrooster/models.py:644 +msgid "Timebound course config" +msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ ÐºÑƒÑ€Ñу" + +#: aleksis/apps/lesrooster/models.py:645 +msgid "Timebound course configs" +msgstr "Конфігурації курÑу" + +#: aleksis/apps/lesrooster/models.py:652 +msgid "Can manage lesson raster" +msgstr "Може керувати чаÑовою Ñіткою" + +#: aleksis/apps/lesrooster/models.py:653 +msgid "Can plan timetables" +msgstr "Може планувати розклад уроків" + +#: aleksis/apps/lesrooster/preferences.py:9 +msgid "Lesson management" +msgstr "ÐšÐµÑ€ÑƒÐ²Ð°Ð½Ð½Ñ ÑƒÑ€Ð¾ÐºÐ°Ð¼Ð¸" + +#: aleksis/apps/lesrooster/preferences.py:17 +msgid "Create course group when planning new courses" +msgstr "Створити групу курÑу при плануванні нового курÑу" + +#: aleksis/apps/lesrooster/preferences.py:19 +msgid "If creating a new course with the 'Plan courses' feature, also create a seperate course group with the original group(s) as parent group(s) and link that/these group(s) to the newly created course" +msgstr "" +"Під Ñ‡Ð°Ñ ÑÑ‚Ð²Ð¾Ñ€ÐµÐ½Ð½Ñ Ð½Ð¾Ð²Ð¾Ð³Ð¾ курÑу через функцію \"ÐŸÐ»Ð°Ð½ÑƒÐ²Ð°Ð½Ð½Ñ ÐºÑƒÑ€Ñу\" Ñтворювати " +"також окрему групу курÑу з оригінальною батьківÑькою групою (групами) та " +"поєднати цю/ці групу(и) до Ñтвореного курÑу" + +#: aleksis/apps/lesrooster/preferences.py:33 +msgid "Group type for automatically created course groups" +msgstr "Тип групи Ð´Ð»Ñ Ð°Ð²Ñ‚Ð¾Ð¼Ð°Ñ‚Ð¸Ñ‡Ð½Ð¾ Ñтворюваної групи курÑу" + +#: aleksis/apps/lesrooster/preferences.py:34 +msgid "If you leave it empty, no group type will be used." +msgstr "Якщо залишити порожнім, жодний тип групи не буде викориÑтаний." + +#: aleksis/apps/lesrooster/preferences.py:45 +msgid "Fill course group with the given courses' members" +msgstr "Заповнити групу курÑу вказаними учаÑниками курÑів" + +#: aleksis/apps/lesrooster/preferences.py:47 +msgid "If creating a new course with the 'Plan courses' feature, fill the seperately created course group with the members of the original group(s)." +msgstr "" +"Якщо Ñтворювати ÐºÑƒÑ€Ñ Ñ‡ÐµÑ€ÐµÐ· функцію \"План курÑів\", окремо буде Ñтворена " +"група курÑу з учаÑниками оригінальних груп(и)." diff --git a/aleksis/apps/lesrooster/managers.py b/aleksis/apps/lesrooster/managers.py index 10703b00c271ed9d9be8e74b639c47aa0d76d390..a5ad9d114a621ed6cfcd39b4020a90a0d33dc15b 100644 --- a/aleksis/apps/lesrooster/managers.py +++ b/aleksis/apps/lesrooster/managers.py @@ -1,8 +1,52 @@ +from typing import Optional + from django.db.models import QuerySet from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations, DateRangeQuerySetMixin +class TeacherPropertiesMixin: + """Mixin for common teacher properties. + + Necessary method: `get_teachers` + """ + + def get_teacher_names(self, sep: Optional[str] = ", ") -> str: + return sep.join([teacher.full_name for teacher in self.get_teachers()]) + + @property + def teacher_names(self) -> str: + return self.get_teacher_names() + + def get_teacher_short_names(self, sep: str = ", ") -> str: + return sep.join([teacher.short_name for teacher in self.get_teachers()]) + + @property + def teacher_short_names(self) -> str: + return self.get_teacher_short_names() + + +class RoomPropertiesMixin: + """Mixin for common room properties. + + Necessary method: `get_rooms` + """ + + def get_room_names(self, sep: Optional[str] = ", ") -> str: + return sep.join([room.name for room in self.get_rooms()]) + + @property + def room_names(self) -> str: + return self.get_room_names() + + def get_room_short_names(self, sep: str = ", ") -> str: + return sep.join([room.short_name for room in self.get_rooms()]) + + @property + def room_short_names(self) -> str: + return self.get_room_short_names() + + class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin): """Custom query set for validity ranges.""" diff --git a/aleksis/apps/lesrooster/migrations/0001_initial.py b/aleksis/apps/lesrooster/migrations/0001_initial.py index 8edc0192d90f81d5f333ae5b15d21c620b2c4bec..97155f245cc8fbcb7c5c42a4c36573e0861b1d5c 100644 --- a/aleksis/apps/lesrooster/migrations/0001_initial.py +++ b/aleksis/apps/lesrooster/migrations/0001_initial.py @@ -85,11 +85,7 @@ class Migration(migrations.Migration): "subject", ], }, - bases=( - aleksis.apps.chronos.managers.TeacherPropertiesMixin, - aleksis.apps.chronos.managers.RoomPropertiesMixin, - models.Model, - ), + bases=(models.Model,), managers=[ ("objects", aleksis.core.managers.AlekSISBaseManager()), ], @@ -210,11 +206,7 @@ class Migration(migrations.Migration): "verbose_name": "Supervision", "verbose_name_plural": "Supervisions", }, - bases=( - aleksis.apps.chronos.managers.TeacherPropertiesMixin, - aleksis.apps.chronos.managers.RoomPropertiesMixin, - models.Model, - ), + bases=(models.Model,), managers=[ ("objects", aleksis.core.managers.AlekSISBaseManager()), ], @@ -348,7 +340,7 @@ class Migration(migrations.Migration): "supervision__break_slot__period", ], }, - bases=(aleksis.apps.chronos.managers.TeacherPropertiesMixin, models.Model), + bases=(models.Model,), managers=[ ("objects", aleksis.core.managers.AlekSISBaseManager()), ], @@ -430,11 +422,7 @@ class Migration(migrations.Migration): "verbose_name_plural": "Substitutions", "ordering": ["date", "lesson__slot_start__weekday", "lesson__slot_start__period"], }, - bases=( - aleksis.apps.chronos.managers.RoomPropertiesMixin, - aleksis.apps.chronos.managers.TeacherPropertiesMixin, - models.Model, - ), + bases=(models.Model,), managers=[ ("objects", aleksis.core.managers.AlekSISBaseManager()), ], diff --git a/aleksis/apps/lesrooster/migrations/0017_migrate_from_chronos.py b/aleksis/apps/lesrooster/migrations/0017_migrate_from_chronos.py new file mode 100644 index 0000000000000000000000000000000000000000..5b4f5b1ab7b7d8782d43df9b2dbc7f80a4a035c9 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0017_migrate_from_chronos.py @@ -0,0 +1,843 @@ +import logging +from datetime import datetime, timedelta + +from django.apps import apps as global_apps +from django.core.paginator import Paginator +from django.db import migrations, reset_queries +from django.db.models import Count +from django.utils import timezone + +import recurrence +from calendarweek import CalendarWeek +from recurrence import serialize +from tqdm import tqdm + +logger = logging.getLogger(__name__) + + +def _build_recurrence(slot): + pattern = recurrence.Recurrence( + dtstart=timezone.make_aware( + datetime.combine(slot.time_grid.validity_range.date_start, slot.time_start) + ), + rrules=[ + recurrence.Rule( + recurrence.WEEKLY, + until=timezone.make_aware( + datetime.combine(slot.time_grid.validity_range.date_end, slot.time_end) + ), + ) + ], + ) + return pattern + + +def _real_recurrence(lesson, holidays_map): + rrules = lesson.recurrence.rrules + + slot_start = lesson.slot_start if hasattr(lesson, "slot_start") else lesson.break_slot + slot_end = lesson.slot_end if hasattr(lesson, "slot_end") else lesson.break_slot + week_start = CalendarWeek.from_date(slot_start.time_grid.validity_range.date_start) + week_end = CalendarWeek.from_date(slot_start.time_grid.validity_range.date_end) + + datetime_start = timezone.make_aware( + datetime.combine(week_start[slot_start.weekday], slot_start.time_start) + ) + datetime_end = timezone.make_aware( + datetime.combine(week_end[slot_end.weekday], slot_start.time_end) + ) + + for rrule in rrules: + rrule.until = datetime_end + pattern = recurrence.Recurrence( + dtstart=datetime_start, + rrules=rrules, + ) + delta = datetime_end.date() - datetime_start.date() + for i in range(delta.days + 1): + holiday_date = datetime_start.date() + timedelta(days=i) + if holiday_date in holidays_map: + ex_datetime = timezone.make_aware(datetime.combine(holiday_date, slot_start.time_start)) + pattern.exdates.append(ex_datetime) + return pattern + + +def _sync_supervision(apps, schema_editor, supervision, holidays_map): + SupervisionEvent = apps.get_model("chronos", "SupervisionEvent") + ContentType = apps.get_model("contenttypes", "ContentType") + + ct_supervision_event = ContentType.objects.get_for_model(SupervisionEvent) + + week_start = CalendarWeek.from_date(supervision.break_slot.time_grid.validity_range.date_start) + datetime_start = timezone.make_aware( + datetime.combine( + week_start[supervision.break_slot.weekday], supervision.break_slot.time_start + ) + ) + datetime_end = timezone.make_aware( + datetime.combine( + week_start[supervision.break_slot.weekday], supervision.break_slot.time_end + ) + ) + + supervision_event = ( + supervision.supervision_event if supervision.supervision_event else SupervisionEvent() + ) + + supervision_event.datetime_start = datetime_start + supervision_event.datetime_end = datetime_end + supervision_event.subject = supervision.subject + + supervision_event.recurrences = _real_recurrence(supervision, holidays_map) + supervision_event.timezone = supervision_event.datetime_start.tzinfo + supervision_event.exdatetimes = supervision_event.recurrences.exdates + supervision_event.rdatetimes = supervision_event.recurrences.rdates + supervision_event.rrule = serialize(supervision_event.recurrences.rrules[0]) + if supervision_event.rrule.startswith("RRULE:"): + supervision_event.rrule = supervision_event.rrule[6:] + + supervision_event.polymorphic_ctype_id = ct_supervision_event.id + + supervision_event.save() + + supervision_event.teachers.set(supervision.teachers.all()) + supervision_event.rooms.set(supervision.rooms.all()) + + supervision.supervision_event = supervision_event + supervision.save() + + +def forwards(apps, schema_editor): + ContentType = apps.get_model("contenttypes", "ContentType") + + Room = apps.get_model("core", "Room") + Holiday = apps.get_model("core", "Holiday") + ct_holiday = ContentType.objects.get_for_model(Holiday) + + LessonEvent = apps.get_model("chronos", "LessonEvent") + SupervisionEvent = apps.get_model("chronos", "SupervisionEvent") + ct_lesson_event = ContentType.objects.get_for_model(LessonEvent) + ct_supervision_event = ContentType.objects.get_for_model(SupervisionEvent) + + ChronosSubject = apps.get_model("chronos", "Subject") + ChronosValidityRange = apps.get_model("chronos", "ValidityRange") + ChronosTimePeriod = apps.get_model("chronos", "TimePeriod") + ChronosBreak = apps.get_model("chronos", "Break") + ChronosLesson = apps.get_model("chronos", "Lesson") + ChronosLessonPeriod = apps.get_model("chronos", "LessonPeriod") + ChronosSupervisionArea = apps.get_model("chronos", "SupervisionArea") + ChronosSupervision = apps.get_model("chronos", "Supervision") + ChronosLessonSubstitution = apps.get_model("chronos", "LessonSubstitution") + ChronosSupervisionSubstitution = apps.get_model("chronos", "SupervisionSubstitution") + ChronosEvent = apps.get_model("chronos", "Event") + ChronosExtraLesson = apps.get_model("chronos", "ExtraLesson") + ChronosHoliday = apps.get_model("chronos", "Holiday") + ChronosAbsenceReason = apps.get_model("chronos", "AbsenceReason") + ChronosAbsence = apps.get_model("chronos", "Absence") + + CursusSubject = apps.get_model("cursus", "Subject") + CursusCourse = apps.get_model("cursus", "Course") + + LesroosterValidityRange = apps.get_model("lesrooster", "ValidityRange") + LesroosterTimeGrid = apps.get_model("lesrooster", "TimeGrid") + LesroosterSlot = apps.get_model("lesrooster", "Slot") + LesroosterBreakSlot = apps.get_model("lesrooster", "BreakSlot") + LesroosterLesson = apps.get_model("lesrooster", "Lesson") + LesroosterSupervision = apps.get_model("lesrooster", "Supervision") + + subject_map = {} + for subject in tqdm(ChronosSubject.objects.iterator(), "Subjects"): + cursus_subject = CursusSubject.objects.create( + short_name=subject.short_name, + name=subject.name, + colour_fg=subject.colour_fg, + colour_bg=subject.colour_bg, + extended_data=subject.extended_data, + ) + subject_map[subject.id] = cursus_subject + + validity_range_map = {} + time_grid_map = {} + for validity_range in tqdm(ChronosValidityRange.objects.iterator(), "Validity Ranges"): + lesrooster_validity_range = LesroosterValidityRange.objects.create( + school_term_id=validity_range.school_term_id, + name=validity_range.name, + date_start=validity_range.date_start, + date_end=validity_range.date_end, + status="published", + extended_data=validity_range.extended_data, + ) + time_grid = LesroosterTimeGrid.objects.create( + validity_range_id=lesrooster_validity_range.id + ) + validity_range_map[validity_range.id] = lesrooster_validity_range + time_grid_map[validity_range.id] = time_grid + + slot_map = {} + for time_period in tqdm(ChronosTimePeriod.objects.iterator(), "Time Periods"): + time_grid = time_grid_map[time_period.validity_id] + lesrooster_slot = LesroosterSlot.objects.create( + time_grid_id=time_grid.id, + weekday=time_period.weekday, + period=time_period.period, + time_start=time_period.time_start, + time_end=time_period.time_end, + extended_data=time_period.extended_data, + ) + slot_map[time_period.id] = lesrooster_slot + + break_slot_map = {} + for break_ in tqdm(ChronosBreak.objects.iterator(), "Breaks"): + weekday = ( + break_.after_period.weekday if break_.after_period else break_.before_period.weekday + ) + + time_start = break_.after_period.time_end if break_.after_period else None + time_end = break_.before_period.time_start if break_.before_period else None + if not time_start: + time_start = time_end + if not time_end: + time_end = time_start + + time_grid = time_grid_map[break_.validity_id] + lesrooster_slot = LesroosterBreakSlot.objects.create( + time_grid_id=time_grid.id, + weekday=weekday, + time_start=time_start, + time_end=time_end, + extended_data=break_.extended_data, + ) + + break_slot_map[break_.id] = lesrooster_slot + + holiday_map = {} + holiday_dates = {} + for holiday in tqdm(ChronosHoliday.objects.iterator(), "Holidays"): + core_holiday = Holiday.objects.create( + holiday_name=holiday.title, + date_start=holiday.date_start, + date_end=holiday.date_end, + extended_data=holiday.extended_data, + polymorphic_ctype_id=ct_holiday.id, + ) + delta = holiday.date_end - holiday.date_start + for i in range(delta.days + 1): + holiday_date = holiday.date_start + timedelta(days=i) + holiday_dates.setdefault(holiday_date, []).append(core_holiday) + holiday_map[holiday.id] = core_holiday + + course_map = {} + lesson_map = {} + qs = ChronosLesson.objects.prefetch_related("groups", "teachers").order_by("id") + paginator = Paginator(qs, 1000) + for page_number in tqdm( + paginator.page_range, + "Lessons", + ): + page = paginator.page(page_number) + create_list_course_groups = [] + create_list_course_teachers = [] + create_list_lesson_teachers = [] + create_list_lesson_rooms = [] + create_list_lesson_event_groups = [] + create_list_lesson_event_teachers = [] + create_list_lesson_event_rooms = [] + for lesson in tqdm(page.object_list): + subject = subject_map[lesson.subject_id] + possible_courses = CursusCourse.objects.annotate(groups_count=Count("groups")).filter( + subject_id=subject.id, + groups__in=lesson.groups.all(), + groups_count=len(lesson.groups.all()), + ) + course = None + if possible_courses: + course = possible_courses[0] + + if not course: + name = "{}: {}".format( + ", ".join([c.short_name or c.name for c in lesson.groups.all()]), + subject.short_name or subject.name, + ) + course = CursusCourse.objects.create(subject_id=subject.id, name=name) + create_list_course_groups += [ + CursusCourse.groups.through(group_id=c.id, course_id=course.id) + for c in lesson.groups.all() + ] + create_list_course_teachers += [ + CursusCourse.teachers.through(person_id=c.id, course_id=course.id) + for c in lesson.teachers.all() + ] + course_map[lesson.id] = course + + # FIXME Check that TCC is created (should be done automatically by @permcu's changes) + + for lesson_period in ChronosLessonPeriod.objects.filter(lesson=lesson).all(): + slot = slot_map[lesson_period.period_id] + + lesrooster_lesson = LesroosterLesson.objects.create( + course_id=course.id, + slot_start_id=slot.id, + slot_end_id=slot.id, + recurrence=_build_recurrence(slot), + subject_id=subject.id, + extended_data=lesson_period.extended_data, + ) + + create_list_lesson_teachers += [ + LesroosterLesson.teachers.through( + person_id=c.id, lesson_id=lesrooster_lesson.id + ) + for c in lesson.teachers.all() + ] + if lesson_period.room_id: + create_list_lesson_rooms.append( + LesroosterLesson.rooms.through( + room_id=lesson_period.room_id, lesson_id=lesrooster_lesson.id + ) + ) + + # Sync to lesson event + + week_start = CalendarWeek.from_date(slot.time_grid.validity_range.date_start) + datetime_start = timezone.make_aware( + datetime.combine(week_start[slot.weekday], slot.time_start) + ) + datetime_end = timezone.make_aware( + datetime.combine(week_start[slot.weekday], slot.time_end) + ) + + lesson_event = LessonEvent() + + lesson_event.slot_number_start = slot.period + lesson_event.slot_number_end = slot.period + + lesson_event.course = course + lesson_event.subject = subject + lesson_event.datetime_start = datetime_start + lesson_event.datetime_end = datetime_end + + lesson_event.recurrences = _real_recurrence(lesrooster_lesson, holiday_dates) + lesson_event.timezone = lesson_event.datetime_start.tzinfo + lesson_event.exdatetimes = lesson_event.recurrences.exdates + lesson_event.rdatetimes = lesson_event.recurrences.rdates + lesson_event.rrule = serialize(lesson_event.recurrences.rrules[0]) + if lesson_event.rrule.startswith("RRULE:"): + lesson_event.rrule = lesson_event.rrule[6:] + + lesson_event.polymorphic_ctype_id = ct_lesson_event.id + + lesson_event.lesson = lesrooster_lesson + lesson_event.save() + + create_list_lesson_event_teachers += [ + LessonEvent.teachers.through(person_id=c.id, lessonevent_id=lesson_event.id) + for c in lesson.teachers.all() + ] + create_list_lesson_event_groups += [ + LessonEvent.groups.through(group_id=c.id, lessonevent_id=lesson_event.id) + for c in lesson.groups.all() + ] + if lesson_period.room_id: + create_list_lesson_event_rooms.append( + LessonEvent.rooms.through( + room_id=lesson_period.room_id, lessonevent_id=lesson_event.id + ) + ) + + logger.info(f"Imported {lesson_period.id} as {lesrooster_lesson.id}") + lesson_map[lesson_period.id] = lesrooster_lesson + + CursusCourse.groups.through.objects.bulk_create( + create_list_course_groups, ignore_conflicts=True + ) + CursusCourse.teachers.through.objects.bulk_create( + create_list_course_teachers, ignore_conflicts=True + ) + LessonEvent.groups.through.objects.bulk_create( + create_list_lesson_event_groups, ignore_conflicts=True + ) + LessonEvent.teachers.through.objects.bulk_create( + create_list_lesson_event_teachers, ignore_conflicts=True + ) + LessonEvent.rooms.through.objects.bulk_create( + create_list_lesson_event_rooms, ignore_conflicts=True + ) + + supervision_room_map = {} + for supervision_area in tqdm(ChronosSupervisionArea.objects.all(), "Supervision Areas"): + room = Room.objects.create( + short_name=f"Area: {supervision_area.short_name}", + name=f"Area: {supervision_area.name}", + extended_data=supervision_area.extended_data, + ) + supervision_room_map[supervision_area.id] = room + + supervision_map = {} + for supervision in tqdm(ChronosSupervision.objects.iterator(), "Supervisions"): + break_slot = break_slot_map[supervision.break_item_id] + room = supervision_room_map[supervision.area_id] + lesrooster_supervision = LesroosterSupervision.objects.create( + break_slot=break_slot, + recurrence=_build_recurrence(break_slot), + extended_data=supervision.extended_data, + ) + lesrooster_supervision.rooms.add(room.id) + lesrooster_supervision.teachers.add(supervision.teacher_id) + + # Sync to supervision event + _sync_supervision(apps, schema_editor, lesrooster_supervision, holiday_dates) + + supervision_map[supervision.id] = lesrooster_supervision + + substitution_map = {} + paginator = Paginator(ChronosLessonSubstitution.objects.order_by("id"), 1000) + for page_number in tqdm(paginator.page_range, "Lesson Substitutions"): + page = paginator.page(page_number) + create_list_lesson_event_teachers = [] + create_list_lesson_event_rooms = [] + for lesson_substitution in tqdm(page.object_list): + lesson = lesson_map[lesson_substitution.lesson_period_id] + + week = CalendarWeek(week=lesson_substitution.week, year=lesson_substitution.year) + + datetime_start = timezone.make_aware( + datetime.combine(week[lesson.slot_start.weekday], lesson.slot_start.time_start) + ) + datetime_end = timezone.make_aware( + datetime.combine(week[lesson.slot_end.weekday], lesson.slot_end.time_end) + ) + + new_substitution = LessonEvent.objects.create( + amends=lesson.lesson_event, + datetime_start=datetime_start, + datetime_end=datetime_end, + subject=subject_map.get(lesson_substitution.subject_id), + cancelled=lesson_substitution.cancelled, + comment=lesson_substitution.comment, + extended_data=lesson_substitution.extended_data, + polymorphic_ctype_id=ct_lesson_event.id, + ) + + create_list_lesson_event_teachers += [ + LessonEvent.teachers.through(person_id=c.id, lessonevent_id=new_substitution.id) + for c in lesson_substitution.teachers.all() + ] + if lesson_substitution.room_id: + create_list_lesson_event_rooms.append( + LessonEvent.rooms.through( + room_id=lesson_substitution.room_id, lessonevent_id=new_substitution.id + ) + ) + substitution_map.setdefault(lesson_substitution.lesson_period_id, {}) + substitution_map[lesson_substitution.lesson_period_id].setdefault( + lesson_substitution.year, {} + ) + substitution_map[lesson_substitution.lesson_period_id][lesson_substitution.year][ + lesson_substitution.week + ] = new_substitution + LessonEvent.teachers.through.objects.bulk_create(create_list_lesson_event_teachers) + LessonEvent.rooms.through.objects.bulk_create(create_list_lesson_event_rooms) + + for supervision_substitution in tqdm( + ChronosSupervisionSubstitution.objects.iterator(), "Supervision Substitutions" + ): + supervision = supervision_map[supervision_substitution.supervision_id] + + day = supervision_substitution.date + + datetime_start = timezone.make_aware( + datetime.combine(day, supervision.break_slot.time_start) + ) + datetime_end = timezone.make_aware(datetime.combine(day, supervision.break_slot.time_end)) + + new_substitution = SupervisionEvent.objects.create( + amends_id=supervision.supervision_event_id, + datetime_start=datetime_start, + datetime_end=datetime_end, + extended_data=supervision_substitution.extended_data, + polymorphic_ctype_id=ct_supervision_event.id, + ) + + new_substitution.teachers.add(supervision_substitution.teacher) + + event_map = {} + for event in tqdm( + ChronosEvent.objects.prefetch_related("teachers", "groups", "rooms").iterator( + chunk_size=2000 + ), + "Events", + ): + slot_from = slot_map[event.period_from_id] + slot_to = slot_map[event.period_to_id] + datetime_start = timezone.make_aware( + datetime.combine(event.date_start, slot_from.time_start) + ) + datetime_end = timezone.make_aware(datetime.combine(event.date_end, slot_to.time_end)) + lesson_event = LessonEvent.objects.create( + title=event.title, + slot_number_start=slot_from.period, + slot_number_end=slot_to.period, + datetime_start=datetime_start, + datetime_end=datetime_end, + extended_data=event.extended_data, + polymorphic_ctype_id=ct_lesson_event.id, + ) + + lesson_event.teachers.set(event.teachers.all()) + lesson_event.groups.set(event.groups.all()) + lesson_event.rooms.set(event.rooms.all()) + + event_map[event.id] = lesson_event + + extra_lesson_map = {} + for extra_lesson in tqdm( + ChronosExtraLesson.objects.prefetch_related("teachers", "groups").iterator(chunk_size=2000), + "Extra Lessons", + ): + slot = slot_map[extra_lesson.period_id] + week = CalendarWeek(week=extra_lesson.week, year=extra_lesson.year) + datetime_start = timezone.make_aware(datetime.combine(week[slot.weekday], slot.time_start)) + datetime_end = timezone.make_aware(datetime.combine(week[slot.weekday], slot.time_end)) + lesson_event = LessonEvent.objects.create( + slot_number_start=slot.period, + slot_number_end=slot.period, + datetime_start=datetime_start, + datetime_end=datetime_end, + subject=subject_map.get(extra_lesson.subject_id, None), + comment=extra_lesson.comment, + extended_data=extra_lesson.extended_data, + polymorphic_ctype_id=ct_lesson_event.id, + ) + + lesson_event.teachers.set(extra_lesson.teachers.all()) + lesson_event.groups.set(extra_lesson.groups.all()) + if extra_lesson.room_id: + lesson_event.rooms.add(extra_lesson.room_id) + + extra_lesson_map[extra_lesson.id] = lesson_event + + if global_apps.is_installed("aleksis.apps.kolego"): + KolegoAbsenceReason = apps.get_model("kolego", "AbsenceReason") + KolegoAbsence = apps.get_model("kolego", "Absence") + ct_kolego_absence = ContentType.objects.get_for_model(KolegoAbsence) + + absence_reason_map = {} + for absence_reason in tqdm(ChronosAbsenceReason.objects.iterator(), "Absence Reasons"): + kolego_absence_reason = KolegoAbsenceReason.objects.create( + name=absence_reason.name, + short_name=absence_reason.short_name, + extended_data=absence_reason.extended_data, + ) + absence_reason_map[absence_reason.id] = kolego_absence_reason + + default_absence_reason = KolegoAbsenceReason.objects.get_or_create( + short_name="?", defaults={"name": "?"} + ) + + for absence in tqdm(ChronosAbsence.objects.filter(teacher__isnull=False), "Absences"): + slot_from = slot_map[absence.period_from_id] + slot_to = slot_map[absence.period_to_id] + datetime_start = timezone.make_aware( + datetime.combine(absence.date_start, slot_from.time_start) + ) + datetime_end = timezone.make_aware(datetime.combine(absence.date_end, slot_to.time_end)) + + absence_reason = absence_reason_map.get(absence.reason_id) or default_absence_reason + + kolego_absence = KolegoAbsence.objects.create( + datetime_start=datetime_start, + datetime_end=datetime_end, + reason_id=absence_reason.id, + comment=absence.comment, + person_id=absence.teacher_id, + extended_data=absence.extended_data, + polymorphic_ctype_id=ct_kolego_absence.id, + ) + + if global_apps.is_installed("aleksis.apps.alsijil"): + if not global_apps.is_installed("aleksis.apps.kolego"): + raise RuntimeError( + "To migrate from AlekSIS-App-Alsijil, you need to install AlekSIS-App-Kolego." + ) + + AlsijilExcuseType = apps.get_model("alsijil", "ExcuseType") + AlsijilLessonDocumentation = apps.get_model("alsijil", "LessonDocumentation") + AlsijilDocumentation = apps.get_model("alsijil", "Documentation") + AlsijilPersonalNote = apps.get_model("alsijil", "PersonalNote") + AlsijilParticipationStatus = apps.get_model("alsijil", "ParticipationStatus") + AlsijilNewPersonalNote = apps.get_model("alsijil", "NewPersonalNote") + ct_documentation = ContentType.objects.get_for_model(AlsijilDocumentation) + ct_participation_status = ContentType.objects.get_for_model(AlsijilParticipationStatus) + + excuse_type_map = {} + for excuse_type in tqdm(AlsijilExcuseType.objects.iterator(), "Excuse Types"): + kolego_absence_reason = KolegoAbsenceReason.objects.create( + name=excuse_type.name, + short_name=excuse_type.short_name, + count_as_absent=excuse_type.count_as_absent, + extended_data=excuse_type.extended_data, + ) + excuse_type_map[excuse_type.id] = kolego_absence_reason + + documentation_map_lessons = {} + documentation_map_events = {} + documentation_map_extra_lessons = {} + paginator = Paginator(AlsijilLessonDocumentation.objects.order_by("id"), 1000) + for page_number in tqdm(paginator.page_range, "Lesson Documentations"): + page = paginator.page(page_number) + create_list_documentation = [] + create_list_documentation_teachers = [] + for lesson_documentation in tqdm(page.object_list): + if lesson_documentation.lesson_period_id: + lesson = lesson_map[lesson_documentation.lesson_period_id] + + original_amends = lesson.lesson_event + amends = original_amends + if ( + lesson_documentation.lesson_period_id in substitution_map + and lesson_documentation.year + in substitution_map[lesson_documentation.lesson_period_id] + and lesson_documentation.week + in substitution_map[lesson_documentation.lesson_period_id][ + lesson_documentation.year + ] + ): + amends = substitution_map[lesson_documentation.lesson_period_id][ + lesson_documentation.year + ][lesson_documentation.week] + week = CalendarWeek( + week=lesson_documentation.week, year=lesson_documentation.year + ) + + datetime_start = timezone.make_aware( + datetime.combine( + week[lesson.slot_start.weekday], lesson.slot_start.time_start + ) + ) + datetime_end = timezone.make_aware( + datetime.combine(week[lesson.slot_end.weekday], lesson.slot_end.time_end) + ) + elif lesson_documentation.event_id: + original_amends = amends = event_map[lesson_documentation.event_id] + datetime_start = amends.datetime_start + datetime_end = amends.datetime_end + else: + original_amends = amends = extra_lesson_map[ + lesson_documentation.extra_lesson_id + ] + datetime_start = amends.datetime_start + datetime_end = amends.datetime_end + + new_documentation = AlsijilDocumentation.objects.create( + datetime_start=datetime_start, + datetime_end=datetime_end, + amends_id=amends.id, + topic=lesson_documentation.topic, + homework=lesson_documentation.homework, + group_note=lesson_documentation.group_note, + course_id=original_amends.course_id, + subject_id=amends.subject_id or original_amends.subject_id, + participation_touched_at=datetime_start, + polymorphic_ctype_id=ct_documentation.id, + ) + create_list_documentation.append(new_documentation) + create_list_documentation_teachers += [ + AlsijilDocumentation.teachers.through( + documentation_id=new_documentation.id, person_id=c.id + ) + for c in list(amends.teachers.all()) + list(original_amends.teachers.all()) + ] + + if lesson_documentation.lesson_period_id: + documentation_map_lessons.setdefault(lesson_documentation.lesson_period_id, {}) + documentation_map_lessons[lesson_documentation.lesson_period_id].setdefault( + lesson_documentation.year, {} + ) + documentation_map_lessons[lesson_documentation.lesson_period_id][ + lesson_documentation.year + ][lesson_documentation.week] = new_documentation + elif lesson_documentation.event_id: + documentation_map_events[lesson_documentation.event_id] = new_documentation + else: + documentation_map_extra_lessons[ + lesson_documentation.extra_lesson_id + ] = new_documentation + + AlsijilDocumentation.teachers.through.objects.bulk_create( + create_list_documentation_teachers, ignore_conflicts=True + ) + reset_queries() + + absent_absence_reason, __ = KolegoAbsenceReason.objects.update_or_create( + short_name="a", defaults={"name": "absent", "default": True, "count_as_absent": True} + ) + excused_absence_reason, __ = KolegoAbsenceReason.objects.update_or_create( + short_name="e", defaults={"name": "excused", "count_as_absent": True} + ) + + paginator = Paginator( + AlsijilPersonalNote.objects.prefetch_related( + "extra_marks", "groups_of_person" + ).order_by("id"), + 1000, + ) + key_counter = 0 + for page_number in tqdm( + paginator.page_range, + "Personal Notes", + ): + page = paginator.page(page_number) + create_list_groups_of_person = [] + create_list_personal_note = [] + for personal_note in tqdm(page.object_list): + if personal_note.lesson_period_id: + try: + documentation = documentation_map_lessons[personal_note.lesson_period_id][ + personal_note.year + ][personal_note.week] + except KeyError: + lesson = lesson_map[personal_note.lesson_period_id] + week = CalendarWeek(week=personal_note.week, year=personal_note.year) + + datetime_start = timezone.make_aware( + datetime.combine( + week[lesson.slot_start.weekday], lesson.slot_start.time_start + ) + ) + datetime_end = timezone.make_aware( + datetime.combine( + week[lesson.slot_end.weekday], lesson.slot_end.time_end + ) + ) + + documentation = AlsijilDocumentation.objects.create( + datetime_start=datetime_start, + datetime_end=datetime_end, + amends_id=lesson.lesson_event_id, + course_id=lesson.course_id, + subject_id=lesson.subject_id, + participation_touched_at=datetime_start, + polymorphic_ctype_id=ct_documentation.id, + ) + documentation.teachers.set(lesson.teachers.all()) + documentation_map_lessons.setdefault(personal_note.lesson_period_id, {}) + documentation_map_lessons[personal_note.lesson_period_id].setdefault( + personal_note.year, {} + ) + documentation_map_lessons[personal_note.lesson_period_id][ + personal_note.year + ][personal_note.week] = documentation + logger.info( + f"Created documentation for {personal_note.lesson_period_id} {personal_note.year} {personal_note.week}" + ) + key_counter += 1 + + elif personal_note.event_id: + documentation = documentation_map_events.get(personal_note.event_id) + if not documentation: + event = event_map[personal_note.event_id] + documentation = AlsijilDocumentation.objects.create( + datetime_start=event.datetime_start, + datetime_end=event.datetime_end, + amends_id=event.id, + course_id=event.course_id, + subject_id=event.subject_id, + participation_touched_at=event.datetime_start, + polymorphic_ctype_id=ct_documentation.id, + ) + documentation.teachers.set(event.teachers.all()) + documentation_map_events[personal_note.event_id] = documentation + else: + documentation = documentation_map_extra_lessons.get( + personal_note.extra_lesson_id + ) + if not documentation: + extra_lesson = extra_lesson_map[personal_note.extra_lesson_id] + documentation = AlsijilDocumentation.objects.create( + datetime_start=extra_lesson.datetime_start, + datetime_end=extra_lesson.datetime_end, + amends_id=extra_lesson.id, + course_id=extra_lesson.course_id, + subject_id=extra_lesson.subject_id, + participation_touched_at=extra_lesson.datetime_start, + polymorphic_ctype_id=ct_documentation.id, + ) + documentation.teachers.set(extra_lesson.teachers.all()) + documentation_map_extra_lessons[ + personal_note.extra_lesson_id + ] = documentation + + absence_reason = None + if personal_note.absent and personal_note.excuse_type: + absence_reason = excuse_type_map[personal_note.excuse_type_id] + elif personal_note.absent and personal_note.excused: + absence_reason = excused_absence_reason + elif personal_note.absent: + absence_reason = absent_absence_reason + + logger.info( + f"Create participation status for person {personal_note.person_id} and documentation {documentation.id} out of {personal_note.id}" + ) + + participation_status = AlsijilParticipationStatus.objects.create( + person_id=personal_note.person_id, + related_documentation_id=documentation.id, + datetime_start=documentation.datetime_start, + datetime_end=documentation.datetime_end, + tardiness=personal_note.tardiness, + absence_reason_id=absence_reason.id if absence_reason else None, + polymorphic_ctype_id=ct_participation_status.id, + ) + + create_list_groups_of_person += [ + AlsijilParticipationStatus.groups_of_person.through( + participationstatus_id=participation_status.id, group_id=c.id + ) + for c in personal_note.groups_of_person.all() + ] + + if personal_note.remarks: + create_list_personal_note.append( + AlsijilNewPersonalNote( + person=personal_note.person, + note=personal_note.remarks, + documentation_id=documentation.id, + ) + ) + + for extra_mark in personal_note.extra_marks.all(): + create_list_personal_note.append( + AlsijilNewPersonalNote( + person=personal_note.person, + extra_mark_id=extra_mark.id, + documentation_id=documentation.id, + ) + ) + + AlsijilParticipationStatus.groups_of_person.through.objects.bulk_create( + create_list_groups_of_person, ignore_conflicts=True + ) + AlsijilNewPersonalNote.objects.bulk_create( + create_list_personal_note, ignore_conflicts=True + ) + reset_queries() + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0064_rrule_model"), + ("chronos", "0018_check_new_models"), + ("cursus", "0003_drop_site"), + ("lesrooster", "0016_remove_substitutions"), + ] + run_before = [ + ("chronos", "0019_remove_old_models"), + ] + + if global_apps.is_installed("aleksis.apps.kolego"): + dependencies.append(("kolego", "0004_absencereasontag_absencereason_tags")) + if global_apps.is_installed("aleksis.apps.alsijil"): + dependencies.append(("alsijil", "0024_check_new_models")) + run_before.append(("alsijil", "0025_remove_old_models")) + + operations = [migrations.RunPython(forwards, migrations.RunPython.noop)] diff --git a/aleksis/apps/lesrooster/migrations/0018_uniform_tcc.py b/aleksis/apps/lesrooster/migrations/0018_uniform_tcc.py new file mode 100644 index 0000000000000000000000000000000000000000..f8b2823540495e59171bddd72bbdf3b80c4ebdb1 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0018_uniform_tcc.py @@ -0,0 +1,34 @@ +from django.db import migrations + +from django.db.models import Q + +def _create_tcc(apps, schema_editor): + ValidityRange = apps.get_model("lesrooster", "ValidityRange") + TimeboundCourseConfig = apps.get_model("lesrooster", "TimeboundCourseConfig") + Course = apps.get_model("cursus", "Course") + db_alias = schema_editor.connection.alias + for validity_range in ValidityRange.objects.using(db_alias).all(): + # Duplicated from models.py TimeboundCourseConfig.create_for_validity_range + # because the historical model used here does not have this method. + for course in Course.objects.filter( + Q(groups__school_term__pk=validity_range.school_term.pk) | + Q(groups__parent_groups__school_term__pk=validity_range.school_term.pk) + ): + tcc, __ = TimeboundCourseConfig.objects.update_or_create( + course=course, + validity_range=validity_range, + defaults=dict(managed_by_app_label=TimeboundCourseConfig._meta.app_label, + lesson_quota=course.lesson_quota), + ) + tcc.teachers.set(course.teachers.all()) + + +class Migration(migrations.Migration): + + dependencies = [ + ('lesrooster', '0017_migrate_from_chronos'), + ] + + operations = [ + migrations.RunPython(_create_tcc), + ] diff --git a/aleksis/apps/lesrooster/migrations/0019_add_lessonbundle.py b/aleksis/apps/lesrooster/migrations/0019_add_lessonbundle.py new file mode 100644 index 0000000000000000000000000000000000000000..875aed4e26ad1ef5d2908f5564ace8631e66fdd5 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0019_add_lessonbundle.py @@ -0,0 +1,70 @@ +# Generated by Django 4.2.10 on 2024-07-18 09:22 + +from django.db import migrations, models +import django.db.models.deletion +import recurrence.fields + + +def _create_lesson_bundles(apps, schema_editor): + Lesson = apps.get_model("lesrooster", "Lesson") + LessonBundle = apps.get_model("lesrooster", "LessonBundle") + CourseBundle = apps.get_model("cursus", "CourseBundle") + db_alias = schema_editor.connection.alias + for lesson in Lesson.objects.using(db_alias).all(): + # Duplicated from models.py LessonBundle.create_from_course_bundle + # because the historical model used here does not have this method. + app_label = CourseBundle._meta.app_label + course_bundle = lesson.course.bundle.get(courses__pk=lesson.course.pk, managed_by_app_label=app_label) + lesson_bundle = LessonBundle.objects.create( + course_bundle=course_bundle, + slot_start=lesson.slot_start, + slot_end=lesson.slot_end, + recurrence=lesson.recurrence + ) + lesson_bundle.lessons.set([lesson]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('lesrooster', '0018_uniform_tcc'), + ('cursus', '0004_add_course_bundle'), + ] + + operations = [ + migrations.CreateModel( + name='LessonBundle', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('managed_by_app_label', models.CharField(blank=True, editable=False, max_length=255, verbose_name='App label of app responsible for managing this instance')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('recurrence', recurrence.fields.RecurrenceField(blank=True, help_text='Leave empty for a single lesson.', null=True, verbose_name='Recurrence')), + ('course_bundle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_bundle', to='cursus.coursebundle', verbose_name='Bundled lessons')), + ('lessons', models.ManyToManyField(related_name='bundle', to='lesrooster.lesson', verbose_name='Bundled lessons')), + ('slot_end', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='lesrooster.slot', verbose_name='End slot')), + ('slot_start', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='lesrooster.slot', verbose_name='Start slot')), + ], + options={ + 'verbose_name': 'Lesson bundle', + 'verbose_name_plural': 'Lesson bundles', + 'ordering': (['slot_start__time_grid__validity_range__date_start', 'slot_start__weekday', 'slot_start__time_start'],), + }, + ), + migrations.RunPython(_create_lesson_bundles), + migrations.AlterModelOptions( + name='lesson', + options={'ordering': ['subject'], 'verbose_name': 'Lesson', 'verbose_name_plural': 'Lessons'}, + ), + migrations.RemoveField( + model_name='lesson', + name='recurrence', + ), + migrations.RemoveField( + model_name='lesson', + name='slot_end', + ), + migrations.RemoveField( + model_name='lesson', + name='slot_start', + ), + ] diff --git a/aleksis/apps/lesrooster/model_extensions.py b/aleksis/apps/lesrooster/model_extensions.py new file mode 100644 index 0000000000000000000000000000000000000000..a7b2949c015206d75443b536d0d9fe7a2213299b --- /dev/null +++ b/aleksis/apps/lesrooster/model_extensions.py @@ -0,0 +1,36 @@ +from django.apps import apps +from django.db.models import Model +from django.utils.translation import gettext as _ + +import recurrence + +from .models import Slot + +if apps.is_installed("aleksis.apps.csv_import"): + from aleksis.apps.csv_import.field_types import ProcessFieldType + + class SlotByDayAndPeriodFieldType(ProcessFieldType): + name = "slot_by_day_and_period" + verbose_name = _("Slot by day (1-7) and period") + run_before_save = True + + def process(self, instance: Model, value): + day, period = value.split("-") + day = int(day) - 1 + period = int(period) + slot = Slot.objects.get( + time_grid__validity_range=self.additional_params["validity_range"], + time_grid__group=None, + weekday=day, + period=period, + ) + instance.slot_start = slot + instance.slot_end = slot + + class WeeklyRecurrenceFieldType(ProcessFieldType): + name = "weekly_recurrence" + verbose_name = _("Weekly recurrence (for lessons, supervisions etc.)") + + def process(self, instance: Model, value): + instance.recurrence = instance.build_recurrence(recurrence.WEEKLY) + instance.save() diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py index 1592ae3c9fc05b82cb6a2a7910fc43c12f328efa..91bc8d2fa993a35235316a26d9cea17f68107100 100644 --- a/aleksis/apps/lesrooster/models.py +++ b/aleksis/apps/lesrooster/models.py @@ -1,9 +1,12 @@ -from datetime import date, datetime +import logging +from collections.abc import Sequence +from datetime import date, datetime, timedelta from typing import Optional, Union from django.core.exceptions import ValidationError from django.db import models from django.db.models import F, Q, QuerySet +from django.http import HttpRequest from django.utils import timezone from django.utils.formats import date_format, time_format from django.utils.functional import classproperty @@ -12,15 +15,22 @@ from django.utils.translation import gettext_lazy as _ import recurrence from calendarweek import CalendarWeek from calendarweek.django import i18n_day_abbr_choices_lazy, i18n_day_name_choices_lazy +from model_utils import FieldTracker from recurrence.fields import RecurrenceField -from aleksis.apps.chronos.managers import RoomPropertiesMixin, TeacherPropertiesMixin from aleksis.apps.chronos.models import LessonEvent, SupervisionEvent -from aleksis.apps.cursus.models import Course, Subject +from aleksis.apps.cursus.models import Course, CourseBundle, Subject +from aleksis.apps.lesrooster.tasks import sync_validity_range from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel, GlobalPermissionModel 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 ( + RoomPropertiesMixin, + TeacherPropertiesMixin, + ValidityRangeManager, + ValidityRangeQuerySet, +) class ValidityRangeStatus(models.TextChoices): @@ -50,9 +60,11 @@ class ValidityRange(ExtensibleModel): verbose_name=_("Status"), max_length=255, choices=ValidityRangeStatus.choices, - default=ValidityRangeStatus.DRAFT, + default=ValidityRangeStatus.DRAFT.value, ) + status_tracker = FieldTracker(fields=["status", "date_start", "date_end", "school_term"]) + @property def published(self): return self.status == ValidityRangeStatus.PUBLISHED.value @@ -76,8 +88,23 @@ class ValidityRange(ExtensibleModel): def is_current(self) -> bool: return self.date_start <= (today := timezone.now().date()) and self.date_end >= today + def save(self, *args, **kwargs): + is_new = self.pk is None + + super().save(*args, **kwargs) + + if is_new: + # This is a new ValidityRange being saved for the first time + # Do the initialization + TimeboundCourseConfig.create_for_validity_range(self) + TimeGrid.objects.create(validity_range=self) + def clean(self): """Ensure that there is only one validity range at each point of time.""" + + if self.status_tracker.changed().get("status", "") == ValidityRangeStatus.PUBLISHED.value: + raise ValidationError(_("You can't unpublish a validity range.")) + if self.date_end < self.date_start: raise ValidationError(_("The start date must be earlier than the end date.")) @@ -87,7 +114,36 @@ class ValidityRange(ExtensibleModel): ): raise ValidationError(_("The validity range must be within the school term.")) - if self.status == ValidityRangeStatus.PUBLISHED.value: + if self.published: + errors = {} + if "school_term" in self.status_tracker.changed(): + errors["school_term"] = _( + "The school term of a published validity range can't be changed." + ) + + if "date_start" in self.status_tracker.changed(): + if self.status_tracker.changed()["date_start"] < datetime.now().date(): + errors["date_start"] = _( + "You can't change the start date if the validity range is already active." + ) + elif self.date_start < datetime.now().date(): + errors["date_start"] = _("You can't set the start date to a date in the past.") + + if "date_end" in self.status_tracker.changed(): + if self.status_tracker.changed()["date_end"] < datetime.now().date(): + errors["date_end"] = _( + "You can't change the end date " + "if the validity range is already in the past." + ) + elif self.date_end < datetime.now().date(): + errors["date_end"] = _( + "To avoid data loss, the validity range can " + "be only shortened until the current day." + ) + + if errors: + raise ValidationError(errors) + qs = ValidityRange.objects.within_dates(self.date_start, self.date_end).filter( status=ValidityRangeStatus.PUBLISHED ) @@ -101,6 +157,49 @@ class ValidityRange(ExtensibleModel): ) ) + def publish(self, request: HttpRequest | None = None): + """Publish this validity range and sync all lessons/supervisions. + + :param request: Optional :class:`HttpRequest` to show progress of syncing in frontend + """ + self.status = ValidityRangeStatus.PUBLISHED.value + self.full_clean() + self.save() + self.sync(request=request) + + def sync(self, request: HttpRequest | None): + """Sync all lessons and supervisions of this validity range. + + :params request: Optional request to show progress of syncing in frontend + """ + if not self.published: + return + if not request: + self._sync() + else: + result = sync_validity_range.delay(self.pk) + return render_progress_page( + request, + task_result=result, + title=_("Publish validity range {}".format(self)), + progress_title=_( + "All lessons and supervisions in the validity range {} are being synced …" + ).format(self), + success_message=_("The validity range has been published successfully."), + error_message=_("There was a problem while publishing the validity range."), + ) + + def _sync(self, recorder: ProgressRecorder | None = None): + objs_to_update = list( + LessonBundle.objects.filter(slot_start__time_grid__validity_range=self) + ) + list(Supervision.objects.filter(break_slot__time_grid__validity_range=self)) + + iterate = recorder.iterate(objs_to_update) if recorder else objs_to_update + + for obj in iterate: + logging.info(f"Syncing object {obj} ({type(obj)}, {obj.pk})") + obj.sync() + def __str__(self) -> str: return self.name or f"{date_format(self.date_start)}–{date_format(self.date_end)}" @@ -197,22 +296,38 @@ class Slot(ExtensiblePolymorphicModel): def get_datetime_start(self, date_ref: Union[CalendarWeek, date]) -> datetime: """Get datetime of lesson start in a specific week or on a specific day.""" - day = date_ref if isinstance(date_ref, date) else self.get_date(date_ref) + if isinstance(date_ref, date): + date_ref = CalendarWeek.from_date(date_ref) + day = self.get_date(date_ref) return timezone.make_aware(datetime.combine(day, self.time_start)) def get_first_datetime(self) -> datetime: - return self.get_datetime_start(self.time_grid.validity_range.date_start) + start = self.get_datetime_start(self.time_grid.validity_range.date_start) + if start.date() < self.time_grid.validity_range.date_start: + start = self.get_datetime_start( + self.time_grid.validity_range.date_start + timedelta(days=7) + ) + return start def get_datetime_end(self, date_ref: Union[CalendarWeek, int, date]) -> datetime: """Get datetime of lesson end in a specific week or on a specific day.""" - day = date_ref if isinstance(date_ref, date) else self.get_date(date_ref) + if isinstance(date_ref, date): + date_ref = CalendarWeek.from_date(date_ref) + day = self.get_date(date_ref) return timezone.make_aware(datetime.combine(day, self.time_end)) def get_last_datetime(self) -> datetime: - return self.get_datetime_end(self.time_grid.validity_range.date_end) - - def build_recurrence(self, *args, **kwargs) -> recurrence.Recurrence: + end = self.get_datetime_end(self.time_grid.validity_range.date_end) + if end.date() > self.time_grid.validity_range.date_end: + end = self.get_datetime_end(self.time_grid.validity_range.date_end - timedelta(days=7)) + return end + + def build_recurrence( + self, *args, slot_end: Optional["Slot"] = None, **kwargs + ) -> recurrence.Recurrence: """Build a recurrence for this slot respecting the validity range borders.""" + if not slot_end: + slot_end = self pattern = recurrence.Recurrence( dtstart=timezone.make_aware( datetime.combine(self.time_grid.validity_range.date_start, self.time_start) @@ -222,7 +337,7 @@ class Slot(ExtensiblePolymorphicModel): *args, **kwargs, until=timezone.make_aware( - datetime.combine(self.time_grid.validity_range.date_end, self.time_end) + datetime.combine(self.time_grid.validity_range.date_end, slot_end.time_end) ), ) ], @@ -244,25 +359,23 @@ class Slot(ExtensiblePolymorphicModel): verbose_name_plural = _("Slots") -class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): - """A lesson represents a single teaching event.""" +class LessonBundle(ExtensibleModel): + """A lesson bundle represents several lessons with the same slot and recurrence.""" - lesson_event = models.OneToOneField( - LessonEvent, - on_delete=models.SET_NULL, - related_name="lesson", - verbose_name=_("Linked lesson event"), - blank=True, + course_bundle = models.ForeignKey( + CourseBundle, + on_delete=models.CASCADE, + related_name="lesson_bundle", + verbose_name=_("Course Bundle"), null=True, + blank=True, ) - # A Course is the base of each lesson, it is its planing base - - course = models.ForeignKey( - Course, on_delete=models.CASCADE, verbose_name=_("Course"), null=True, blank=True + lessons = models.ManyToManyField( + "Lesson", related_name="bundle", verbose_name=_("Bundled lessons") ) - # Allow lessons to go over multiple slots to ensure + # Allow lesson bundles to go over multiple slots to ensure # that they are later tracked as single event slot_start = models.ForeignKey( Slot, @@ -277,7 +390,7 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): related_name="+", ) - # Recurrence rules allow to define a series of lessons + # Recurrence rules allow to define a series of lesson bundles # Common examples are weekly or every second week recurrence = RecurrenceField( verbose_name=_("Recurrence"), @@ -286,38 +399,6 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): help_text=_("Leave empty for a single lesson."), ) - # None of the following attributes is required - # as practice has shown that all possible combinations can occur - rooms = models.ManyToManyField( - Room, - verbose_name=_("Rooms"), - related_name="lr_lessons", - blank=True, - ) - teachers = models.ManyToManyField( - Person, - verbose_name=_("Teachers"), - related_name="lr_lessons_as_teacher", - blank=True, - ) - subject = models.ForeignKey( - Subject, - on_delete=models.CASCADE, - verbose_name=_("Subject"), - related_name="lr_lessons", - blank=True, - null=True, - ) - - def get_teachers(self) -> QuerySet[Person]: - return self.teachers.all() - - def get_rooms(self) -> QuerySet[Room]: - return self.rooms.all() - - def get_groups(self) -> QuerySet[Group]: - return self.course.groups.all() - def clean(self): """Ensure that the slots are in the same validity range.""" if self.slot_start.time_grid != self.slot_end.time_grid: @@ -325,7 +406,7 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): def build_recurrence(self, *args, **kwargs) -> "recurrence.Recurrence": """Build a recurrence for this lesson respecting the validity range borders.""" - return self.slot_start.build_recurrence(*args, **kwargs) + return self.slot_start.build_recurrence(*args, slot_end=self.slot_end, **kwargs) @property def real_recurrence(self) -> "recurrence.Recurrence": @@ -344,39 +425,150 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): ) return pattern - def sync(self) -> LessonEvent: + def sync(self) -> Sequence[LessonEvent]: """Sync the lesson with its lesson event.""" week_start = CalendarWeek.from_date(self.slot_start.time_grid.validity_range.date_start) datetime_start = self.slot_start.get_datetime_start(week_start) datetime_end = self.slot_end.get_datetime_end(week_start) - lesson_event = LessonEvent() if not self.lesson_event else self.lesson_event + lesson_events = [] + for lesson in self.lessons.all(): + lesson_event = LessonEvent() if not lesson.lesson_event else lesson.lesson_event - lesson_event.course = self.course - lesson_event.subject = self.subject - lesson_event.datetime_start = datetime_start - lesson_event.datetime_end = datetime_end + lesson_event.slot_number_start = self.slot_start.period + lesson_event.slot_number_end = self.slot_end.period - lesson_event.recurrences = self.real_recurrence + lesson_event.course = lesson.course + lesson_event.subject = lesson.subject + lesson_event.datetime_start = datetime_start + lesson_event.datetime_end = datetime_end - lesson_event.save() + lesson_event.recurrences = self.real_recurrence - if self.course: - lesson_event.groups.set(self.course.groups.all()) - lesson_event.teachers.set(self.teachers.all()) - lesson_event.rooms.set(self.rooms.all()) + lesson_event.save() - if self.lesson_event != lesson_event: - self.lesson_event = lesson_event - self.save() + if lesson.course: + lesson_event.groups.set(lesson.course.groups.all()) + else: + lesson_event.groups.clear() + lesson_event.teachers.set(lesson.teachers.all()) + lesson_event.rooms.set(lesson.rooms.all()) + + if lesson.lesson_event != lesson_event: + lesson.lesson_event = lesson_event + lesson.save() - return lesson_event + lesson_events.append(lesson_event) + + return lesson_events + + @classmethod + def create_from_course_bundle( + cls, + course_bundle: CourseBundle, + validity_range: ValidityRange, + slot_start: Slot, + slot_end: Slot, + recurrence: str, + ) -> "LessonBundle": + """Create a lesson bundle from a course bundle.""" + lesson_bundle = cls.objects.create( + course_bundle=course_bundle, + slot_start=slot_start, + slot_end=slot_end, + recurrence=recurrence, + ) + + lesson_bundle.lessons.set( + [ + Lesson.create_from_course(course, validity_range) + for course in course_bundle.courses.all() + ] + ) + + return lesson_bundle class Meta: ordering = [ "slot_start__time_grid__validity_range__date_start", "slot_start__weekday", "slot_start__time_start", + ] + verbose_name = _("Lesson Bundle") + verbose_name_plural = _("Lesson Bundles") + + def __str__(self): + return f"{str(self.slot_start.time_grid.validity_range)}: {self.course_bundle.name}" + + +class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): + """A lesson represents a single teaching event.""" + + lesson_event = models.OneToOneField( + LessonEvent, + on_delete=models.SET_NULL, + related_name="lesson", + verbose_name=_("Linked lesson event"), + blank=True, + null=True, + ) + + # A Course is the base of each lesson, it is its planing base + + course = models.ForeignKey( + Course, on_delete=models.CASCADE, verbose_name=_("Course"), null=True, blank=True + ) + + # None of the following attributes is required + # as practice has shown that all possible combinations can occur + rooms = models.ManyToManyField( + Room, + verbose_name=_("Rooms"), + related_name="lr_lessons", + blank=True, + ) + teachers = models.ManyToManyField( + Person, + verbose_name=_("Teachers"), + related_name="lr_lessons_as_teacher", + blank=True, + ) + subject = models.ForeignKey( + Subject, + on_delete=models.CASCADE, + verbose_name=_("Subject"), + related_name="lr_lessons", + blank=True, + null=True, + ) + + def get_teachers(self) -> QuerySet[Person]: + return self.teachers.all() + + def get_rooms(self) -> QuerySet[Room]: + return self.rooms.all() + + def get_groups(self) -> QuerySet[Group]: + return self.course.groups.all() + + @classmethod + def create_from_course(cls, course: Course, validity_range: ValidityRange) -> "Lesson": + """Create a lesson from a course backed by a validity range.""" + # Lookup the TCC for the course in the validity_range + tcc = TimeboundCourseConfig.objects.managed_and_unmanaged().get( + course=course, validity_range=validity_range + ) + lesson = cls.objects.create( + course=course, + subject=course.subject, + ) + if course.default_room: + lesson.rooms.set([course.default_room]) + lesson.teachers.set(tcc.teachers.all()) + return lesson + + class Meta: + ordering = [ "subject", ] verbose_name = _("Lesson") @@ -531,6 +723,27 @@ class TimeboundCourseConfig(ExtensibleModel): related_name="lr_timebound_course_configs", ) + # This seems a bit slow -> @hansegucker please have a look. + @classmethod + def create_for_validity_range( + cls, validity_range: ValidityRange + ) -> Sequence["TimeboundCourseConfig"]: + timebound_course_configs = [] + for course in Course.objects.filter( + Q(groups__school_term__pk=validity_range.school_term.pk) + | Q(groups__parent_groups__school_term__pk=validity_range.school_term.pk) + ): + tcc = cls.objects.create( + managed_by_app_label=TimeboundCourseConfig._meta.app_label, + course=course, + validity_range=validity_range, + lesson_quota=course.lesson_quota, + ) + tcc.teachers.set(course.teachers.all()) + timebound_course_configs.append(tcc) + + return timebound_course_configs + class Meta: constraints = [ models.UniqueConstraint( diff --git a/aleksis/apps/lesrooster/rules.py b/aleksis/apps/lesrooster/rules.py index 416deee4f157d1c8bb0ad6b66a65c44c17683d62..8e61597a6a45abf36ef68047e10cee5d4b3775c3 100644 --- a/aleksis/apps/lesrooster/rules.py +++ b/aleksis/apps/lesrooster/rules.py @@ -10,6 +10,7 @@ from aleksis.core.util.predicates import ( from .models import ( BreakSlot, Lesson, + LessonBundle, Slot, Supervision, TimeboundCourseConfig, @@ -97,6 +98,40 @@ delete_break_slot_predicate = view_break_slot_predicate & ( ) add_perm("lesrooster.delete_breakslot_rule", delete_break_slot_predicate) +# Lesson bundles +view_lesson_bundles_predicate = has_person & ( + has_global_perm("lesrooster.view_lessonbundle") + | has_any_object("lesrooster.view_lessonbundle", LessonBundle) + | plan_timetables_predicate +) +add_perm("lesrooster.view_lesson_bundles_rule", view_lesson_bundles_predicate) + +view_lesson_bundle_predicate = has_person & ( + has_global_perm("lesrooster.view_lessonbundle") + | has_object_perm("lesrooster.view_lessonbundle") + | plan_timetables_predicate +) +add_perm("lesrooster.view_lesson_bundle_rule", view_lesson_bundle_predicate) + +create_lesson_bundle_predicate = has_person & ( + has_global_perm("lesrooster.add_lesson_bundle") | plan_timetables_predicate +) +add_perm("lesrooster.create_lesson_bundle_rule", create_lesson_bundle_predicate) + +edit_lesson_bundle_predicate = view_lesson_bundle_predicate & ( + has_global_perm("lesrooster.change_lesson_bundle") + | has_object_perm("lesrooster.change_lesson_bundle") + | plan_timetables_predicate +) +add_perm("lesrooster.edit_lesson_bundle_rule", edit_lesson_bundle_predicate) + +delete_lesson_bundle_predicate = view_lesson_bundle_predicate & ( + has_global_perm("lesrooster.delete_lesson_bundle") + | has_object_perm("lesrooster.delete_lesson_bundle") + | plan_timetables_predicate +) +add_perm("lesrooster.delete_lesson_bundle_rule", delete_lesson_bundle_predicate) + # Lessons view_lessons_predicate = has_person & ( diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py index 354aea19c5038f5872743ba34ef13408fc1151a1..a9e279b69ad5cac185c4bcd74a32ab4ef3de10f0 100644 --- a/aleksis/apps/lesrooster/schema/__init__.py +++ b/aleksis/apps/lesrooster/schema/__init__.py @@ -1,21 +1,20 @@ -from itertools import chain - -from django.db.models import Prefetch, Q +from django.db.models import Prefetch, Q, Value import graphene +import graphene_django_optimizer from guardian.shortcuts import get_objects_for_user from aleksis.apps.chronos.schema import TimetableGroupType -from aleksis.apps.cursus.models import Course, Subject -from aleksis.apps.cursus.schema import CourseInterface +from aleksis.apps.cursus.models import Course, CourseBundle, Subject from aleksis.core.models import Group from aleksis.core.schema.base import FilterOrderList from aleksis.core.schema.group import GroupType -from aleksis.core.util.core_helpers import get_site_preferences +from aleksis.core.util.core_helpers import filter_active_school_term, get_site_preferences from ..models import ( BreakSlot, Lesson, + LessonBundle, Slot, Supervision, TimeboundCourseConfig, @@ -29,11 +28,15 @@ from .break_slot import ( BreakSlotType, ) from .lesson import ( - LessonBatchCreateMutation, - LessonBatchDeleteMutation, LessonBatchPatchMutation, LessonType, ) +from .lesson_bundle import ( + LessonBundleBatchCreateMutation, + LessonBundleBatchDeleteMutation, + LessonBundleBatchPatchMutation, + LessonBundleType, +) from .slot import ( CarryOverSlotsMutation, CopySlotsFromDifferentTimeGridMutation, @@ -53,6 +56,7 @@ from .time_grid import ( TimeGridBatchDeleteMutation, TimeGridType, ) +from .timebound_course_bundle import TimeboundCourseBundleType from .timebound_course_config import ( CourseBatchCreateForSchoolTermMutation, LesroosterExtendedSubjectType, @@ -62,6 +66,7 @@ from .timebound_course_config import ( TimeboundCourseConfigType, ) from .validity_range import ( + PublishValidityRangeMutation, ValidityRangeBatchCreateMutation, ValidityRangeBatchDeleteMutation, ValidityRangeBatchPatchMutation, @@ -79,28 +84,39 @@ class Query(graphene.ObjectType): supervisions = FilterOrderList(SupervisionType) groups_for_planning = graphene.List(TimetableGroupType) - course_objects_for_group = graphene.List( - CourseInterface, + course_bundles_for_group = graphene.List( + TimeboundCourseBundleType, + group=graphene.ID(required=True), + validity_range=graphene.ID(required=True), + ) + lesson_bundles_for_group = graphene.List( + LessonBundleType, group=graphene.ID(required=True), time_grid=graphene.ID(required=True), ) - lesson_objects_for_group = graphene.List( + lesson_bundles_for_rooms_or_teachers = graphene.List( + LessonBundleType, + rooms=graphene.List(graphene.ID, required=True), + teachers=graphene.List(graphene.ID, required=True), + time_grid=graphene.ID(required=True), + ) + lessons_for_group = graphene.List( LessonType, group=graphene.ID(required=True), time_grid=graphene.ID(required=True), ) - lesson_objects_for_teacher = graphene.List( + lessons_for_teacher = graphene.List( LessonType, teacher=graphene.ID(required=True), time_grid=graphene.ID(required=True), ) - lesson_objects_for_room = graphene.List( + lessons_for_room = graphene.List( LessonType, room=graphene.ID(required=True), time_grid=graphene.ID(required=True), ) - lessons_objects_for_rooms_or_teachers = graphene.List( + lessons_for_rooms_or_teachers = graphene.List( LessonType, rooms=graphene.List(graphene.ID, required=True), teachers=graphene.List(graphene.ID, required=True), @@ -120,48 +136,74 @@ class Query(graphene.ObjectType): @staticmethod def resolve_break_slots(root, info): + qs = filter_active_school_term( + info.context, BreakSlot.objects.all(), "time_grid__validity_range__school_term" + ) if not info.context.user.has_perm("lesrooster.view_breakslot_rule"): - return get_objects_for_user(info.context.user, "lesrooster.view_breakslot", BreakSlot) - return BreakSlot.objects.all() + return graphene_django_optimizer.query( + get_objects_for_user(info.context.user, "lesrooster.view_breakslot", qs), info + ) + return graphene_django_optimizer.query(qs, info) @staticmethod def resolve_slots(root, info): # Note: This does also return `Break` objects (but with type set to Slot). This is intended slots = Slot.objects.non_polymorphic() + slots = filter_active_school_term( + info.context, slots, "time_grid__validity_range__school_term" + ) if not info.context.user.has_perm("lesrooster.view_slot_rule"): - return get_objects_for_user(info.context.user, "lesrooster.view_slot", slots) - return slots + return graphene_django_optimizer.query( + get_objects_for_user(info.context.user, "lesrooster.view_slot", slots), info + ) + return graphene_django_optimizer.query(slots, info) @staticmethod def resolve_timebound_course_configs(root, info): - tccs = TimeboundCourseConfig.objects.all() + tccs = filter_active_school_term( + info.context, TimeboundCourseConfig.objects.all(), "validity_range__school_term" + ) if not info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"): - return get_objects_for_user( - info.context.user, "lesrooster.view_timeboundcourseconfig", tccs + return graphene_django_optimizer.query( + get_objects_for_user( + info.context.user, "lesrooster.view_timeboundcourseconfig", tccs + ), + info, ) - return tccs + return graphene_django_optimizer.query(tccs, info) @staticmethod def resolve_validity_ranges(root, info): + qs = filter_active_school_term(info.context, ValidityRange.objects.all()) if not info.context.user.has_perm("lesrooster.view_validityrange_rule"): - return get_objects_for_user( - info.context.user, "lesrooster.view_validityrange", ValidityRange + return graphene_django_optimizer.query( + get_objects_for_user(info.context.user, "lesrooster.view_validityrange", qs), info ) - return ValidityRange.objects.all() + return graphene_django_optimizer.query(qs, info) @staticmethod def resolve_time_grids(root, info): + qs = filter_active_school_term( + info.context, TimeGrid.objects.all(), "validity_range__school_term" + ) if not info.context.user.has_perm("lesrooster.view_timegrid_rule"): - return get_objects_for_user(info.context.user, "lesrooster.view_timegrid", TimeGrid) - return TimeGrid.objects.all() + return graphene_django_optimizer.query( + get_objects_for_user(info.context.user, "lesrooster.view_timegrid", qs), info + ) + return graphene_django_optimizer.query(qs, info) @staticmethod def resolve_supervisions(root, info): + qs = filter_active_school_term( + info.context, + Supervision.objects.all(), + "break_slot__time_grid__validity_range__school_term", + ) if not info.context.user.has_perm("lesrooster.view_supervision_rule"): - return get_objects_for_user( - info.context.user, "lesrooster.view_supervision", Supervision + return graphene_django_optimizer.query( + get_objects_for_user(info.context.user, "lesrooster.view_supervision", qs), info ) - return Supervision.objects.all() + return graphene_django_optimizer.query(qs, info) @staticmethod def resolve_lesrooster_extended_subjects( @@ -173,21 +215,28 @@ class Query(graphene.ObjectType): ) else: courses = Course.objects.filter(groups__in=groups) + course_configs = TimeboundCourseConfig.objects.filter(validity_range=validity_range) - courses = get_objects_for_user( - info.context.user, "cursus.view_course", courses - ).prefetch_related(Prefetch("lr_timebound_course_configs", queryset=course_configs)) - subjects = get_objects_for_user( - info.context.user, "cursus.view_subject", Subject.objects.all() - ) - return subjects.prefetch_related( + subjects = Subject.objects.all().prefetch_related( Prefetch( "courses", - queryset=courses, + queryset=get_objects_for_user( + info.context.user, + "cursus.view_course", + courses.prefetch_related( + Prefetch("lr_timebound_course_configs", queryset=course_configs) + ), + ), ) ) + if not info.context.user.has_perm("lesrooster.view_subject_rule"): + return graphene_django_optimizer.query( + get_objects_for_user(info.context.user, "cursus.view_subject", subjects), info + ) + return graphene_django_optimizer.query(subjects, info) + @staticmethod def resolve_current_validity_range(root, info): validity_range = ValidityRange.current @@ -198,37 +247,70 @@ class Query(graphene.ObjectType): def resolve_groups_for_planning(root, info): if not info.context.user.has_perm("lesrooster.plan_timetables_rule"): return [] - groups = Group.objects.all() + groups = filter_active_school_term(info.context, Group.objects.all()) group_types = get_site_preferences()["chronos__group_types_timetables"] if group_types: groups = groups.filter(group_type__in=group_types) - return groups + return graphene_django_optimizer.query(groups, info) + + def resolve_course_bundles_for_group(root, info, group, validity_range): + """Get all course_bundles for group & validity_range.""" + + # The validity_range is used to lookup the timebound_course_config. + + # The argument could be time_grid aswell since it is group & + # validity_range but then the backend has to lookup what the + # frontend already knows. - def resolve_course_objects_for_group(root, info, group, time_grid): if not info.context.user.has_perm("lesrooster.plan_timetables_rule"): return [] - group = Group.objects.get(pk=group) + return graphene_django_optimizer.query( + CourseBundle.objects.managed_and_unmanaged() + .filter(Q(courses__groups__id=group) | Q(courses__groups__parent_groups__id=group)) + .distinct() + .annotate(validity_range_id=Value(validity_range)), + info, + ) - if not group: + def resolve_lesson_bundles_for_group(root, info, group, time_grid): + """Get all lesson_bundles for group & validity_range.""" + if not info.context.user.has_perm("lesrooster.plan_timetables_rule"): return [] - courses = Course.objects.filter( - (Q(groups__in=group.child_groups.all()) | Q(groups=group)) - & Q(lr_timebound_course_configs__isnull=True) - ) + return LessonBundle.objects.filter( + Q(lessons__course__groups__id=group) + | Q(lessons__course__groups__parent_groups__id=group), + slot_start__time_grid_id=time_grid, + slot_end__time_grid_id=time_grid, + ).distinct() - timebound_course_configs = TimeboundCourseConfig.objects.filter( - Q(validity_range__time_grids__in=time_grid) - & (Q(course__groups__in=group.child_groups.all()) | Q(course__groups=group)) - ) + @staticmethod + def resolve_lesson_bundles_for_rooms_or_teachers( + root, info, time_grid, rooms=None, teachers=None + ): + if teachers is None: + teachers = [] + if rooms is None: + rooms = [] + if not info.context.user.has_perm("lesrooster.plan_timetables_rule"): + return [] - return list(chain(courses, timebound_course_configs)) + return graphene_django_optimizer.query( + LessonBundle.objects.filter( + Q(lessons__rooms__in=rooms) + | Q(lessons__teachers__in=teachers) + | Q(lessons__course__teachers__in=teachers), + slot_start__time_grid_id=time_grid, + slot_end__time_grid_id=time_grid, + ).distinct(), + info, + ) @staticmethod - def resolve_lesson_objects_for_group(root, info, group, time_grid): + def resolve_lessons_for_group(root, info, group, time_grid): if not info.context.user.has_perm("lesrooster.plan_timetables_rule"): return [] @@ -239,33 +321,42 @@ class Query(graphene.ObjectType): courses = Course.objects.filter(Q(groups__in=group.child_groups.all()) | Q(groups=group)) - return Lesson.objects.filter( - course__in=courses, - slot_start__time_grid_id=time_grid, - slot_end__time_grid_id=time_grid, + return graphene_django_optimizer.query( + Lesson.objects.filter( + course__in=courses, + bundle__slot_start__time_grid_id=time_grid, + bundle__slot_end__time_grid_id=time_grid, + ), + info, ) @staticmethod - def resolve_lesson_objects_for_teacher(root, info, teacher, time_grid): + def resolve_lessons_for_teacher(root, info, teacher, time_grid): if not info.context.user.has_perm("lesrooster.plan_timetables_rule"): return [] - return Lesson.objects.filter( - Q(teachers=teacher) | Q(course__teachers=teacher), - slot_start__time_grid_id=time_grid, - slot_end__time_grid_id=time_grid, - ).distinct() + return graphene_django_optimizer.query( + Lesson.objects.filter( + Q(teachers=teacher) | Q(course__teachers=teacher), + bundle__slot_start__time_grid_id=time_grid, + bundle__slot_end__time_grid_id=time_grid, + ).distinct(), + info, + ) @staticmethod - def resolve_lesson_objects_for_room(root, info, room, time_grid): + def resolve_lessons_for_room(root, info, room, time_grid): if not info.context.user.has_perm("lesrooster.plan_timetables_rule"): return [] - return Lesson.objects.filter( - rooms=room, - slot_start__time_grid_id=time_grid, - slot_end__time_grid_id=time_grid, - ).distinct() + return graphene_django_optimizer.query( + Lesson.objects.filter( + rooms=room, + bundle__slot_start__time_grid_id=time_grid, + bundle__slot_end__time_grid_id=time_grid, + ).distinct(), + info, + ) @staticmethod def resolve_lessons_objects_for_rooms_or_teachers( @@ -278,11 +369,14 @@ class Query(graphene.ObjectType): if not info.context.user.has_perm("lesrooster.plan_timetables_rule"): return [] - return Lesson.objects.filter( - Q(rooms__in=rooms) | Q(teachers__in=teachers) | Q(course__teachers__in=teachers), - slot_start__time_grid_id=time_grid, - slot_end__time_grid_id=time_grid, - ).distinct() + return graphene_django_optimizer.query( + Lesson.objects.filter( + Q(rooms__in=rooms) | Q(teachers__in=teachers) | Q(course__teachers__in=teachers), + bundle__slot_start__time_grid_id=time_grid, + bundle__slot_end__time_grid_id=time_grid, + ).distinct(), + info, + ) @staticmethod def resolve_groups_by_time_grid(root, info, time_grid=None, **kwargs): @@ -298,14 +392,14 @@ class Query(graphene.ObjectType): # to have a fitting school_term if time_grid_obj is None or time_grid_obj.group is None: return ( - Group.objects.filter(school_term__lr_validity_ranges__time_grids__id=time_grid) + filter_active_school_term(info.context, Group.objects.all()) .annotate(has_cg=Q(child_groups__isnull=False)) .order_by("-has_cg", "name") ) group_id = time_grid_obj.group.pk - return ( + return graphene_django_optimizer.query( Group.objects.filter( Q(pk=group_id) | Q(parent_groups=group_id) @@ -313,7 +407,8 @@ class Query(graphene.ObjectType): ) .distinct() .annotate(has_cg=Q(child_groups__isnull=False)) - .order_by("-has_cg", "name") + .order_by("-has_cg", "name"), + info, ) @@ -335,13 +430,16 @@ class Mutation(graphene.ObjectType): create_validity_ranges = ValidityRangeBatchCreateMutation.Field() delete_validity_ranges = ValidityRangeBatchDeleteMutation.Field() update_validity_ranges = ValidityRangeBatchPatchMutation.Field() + publish_validity_range = PublishValidityRangeMutation.Field() create_time_grids = TimeGridBatchCreateMutation.Field() delete_time_grids = TimeGridBatchDeleteMutation.Field() update_time_grids = TimeGridBatchDeleteMutation.Field() - create_lessons = LessonBatchCreateMutation.Field() - delete_lessons = LessonBatchDeleteMutation.Field() + create_lesson_bundles = LessonBundleBatchCreateMutation.Field() + delete_lesson_bundles = LessonBundleBatchDeleteMutation.Field() + update_lesson_bundles = LessonBundleBatchPatchMutation.Field() + update_lessons = LessonBatchPatchMutation.Field() create_supervisions = SupervisionBatchCreateMutation.Field() diff --git a/aleksis/apps/lesrooster/schema/break_slot.py b/aleksis/apps/lesrooster/schema/break_slot.py index ed6aa19e5c51f06e2074e3de6eae7c1ffebb2ded..58144b72843bea599162aee80436bc32b42ab9eb 100644 --- a/aleksis/apps/lesrooster/schema/break_slot.py +++ b/aleksis/apps/lesrooster/schema/break_slot.py @@ -5,7 +5,6 @@ from graphene_django_cud.mutations import ( DjangoBatchDeleteMutation, DjangoBatchPatchMutation, ) -from guardian.shortcuts import get_objects_for_user from aleksis.core.schema.base import ( DjangoFilterMixin, @@ -36,12 +35,6 @@ class BreakSlotType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): ) filter_fields = break_filters - @classmethod - def get_queryset(cls, queryset, info): - if not info.context.user.has_perm("lesrooster.view_breakslot_rule"): - return get_objects_for_user(info.context.user, "lesrooster.view_breakslot", queryset) - return queryset - class BreakSlotBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCreateMutation): class Meta: diff --git a/aleksis/apps/lesrooster/schema/lesson.py b/aleksis/apps/lesrooster/schema/lesson.py index a3ceb68e72cac1bdeba2940a245497373c481574..326713165bde168e6d55563b1a7efbdcf215756e 100644 --- a/aleksis/apps/lesrooster/schema/lesson.py +++ b/aleksis/apps/lesrooster/schema/lesson.py @@ -5,8 +5,7 @@ from graphene_django_cud.mutations import ( DjangoBatchDeleteMutation, DjangoBatchPatchMutation, ) -from guardian.shortcuts import get_objects_for_user -from recurrence import Recurrence, deserialize, serialize +from recurrence import Recurrence, deserialize from aleksis.core.schema.base import ( DjangoFilterMixin, @@ -22,26 +21,20 @@ from ..models import Lesson class LessonType( PermissionsTypeMixin, DjangoFilterMixin, OptimisticResponseTypeMixin, DjangoObjectType ): - recurrence = graphene.String() - class Meta: model = Lesson - fields = ("id", "course", "slot_start", "slot_end", "rooms", "teachers", "subject") + fields = ("id", "bundle", "course", "rooms", "teachers", "subject") filter_fields = { "id": ["exact"], - "slot_start": ["exact"], - "slot_end": ["exact"], } - @classmethod - def get_queryset(cls, queryset, info): - if not info.context.user.has_perm("lesrooster.view_lesson_rule"): - return get_objects_for_user(info.context.user, "lesrooster.view_lesson", queryset) - return queryset - @staticmethod - def resolve_recurrence(root, info, **kwargs): - return serialize(root.recurrence) + def resolve_subject(root, info): + # Return subject of course if lesson has no explicit subject + if root.subject is None: + return root.course.subject + else: + return root.subject class LessonBatchCreateMutation(DjangoBatchCreateMutation): diff --git a/aleksis/apps/lesrooster/schema/lesson_bundle.py b/aleksis/apps/lesrooster/schema/lesson_bundle.py new file mode 100644 index 0000000000000000000000000000000000000000..ce4d692553e6b6a413fc403a67375200c6037bf0 --- /dev/null +++ b/aleksis/apps/lesrooster/schema/lesson_bundle.py @@ -0,0 +1,94 @@ +import graphene +from graphene_django.types import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchCreateMutation, + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, +) +from recurrence import Recurrence, deserialize, serialize + +from aleksis.core.schema.base import ( + DjangoFilterMixin, + OptimisticResponseTypeMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) + +from ..models import Lesson, LessonBundle +from .timebound_course_bundle import TimeboundCourseBundleType + + +class LessonBundleType( + PermissionsTypeMixin, DjangoFilterMixin, OptimisticResponseTypeMixin, DjangoObjectType +): + class Meta: + model = LessonBundle + fields = ("id", "lessons", "slot_start", "slot_end") + filter_fields = { + "id": ["exact"], + "slot_start": ["exact"], + "slot_end": ["exact"], + } + + recurrence = graphene.String() + course_bundle = graphene.Field(TimeboundCourseBundleType) + + @staticmethod + def resolve_recurrence(root, info, **kwargs): + return serialize(root.recurrence) + + @staticmethod + def resolve_course_bundle(root, info, **kwargs): + return root.course_bundle + + +class LessonBundleBatchCreateMutation(DjangoBatchCreateMutation): + class Meta: + model = LessonBundle + only_fields = ("id", "slot_start", "slot_end", "recurrence", "course_bundle") + field_types = {"recurrence": graphene.String()} + permissions = ("lesrooster.create_lesson_bundle_rule",) + + @classmethod + def handle_recurrence(cls, value: str, name, info) -> Recurrence: + return deserialize(value) + + @classmethod + def before_save(cls, root, info, input, created_objects): # noqa + """Create and add lessons.""" + for lesson_bundle in created_objects: + validity_range = lesson_bundle.slot_start.time_grid.validity_range + lesson_bundle.lessons.set( + [ + Lesson.create_from_course(course, validity_range) + for course in lesson_bundle.course_bundle.courses.all() + ] + ) + + return created_objects + + +class LessonBundleBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = LessonBundle + only_fields = ( + "id", + "course_bundle", + "lessons", + "slot_start", + "slot_end", + "recurrence", + ) + field_types = {"recurrence": graphene.String()} + permissions = ("lesrooster.edit_lesson_bundle_rule",) + + @classmethod + def handle_recurrence(cls, value: str, name, info) -> Recurrence: + return deserialize(value) + + +class LessonBundleBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = LessonBundle + permissions = ("lesrooster.delete_lesson_bundle_rule",) diff --git a/aleksis/apps/lesrooster/schema/slot.py b/aleksis/apps/lesrooster/schema/slot.py index 73f135a9b223108d1ed034b786d727c9e9750877..36160efa89570c112b973a5bc64bde0cb9b0ee23 100644 --- a/aleksis/apps/lesrooster/schema/slot.py +++ b/aleksis/apps/lesrooster/schema/slot.py @@ -7,7 +7,6 @@ from graphene_django_cud.mutations import ( DjangoBatchDeleteMutation, DjangoBatchPatchMutation, ) -from guardian.shortcuts import get_objects_for_user from aleksis.core.schema.base import ( DjangoFilterMixin, @@ -37,12 +36,6 @@ class SlotType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): fields = ("id", "time_grid", "name", "weekday", "period", "time_start", "time_end") filter_fields = slot_filters - @classmethod - def get_queryset(cls, queryset, info): - if not info.context.user.has_perm("lesrooster.view_slot_rule"): - return get_objects_for_user(info.context.user, "lesrooster.view_slot", queryset) - return queryset - @staticmethod def resolve_model(root, info): return root.get_real_instance_class().__name__ diff --git a/aleksis/apps/lesrooster/schema/supervision.py b/aleksis/apps/lesrooster/schema/supervision.py index 80603a12ed15cdf8237bfb7f560c338ec14f152e..b6751ab82841be1aa2f5cd293f160ac6a96396e2 100644 --- a/aleksis/apps/lesrooster/schema/supervision.py +++ b/aleksis/apps/lesrooster/schema/supervision.py @@ -5,7 +5,6 @@ from graphene_django_cud.mutations import ( DjangoBatchDeleteMutation, DjangoBatchPatchMutation, ) -from guardian.shortcuts import get_objects_for_user from recurrence import Recurrence, deserialize, serialize from aleksis.core.schema.base import ( @@ -41,12 +40,6 @@ class SupervisionType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType) ) filter_fields = supervision_filters - @classmethod - def get_queryset(cls, queryset, info): - if not info.context.user.has_perm("lesrooster.view_supervision_rule"): - return get_objects_for_user(info.context.user, "lesrooster.view_supervision", queryset) - return queryset - @staticmethod def resolve_recurrence(root, info, **kwargs): return serialize(root.recurrence) diff --git a/aleksis/apps/lesrooster/schema/time_grid.py b/aleksis/apps/lesrooster/schema/time_grid.py index a823920d1fe3cda1e19eaf6d6facb8cdc3695363..45a57701b034ce2478b34ca318e8b2a4ed39ce4f 100644 --- a/aleksis/apps/lesrooster/schema/time_grid.py +++ b/aleksis/apps/lesrooster/schema/time_grid.py @@ -4,7 +4,6 @@ from graphene_django_cud.mutations import ( DjangoBatchDeleteMutation, DjangoBatchPatchMutation, ) -from guardian.shortcuts import get_objects_for_user from aleksis.core.schema.base import ( DjangoFilterMixin, @@ -32,12 +31,6 @@ class TimeGridType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): "validity_range__date_end": ["exact", "lt", "lte", "gt", "gte"], } - @classmethod - def get_queryset(cls, queryset, info): - if not info.context.user.has_perm("lesrooster.view_timegrid_rule"): - return get_objects_for_user(info.context.user, "lesrooster.view_timegrid", queryset) - return queryset - class TimeGridBatchCreateMutation(DjangoBatchCreateMutation): class Meta: diff --git a/aleksis/apps/lesrooster/schema/timebound_course_bundle.py b/aleksis/apps/lesrooster/schema/timebound_course_bundle.py new file mode 100644 index 0000000000000000000000000000000000000000..891cb1eea229c6b772c290aaba55f2897dabde2c --- /dev/null +++ b/aleksis/apps/lesrooster/schema/timebound_course_bundle.py @@ -0,0 +1,50 @@ +import graphene + +from aleksis.apps.cursus.models import Course, CourseBundle +from aleksis.apps.cursus.schema import BaseCourseBundleType, BaseCourseType + +from ..models import TimeboundCourseConfig + + +class TimeboundCourseType(BaseCourseType): + class Meta: + # Do not register as type for model. Use only if explicit request. + skip_registry = True + model = Course + fields = ("id", "name", "subject", "teachers", "groups", "lesson_quota", "default_room") + + def resolve_lesson_quota(root, info): + """Resolve lesson_quota from timebound_course_config""" + return ( + TimeboundCourseConfig.objects.managed_and_unmanaged() + .get( + course=root, + validity_range__id=root.validity_range_id, + ) + .lesson_quota + ) + + def resolve_teachers(root, info): + """Resolve teachers from timebound_course_config""" + return ( + TimeboundCourseConfig.objects.managed_and_unmanaged() + .get(course=root, validity_range__id=root.validity_range_id) + .teachers.all() + ) + + +class TimeboundCourseBundleType(BaseCourseBundleType): + class Meta: + # Do not register as type for model. Use only if explicit request. + skip_registry = True + model = CourseBundle + fields = ("id", "name", "lesson_quota") + + courses = graphene.List(TimeboundCourseType) + + def resolve_courses(root, info): + courses = root.courses.all() + for course in courses: + course.validity_range_id = root.validity_range_id + + return courses diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py index 87426363328ce1c3d8b981a5f20649df32d64094..52f91d2d4f40793925d3ea47a9cd7d276290fca6 100644 --- a/aleksis/apps/lesrooster/schema/timebound_course_config.py +++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py @@ -1,3 +1,6 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + import graphene from graphene_django.types import DjangoObjectType from graphene_django_cud.mutations import ( @@ -8,14 +11,17 @@ from graphene_django_cud.mutations import ( from guardian.shortcuts import get_objects_for_user from aleksis.apps.cursus.models import Course, Subject -from aleksis.apps.cursus.schema import CourseInterface, CourseType, SubjectType -from aleksis.core.models import Group, Person, SchoolTerm +from aleksis.apps.cursus.schema import CourseType, SubjectType +from aleksis.core.models import Group, Person from aleksis.core.schema.base import ( DjangoFilterMixin, PermissionBatchPatchMixin, PermissionsTypeMixin, ) -from aleksis.core.util.core_helpers import get_site_preferences +from aleksis.core.util.core_helpers import ( + get_active_school_term, + get_site_preferences, +) from ..models import TimeboundCourseConfig @@ -25,20 +31,9 @@ timebound_course_config_filters = {"course": ["in"], "validity_range": ["in"], " class TimeboundCourseConfigType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = TimeboundCourseConfig - interfaces = (CourseInterface,) fields = ("id", "course", "validity_range", "lesson_quota", "teachers") filter_fields = timebound_course_config_filters - @classmethod - def get_queryset(cls, queryset, info): - if not info.context.user.has_perm("lesrostter.view_timeboundcourseconfig_rule"): - return get_objects_for_user( - info.context.user, - "lesrooster.view_timeboundcourseconfig", - queryset, - ) - return queryset - @staticmethod def resolve_name(root, info, **kwargs): return root.course.name @@ -117,8 +112,6 @@ class CourseInputType(graphene.InputObjectType): lesson_quota = graphene.Int(required=False) default_room = graphene.ID(required=False) - school_term = graphene.ID(required=True) - class CourseBatchCreateForSchoolTermMutation(graphene.Mutation): class Arguments: @@ -142,7 +135,11 @@ class CourseBatchCreateForSchoolTermMutation(graphene.Mutation): course.teachers.set(teachers) if get_site_preferences()["lesrooster__create_course_group"]: - school_term = SchoolTerm.objects.get(pk=course_input.school_term) + school_term = get_active_school_term(info.context) + + if school_term is None: + raise ValidationError(_("There is no school term for the school structure.")) + group_type = get_site_preferences()["lesrooster__group_type_course_groups"] course_group, created = Group.objects.get_or_create( diff --git a/aleksis/apps/lesrooster/schema/validity_range.py b/aleksis/apps/lesrooster/schema/validity_range.py index 0d9a8bd3a36069352af3daec60440ae162be8a12..237b40a8b24d20d8ecfdd8385953bfa27d14d25c 100644 --- a/aleksis/apps/lesrooster/schema/validity_range.py +++ b/aleksis/apps/lesrooster/schema/validity_range.py @@ -1,18 +1,17 @@ +from django.core.exceptions import PermissionDenied, ValidationError +from django.utils.translation import gettext_lazy as _ + import graphene from graphene_django.types import DjangoObjectType -from graphene_django_cud.mutations import ( - DjangoBatchCreateMutation, - DjangoBatchDeleteMutation, - DjangoBatchPatchMutation, -) -from guardian.shortcuts import get_objects_for_user from aleksis.core.schema.base import ( + BaseBatchCreateMutation, + BaseBatchDeleteMutation, + BaseBatchPatchMutation, DjangoFilterMixin, - PermissionBatchDeleteMixin, - PermissionBatchPatchMixin, PermissionsTypeMixin, ) +from aleksis.core.util.core_helpers import get_active_school_term from ..models import ValidityRange @@ -26,54 +25,99 @@ class ValidityRangeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp filter_fields = { "id": ["exact"], "school_term": ["exact", "in"], - "status": ["exact"], + "status": ["iexact"], "name": ["icontains", "exact"], "date_start": ["exact", "lt", "lte", "gt", "gte"], "date_end": ["exact", "lt", "lte", "gt", "gte"], } - @classmethod - def get_queryset(cls, queryset, info): - if not info.context.user.has_perm("lesrooster.view_validityrange_rule"): - return get_objects_for_user( - info.context.user, "lesrooster.view_validityrange", queryset - ) - return queryset - -class ValidityRangeBatchCreateMutation(DjangoBatchCreateMutation): +class ValidityRangeBatchCreateMutation(BaseBatchCreateMutation): class Meta: model = ValidityRange - permissions = ("lesrooster.create_validity_range_rule",) + permissions = ("lesrooster.create_validityrange_rule",) only_fields = ( "id", - "school_term", "name", "date_start", "date_end", - "status", "time_grids", ) - field_types = {"status": graphene.String()} + @classmethod + def before_mutate(cls, root, info, input): # noqa: A002 + active_school_term = get_active_school_term(info.context) + + if active_school_term is None: + raise ValidationError(_("There is no school term for the school structure.")) -class ValidityRangeBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + for obj in input: # noqa: A002 + obj["school_term"] = active_school_term.pk + + return input # noqa: A002 + + +class ValidityRangeBatchDeleteMutation(BaseBatchDeleteMutation): class Meta: model = ValidityRange permissions = ("lesrooster.delete_validityrange_rule",) -class ValidityRangeBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): +class ValidityRangeBatchPatchMutation(BaseBatchPatchMutation): + @classmethod + def before_save(cls, root, info, input, updated_objects): # noqa: A002 + res = super().before_save(root, info, input, updated_objects) + + # Get changes and cache them for after_mutate + cls._changes = {} + for updated_obj in updated_objects: + if updated_obj.published: + cls._changes[updated_obj.id] = updated_obj.status_tracker.changed() + return res + + @classmethod + def after_mutate(cls, root, info, input, updated_objs, return_data): # noqa: A002 + res = super().after_mutate(root, info, input, updated_objs, return_data) + + # Sync validity range if date end has been changed + for updated_obj in updated_objs: + if updated_obj.published and updated_obj.id in cls._changes: + changes = cls._changes[updated_obj.id] + if "date_end" in changes: + updated_obj.sync(request=info.context) + del cls._changes + + return res + class Meta: model = ValidityRange - permissions = ("lesrooster.change_validityrange",) + permissions = ("lesrooster.edit_validityrange_rule",) only_fields = ( "id", - "school_term", "name", "date_start", "date_end", - "status", "time_grids", ) - field_types = {"status": graphene.String()} + + +class PublishValidityRangeMutation(graphene.Mutation): + # No batch mutation as publishing can only be done for one validity range + + class Arguments: + id = graphene.ID() # noqa + + validity_range = graphene.Field(ValidityRangeType) + + @classmethod + def mutate(cls, root, info, id): # noqa + validity_range = ValidityRange.objects.get(pk=id) + + if ( + not info.context.user.has_perm("lesrooster.edit_validityrange_rule", validity_range) + or validity_range.published + ): + raise PermissionDenied() + validity_range.publish(request=info.context) + + return PublishValidityRangeMutation(validity_range=validity_range) diff --git a/aleksis/apps/lesrooster/tasks.py b/aleksis/apps/lesrooster/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..c9b11381506ccc29e97a3f9200f0add386cd1659 --- /dev/null +++ b/aleksis/apps/lesrooster/tasks.py @@ -0,0 +1,11 @@ +from aleksis.core.util.celery_progress import ProgressRecorder, recorded_task + + +@recorded_task +def sync_validity_range(validity_range: int, recorder: ProgressRecorder): + """Sync all lessons and supervisions of this validity range.""" + from .models import ValidityRange + + validity_range = ValidityRange.objects.get(pk=validity_range) + + validity_range._sync(recorder=recorder) diff --git a/aleksis/apps/lesrooster/tests/test_recurrence.py b/aleksis/apps/lesrooster/tests/test_recurrence.py index 78c204b385338c7d2f8c542a3d6e6189132e6876..93e85269b36e60638198a53b9d458786b9860c91 100644 --- a/aleksis/apps/lesrooster/tests/test_recurrence.py +++ b/aleksis/apps/lesrooster/tests/test_recurrence.py @@ -1,5 +1,4 @@ from datetime import date, datetime, time -from pprint import pprint from django.utils.timezone import get_current_timezone @@ -44,10 +43,11 @@ def test_slot_build_recurrence(time_grid): slot = Slot.objects.create( time_grid=time_grid, weekday=0, period=1, time_start=time(8, 0), time_end=time(9, 0) ) + slot_b = Slot.objects.create( + time_grid=time_grid, weekday=0, period=2, time_start=time(9, 0), time_end=time(10, 0) + ) rec = slot.build_recurrence(recurrence.WEEKLY) - pprint(rec.rrules[0].__dict__) - assert rec.dtstart == datetime(2024, 1, 1, 8, 0, tzinfo=get_current_timezone()) assert len(rec.rrules) == 1 @@ -56,24 +56,42 @@ def test_slot_build_recurrence(time_grid): assert rrule.freq == 2 assert rrule.interval == 1 + rec = slot.build_recurrence(recurrence.WEEKLY, slot_end=slot_b) + + assert rec.dtstart == datetime(2024, 1, 1, 8, 0, tzinfo=get_current_timezone()) + assert len(rec.rrules) == 1 + + rrule = rec.rrules[0] + assert rrule.until == datetime(2024, 6, 1, 10, 0, tzinfo=get_current_timezone()) + assert rrule.freq == 2 + assert rrule.interval == 1 + def test_lesson_recurrence(time_grid): - slot = Slot.objects.create( + slot_start = Slot.objects.create( time_grid=time_grid, weekday=0, period=1, time_start=time(8, 0), time_end=time(9, 0) ) + slot_end = Slot.objects.create( + time_grid=time_grid, weekday=0, period=2, time_start=time(9, 0), time_end=time(10, 0) + ) break_slot = BreakSlot.objects.create( time_grid=time_grid, weekday=0, time_start=time(9, 0), time_end=time(9, 15) ) - lesson = Lesson.objects.create( - slot_start=slot, - slot_end=slot, + lesson_a = Lesson.objects.create( + slot_start=slot_start, + slot_end=slot_end, ) - assert lesson.build_recurrence(recurrence.WEEKLY) == slot.build_recurrence(recurrence.WEEKLY) + assert lesson_a.build_recurrence(recurrence.WEEKLY) == slot_start.build_recurrence( + recurrence.WEEKLY, slot_end=slot_end + ) supervision = Supervision.objects.create(break_slot=break_slot) assert supervision.build_recurrence(recurrence.WEEKLY) == break_slot.build_recurrence( recurrence.WEEKLY ) + + +# TODO Test real_recurrence for supervision and lesson with holidays diff --git a/aleksis/apps/lesrooster/tests/test_sync.py b/aleksis/apps/lesrooster/tests/test_sync.py new file mode 100644 index 0000000000000000000000000000000000000000..017cc9bcca211e6793f4e8f41225d852cb9d62c7 --- /dev/null +++ b/aleksis/apps/lesrooster/tests/test_sync.py @@ -0,0 +1,194 @@ +from datetime import date, time + +import pytest +import recurrence +from calendarweek import CalendarWeek + +from aleksis.apps.cursus.models import Course, Subject +from aleksis.apps.lesrooster.models import ( + BreakSlot, + Lesson, + Room, + Slot, + Supervision, + TimeGrid, + ValidityRange, + ValidityRangeStatus, +) +from aleksis.core.models import Group, Person, SchoolTerm + +pytestmark = pytest.mark.django_db(databases=["default", "default_oot"]) + + +@pytest.fixture +def school_term(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + school_term = SchoolTerm.objects.get_or_create( + name="Test", date_start=date_start, date_end=date_end + )[0] + return school_term + + +@pytest.fixture +def validity_range(school_term): + validity_range = ValidityRange.objects.get_or_create( + school_term=school_term, date_start=school_term.date_start, date_end=school_term.date_end + )[0] + return validity_range + + +@pytest.fixture +def time_grid(validity_range): + return TimeGrid.objects.get_or_create(validity_range=validity_range, group=None)[0] + + +@pytest.fixture +def lesson(time_grid): + slot_a = Slot.objects.create( + time_grid=time_grid, weekday=0, period=1, time_start=time(8, 0), time_end=time(9, 0) + ) + slot_b = Slot.objects.create( + time_grid=time_grid, weekday=0, period=2, time_start=time(9, 0), time_end=time(10, 0) + ) + + subject = Subject.objects.create(name="Math", short_name="Ma") + + course_teachers = [ + Person.objects.create(first_name=f"course_{i}", last_name=f"{i}") for i in range(2) + ] + course_groups = [Group.objects.create(name=f"course_{i}") for i in range(2)] + + course_subject = Subject.objects.create(name="English", short_name="En") + course = Course.objects.create(name="Testcourse", subject=course_subject) + course.groups.set(course_groups) + course.teachers.set(course_teachers) + + teachers = [Person.objects.create(first_name=f"lesson_{i}", last_name=f"{i}") for i in range(2)] + rooms = [Room.objects.create(name=f"lesson_{i}", short_name=f"lesson_{i}") for i in range(2)] + + lesson = Lesson.objects.create( + course=course, subject=subject, slot_start=slot_a, slot_end=slot_b + ) + lesson.recurrence = lesson.build_recurrence(recurrence.WEEKLY) + lesson.save() + lesson.teachers.set(teachers) + lesson.rooms.set(rooms) + + return lesson + + +@pytest.fixture +def supervision(time_grid): + slot = BreakSlot.objects.create( + time_grid=time_grid, weekday=0, time_start=time(10, 0), time_end=time(10, 15) + ) + + teachers = [ + Person.objects.create(first_name=f"supervision_{i}", last_name=f"{i}") for i in range(2) + ] + rooms = [ + Room.objects.create(name=f"supervision_{i}", short_name=f"supervision_{i}") + for i in range(2) + ] + + supervision = Supervision.objects.create( + break_slot=slot, + ) + supervision.recurrence = supervision.build_recurrence(recurrence.WEEKLY) + supervision.save() + supervision.teachers.set(teachers) + supervision.rooms.set(rooms) + + return supervision + + +def test_sync_lesson(lesson): + assert lesson.lesson_event is None + + lesson.sync() + + assert lesson.lesson_event + + lesson_event = lesson.lesson_event + assert lesson_event.course == lesson.course + assert lesson_event.subject == lesson.subject + + assert list(lesson_event.groups.all()) == list(lesson.course.groups.all()) + assert list(lesson_event.teachers.all()) == list(lesson.teachers.all()) + assert list(lesson.rooms.all()) == list(lesson.rooms.all()) + + week_start = CalendarWeek.from_date(lesson.slot_start.time_grid.validity_range.date_start) + datetime_start = lesson.slot_start.get_datetime_start(week_start) + datetime_end = lesson.slot_end.get_datetime_end(week_start) + + assert lesson_event.datetime_start == datetime_start + assert lesson_event.datetime_end == datetime_end + + assert lesson_event.recurrences == lesson.real_recurrence + + lesson.course = None + lesson.save() + lesson.sync() + + assert len(lesson.lesson_event.groups.all()) == 0 + + +def test_sync_supervision(supervision): + assert supervision.supervision_event is None + + supervision.sync() + assert supervision.supervision_event + + supervision_event = supervision.supervision_event + assert list(supervision_event.rooms.all()) == list(supervision.rooms.all()) + assert list(supervision_event.teachers.all()) == list(supervision.teachers.all()) + + week_start = CalendarWeek.from_date(supervision.break_slot.time_grid.validity_range.date_start) + datetime_start = supervision.break_slot.get_datetime_start(week_start) + datetime_end = supervision.break_slot.get_datetime_end(week_start) + + assert supervision_event.datetime_start == datetime_start + assert supervision_event.datetime_end == datetime_end + + assert supervision_event.recurrences == supervision.real_recurrence + + +def test_sync_on_publish(lesson, supervision): + validity_range = lesson.slot_start.time_grid.validity_range + validity_range.publish() + + lesson.refresh_from_db() + supervision.refresh_from_db() + + assert lesson.lesson_event + assert supervision.supervision_event + + +def test_sync_on_date_end_changed(): + pass + + +def test_sync_on_date_start_changed(): + pass + + +def test_sync_async(lesson, mocker, rf, admin_user): + mock = mocker.patch("aleksis.apps.lesrooster.tasks.sync_validity_range.delay") + mocker.patch("aleksis.apps.lesrooster.models.render_progress_page") + + request = rf.get("/") + request.user = admin_user + + validity_range = lesson.slot_start.time_grid.validity_range + + validity_range.sync(request) + + assert not mock.called + + validity_range.status = ValidityRangeStatus.PUBLISHED + validity_range.save() + + validity_range.sync(request) + + assert mock.called diff --git a/aleksis/apps/lesrooster/tests/test_validity_range.py b/aleksis/apps/lesrooster/tests/test_validity_range.py new file mode 100644 index 0000000000000000000000000000000000000000..aad9393c64526356fb6a1cf29356b111a69689bb --- /dev/null +++ b/aleksis/apps/lesrooster/tests/test_validity_range.py @@ -0,0 +1,233 @@ +from datetime import date, timedelta + +from django.core.exceptions import ValidationError + +import pytest +from freezegun import freeze_time + +from aleksis.apps.lesrooster.models import TimeGrid, ValidityRange, ValidityRangeStatus +from aleksis.core.models import SchoolTerm + +pytestmark = pytest.mark.django_db + + +def test_create_default_time_grid(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + validity_range = ValidityRange.objects.create( + school_term=school_term, date_start=date_start, date_end=date_end + ) + + assert TimeGrid.objects.filter(validity_range=validity_range, group=None).exists() + + +def test_current_validity_range(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + validity_range = ValidityRange.objects.create( + school_term=school_term, date_start=date_start, date_end=date_end + ) + + assert ValidityRange.get_current(date_end) == validity_range + assert ValidityRange.get_current(date_end + timedelta(days=1)) is None + + with freeze_time(date_start): + assert ValidityRange.current == validity_range + assert validity_range.is_current + + with freeze_time(date_end + timedelta(days=1)): + assert ValidityRange.current is None + assert not validity_range.is_current + + +def test_validity_range_date_start_before_date_end(): + date_start = date(2024, 1, 2) + date_end = date(2024, 1, 1) + + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + validity_range = ValidityRange( + school_term=school_term, date_start=date_start, date_end=date_end + ) + with pytest.raises( + ValidationError, match=r".*The start date must be earlier than the end date\..*" + ): + validity_range.full_clean() + + +def test_validity_range_within_school_term(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + dates_fail = [ + (date_start - timedelta(days=1), date_end), + (date_start, date_end + timedelta(days=1)), + (date_start - timedelta(days=1), date_end + timedelta(days=1)), + ] + + dates_success = [ + (date_start, date_end), + (date_start + timedelta(days=1), date_end), + (date_start, date_end - timedelta(days=1)), + (date_start + timedelta(days=1), date_end - timedelta(days=1)), + ] + + for d_start, d_end in dates_fail: + validity_range = ValidityRange(school_term=school_term, date_start=d_start, date_end=d_end) + with pytest.raises( + ValidationError, match=r".*The validity range must be within the school term\..*" + ): + validity_range.full_clean() + + for d_start, d_end in dates_success: + validity_range = ValidityRange(school_term=school_term, date_start=d_start, date_end=d_end) + validity_range.full_clean() + + +def test_validity_range_overlaps(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + validity_range_1 = ValidityRange.objects.create( + date_start=date_start + timedelta(days=10), + date_end=date_end - timedelta(days=10), + school_term=school_term, + status=ValidityRangeStatus.PUBLISHED, + ) + + dates_fail = [ + (date_start, validity_range_1.date_start), + (date_start, date_end), + (date_start, validity_range_1.date_end), + (validity_range_1.date_start, validity_range_1.date_end), + (validity_range_1.date_end, date_end), + ] + + for d_start, d_end in dates_fail: + validity_range_2 = ValidityRange.objects.create( + date_start=d_start, date_end=d_end, school_term=school_term + ) + with pytest.raises( + ValidationError, + match=r".*There is already a published validity range " + r"for this time or a part of this time\..*", + ): + validity_range_2.publish() + + +def test_change_published_validity_range(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + school_term = SchoolTerm.objects.create( + name="Test", + date_start=date_start - timedelta(days=5), + date_end=date_end + timedelta(days=5), + ) + school_term_2 = SchoolTerm.objects.create( + name="Test 2", + date_start=date_end + timedelta(days=6), + date_end=date_end + timedelta(days=7), + ) + + validity_range = ValidityRange.objects.create( + date_start=date_start, + date_end=date_end, + school_term=school_term, + status=ValidityRangeStatus.PUBLISHED, + ) + + # School term + validity_range.refresh_from_db() + validity_range.school_term = school_term_2 + with pytest.raises(ValidationError): + validity_range.full_clean() + + # Name + validity_range.refresh_from_db() + validity_range.name = "Test" + validity_range.full_clean() + + # Status + validity_range.refresh_from_db() + validity_range.status = ValidityRangeStatus.DRAFT + with pytest.raises(ValidationError): + validity_range.full_clean() + + with freeze_time(date_start - timedelta(days=1)): # current date start is in the future + # Date start in the past + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start - timedelta(days=2) + with pytest.raises( + ValidationError, match=r".*You can't set the start date to a date in the past.*" + ): + validity_range.full_clean() + + # Date start today + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start - timedelta(days=1) + validity_range.full_clean() + + # Date start in the future + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start + timedelta(days=2) + validity_range.full_clean() + + with freeze_time(date_start + timedelta(days=1)): # current date start is in the past + # Date start in the past + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start - timedelta(days=2) + with pytest.raises( + ValidationError, + match=r".*You can't change the start date if the validity range is already active.*", + ): + validity_range.full_clean() + + # Date start in the future + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start + timedelta(days=2) + with pytest.raises( + ValidationError, + match=r".*You can't change the start date if the validity range is already active.*", + ): + validity_range.full_clean() + + with freeze_time(date_end - timedelta(days=3)): # current date end is in the future + # Date end in the past + validity_range.refresh_from_db() + validity_range.date_end = validity_range.date_end - timedelta(days=4) + with pytest.raises( + ValidationError, + match=r".*To avoid data loss, the validity range " + r"can be only shortened until the current day.*", + ): + validity_range.full_clean() + + # Date end today + validity_range.refresh_from_db() + validity_range.date_end = validity_range.date_end - timedelta(days=3) + validity_range.full_clean() + + # Date end in the future + validity_range.refresh_from_db() + validity_range.date_end = validity_range.date_end - timedelta(days=2) + validity_range.full_clean() + + with freeze_time(date_end + timedelta(days=1)): # current date end is in the past + validity_range.refresh_from_db() + validity_range.date_end = validity_range.date_end - timedelta(days=2) + with pytest.raises( + ValidationError, + match=r".*You can't change the end date if the validity range is already in the past.*", + ): + validity_range.full_clean() + + +# TODO Test sync with date change diff --git a/aleksis/apps/lesrooster/util/signal_handlers.py b/aleksis/apps/lesrooster/util/signal_handlers.py deleted file mode 100644 index 6240a52421e92d8b2fdb6ba5876038a38acb2cf1..0000000000000000000000000000000000000000 --- a/aleksis/apps/lesrooster/util/signal_handlers.py +++ /dev/null @@ -1,49 +0,0 @@ -import logging - - -def post_save_handler(sender, instance, created, **kwargs): - """Sync the instance with Chronos after it has been saved.""" - if hasattr(instance, "sync"): - logging.debug(f"Syncing {instance} (of type {sender}) after post_save signal") - instance.sync() - - -def m2m_changed_handler(sender, instance, action, **kwargs): - """Sync the instance with Chronos after a m2m relationship has been changed.""" - if hasattr(instance, "sync"): - logging.debug(f"Syncing {instance} (of type {sender}) after m2m_changed signal") - instance.sync() - - -def pre_delete_handler(sender, instance, **kwargs): - """Sync the instance with Chronos after it has been deleted.""" - if hasattr(instance, "lesson_event"): - logging.debug( - f"Delete lesson event {instance.lesson_event} after deletion of lesson {instance}" - ) - instance.lesson_event.delete() - elif hasattr(instance, "supervision_event"): - logging.debug( - f"Delete supervision event {instance.supervision_event} " - f"after deletion of lesson {instance}" - ) - instance.supervision_event.delete() - - -def create_time_grid_for_new_validity_range(sender, instance, created, **kwargs): - from ..models import TimeGrid # noqa - - if created: - TimeGrid.objects.create(validity_range=instance) - - -def publish_validity_range(sender, instance, created, **kwargs): - from ..models import Lesson, Supervision - - # FIXME Move this to a background job - objs_to_update = list( - Lesson.objects.filter(slot_start__time_grid__validity_range=instance) - ) + list(Supervision.objects.filter(break_slot__time_grid__validity_range=instance)) - for obj in objs_to_update: - logging.info(f"Syncing object {obj} ({type(obj)}, {obj.pk})") - obj.sync() diff --git a/pyproject.toml b/pyproject.toml index 88c2857fa9e0d90248e687d7e4f09ea4e11aa029..7a934182ce283aa5fe8b011a2a5fe68571ac08ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "AlekSIS-App-Lesrooster" -version = "0.1.0.dev3" +version = "0.1.0.dev7" packages = [ { include = "aleksis" } ] @@ -11,18 +11,23 @@ include = [ { path = "tox.ini", format = "sdist" } ] -description = "AlekSIS (School Information System) — App Lesrooster (Manage lessons in a timetable schema)" -authors = ["Jonathan Weth <dev@jonathanweth.de>"] +description = "AlekSIS (School Information System) — App Lesrooster (Timetable Management Using Time Grids)" +authors = [ + "Julian Leucker <julian.leucker@teckids.org>", + "magicfelix <felix@felix-zauberer.de>", + "Jonathan Weth <dev@jonathanweth.de>", + "Hangzhi Yu <hangzhi.yu@teckids.org>", +] license = "EUPL-1.2-or-later" homepage = "https://aleksis.org" -repository = "https://edugit.org/AlekSIS/onboarding//AlekSIS-App-Lesrooster" +repository = "https://edugit.org/AlekSIS/onboarding/AlekSIS-App-Lesrooster" documentation = "https://aleksis.org/official/AlekSIS/docs/html/" classifiers = [ "Environment :: Web Environment", "Intended Audience :: Education", "Topic :: Education" ] -maintainers = ["Jonathan Weth <dev@jonathanweth.de>", "Dominik George <dominik.george@teckids.org>"] +maintainers = ["Jonathan Weth <jonathan.weth@teckids.org>", "Dominik George <dominik.george@teckids.org>"] [[tool.poetry.source]] name = "PyPI" @@ -32,34 +37,31 @@ priority = "primary" name = "gitlab" url = "https://edugit.org/api/v4/projects/461/packages/pypi/simple" priority = "supplemental" - [tool.poetry.dependencies] python = "^3.10" -AlekSIS-Core = "^4.0.0.dev6" -AlekSIS-App-Chronos = "^4.0.0.dev3" -AlekSIS-App-Cursus = "^0.1.0.dev1" +AlekSIS-Core = "^4.0.0.dev11" +AlekSIS-App-Chronos = "^4.0.0.dev7" +AlekSIS-App-Cursus = "^0.1.0.dev5" django-recurrence = "^1.11.1" [tool.poetry.plugins."aleksis.app"] lesrooster = "aleksis.apps.lesrooster.apps:DefaultConfig" - [tool.poetry.group.dev.dependencies] django-stubs = "^4.2" safety = "^2.3.5" -ruff = "^0.1.5" - -curlylint = "^0.13.0" +ruff = "^0.8.2" [tool.poetry.group.test.dependencies] -pytest = "^7.2" -pytest-django = "^4.1" +pytest = "^8.3" +pytest-django = "^4.9" pytest-django-testing-postgresql = "^0.2" -pytest-cov = "^4.0.0" -pytest-sugar = "^0.9.2" -selenium = "<4.10.0" -freezegun = "^1.1.0" +pytest-cov = "^6.0.0" +pytest-sugar = "^1.0.0" +selenium = "^4.27.0" +freezegun = "^1.5.0" +pytest-mock = "^3.14.0" [tool.poetry.group.docs] optional = true @@ -70,7 +72,6 @@ sphinxcontrib-django = "^2.3.0" sphinxcontrib-svg2pdfconverter = "^1.1.1" sphinx-autodoc-typehints = "^1.7" sphinx_material = "^0.0.35" - [tool.curlylint] include = '\.html' @@ -83,20 +84,20 @@ meta_viewport = true no_autofocus = true tabindex_no_positive = true - [tool.ruff] -exclude = ["migrations", "tests"] +exclude = ["migrations"] line-length = 100 [tool.ruff.lint] select = ["E", "F", "UP", "B", "SIM", "I", "DJ", "A", "S"] ignore = ["UP034", "UP015", "B028"] - -[tool.ruff.isort] +[tool.ruff.lint.extend-per-file-ignores] +"**/*/tests/**/*.py" = ["S101", "ARG", "FBT", "PLR2004", "S311", "S105"] +[tool.ruff.lint.isort] known-first-party = ["aleksis"] section-order = ["future", "standard-library", "django", "third-party", "first-party", "local-folder"] -[tool.ruff.isort.sections] +[tool.ruff.lint.isort.sections] django = ["django"] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tox.ini b/tox.ini index 294e65bc96d4e06262b67e3e6b8a987307b226e4..92f26b55d9ebd6f5b3db425cab8d151bde0c0c69 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] skipsdist = True skip_missing_interpreters = true -envlist = py39,py310,py311 +envlist = py310,py311,py312 [testenv] allowlist_externals =