diff --git a/aleksis/apps/lesrooster/apps.py b/aleksis/apps/lesrooster/apps.py index f4dd55a8af02a5597297ad875d87251da38ec0fc..7f07c18492012e5510c41302eaa5ee84bdafb604 100644 --- a/aleksis/apps/lesrooster/apps.py +++ b/aleksis/apps/lesrooster/apps.py @@ -2,7 +2,13 @@ from django.db.models import signals from aleksis.core.util.apps import AppConfig -from .util.signal_handlers import m2m_changed_handler, post_save_handler +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): @@ -18,7 +24,13 @@ class DefaultConfig(AppConfig): def ready(self): # Configure change tracking for models to sync changes with LessonEvent in Chronos - from .models import Lesson, Substitution, Supervision, SupervisionSubstitution + from .models import ( + Lesson, + Substitution, + Supervision, + SupervisionSubstitution, + ValidityRange, + ) models = [Lesson, Supervision, Substitution, SupervisionSubstitution] @@ -29,5 +41,23 @@ class DefaultConfig(AppConfig): ) signals.m2m_changed.connect( m2m_changed_handler, - sender=model, + 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.m2m_changed.connect( + m2m_changed_handler, + sender=Substitution.rooms.through, + ) + + signals.post_save.connect(create_time_grid_for_new_validity_range, sender=ValidityRange) + + signals.post_save.connect(publish_validity_range, sender=ValidityRange) diff --git a/aleksis/apps/lesrooster/form_extensions.py b/aleksis/apps/lesrooster/form_extensions.py new file mode 100644 index 0000000000000000000000000000000000000000..d5b97204c13db4f0bf1e7545efb88de7dce49baf --- /dev/null +++ b/aleksis/apps/lesrooster/form_extensions.py @@ -0,0 +1,7 @@ +from django.utils.translation import gettext as _ + +from material import Fieldset + +from aleksis.core.forms import PersonForm + +PersonForm.add_node_to_layout(Fieldset(_("Lesson quota as a teacher"), "lesson_quota")) diff --git a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/Break.vue b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/Break.vue new file mode 100644 index 0000000000000000000000000000000000000000..b1b6156eb677aa3d0fa98fdeb792ec5cd87a746b --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/Break.vue @@ -0,0 +1,74 @@ +<script> +import { + breakSlots, + createBreakSlot, + deleteBreakSlot, + deleteBreakSlots, + updateBreakSlots, +} from "./break.graphql"; +import LesroosterSlot from "./LesroosterSlot.vue"; + +export default { + name: "Break", + extends: LesroosterSlot, + data() { + return { + headers: [ + { + text: this.$t("lesrooster.slot.name"), + value: "name", + }, + { + text: this.$t("lesrooster.validity_range.title"), + value: "timeGrid", + orderKey: "time_grid__validity_range__date_start", + }, + { + text: this.$t("lesrooster.slot.weekday"), + value: "weekday", + }, + { + text: this.$t("lesrooster.slot.time_start"), + value: "timeStart", + }, + { + text: this.$t("lesrooster.slot.time_end"), + value: "timeEnd", + }, + ], + i18nKey: "lesrooster.break", + createItemI18nKey: "lesrooster.break.create_item", + gqlQuery: breakSlots, + gqlCreateMutation: createBreakSlot, + gqlPatchMutation: updateBreakSlots, + gqlDeleteMutation: deleteBreakSlot, + gqlDeleteMultipleMutation: deleteBreakSlots, + }; + }, + methods: { + getCreateData(item) { + console.log("in getCreateData", item); + return { + ...item, + period: null, + weekday: this.weekdayAsInt(item.weekday), + timeGrid: item.timeGrid.id, + }; + }, + getPatchData(items) { + console.log("patch items", items); + return items.map((item) => ({ + id: item.id, + name: item.name, + weekday: this.weekdayAsInt(item.weekday), + period: null, + timeStart: item.timeStart, + timeEnd: item.timeEnd, + timeGrid: item.timeGrid.id, + })); + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/LesroosterSlot.vue b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/LesroosterSlot.vue new file mode 100644 index 0000000000000000000000000000000000000000..00b17c5eed9491cacdf0b6a9238d68d271d3fd15 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/LesroosterSlot.vue @@ -0,0 +1,224 @@ +<script setup> +import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; +import WeekDayField from "aleksis.core/components/generic/forms/WeekDayField.vue"; +import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue"; +import TimeField from "aleksis.core/components/generic/forms/TimeField.vue"; +import TimeGridField from "../validity_range/TimeGridField.vue"; +</script> + +<template> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + :create-item-i18n-key="createItemI18nKey" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :gql-delete-multiple-mutation="gqlDeleteMultipleMutation" + :default-item="defaultItem" + :get-create-data="getCreateData" + :get-patch-data="getPatchData" + filter + > + <template #weekday="{ item }"> + {{ $t("weekdays." + item.weekday) }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #weekday.field="{ attrs, on }"> + <div aria-required="true"> + <week-day-field v-bind="attrs" v-on="on" :rules="required" required /> + </div> + </template> + + <template #timeGrid="{ item }"> + {{ formatTimeGrid(item.timeGrid) }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #timeGrid.field="{ attrs, on }"> + <div aria-required="true"> + <time-grid-field v-bind="attrs" v-on="on" :rules="required" required /> + </div> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #period.field="{ attrs, on }"> + <positive-small-integer-field v-bind="attrs" v-on="on" /> + </template> + + <template #timeStart="{ item }"> + {{ $d(new Date("1970-01-01T" + item.timeStart), "shortTime") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #timeStart.field="{ attrs, on }"> + <div aria-required="true"> + <time-field v-bind="attrs" v-on="on" :rules="required" required /> + </div> + </template> + + <template #timeEnd="{ item }"> + {{ $d(new Date("1970-01-01T" + item.timeEnd), "shortTime") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #timeEnd.field="{ attrs, on }"> + <div aria-required="true"> + <time-field v-bind="attrs" v-on="on" :rules="required" required /> + </div> + </template> + + <template #filters="{ attrs, on }"> + <week-day-field + v-bind="attrs('weekday')" + v-on="on('weekday')" + return-int + clearable + :label="$t('lesrooster.slot.weekday')" + /> + + <v-row> + <v-col> + <positive-small-integer-field + v-bind="attrs('period__gte')" + v-on="on('period__gte')" + :label="$t('lesrooster.slot.period_gte')" + /> + </v-col> + + <v-col> + <positive-small-integer-field + v-bind="attrs('period__lte')" + v-on="on('period__lte')" + :label="$t('lesrooster.slot.period_lte')" + /> + </v-col> + </v-row> + + <v-row> + <v-col> + <time-field + v-bind="attrs('time_end__gte')" + v-on="on('time_end__gte')" + :label="$t('school_term.after')" + /> + </v-col> + <v-col> + <time-field + v-bind="attrs('time_start__lte')" + v-on="on('time_start__lte')" + :label="$t('school_term.before')" + /> + </v-col> + </v-row> + </template> + </inline-c-r-u-d-list> +</template> + +<script> +import { + slots, + createSlot, + deleteSlot, + deleteSlots, + updateSlots, +} from "./slot.graphql"; + +export default { + name: "LesroosterSlot", + data() { + return { + headers: [ + { + text: this.$t("lesrooster.slot.name"), + value: "name", + }, + { + text: this.$t("lesrooster.validity_range.title"), + value: "timeGrid", + orderKey: "time_grid__validity_range__date_start", + }, + { + text: this.$t("lesrooster.slot.weekday"), + value: "weekday", + }, + { + text: this.$t("lesrooster.slot.period"), + value: "period", + }, + { + text: this.$t("lesrooster.slot.time_start"), + value: "timeStart", + }, + { + text: this.$t("lesrooster.slot.time_end"), + value: "timeEnd", + }, + ], + i18nKey: "lesrooster.slot", + createItemI18nKey: "lesrooster.slot.create_slot", + gqlQuery: slots, + gqlCreateMutation: createSlot, + gqlPatchMutation: updateSlots, + gqlDeleteMutation: deleteSlot, + gqlDeleteMultipleMutation: deleteSlots, + defaultItem: { + name: "", + timeStart: "", + timeEnd: "", + weekday: "A_0", + timeGrid: null, + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, + methods: { + weekdayAsInt(weekday) { + // Weekday is in format A_0 (monday) to A_6 + if ( + (weekday instanceof String || typeof weekday === "string") && + weekday.length === 3 && + weekday.startsWith("A_") && + !isNaN(parseInt(weekday.charAt(2))) + ) { + return parseInt(weekday.charAt(2)); + } + console.error("Invalid Weekday:", weekday); + return NaN; + }, + getCreateData(item) { + console.log("in getCreateData", item); + return { + ...item, + weekday: this.weekdayAsInt(item.weekday), + timeGrid: item.timeGrid.id, + }; + }, + getPatchData(items) { + console.log("patch items", items); + return items.map((item) => ({ + id: item.id, + name: item.name, + weekday: this.weekdayAsInt(item.weekday), + period: item.period, + timeStart: item.timeStart, + timeEnd: item.timeEnd, + timeGrid: item.timeGrid.id, + })); + }, + formatTimeGrid(item) { + if (!item) return null; + if (item.group === null) { + return this.$t( + "lesrooster.validity_range.time_grid.repr.generic", + item.validityRange + ); + } + return this.$t("lesrooster.validity_range.time_grid.repr.default", [ + item.validityRange.name, + item.group.name, + ]); + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/break.graphql b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/break.graphql new file mode 100644 index 0000000000000000000000000000000000000000..63c2087d1f17c516c84ef8ac8e3fd84381fccd1c --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/break.graphql @@ -0,0 +1,116 @@ +query breakSlots($orderBy: [String], $filters: JSONString) { + items: breakSlots(orderBy: $orderBy, filters: $filters) { + id + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + dateStart + dateEnd + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } +} + +mutation createBreakSlot($input: CreateBreakSlotInput!) { + createBreakSlot(input: $input) { + item: breakSlot { + id + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } + } +} + +mutation createBreakSlots($input: [BatchCreateBreakSlotInput]!) { + createBreakSlots(input: $input) { + items: breakSlots { + id + model + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } + } +} + +mutation deleteBreakSlot($id: ID!) { + deleteBreakSlot(id: $id) { + ok + } +} + +mutation deleteBreakSlots($ids: [ID]!) { + deleteBreakSlots(ids: $ids) { + deletionCount + } +} + +mutation updateBreakSlots($input: [BatchPatchBreakSlotInput]!) { + batchMutation: updateBreakSlots(input: $input) { + items: breakSlots { + id + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } + } +} diff --git a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql new file mode 100644 index 0000000000000000000000000000000000000000..269faa53c9f3bb62ea2a5d138c778279089d433f --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql @@ -0,0 +1,181 @@ +query slots($orderBy: [String], $filters: JSONString) { + items: slots(orderBy: $orderBy, filters: $filters) { + id + model + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } +} + +mutation createSlot($input: CreateSlotInput!) { + createSlot(input: $input) { + item: slot { + id + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } + } +} + +mutation createSlots($input: [BatchCreateSlotInput]!) { + createSlots(input: $input) { + items: slots { + id + model + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } + } +} + +mutation deleteSlot($id: ID!) { + deleteSlot(id: $id) { + ok + } +} + +mutation deleteSlots($ids: [ID]!) { + deleteSlots(ids: $ids) { + deletionCount + } +} + +mutation updateSlots($input: [BatchPatchSlotInput]!) { + batchMutation: updateSlots(input: $input) { + items: slots { + id + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } + } +} + +mutation carryOverSlots( + $timeGrid: ID! + $fromDay: Int! + $toDay: Int! + $only: [ID] +) { + carryOverSlots( + timeGrid: $timeGrid + fromDay: $fromDay + toDay: $toDay + only: $only + ) { + deleted + result { + id + model + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } + } +} + +mutation copySlotsFromGrid($toTimeGrid: ID!, $fromTimeGrid: ID!) { + copySlotsFromGrid(timeGrid: $toTimeGrid, fromTimeGrid: $fromTimeGrid) { + result { + id + model + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + } + } + weekday + period + timeStart + timeEnd + canEdit + canDelete + } + deleted + } +} diff --git a/aleksis/apps/lesrooster/frontend/components/helper.graphql b/aleksis/apps/lesrooster/frontend/components/helper.graphql new file mode 100644 index 0000000000000000000000000000000000000000..417870dfca049ce5c85c082555459e379b3ed274 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/helper.graphql @@ -0,0 +1,48 @@ +query gqlPersons { + persons { + id + fullName + } +} + +query gqlTeachers { + persons: teachers { + id + fullName + shortName + } +} + +query gqlGroups { + groups { + id + name + shortName + } +} + +query gqlClasses { + groups: schoolClasses { + id + name + shortName + } +} + +query gqlCourses { + courses { + id + name + subject { + id + shortName + name + colourFg + colourBg + } + teachers { + id + fullName + } + } +} diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue new file mode 100644 index 0000000000000000000000000000000000000000..1258345642b7b6d2e501a445350ec457c0174962 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue @@ -0,0 +1,521 @@ +<template> + <div id="slot-container"> + <v-card class="sidebar"> + <v-navigation-drawer floating permanent> + <v-list dense rounded> + <time-grid-field + solo + rounded + hide-details + v-model="internalTimeGrid" + /> + <slot-creator + :query="$apollo.queries.items" + :time-grid="internalTimeGrid.id" + v-if="internalTimeGrid" + :breaks="createBreaks" + > + <template #activator="{ on, attrs }"> + <v-list-item + link + v-bind="attrs" + v-on="on" + @click="createBreaks = false" + > + <v-list-item-icon> + <v-icon>$plus</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title>{{ + $t("lesrooster.slot.create_items") + }}</v-list-item-title> + </v-list-item-content> + </v-list-item> + <v-list-item + link + v-bind="attrs" + v-on="on" + @click="createBreaks = true" + > + <v-list-item-icon> + <v-icon>$plus</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title>{{ + $t("lesrooster.break.create_items") + }}</v-list-item-title> + </v-list-item-content> + </v-list-item> + </template> + </slot-creator> + + <copy-from-time-grid-menu + v-if="internalTimeGrid" + :deny-ids="[internalTimeGrid.id]" + @confirm="copyFromGrid" + > + <template #activator="{ on, attrs }"> + <v-list-item link v-bind="attrs" v-on="on"> + <v-list-item-icon> + <v-icon>mdi-content-copy</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title> + {{ $t("actions.copy_last_configuration") }} + </v-list-item-title> + </v-list-item-content> + </v-list-item> + </template> + </copy-from-time-grid-menu> + </v-list> + </v-navigation-drawer> + </v-card> + + <v-hover + v-for="weekday in weekdays" + :key="'weekday-' + weekday" + :style="{ + gridColumn: weekday, + }" + v-slot="{ hover }" + > + <v-card :loading="$apollo.queries.items.loading || loading.main"> + <v-card-title + class="d-flex flex-wrap justify-space-between align-center fill-height" + > + <span class="min-height">{{ $t("weekdays." + weekday) }}</span> + + <v-tooltip bottom> + <template #activator="{ on, attrs }"> + <v-btn + @click="deleteSlotsOfDay(weekday)" + icon + v-bind="attrs" + v-on="on" + v-show="hover" + > + <v-icon>$deleteContent</v-icon> + </v-btn> + </template> + <span v-t="'actions.delete'"></span> + </v-tooltip> + + <v-menu offset-y> + <template #activator="{ on: menu, attrs }"> + <v-tooltip bottom> + <template #activator="{ on: tooltip }"> + <v-btn + icon + v-bind="attrs" + v-on="{ ...tooltip, ...menu }" + :loading="loading[weekday] || loading.main" + v-show="hover" + > + <v-icon>mdi-application-export</v-icon> + </v-btn> + </template> + <span v-t="'actions.copy_to_day'"></span> + </v-tooltip> + </template> + <v-list> + <v-list-item + v-for="(item, index) in weekdays.filter( + (day) => day !== weekday + )" + :key="index" + link + > + <v-list-item-title @click="copyTo(weekday, item)">{{ + $t("weekdays." + item) + }}</v-list-item-title> + </v-list-item> + </v-list> + </v-menu> + + <v-btn + v-if="canAddDay(left(weekday))" + v-show="hover" + color="secondary" + fab + dark + small + absolute + left + style="left: calc(-20px - 0.5rem)" + @click="add(left(weekday))" + > + <v-icon>mdi-table-column-plus-before</v-icon> + </v-btn> + <v-btn + v-if="canAddDay(right(weekday))" + v-show="hover" + color="secondary" + fab + dark + small + absolute + right + style="right: calc(-20px - 0.5rem)" + @click="add(right(weekday))" + > + <v-icon>mdi-table-column-plus-after</v-icon> + </v-btn> + </v-card-title> + </v-card> + </v-hover> + + <slot-card + v-for="slot in slots" + :key="'slot-' + slot.id" + :item="slot" + :disabled=" + $apollo.queries.items.loading || loading.main || loading[slot.weekday] + " + @click:delete="deleteSingularSlot" + @click:copy="copySingularSlotTodDay($event.item, $event.weekday)" + :weekdays="weekdays" + :id="'#slot-' + slot.id" + /> + + <delete-dialog + :gql-mutation="deleteMutation" + :gql-query="$apollo.queries.items" + v-model="deleteDialog" + :item="itemToDelete" + > + <template #body> + {{ + $t( + "lesrooster." + itemToDelete.model.toLowerCase() + ".repr", + itemToDelete + ) + }} + </template> + </delete-dialog> + + <delete-multiple-dialog + :gql-mutation="deleteMultipleMutation" + :gql-query="$apollo.queries.items" + :items="itemsToDelete" + v-model="deleteMultipleDialog" + > + <template #title> + {{ + $t("lesrooster.slot.confirm_delete_multiple_slots", { + day: $t("weekdays." + weekdayToDelete), + }) + }} + </template> + + <template #body> + <ul class="text-body-1"> + <li v-for="item in itemsToDelete" :key="'delete-' + item.id"> + {{ $t("lesrooster." + item.model.toLowerCase() + ".repr", item) }} + </li> + </ul> + </template> + </delete-multiple-dialog> + </div> +</template> + +<script> +import { + carryOverSlots, + copySlotsFromGrid, + slots, + deleteSlot, + deleteSlots, +} from "../breaks_and_slots/slot.graphql"; +import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue"; +import DeleteMultipleDialog from "aleksis.core/components/generic/dialogs/DeleteMultipleDialog.vue"; +import CopyFromTimeGridMenu from "../validity_range/CopyFromTimeGridMenu.vue"; +import SlotCard from "./SlotCard.vue"; +import SlotCreator from "./SlotCreator.vue"; +import TimeGridField from "../validity_range/TimeGridField.vue"; + +export default { + name: "LessonRaster", + components: { + TimeGridField, + CopyFromTimeGridMenu, + SlotCreator, + DeleteDialog, + DeleteMultipleDialog, + SlotCard, + }, + apollo: { + items: { + query: slots, + variables() { + return { + filters: JSON.stringify({ + time_grid: this.internalTimeGrid.id, + }), + }; + }, + result(data) { + console.log(data); + this.weekdays = Array.from( + new Set(data.data.items.map((slot) => slot.weekday)) + ).sort(); + }, + skip() { + return this.internalTimeGrid === null; + }, + }, + }, + data() { + return { + weekdays: [], + internalTimeGrid: null, + loading: { + main: false, + }, + gqlQuery: slots, + deleteMutation: deleteSlot, + deleteMultipleMutation: deleteSlots, + deleteDialog: false, + deleteMultipleDialog: false, + itemToDelete: null, + itemsToDelete: [], + weekdayToDelete: "", + createBreaks: false, + }; + }, + computed: { + slots() { + return ( + [...(this.items || [])].sort( + (a, b) => + parseInt(a.timeStart.replace(":", "")) - + parseInt(b.timeStart.replace(":", "")) + ) || [] + ); + }, + columns() { + return ( + "[side] 256px " + this.weekdays.map((day) => `[${day}] 1fr`).join(" ") + ); + }, + }, + methods: { + intDay(weekday) { + return Number.isInteger(weekday) ? weekday : parseInt(weekday[2]); + }, + canAddDay(weekday) { + if (!weekday) { + return false; + } + + return !this.weekdays.includes(weekday); + }, + add(weekday) { + if (!this.weekdays.includes(weekday)) { + this.weekdays.push(weekday); + this.weekdays.sort(); + } + }, + right(weekday) { + return weekday === "A_6" + ? null + : weekday.replace(/\d+$/, (match) => parseInt(match) + 1); + }, + left(weekday) { + return weekday === "A_0" + ? null + : weekday.replace(/\d+$/, (match) => parseInt(match) - 1); + }, + async copyTo(src, dest) { + this.loading[dest] = true; + + // As there is an error when deleting breaks and normal slots in one action, we delete them separately + // FIXME NO ACtion + + let that = this; + + await this.$apollo.mutate({ + mutation: carryOverSlots, + variables: { + timeGrid: this.internalTimeGrid.id, + fromDay: this.intDay(src), + toDay: this.intDay(dest), + }, + update( + store, + { + data: { + carryOverSlots: { result }, + }, + } + ) { + let query = { + ...that.$apollo.queries.items.options, + variables: JSON.parse( + that.$apollo.queries.items.previousVariablesJson + ), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + storedData.items = [ + ...storedData.items.filter((item) => item.weekday !== dest), + ...result, + ]; + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + }); + + this.weekdays = this.weekdays.sort((a, b) => a[2] - b[2]); + this.loading[dest] = false; + }, + async copySingularSlotTodDay(slot, day) { + const that = this; + + this.loading[day] = true; + this.$apollo + .mutate({ + mutation: carryOverSlots, + variables: { + timeGrid: this.internalTimeGrid.id || slot.timeGrid.id, + fromDay: this.intDay(slot.weekday), + toDay: this.intDay(day), + only: [slot.id], + }, + update( + store, + { + data: { + carryOverSlots: { result }, + }, + } + ) { + let query = { + ...that.$apollo.queries.items.options, + variables: JSON.parse( + that.$apollo.queries.items.previousVariablesJson + ), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + storedData.items.push(result[0]); + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + }) + .then(() => { + this.$toastSuccess(); + }) + .catch(() => { + this.$toastError(); + }) + .finally(() => { + this.loading[day] = false; + }); + }, + deleteSingularSlot(slot) { + this.itemToDelete = slot; + this.deleteDialog = true; + }, + deleteSlotsOfDay(weekday) { + this.itemsToDelete = this.items.filter( + (slot) => slot.weekday === weekday + ); + this.weekdayToDelete = weekday; + this.deleteMultipleDialog = true; + }, + copyFromGrid(existingTimeGrid) { + if (!this.internalTimeGrid || !this.internalTimeGrid.id) return; + + let that = this; + this.loading.main = true; + + this.$apollo + .mutate({ + mutation: copySlotsFromGrid, + variables: { + fromTimeGrid: existingTimeGrid.id, + toTimeGrid: this.internalTimeGrid.id, + }, + update( + store, + { + data: { + copySlotsFromGrid: { result, deleted }, + }, + } + ) { + let query = { + ...that.$apollo.queries.items.options, + variables: JSON.parse( + that.$apollo.queries.items.previousVariablesJson + ), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + for (const id of deleted) { + // Remove item from stored data + const index = storedData.items.findIndex((m) => m.id === id); + storedData.items.splice(index, 1); + } + + storedData.items.push(...result); + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + }) + .then(() => { + this.$toastSuccess(); + }) + .catch(() => { + this.$toastError(); + }) + .finally(() => { + this.loading.main = false; + }); + }, + }, +}; +</script> + +<style scoped> +#slot-container { + display: grid; + grid-template-columns: v-bind(columns); + grid-auto-rows: 1fr; + gap: 0.7rem; + overflow-x: scroll; + margin: -1em; + padding: 1em; + grid-auto-flow: column; +} + +.min-height { + min-height: 36px; +} + +.sidebar { + position: fixed; + z-index: 1; +} +</style> diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..87d413663e2e551c473344fcf34f1747ca44994c --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue @@ -0,0 +1,120 @@ +<script> +import { defineComponent } from "vue"; + +export default defineComponent({ + name: "SlotCard", + props: { + item: { + type: Object, + required: true, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + weekdays: { + type: Array, + required: false, + default: () => [], + }, + }, + methods: { + handleDelete() { + this.$emit("click:delete", this.item); + }, + handleCopy(weekday) { + this.$emit("click:copy", { item: this.item, weekday: weekday }); + }, + }, +}); +</script> + +<template> + <v-card + :style="{ + gridColumn: item.weekday, + }" + :disabled="disabled" + > + <v-hover v-slot="{ hover }"> + <v-card-text class="d-flex align-center"> + <v-col cols="4" class="text-h4"> + <span v-if="item.model === 'Slot'">{{ item.period }}</span> + <v-icon v-else>mdi-timer-sand-paused</v-icon> + </v-col> + + <v-col cols="6"> + <div class="time"> + {{ $d(new Date("1970-01-01T" + item.timeStart), "shortTime") }} + </div> + <div class="time"> + {{ $d(new Date("1970-01-01T" + item.timeEnd), "shortTime") }} + </div> + </v-col> + + <v-col + cols="2" + class="d-flex flex-column align-center pa-0 my-n1 hover-box" + > + <v-tooltip left> + <template #activator="{ on, attrs }"> + <v-btn + icon + v-bind="attrs" + v-on="on" + @click="handleDelete" + v-show="hover" + > + <v-icon>$deleteContent</v-icon> + </v-btn> + </template> + <span v-t="'actions.delete'"></span> + </v-tooltip> + + <v-menu offset-y> + <template #activator="{ on: menu, attrs }"> + <v-tooltip left> + <template #activator="{ on: tooltip }"> + <v-btn + icon + v-bind="attrs" + v-on="{ ...tooltip, ...menu }" + v-show="hover" + > + <v-icon>mdi-application-export</v-icon> + </v-btn> + </template> + <span v-t="'actions.copy_to_day'"></span> + </v-tooltip> + </template> + <v-list> + <v-list-item + v-for="(weekday, index) in weekdays.filter( + (day) => day !== item.weekday + )" + :key="index" + link + > + <v-list-item-title @click="handleCopy(weekday)" + >{{ $t("weekdays." + weekday) }} + </v-list-item-title> + </v-list-item> + </v-list> + </v-menu> + </v-col> + </v-card-text> + </v-hover> + </v-card> +</template> + +<style scoped> +.time { + white-space: nowrap; +} + +.hover-box { + padding-inline-end: 0.5em !important; + min-width: calc(36px + 0.5em); +} +</style> diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue new file mode 100644 index 0000000000000000000000000000000000000000..bbf5d06bea283115f809cc6658fdc701854bdd62 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue @@ -0,0 +1,177 @@ +<script> +import { defineComponent } from "vue"; +import { createSlots } from "../breaks_and_slots/slot.graphql"; +import { createBreakSlots } from "../breaks_and_slots/break.graphql"; +import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue"; +import CreateButton from "aleksis.core/components/generic/buttons/CreateButton.vue"; +import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue"; +import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue"; +import TimeField from "aleksis.core/components/generic/forms/TimeField.vue"; +import WeekDayField from "aleksis.core/components/generic/forms/WeekDayField.vue"; + +export default defineComponent({ + name: "SlotCreator", + components: { + CreateButton, + CancelButton, + PositiveSmallIntegerField, + WeekDayField, + MobileFullscreenDialog, + TimeField, + }, + data() { + return { + dialog: false, + slots: { + weekdays: [], + period: null, + timeStart: "8:00", + timeEnd: "9:00", + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, + props: { + timeGrid: { + type: String, + required: true, + }, + breaks: { + type: Boolean, + required: false, + default: false, + }, + query: { + type: Object, + required: true, + }, + }, + methods: { + save() { + this.loading = true; + this.$apollo + .mutate({ + mutation: this.breaks ? createBreakSlots : createSlots, + variables: { + input: this.slots.weekdays.map((weekday) => ({ + name: "", + timeGrid: this.timeGrid, + period: this.slots.period, + weekday: parseInt(weekday[2]), + timeStart: this.slots.timeStart, + timeEnd: this.slots.timeEnd, + })), + }, + update: (store, data) => { + let mutationName = this.breaks ? "createBreakSlots" : "createSlots"; + this.$emit("update", store, data.data[mutationName].items); + + let query = { + ...this.query.options, + variables: JSON.parse(this.query.previousVariablesJson), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + storedData.items = [ + ...storedData.items, + ...data.data[mutationName].items, + ]; + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + }) + .then((data) => { + this.$emit("save", data); + + this.handleSuccess(); + }) + .catch((error) => { + console.error(error); + this.$emit("error", error); + }) + .finally(() => { + this.loading = false; + this.dialog = false; + }); + }, + handleSuccess() { + this.$root.snackbarItems.push({ + id: crypto.randomUUID(), + timeout: 5000, + messageKey: `lesrooster.${ + this.breaks ? "break" : "slot" + }.create_items_success`, + color: "success", + }); + }, + }, +}); +</script> + +<template> + <mobile-fullscreen-dialog v-model="dialog"> + <template #activator="{ on, attrs }"> + <slot name="activator" v-bind="{ on, attrs }" /> + </template> + + <template #title> + {{ $t(`lesrooster.${breaks ? "break" : "slot"}.create_items`) }} + </template> + + <template #content> + <div aria-required="true"> + <positive-small-integer-field + v-model="slots.period" + :label="$t('lesrooster.slot.period')" + :rules="required" + /> + </div> + + <div aria-required="true"> + <week-day-field + v-model="slots.weekdays" + multiple + chips + :label="$t('lesrooster.slot.weekdays')" + :rules="required" + /> + </div> + + <v-row> + <v-col> + <div aria-required="true"> + <time-field + v-model="slots.timeStart" + :label="$t('lesrooster.slot.time_start')" + :rules="required" + /> + </div> + </v-col> + + <v-col> + <div aria-required="true"> + <time-field + v-model="slots.timeEnd" + :label="$t('lesrooster.slot.time_end')" + :rules="required" + /> + </div> + </v-col> + </v-row> + </template> + + <template #actions> + <cancel-button @click="dialog = false" /> + <create-button @click="save" /> + </template> + </mobile-fullscreen-dialog> +</template> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue b/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue new file mode 100644 index 0000000000000000000000000000000000000000..21b51ebd2ddbc3aa039a3636c4c3e294f6ba97e4 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue @@ -0,0 +1,315 @@ +<script setup> +// eslint-disable-next-line no-unused-vars +import CreateSubject from "aleksis.apps.cursus/components/CreateSubject.vue"; +import ForeignKeyField from "aleksis.core/components/generic/forms/ForeignKeyField.vue"; +import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; +import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; +</script> + +<template> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + :create-item-i18n-key="createItemI18nKey" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :gql-delete-multiple-mutation="gqlDeleteMultipleMutation" + :default-item="defaultItem" + :get-create-data="getCreateData" + :get-patch-data="getPatchData" + filter + > + <template #breakSlot="{ item }"> + <div class="body-1">{{ formatBreakSlotItem(item.breakSlot) }}</div> + <div class="caption"> + {{ formatTimeGridItem(item.breakSlot.timeGrid) }} + </div> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #breakSlot.field="{ attrs, on }"> + <div aria-required="true"> + <v-autocomplete + return-object + :items="internalBreakSlots" + :item-text="formatBreakSlotItem" + item-value="id" + :loading="$apollo.queries.internalBreakSlots.loading" + v-bind="attrs" + v-on="on" + > + <template #item="data"> + <v-list-item-content> + <v-list-item-title>{{ + formatBreakSlotItem(data.item) + }}</v-list-item-title> + <v-list-item-subtitle>{{ + formatTimeGridItem(data.item.timeGrid) + }}</v-list-item-subtitle> + </v-list-item-content> + </template> + </v-autocomplete> + </div> + </template> + + <template #rooms="{ item }"> + <v-chip v-for="room in item.rooms" dense class="mx-1" :key="room.id">{{ + room.shortName + }}</v-chip> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #rooms.field="{ attrs, on }"> + <div aria-required="true"> + <v-autocomplete + multiple + return-object + :items="internalRooms" + item-text="name" + item-value="id" + :loading="$apollo.queries.internalRooms.loading" + v-bind="attrs" + v-on="on" + /> + </div> + </template> + + <template #teachers="{ item }"> + <v-chip + v-for="teacher in item.teachers" + dense + class="mx-1" + :key="teacher.id" + >{{ teacher.fullName }}</v-chip + > + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #teachers.field="{ attrs, on }"> + <div aria-required="true"> + <v-autocomplete + multiple + return-object + :items="persons" + item-text="fullName" + item-value="id" + v-bind="attrs" + v-on="on" + :loading="$apollo.queries.persons.loading" + > + <template #item="data"> + <v-list-item-action> + <v-checkbox v-model="data.attrs.inputValue" /> + </v-list-item-action> + <v-list-item-content> + <v-list-item-title>{{ data.item.fullName }}</v-list-item-title> + <v-list-item-subtitle v-if="data.item.shortName">{{ + data.item.shortName + }}</v-list-item-subtitle> + </v-list-item-content> + </template> + </v-autocomplete> + </div> + </template> + + <template #subject="{ item }"> + <subject-chip v-if="item.subject" :subject="item.subject" /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #subject.field="{ attrs, on }"> + <foreign-key-field + v-bind="attrs" + v-on="on" + :fields="subject.fields" + :default-item="subject.defaultItem" + :gql-query="subject.gqlQuery" + :gql-patch-mutation="{}" + :gql-create-mutation="subject.gqlCreateMutation" + :get-create-data="subject.transformCreateData" + create-item-i18n-key="cursus.subject.create" + return-object + > + <template #createComponent="{ attrs: attrs2, on: on2 }"> + <create-subject v-bind="attrs2" v-on="on2"></create-subject> + </template> + </foreign-key-field> + </template> + + <!--<template #filters="{ attrs, on }">--> + <!-- <time-grid-field--> + <!-- outlined--> + <!-- filled--> + <!-- v-bind="attrs('break_slot__time_grid__exact')"--> + <!-- v-on="on('break_slot__time_grid__exact')"--> + <!-- :label="$t('labels.select_validity_range')"--> + <!-- hide-details--> + <!-- />--> + <!--</template>--> + </inline-c-r-u-d-list> +</template> + +<script> +import { + supervisions, + createSupervision, + deleteSupervision, + deleteSupervisions, + updateSupervisions, +} from "./supervision.graphql"; + +import { gqlTeachers } from "../helper.graphql"; +import { rooms } from "aleksis.core/components/room/room.graphql"; +import { breakSlots } from "../breaks_and_slots/break.graphql"; +import { + subjects, + createSubject, +} from "aleksis.apps.cursus/components/subject.graphql"; + +import { RRule } from "rrule"; + +export default { + name: "LesroosterSupervision", + data() { + return { + headers: [ + { + text: this.$t("lesrooster.supervision.break_slot"), + value: "breakSlot", + }, + { + text: this.$t("lesrooster.supervision.rooms"), + value: "rooms", + }, + { + text: this.$t("lesrooster.supervision.teachers"), + value: "teachers", + }, + { + text: this.$t("lesrooster.supervision.subject"), + value: "subject", + }, + ], + i18nKey: "lesrooster.supervision", + createItemI18nKey: "lesrooster.supervision.create_supervision", + gqlQuery: supervisions, + gqlCreateMutation: createSupervision, + gqlPatchMutation: updateSupervisions, + gqlDeleteMutation: deleteSupervision, + gqlDeleteMultipleMutation: deleteSupervisions, + defaultItem: { + breakSlot: null, + teachers: [], + rooms: [], + }, + subject: { + gqlQuery: subjects, + gqlCreateMutation: createSubject, + transformCreateData(item) { + return { ...item, parent: item.parent?.id }; + }, + defaultItem: { + name: "", + shortName: "", + parent: null, + colourFg: "", + colourBg: "", + }, + fields: [ + { + text: this.$t("cursus.subject.fields.name"), + value: "name", + }, + { + text: this.$t("cursus.subject.fields.short_name"), + value: "shortName", + }, + { + text: this.$t("cursus.subject.fields.parent"), + value: "parent", + }, + { + text: this.$t("cursus.subject.fields.colour_fg"), + value: "colourFg", + }, + { + text: this.$t("cursus.subject.fields.colour_bg"), + value: "colourBg", + }, + { + text: this.$t("cursus.subject.fields.teachers"), + value: "teachers", + }, + ], + }, + rules: { + required: [(value) => !!value || this.$t("forms.errors.required")], + subject: [ + (subject) => !!subject || this.$t("cursus.errors.subject_required"), + ], + }, + }; + }, + apollo: { + persons: { + query: gqlTeachers, + }, + internalRooms: { + query: rooms, + update: (data) => data.items, + }, + internalBreakSlots: { + query: breakSlots, + update: (data) => data.items, + }, + }, + methods: { + formatTimeGridItem(item) { + if (item.group === null) { + return this.$t( + "lesrooster.validity_range.time_grid.repr.generic", + item.validityRange + ); + } + return this.$t("lesrooster.validity_range.time_grid.repr.default", [ + item.validityRange.name, + item.group.name, + ]); + }, + formatBreakSlotItem(item) { + return this.$t("lesrooster.break.repr.weekday_short", { + weekday: this.$t("weekdays." + item.weekday), + timeStart: item.timeStart, + timeEnd: item.timeEnd, + }); + }, + getRRule(timeGrid) { + const rule = new RRule({ + freq: RRule.WEEKLY, // TODO: Make this configurable + dtstart: new Date(timeGrid.validityRange.dateStart), // FIXME: check if this is correct with timezones etc. + until: new Date(timeGrid.validityRange.dateEnd), // FIXME: check if this is correct with timezones etc. + }); + return rule; + }, + getCreateData(item) { + return { + breakSlot: item.breakSlot.id, + rooms: item.rooms.map((r) => r.id), + teachers: item.teachers.map((t) => t.id), + subject: item.subject?.id, + recurrence: this.getRRule(item.breakSlot.timeGrid).toString(), + }; + }, + getPatchData(items) { + return items.map((item) => ({ + id: item.id, + breakSlot: item.breakSlot.id, + rooms: item.rooms.map((r) => r.id), + teachers: item.teachers.map((t) => t.id), + subject: item.subject?.id, + recurrence: this.getRRule(item.breakSlot.timeGrid).toString(), + })); + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/supervision/supervision.graphql b/aleksis/apps/lesrooster/frontend/components/supervision/supervision.graphql new file mode 100644 index 0000000000000000000000000000000000000000..26556dfe7a3abb474e64b04f19243df70e110770 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/supervision/supervision.graphql @@ -0,0 +1,84 @@ +fragment supervisionFields on SupervisionType { + id + rooms { + id + shortName + name + } + teachers { + id + fullName + } + subject { + id + name + colourFg + colourBg + } + breakSlot { + id + name + timeGrid { + id + group { + id + name + } + validityRange { + id + name + dateStart + dateEnd + } + } + weekday + timeStart + timeEnd + canEdit + canDelete + } + canEdit + canDelete +} + +query supervisions($orderBy: [String], $filters: JSONString) { + items: supervisions(orderBy: $orderBy, filters: $filters) { + ...supervisionFields + } +} + +mutation createSupervision($input: CreateSupervisionInput!) { + createSupervision(input: $input) { + item: supervision { + ...supervisionFields + } + } +} + +mutation createSupervisions($input: [BatchCreateSupervisionInput]!) { + createSupervisions(input: $input) { + items: supervisions { + ...supervisionFields + } + } +} + +mutation deleteSupervision($id: ID!) { + deleteSupervision(id: $id) { + ok + } +} + +mutation deleteSupervisions($ids: [ID]!) { + deleteSupervisions(ids: $ids) { + deletionCount + } +} + +mutation updateSupervisions($input: [BatchPatchSupervisionInput]!) { + batchMutation: updateSupervisions(input: $input) { + items: supervisions { + ...supervisionFields + } + } +} diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigCRUDTable.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigCRUDTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..bf93ef62c6150e8610ad570b437a140f483d2b7c --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigCRUDTable.vue @@ -0,0 +1,227 @@ +<script setup> +import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; +import WeekDayField from "aleksis.core/components/generic/forms/WeekDayField.vue"; +import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue"; +import TimeField from "aleksis.core/components/generic/forms/TimeField.vue"; +import ValidityRangeField from "../validity_range/ValidityRangeField.vue"; +import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; +</script> + +<template> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + :create-item-i18n-key="createItemI18nKey" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :default-item="defaultItem" + :get-create-data="getCreateData" + :get-patch-data="getPatchData" + filter + > + <template #course="{ item }"> + {{ item.course.name }} + <subject-chip v-if="item.course.subject" :subject="item.course.subject" /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #course.field="{ attrs, on }"> + <v-autocomplete + :items="courses" + item-text="name" + item-value="id" + v-bind="attrs" + v-on="on" + return-object + /> + </template> + + <template #validityRange="{ item }"> + {{ item.validityRange?.name }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #validityRange.field="{ attrs, on }"> + <validity-range-field v-bind="attrs" v-on="on" :rules="required" /> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #lessonQuota.field="{ attrs, on }"> + <positive-small-integer-field + v-bind="attrs" + v-on="on" + :rules="required" + /> + </template> + + <template #teachers="{ item }"> + <v-chip v-for="teacher in item.teachers" :key="teacher.id">{{ + teacher.fullName + }}</v-chip> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #teachers.field="{ attrs, on }"> + <v-autocomplete + multiple + :items="persons" + item-text="fullName" + item-value="id" + v-bind="attrs" + v-on="on" + chips + deletable-chips + return-object + > + <template #item="data"> + <v-list-item-action> + <v-checkbox v-model="data.attrs.inputValue" /> + </v-list-item-action> + <v-list-item-content> + <v-list-item-title>{{ data.item.fullName }}</v-list-item-title> + <v-list-item-subtitle v-if="data.item.shortName">{{ + data.item.shortName + }}</v-list-item-subtitle> + </v-list-item-content> + </template> + </v-autocomplete> + </template> + + <template #filters="{ attrs, on }"> + <week-day-field + v-bind="attrs('weekday')" + v-on="on('weekday')" + return-int + clearable + :label="$t('lesrooster.slot.weekday')" + /> + + <v-row> + <v-col> + <positive-small-integer-field + v-bind="attrs('period__gte')" + v-on="on('period__gte')" + :label="$t('lesrooster.slot.period_gte')" + /> + </v-col> + + <v-col> + <positive-small-integer-field + v-bind="attrs('period__lte')" + v-on="on('period__lte')" + :label="$t('lesrooster.slot.period_lte')" + /> + </v-col> + </v-row> + + <v-row> + <v-col> + <time-field + v-bind="attrs('time_end__gte')" + v-on="on('time_end__gte')" + :label="$t('school_term.after')" + /> + </v-col> + <v-col> + <time-field + v-bind="attrs('time_start__lte')" + v-on="on('time_start__lte')" + :label="$t('school_term.before')" + /> + </v-col> + </v-row> + </template> + </inline-c-r-u-d-list> +</template> + +<script> +import { + timeboundCourseConfigs, + createTimeboundCourseConfig, + deleteTimeboundCourseConfig, + updateTimeboundCourseConfigs, +} from "./timeboundCourseConfig.graphql"; + +import { currentValidityRange as gqlCurrentValidityRange } from "../validity_range/validityRange.graphql"; + +import { gqlPersons, gqlCourses } from "../helper.graphql"; + +export default { + name: "TimeboungCourseConfigCRUDTable", + data() { + return { + headers: [ + { + text: this.$t("lesrooster.timebound_course_config.course"), + value: "course", + }, + { + text: this.$t("lesrooster.validity_range.title"), + value: "validityRange", + orderKey: "validity_range__date_start", + }, + { + text: this.$t("lesrooster.timebound_course_config.teachers"), + value: "teachers", + }, + { + text: this.$t("lesrooster.timebound_course_config.lesson_quota"), + value: "lessonQuota", + }, + ], + i18nKey: "lesrooster.timebound_course_config", + createItemI18nKey: + "lesrooster.timebound_course_config.create_timebound_course_config", + gqlQuery: timeboundCourseConfigs, + gqlCreateMutation: createTimeboundCourseConfig, + gqlPatchMutation: updateTimeboundCourseConfigs, + gqlDeleteMutation: deleteTimeboundCourseConfig, + defaultItem: { + course: { + id: "", + name: "", + }, + validityRange: { + id: "", + name: "", + }, + teachers: [], + lessonQuota: undefined, + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, + methods: { + getCreateData(item) { + console.log("in getCreateData", item); + return { + ...item, + course: item.course.id, + teachers: item.teachers.map((t) => t.id), + validityRange: item.validityRange.id, + }; + }, + getPatchData(items) { + console.log("patch items", items); + return items.map((item) => ({ + id: item.id, + course: item.course.id, + teachers: item.teachers.map((t) => t.id), + validityRange: item.validityRange.id, + lessonQuota: item.lessonQuota, + })); + }, + }, + apollo: { + currentValidityRange: { + query: gqlCurrentValidityRange, + result({ data }) { + this.$set(this.defaultItem, "validityRange", data.currentValidityRange); + }, + }, + persons: gqlPersons, + courses: gqlCourses, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue new file mode 100644 index 0000000000000000000000000000000000000000..2c5bdca9de543eacbeaf89900f380e3f8d353c9c --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue @@ -0,0 +1,507 @@ +<script setup> +import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue"; +import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue"; +import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue"; +import ValidityRangeField from "../validity_range/ValidityRangeField.vue"; +import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; +</script> + +<template> + <div> + <v-data-table + disable-sort + disable-filtering + disable-pagination + hide-default-footer + :headers="headers" + :items="tableItems" + > + <template #top> + <v-row> + <v-col + cols="6" + lg="3" + class="d-flex justify-space-between flex-wrap align-center" + > + <v-autocomplete + outlined + filled + multiple + hide-details + :items="groups" + item-text="shortName" + item-value="id" + return-object + :disabled="$apollo.queries.groups.loading" + :label="$t('lesrooster.timebound_course_config.groups')" + :loading="$apollo.queries.groups.loading" + v-model="selectedGroups" + class="mr-4" + /> + </v-col> + + <v-col + cols="6" + lg="3" + class="d-flex justify-space-between flex-wrap align-center" + > + <validity-range-field + outlined + filled + label="Select Validity Range" + hide-details + v-model="internalValidityRange" + :loading="$apollo.queries.currentValidityRange.loading" + /> + </v-col> + + <v-spacer /> + + <v-col + cols="8" + lg="3" + class="d-flex justify-space-between flex-wrap align-center" + > + <secondary-action-button + i18n-key="actions.copy_last_configuration" + block + class="mr-4" + /> + </v-col> + <v-col + cols="4" + lg="1" + class="d-flex justify-space-between flex-wrap align-center" + > + <save-button + :disabled=" + !editedCourseConfigs.length && + !createdCourseConfigs.length && + !createdCourses.length + " + @click="save" + /> + </v-col> + </v-row> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #item.subject="{ item, value }"> + <subject-chip v-if="value" :subject="value" /> + </template> + + <template + v-for="(groupHeader, index) in groupHeaders" + #[tableItemSlotName(groupHeader)]="{ item, value, header }" + > + <div :key="index"> + <div v-if="value.length"> + <v-row + v-for="(course, index) in value" + :key="index" + no-gutters + class="mt-2" + > + <v-col cols="6"> + <positive-small-integer-field + dense + filled + class="mx-1" + :disabled="loading" + :value=" + getCurrentCourseConfig(course) + ? getCurrentCourseConfig(course).lessonQuota + : course.lessonQuota + " + :label="$t('lesrooster.timebound_course_config.lesson_quota')" + @input=" + (event) => + setCourseConfigData(course, item.subject, header, { + lessonQuota: event, + }) + " + /> + </v-col> + <v-col cols="6"> + <v-autocomplete + counter + dense + filled + multiple + :items="getTeacherList(item.subject.teachers)" + item-text="fullName" + item-value="id" + class="mx-1" + :disabled="loading" + :label="$t('lesrooster.timebound_course_config.teachers')" + :value=" + getCurrentCourseConfig(course) + ? getCurrentCourseConfig(course).teachers + : course.teachers + " + @input=" + (event) => + setCourseConfigData(course, item.subject, header, { + teachers: event, + }) + " + > + <template #item="data"> + <template v-if="typeof data.item !== 'object'"> + <v-list-item-content>{{ data.item }}</v-list-item-content> + </template> + <template v-else> + <v-list-item-action> + <v-checkbox v-model="data.attrs.inputValue" /> + </v-list-item-action> + <v-list-item-content> + <v-list-item-title>{{ + data.item.fullName + }}</v-list-item-title> + <v-list-item-subtitle v-if="data.item.shortName">{{ + data.item.shortName + }}</v-list-item-subtitle> + </v-list-item-content> + </template> + </template> + </v-autocomplete> + </v-col> + </v-row> + </div> + <div v-if="!value.length"> + <v-btn + block + icon + tile + outlined + @click="addCourse(item.subject.id, header.value)" + > + <v-icon>mdi-plus</v-icon> + </v-btn> + </div> + </div> + </template> + </v-data-table> + </div> +</template> + +<script> +import { + subjects, + batchCreateTimeboundCourseConfig, + updateTimeboundCourseConfigs, +} from "./timeboundCourseConfig.graphql"; + +import { currentValidityRange as gqlCurrentValidityRange } from "../validity_range/validityRange.graphql"; + +import { gqlGroups, gqlTeachers } from "../helper.graphql"; + +import { batchCreateCourse } from "aleksis.apps.cursus/components/course.graphql"; + +export default { + name: "TimeboungCourseConfigRaster", + data() { + return { + i18nKey: "lesrooster.timebound_course_config", + createItemI18nKey: + "lesrooster.timebound_course_config.create_timebound_course_config", + defaultItem: { + course: { + id: "", + name: "", + }, + validityRange: { + id: "", + name: "", + }, + teachers: [], + lessonQuota: undefined, + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + internalValidityRange: null, + groups: [], + selectedGroups: [], + subjects: [], + editedCourseConfigs: [], + createdCourseConfigs: [], + newCourses: [], + createdCourses: [], + currentCourse: null, + currentSubject: null, + loading: false, + }; + }, + methods: { + tableItemSlotName(header) { + return "item." + header.value; + }, + getCurrentCourseConfig(course) { + if (course.lrTimeboundCourseConfigs?.length) { + let currentCourseConfigs = course.lrTimeboundCourseConfigs.filter( + (timeboundConfig) => + timeboundConfig.validityRange.id === this.internalValidityRange.id + ); + if (currentCourseConfigs.length) { + return currentCourseConfigs[0]; + } else { + return null; + } + } else { + return null; + } + }, + setCourseConfigData(course, subject, header, newValue) { + if (course.newCourse) { + let existingCreatedCourse = this.createdCourses.find( + (c) => + c.subject === subject.id && + JSON.stringify(c.groups) === header.value + ); + if (!existingCreatedCourse) { + this.createdCourses.push({ + subject: subject.id, + groups: JSON.parse(header.value), + name: `${header.text}-${subject.name}`, + ...newValue, + }); + } else { + Object.assign(existingCreatedCourse, newValue); + } + } else { + if ( + !course.lrTimeboundCourseConfigs.filter( + (c) => c.validityRange.id === this.internalValidityRange?.id + ).length + ) { + let existingCreatedCourseConfig = this.createdCourseConfigs.find( + (c) => + c.course === course.id && + c.validityRange === this.internalValidityRange?.id + ); + if (!existingCreatedCourseConfig) { + this.createdCourseConfigs.push({ + course: course.id, + validityRange: this.internalValidityRange?.id, + teachers: course.teachers.map((t) => t.id), + lessonQuota: course.lessonQuota, + ...newValue, + }); + } else { + Object.assign(existingCreatedCourseConfig, newValue); + } + } else { + let courseConfigID = course.lrTimeboundCourseConfigs[0].id; + let existingEditedCourseConfig = this.editedCourseConfigs.find( + (c) => c.id === courseConfigID + ); + if (!existingEditedCourseConfig) { + this.editedCourseConfigs.push({ id: courseConfigID, ...newValue }); + } else { + Object.assign(existingEditedCourseConfig, newValue); + } + } + } + }, + save() { + this.loading = true; + + for (let mutationCombination of [ + { + data: this.editedCourseConfigs, + mutation: updateTimeboundCourseConfigs, + }, + { + data: this.createdCourseConfigs, + mutation: batchCreateTimeboundCourseConfig, + }, + { + data: this.createdCourses, + mutation: batchCreateCourse, + }, + ]) { + if (mutationCombination.data.length) { + this.$apollo + .mutate({ + mutation: mutationCombination.mutation, + variables: { + input: mutationCombination.data, + }, + }) + .catch(() => {}); // FIXME Error Handling + } + } + + this.editedCourseConfigs = []; + this.createdCourseConfigs = []; + this.createdCourses = []; + this.$apollo.queries.subjects.refetch(); + this.loading = false; + }, + getTeacherList(subjectTeachers) { + return [ + { + header: this.$t( + "lesrooster.timebound_course_config.subject_teachers" + ), + }, + ...this.persons.filter((person) => + subjectTeachers.find((teacher) => teacher.id === person.id) + ), + { divider: true }, + { header: this.$t("lesrooster.timebound_course_config.all_teachers") }, + ...this.persons.filter( + (person) => + !subjectTeachers.find((teacher) => teacher.id === person.id) + ), + ]; + }, + addCourse(subject, groups) { + let courseSubjectGroup = this.newCourses.find( + (courseSubject) => courseSubject.subject === subject + ); + if (courseSubjectGroup) { + if (courseSubjectGroup.groupCombinations) { + this.$set(courseSubjectGroup.groupCombinations, groups, [ + { teachers: [], newCourse: true }, + ]); + } else { + courseSubjectGroup.groupCombinations = { + [groups]: [{ teachers: [], newCourse: true }], + }; + } + } else { + this.newCourses.push({ + subject: subject, + groupCombinations: { [groups]: [{ teachers: [], newCourse: true }] }, + }); + } + }, + }, + computed: { + groupIDList() { + return this.selectedGroups.map((group) => group.id); + }, + subjectGroupCombinations() { + return [].concat.apply( + [], + this.items.map((subject) => Object.keys(subject.groupCombinations)) + ); + }, + groupHeaders() { + return this.selectedGroups + .map((group) => ({ + text: group.shortName, + value: JSON.stringify([group.id]), + })) + .concat( + this.subjectGroupCombinations.map((combination) => { + let parsedCombination = JSON.parse(combination); + return { + text: parsedCombination + .map( + (groupID) => + this.groups.find((group) => group.id === groupID).shortName + ) + .join(", "), + value: combination, + }; + }) + ) + .filter( + (obj, index, self) => + index === self.findIndex((o) => o.value === obj.value) + ); + }, + headers() { + let groupHeadersWithWidth = this.groupHeaders.map((header) => ({ + ...header, + width: `${Math.max(95 / this.groupHeaders.length, 15)}vw`, + })); + return [ + { + text: this.$t("lesrooster.timebound_course_config.subject"), + value: "subject", + width: "5%", + }, + ].concat(groupHeadersWithWidth); + }, + items() { + return this.subjects.map((subject) => { + let groupCombinations = {}; + + subject.courses.forEach((course) => { + let groupIds = JSON.stringify( + course.groups.map((group) => group.id).sort() + ); + + if (!groupCombinations[groupIds]) { + groupCombinations[groupIds] = []; + } + + if (!groupCombinations[groupIds].find((c) => c.id === course.id)) { + groupCombinations[groupIds].push({ + ...course, + }); + } + }); + + subject = { + ...subject, + groupCombinations: { ...groupCombinations }, + newCourses: { + ...this.newCourses.find( + (courseSubject) => courseSubject.subject === subject.id + )?.groupCombinations, + }, + }; + + return subject; + }); + }, + tableItems() { + return this.items.map((subject) => { + // eslint-disable-next-line no-unused-vars + let { courses, groupCombinations, ...reducedSubject } = subject; + return { + subject: reducedSubject, + ...Object.fromEntries( + this.groupHeaders.map((header) => [header.value, []]) + ), + ...subject.groupCombinations, + ...subject.newCourses, + }; + }); + }, + }, + apollo: { + currentValidityRange: { + query: gqlCurrentValidityRange, + result(data) { + this.internalValidityRange = data.data.currentValidityRange; + }, + }, + groups: { + query: gqlGroups, + result(data) { + this.selectedGroups = data.data.groups; + }, + }, + subjects: { + query: subjects, + skip() { + return !this.groupIDList.length; + }, + variables() { + return { + groups: this.groupIDList, + }; + }, + }, + persons: { + query: gqlTeachers, + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql new file mode 100644 index 0000000000000000000000000000000000000000..9ede745ed2fff96245478ed7871745a751cadf58 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql @@ -0,0 +1,126 @@ +fragment subjectFields on LesroosterExtendedSubjectType { + id + shortName + name + colourFg + colourBg + teachers { + id + fullName + shortName + } +} + +fragment courseFields on LesroosterExtendedCourseType { + id + name + teachers { + id + fullName + shortName + } + groups { + id + name + shortName + } + lessonQuota +} + +fragment timeboundCourseConfigFields on TimeboundCourseConfigType { + id + validityRange { + id + name + } + lessonQuota + teachers { + id + fullName + shortName + } + canEdit + canDelete +} + +query timeboundCourseConfigs($orderBy: [String], $filters: JSONString) { + items: timeboundCourseConfigs(orderBy: $orderBy, filters: $filters) { + ...timeboundCourseConfigFields + course { + ...courseFields + subject { + ...subjectFields + } + } + } +} + +mutation createTimeboundCourseConfig( + $input: CreateTimeboundCourseConfigInput! +) { + createTimeboundCourseConfig(input: $input) { + item: timeboundCourseConfig { + ...timeboundCourseConfigFields + course { + ...courseFields + subject { + ...subjectFields + } + } + } + } +} + +mutation batchCreateTimeboundCourseConfig( + $input: [BatchCreateTimeboundCourseConfigInput]! +) { + batchCreateTimeboundCourseConfig(input: $input) { + item: timeboundCourseConfigs { + ...timeboundCourseConfigFields + course { + ...courseFields + subject { + ...subjectFields + } + } + } + } +} + +mutation deleteTimeboundCourseConfig($id: ID!) { + deleteTimeboundCourseConfig(id: $id) { + ok + } +} + +mutation updateTimeboundCourseConfigs( + $input: [BatchPatchTimeboundCourseConfigInput]! +) { + batchMutation: updateTimeboundCourseConfigs(input: $input) { + items: timeboundCourseConfigs { + ...timeboundCourseConfigFields + course { + ...courseFields + subject { + ...subjectFields + } + } + } + } +} + +query subjects($orderBy: [String], $filters: JSONString, $groups: [ID]) { + subjects: lesroosterExtendedSubjects( + orderBy: $orderBy + filters: $filters + groups: $groups + ) { + ...subjectFields + courses { + ...courseFields + lrTimeboundCourseConfigs { + ...timeboundCourseConfigFields + } + } + } +} diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/BlockingCard.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/BlockingCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..c2688cc4000d5912e231eab572393ca9d0b87566 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/BlockingCard.vue @@ -0,0 +1,24 @@ +<script> +import { defineComponent } from "vue"; + +export default defineComponent({ + name: "BlockingCard", +}); +</script> + +<template> + <v-card + height="100%" + class="non-important-flex align-center justify-center" + flat + > + <v-icon color="error" large>mdi-close</v-icon> + <v-overlay absolute color="error" :value="true" opacity="0.12" /> + </v-card> +</template> + +<style scoped> +.non-important-flex { + display: flex; +} +</style> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonCard.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..1efb27a40e0a0e0497ba73da30bce833cbaabfa9 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonCard.vue @@ -0,0 +1,161 @@ +<script> +import { defineComponent } from "vue"; + +export default defineComponent({ + name: "LessonCard", + extends: "v-card", + props: { + lesson: { + type: Object, + required: true, + }, + }, + computed: { + subject() { + return ( + this.lesson.subject || { + name: "", + colourFg: "#000000", + colourBg: "#e6e6e6", + } + ); + }, + teachers() { + return this.lesson.teachers; + }, + groups() { + return this.lesson.groups; + }, + color() { + return this.subject.colourFg; + }, + background() { + return this.subject.colourBg; + }, + loading() { + return ( + this.lesson.isOptimistic || + this.lesson.id.toString().startsWith("temporary") + ); + }, + }, + methods: { + firstNonEmpty(...arrays) { + return ( + arrays.find((array) => Array.isArray(array) && array.length > 0) || [] + ); + }, + }, +}); +</script> + +<template> + <v-card + :color="background" + :disabled="loading" + 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 flex-column align-center my-1"> + <v-card-title + class="color d-flex justify-center flex-wrap px-3 py-0 ma-0" + > + <span> + <v-tooltip bottom tag="span" class="hidden-when-large"> + <template #activator="{ on, attrs }"> + <span v-bind="attrs" v-on="on" class="hidden-when-large"> + {{ subject.shortName }} + </span> + </template> + <span>{{ + "course" in lesson ? lesson.course.name : lesson.name + }}</span> + </v-tooltip> + <span class="hidden-when-small">{{ subject.name }}</span> + </span> + + <v-card-subtitle + class="caption px-3 py-0 ma-0 text-center hidden-when-small" + > + {{ "course" in lesson ? lesson.course.name : lesson.name }} + </v-card-subtitle> + </v-card-title> + <v-card-subtitle class="color pa-0 ma-0 d-flex flex-wrap justify-center"> + <span v-for="(teacher, index) in teachers" :key="teacher.id"> + <v-tooltip bottom> + <template #activator="{ on, attrs }"> + <v-btn + text + :color="color" + rounded + small + v-bind="attrs" + v-on="on" + @click="$emit('click:teacher', teacher)" + > + {{ teacher.shortName }} + </v-btn> + </template> + <span>{{ teacher.fullName }}</span> + </v-tooltip> + </span> + <span v-for="(room, index) in lesson.rooms" :key="room.id"> + <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text --> + <span v-if="index !== 0">, </span> + <v-tooltip bottom> + <template #activator="{ on, attrs }"> + <v-btn + text + :color="color" + rounded + small + v-bind="attrs" + v-on="on" + @click="$emit('click:room', room)" + > + {{ room.shortName }} + </v-btn> + </template> + <span>{{ room.name }}</span> + </v-tooltip> + </span> + </v-card-subtitle> + <slot /> + </div> + <div v-if="loading" class="text-center"> + <v-progress-circular :color="color" indeterminate /> + </div> + </v-card> +</template> + +<style scoped> +.width-title { + container: title/inline-size; +} + +.hidden-when-small { + display: none; +} +.hidden-when-large { + display: inline; +} + +@container title (width > 150px) { + .hidden-when-small { + display: inline; + } + + .hidden-when-large { + display: none; + } +} + +.color { + color: v-bind(color); +} + +.no-select { + user-select: none; +} +</style> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonRatioChip.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonRatioChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..657855db1c61d28ecbea4d90fc44b90ca8498820 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonRatioChip.vue @@ -0,0 +1,43 @@ +<script> +import { defineComponent } from "vue"; + +export default defineComponent({ + name: "LessonRatioChip", + props: { + course: { + type: Object, + required: true, + }, + }, +}); +</script> + +<template> + <v-chip + class="text-body-1 font-weight-500 px-4 mb-1" + small + color="white" + light + > + <v-icon + v-if="course.lessonsUsed === course.lessonQuota" + color="green" + left + size="20px" + > + mdi-check-circle + </v-icon> + <v-icon + v-else-if="course.lessonsUsed < course.lessonQuota" + color="orange" + left + size="20px" + > + mdi-timer-sand-empty + </v-icon> + <v-icon v-else color="red" left size="20px"> mdi-alert </v-icon> + {{ $t("lesrooster.timetable_management.lessons_used_ratio", course) }} + </v-chip> +</template> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/PeriodCard.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/PeriodCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..b76ef7a9c1f7e5851b8b30f7c4cdb9c0be088bce --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/PeriodCard.vue @@ -0,0 +1,98 @@ +<template> + <v-card class="d-flex justify-space-between align-center"> + <v-card-title>{{ period }}</v-card-title> + <div class="ma-0 py-4"><br /><br /></div> + <v-card-subtitle + class="ma-0 pa-4 subtitle text-right" + v-if="timeRanges.length < 2" + > + {{ getTimeRangesByWeekdaysString(timeRanges?.[0]) }} + </v-card-subtitle> + <v-menu v-if="timeRanges.length > 1" offset-x> + <template #activator="{ attrs, on }"> + <v-btn icon color="info" v-bind="attrs" v-on="on"> + <v-icon>$info</v-icon> + </v-btn> + </template> + + <v-list> + <v-list-item v-for="(timeRange, index) in timeRanges" :key="index"> + {{ getTimeRangesByWeekdaysString(timeRange) }} + </v-list-item> + </v-list> + </v-menu> + </v-card> +</template> + +<script> +export default { + name: "PeriodCard", + props: { + period: { + type: Number, + required: true, + }, + weekdays: { + type: Array, + required: true, + }, + timeRanges: { + type: Array, + required: true, + }, + }, + methods: { + getOutermostItems(arr) { + const result = []; + + // Convert the input array into an array of numbers + const numbers = arr.map((item) => parseInt(item.slice(2), 10)); + + let startIndex = 0; + + for (let i = 1; i < numbers.length; i++) { + if (numbers[i] !== numbers[i - 1] + 1) { + result.push(arr.slice(startIndex, i)); + startIndex = i; + } + } + + // Push the last subarray + result.push(arr.slice(startIndex)); + + return result.map((array) => + array.length < 3 ? array : [array[0], array[array.length - 1]] + ); + }, + getTimeRangesByWeekdaysString(timeRange) { + return ( + (timeRange.weekdays.length === this.weekdays.length + ? "" + : this.getOutermostItems(timeRange.weekdays) + .map( + (weekdays) => + weekdays + .map((weekday) => this.$t("weekdays_short." + weekday)) + .join("‑") // Non-breaking hyphen (U+02011) + ) + .join(", ") + ": ") + + this.$d( + new Date("1970-01-01T" + timeRange.timeStart), + "shortTime" + ).replace(" ", " ") + + (timeRange.weekdays.length === this.weekdays.length ? " " : "‑") + // Non-breaking hyphen (U+02011) + this.$d( + new Date("1970-01-01T" + timeRange.timeEnd), + "shortTime" + ).replace(" ", " ") + ); + }, + }, +}; +</script> + +<style scoped> +.subtitle { + width: min-content; +} +</style> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue new file mode 100644 index 0000000000000000000000000000000000000000..b3d4a3c635299a10bcea9722db76947f53a024b8 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue @@ -0,0 +1,1149 @@ +<script> +import { defineComponent } from "vue"; +import { + courses, + createLesson, + deleteLesson, + gqlGroups, + lessonObjects, + moveLesson, + updateLesson, +} from "./timetableManagement.graphql"; +import { gqlTeachers } from "../helper.graphql"; +import { timeGrids } from "../validity_range/validityRange.graphql"; +import { slots } from "../breaks_and_slots/slot.graphql"; +import { rooms } from "aleksis.core/components/room/room.graphql"; +import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue"; +import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue"; +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 { RRule } from "rrule"; +import TeacherTimeTable from "./timetables/TeacherTimeTable.vue"; +import RoomTimeTable from "./timetables/RoomTimeTable.vue"; +import LessonRatioChip from "./LessonRatioChip.vue"; +import TimeGridField from "../validity_range/TimeGridField.vue"; +import BlockingCard from "./BlockingCard.vue"; +import PeriodCard from "./PeriodCard.vue"; + +export default defineComponent({ + name: "TimetableManagement", + components: { + PeriodCard, + BlockingCard, + TimeGridField, + SubjectField, + DialogObjectForm, + LessonRatioChip, + MobileFullscreenDialog, + RoomTimeTable, + TeacherTimeTable, + DeleteDialog, + LessonCard, + SecondaryActionButton, + }, + data() { + return { + weekdays: [], + periods: [], + slotsByPeriods: [], + internalTimeGrid: null, + courseSearch: null, + lessonsUsed: {}, + lessonQuotaTotal: 0, + deleteMutation: deleteLesson, + deleteDialog: false, + itemToDelete: null, + selectedObject: null, + selectedObjectType: null, + selectedObjectTitle: "", + selectedObjectDialogOpen: false, + selectedObjectDialogTab: null, + groups: [], + selectedGroup: null, + lessonEdit: { + open: false, + id: null, + object: {}, + fields: [ + { + text: this.$t( + "lesrooster.timetable_management.lesson_fields.subject" + ), + value: "subject", + }, + { + text: this.$t( + "lesrooster.timetable_management.lesson_fields.teachers" + ), + value: "teachers", + }, + { + text: this.$t( + "lesrooster.timetable_management.lesson_fields.rooms" + ), + value: "rooms", + }, + ], + mutation: updateLesson, + }, + }; + }, + apollo: { + groups: { + query: gqlGroups, + variables() { + return { + timeGrid: this.internalTimeGrid.id, + }; + }, + skip() { + return this.internalTimeGrid === null; + }, + result() { + if (!this.selectedGroup && this.$route.params.id && this.groups) { + this.selectedGroup = this.groups.find( + (group) => group.id === this.$route.params.id + ); + } + }, + }, + slots: { + query: slots, + variables() { + return { + filters: JSON.stringify({ + time_grid: this.internalTimeGrid.id, + }), + }; + }, + skip() { + return !this.readyForQueries; + }, + update: (data) => data.items, + result({ data: { items } }) { + this.weekdays = Array.from( + new Set( + items + .filter((slot) => slot.model === "Slot") + .map((slot) => slot.weekday) + ) + ); + this.periods = Array.from( + new Set( + items + .filter((slot) => slot.model === "Slot") + .map((slot) => slot.period) + ) + ); + this.slotsByPeriods = this.periods.map((period) => ({ + period: period, + slots: items.filter( + (slot) => slot.model === "Slot" && slot.period === period + ), + })); + }, + }, + timeGrids: { + query: timeGrids, + update: (data) => data.items, + variables() { + return { + filters: JSON.stringify({ + validity_range: this.internalTimeGrid.validityRange.id, + }), + }; + }, + skip() { + return !this.internalTimeGrid; + }, + }, + courses: { + query: courses, + variables() { + return { + timeGrid: this.internalTimeGrid.id, + group: this.selectedGroup.id, + }; + }, + skip() { + return !this.readyForQueries; + }, + result({ data }) { + this.lessonQuotaTotal = + data && data.courses + ? data.courses.reduce( + (accumulator, course) => accumulator + course.lessonQuota, + 0 + ) + : 0; + }, + }, + lessonObjects: { + query: lessonObjects, + variables() { + return { + timeGrid: this.internalTimeGrid.id, + group: this.selectedGroup.id, + }; + }, + skip() { + return !this.readyForQueries; + }, + result({ data: { lessonObjects } }) { + this.lessonsUsed = {}; + lessonObjects.forEach((lesson) => { + let increment = + this.periods.indexOf(lesson.slotEnd.period) - + this.periods.indexOf(lesson.slotStart.period) + + 1; + this.lessonsUsed[lesson.course.id] = + this.lessonsUsed[lesson.course.id] + increment || increment; + }); + }, + }, + persons: { + query: gqlTeachers, + }, + rooms: { + query: rooms, + update: (data) => data.items, + }, + }, + computed: { + readyForQueries() { + return this.internalTimeGrid !== null && this.selectedGroup !== 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, + w: + this.weekdays.indexOf(lesson.slotEnd.weekday) - + this.weekdays.indexOf(lesson.slotStart.weekday) + + 1, + h: + this.periods.indexOf(lesson.slotEnd.period) - + this.periods.indexOf(lesson.slotStart.period) + + 1, + key: "lesson-" + lesson.id, + disabled: !lesson.canEdit, + data: lesson, + })) + : []; + }, + gridItems() { + // As we may want to display more in the future + return this.lessons; + }, + gridLoading() { + return ( + this.$apollo.queries.slots.loading || + this.$apollo.queries.lessonObjects.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, + }, + })) + : []; + }, + disabledSlots() { + // Disable all fields in the grid where no slot exists + return this.periods + .map((period, indexY) => + this.weekdays.map((weekday, indexX) => + this.slots.filter( + (slot) => + slot.model === "Slot" && + slot.weekday === weekday && + slot.period === period + ).length === 0 + ? { + x: indexX + 1, + y: indexY + 1, + } + : undefined + ) + ) + .flat() + .filter((val) => val !== undefined); + }, + totalLessonRatio() { + return this.$t( + "lesrooster.timetable_management.lessons_used_ratio_total", + { + lessonsUsed: Object.values(this.lessonsUsed).reduce( + (a, b) => a + b, + 0 + ), + lessonQuota: this.lessonQuotaTotal, + } + ); + }, + }, + watch: { + selectedGroup() { + if (!this.selectedGroup) return; + if (this.selectedGroup.id != this.$route.params.id) { + this.$router.push({ + name: "lesrooster.timetable_management", + params: { id: this.selectedGroup.id }, + }); + } + this.$setToolBarTitle( + this.$t("lesrooster.timetable_management.for_group", { + group: this.selectedGroup.name, + }) + ); + this.$apollo.queries.courses.refetch(); + this.$apollo.queries.lessonObjects.refetch(); + }, + }, + methods: { + itemMovedToLessons(eventData) { + let newStartSlotId = this.slots.filter( + (slot) => + slot.period === this.periods[eventData.y - 1] && + slot.weekday === this.weekdays[eventData.x - 1] + ); + let newEndSlotId = this.slots.filter( + (slot) => + slot.period === this.periods[eventData.y + eventData.h - 2] && + slot.weekday === this.weekdays[eventData.x + eventData.w - 2] + ); + + let newStartSlot, newEndSlot; + + if (newStartSlotId.length === 1 && newStartSlotId.length === 1) { + newStartSlot = newStartSlotId[0]; + newStartSlotId = newStartSlot.id; + + newEndSlot = newEndSlotId[0]; + newEndSlotId = newEndSlot.id; + } else { + throw new Error("Multiple slots matched"); + } + + if (eventData.originGridId === "lessons") { + let that = this; + this.$apollo + .mutate({ + mutation: moveLesson, + variables: { + id: eventData.data.id, + input: { + slotStart: newStartSlotId, + slotEnd: newEndSlotId, + }, + }, + optimisticResponse: { + updateLesson: { + lesson: { + ...eventData.data, + slotStart: newStartSlot, + slotEnd: newEndSlot, + isOptimistic: true, + }, + __typename: "LessonPatchMutation", + }, + }, + update( + store, + { + data: { + updateLesson: { lesson }, + }, + } + ) { + let query = { + ...that.$apollo.queries.lessonObjects.options, + variables: JSON.parse( + that.$apollo.queries.lessonObjects.previousVariablesJson + ), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const index = storedData.lessonObjects.findIndex( + (lessonObject) => lessonObject.id === lesson.id + ); + storedData.lessonObjects[index].slotStart = lesson.slotStart; + storedData.lessonObjects[index].slotEnd = lesson.slotEnd; + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + }) + .then(() => { + this.$toastSuccess( + "lesrooster.timetable_management.snacks.lesson_move.success" + ); + }) + .catch(() => { + this.$toastError( + "lesrooster.timetable_management.snacks.lesson_move.error" + ); + }); + } else if (eventData.originGridId === "courses") { + let that = this; + const rule = new RRule({ + freq: RRule.WEEKLY, // TODO: Make this configurable + dtstart: new Date(this.internalTimeGrid.validityRange.dateStart), // FIXME: check if this is correct with timezones etc. + until: new Date(this.internalTimeGrid.validityRange.dateEnd), // FIXME: check if this is correct with timezones etc. + }); + const recurrenceString = rule.toString(); + this.$apollo + .mutate({ + mutation: createLesson, + 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), + recurrence: recurrenceString, + }, + }, + optimisticResponse: { + createLesson: { + lesson: { + id: "temporary-lesson-id-" + crypto.randomUUID(), + slotStart: newStartSlot, + slotEnd: newEndSlot, + subject: eventData.data.subject, + teachers: eventData.data.teachers, + // rooms: eventData.data.rooms, + rooms: [], + course: eventData.data, + isOptimistic: true, + canEdit: true, + canDelete: true, + recurrence: recurrenceString, + __typename: "LessonType", + }, + __typename: "LessonCreateMutation", + }, + }, + update( + store, + { + data: { + createLesson: { lesson }, + }, + } + ) { + let query = { + ...that.$apollo.queries.lessonObjects.options, + variables: JSON.parse( + that.$apollo.queries.lessonObjects.previousVariablesJson + ), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + storedData.lessonObjects.push(lesson); + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + }) + .then(() => { + this.$toastSuccess( + "lesrooster.timetable_management.snacks.lesson_create.success" + ); + }) + .catch(() => { + this.$toastError( + "lesrooster.timetable_management.snacks.lesson_create.error" + ); + }); + } + }, + itemMovedToCourses(eventData) { + if (eventData.originGridId === "lessons") { + // 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; + }, + canProlongLesson(lesson) { + const nextSlot = this.slots + .filter( + (slot) => + slot.weekday === lesson.slotEnd.weekday && + slot.period > lesson.slotEnd.period + ) + .reduce( + (prev, current) => + prev && prev.period > current.period ? current : prev || current, + null + ); + + return !!nextSlot; + }, + changeLessonSlots(lesson, slotStart, slotEnd) { + let that = this; + this.$apollo + .mutate({ + mutation: moveLesson, + variables: { + id: lesson.id, + input: { + slotStart: slotStart.id, + slotEnd: slotEnd.id, + }, + }, + optimisticResponse: { + updateLesson: { + lesson: { + ...lesson, + slotStart: slotStart, + slotEnd: slotEnd, + isOptimistic: true, + }, + __typename: "LessonPatchMutation", + }, + }, + update( + store, + { + data: { + updateLesson: { lesson }, + }, + } + ) { + let query = { + ...that.$apollo.queries.lessonObjects.options, + variables: JSON.parse( + that.$apollo.queries.lessonObjects.previousVariablesJson + ), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const index = storedData.lessonObjects.findIndex( + (lessonObject) => lessonObject.id === lesson.id + ); + storedData.lessonObjects[index].slotStart = lesson.slotStart; + storedData.lessonObjects[index].slotEnd = lesson.slotEnd; + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + }) + .then(() => { + this.$toastSuccess( + "lesrooster.timetable_management.snacks.lesson_change_length.success" + ); + }) + .catch(() => { + this.$toastError( + "lesrooster.timetable_management.snacks.lesson_change_length.error" + ); + }); + }, + prolongLesson(lesson) { + // Find next slot on the same day + const slotEnd = this.slots + .filter( + (slot) => + slot.weekday === lesson.slotEnd.weekday && + slot.period > lesson.slotEnd.period + ) + .reduce((prev, current) => + prev.period < current.period ? prev : current + ); + + this.changeLessonSlots(lesson, lesson.slotStart, slotEnd); + }, + shortenLesson(lesson) { + // Find previous slot on the same day + const slotEnd = this.slots + .filter( + (slot) => + slot.weekday === lesson.slotEnd.weekday && + slot.period < lesson.slotEnd.period + ) + .reduce((prev, current) => + prev.period > current.period ? prev : current + ); + + this.changeLessonSlots(lesson, lesson.slotStart, slotEnd); + }, + deleteLesson(lesson) { + this.itemToDelete = lesson; + this.deleteDialog = true; + }, + teacherClick(teacher) { + // A teacher was selected for miniplan + this.selectedObjectType = "teacher"; + this.selectedObject = teacher.id; + this.selectedObjectTitle = teacher.fullName; + this.selectedObjectDialogOpen = true; + }, + roomClick(room) { + // A room was selected for miniplan + this.selectedObjectType = "room"; + this.selectedObject = room.id; + this.selectedObjectTitle = room.name; + this.selectedObjectDialogOpen = true; + }, + editLessonClick(lesson) { + this.lessonEdit.id = lesson.id; + this.lessonEdit.object = lesson; + this.lessonEdit.open = true; + }, + getTeacherList(subjectTeachers) { + return [ + { + header: this.$t( + "lesrooster.timebound_course_config.subject_teachers" + ), + }, + ...this.persons.filter((person) => + subjectTeachers.find((teacher) => teacher.id === person.id) + ), + { divider: true }, + { header: this.$t("lesrooster.timebound_course_config.all_teachers") }, + ...this.persons.filter( + (person) => + !subjectTeachers.find((teacher) => teacher.id === person.id) + ), + ]; + }, + handleLessonEditUpdate(store, lesson) { + const query = { + ...this.$apollo.queries.lessonObjects.options, + variables: JSON.parse( + this.$apollo.queries.lessonObjects.previousVariablesJson + ), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const index = storedData.lessonObjects.findIndex( + (lessonObject) => lessonObject.id === lesson.id + ); + storedData.lessonObjects[index].subject = lesson.subject; + storedData.lessonObjects[index].teachers = lesson.teachers; + storedData.lessonObjects[index].rooms = lesson.rooms; + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + handleLessonEditSave() { + this.$toastSuccess( + "lesrooster.timetable_management.snacks.lesson_edit.success" + ); + }, + handleLessonEditError() { + this.$toastError( + "lesrooster.timetable_management.snacks.lesson_edit.error" + ); + }, + lessonEditGetPatchData(lesson) { + return { + subject: lesson.subject.id, + teachers: lesson.teachers.map((teacher) => teacher.id), + rooms: lesson.rooms.map((room) => room.id), + }; + }, + courseSearchFilter(items, search) { + if (!search || !items.length) return items; + search = (search || "").trim().toLowerCase(); + if (!search) return items; + + return items.filter((item) => { + return ( + item.data.name?.toLowerCase().includes(search) || + item.data.subject?.name?.toLowerCase().includes(search) || + item.data.subject?.teachers?.some( + (teacher) => + teacher.fullName?.toLowerCase().includes(search) || + teacher.shortName?.toLowerCase().includes(search) + ) || + item.data.teachers?.some( + (teacher) => + teacher.fullName?.toLowerCase().includes(search) || + teacher.shortName?.toLowerCase().includes(search) + ) || + item.data.groups?.some( + (group) => + group.name?.toLowerCase().includes(search) || + group.shortName?.toLowerCase().includes(search) + ) + ); + }); + }, + formatTimeGrid(item) { + if (!item) return null; + if (item.group === null) { + return this.$t( + "lesrooster.validity_range.time_grid.repr.generic", + item.validityRange + ); + } + return this.$t("lesrooster.validity_range.time_grid.repr.default", [ + item.validityRange.name, + item.group.name, + ]); + }, + timeRangesByWeekdays(period) { + return period.slots + .map((slot) => ({ timeStart: slot.timeStart, timeEnd: slot.timeEnd })) + .filter( + (value, index, self) => + index === + self.findIndex( + (timeRange) => + timeRange.timeStart === value.timeStart && + timeRange.timeEnd === value.timeEnd + ) + ) + .map((timeRange) => ({ + ...timeRange, + weekdays: period.slots + .filter( + (slot) => + slot.timeStart === timeRange.timeStart && + slot.timeEnd === timeRange.timeEnd + ) + .map((slot) => slot.weekday), + })); + }, + }, +}); +</script> + +<template> + <div> + <v-row> + <v-col cols="12" lg="8" xl="9"> + <div class="d-flex justify-space-between flex-wrap align-center"> + <secondary-action-button + i18n-key="lesrooster.timetable_management.back" + :to="{ name: 'cursus.school_structure' }" + /> + + <v-spacer /> + + <v-autocomplete + outlined + filled + hide-details + label="Select Group" + :items="groups" + item-text="name" + item-value="id" + return-object + v-model="selectedGroup" + :loading="$apollo.queries.gqlGroups" + class="mr-4" + /> + + <time-grid-field + outlined + filled + label="Select Validity Range" + hide-details + v-model="internalTimeGrid" + /> + </div> + </v-col> + + <v-col + cols="12" + lg="4" + xl="3" + class="d-flex justify-space-between flex-wrap align-center" + > + <secondary-action-button + i18n-key="actions.copy_last_configuration" + block + disabled + /> + </v-col> + + <v-col cols="12" lg="8" xl="9" class="align-self-start" id="grid"> + <div id="weekdays"> + <v-card + v-for="weekday in weekdays" + :key="weekday" + class="d-flex justify-center align-center" + > + <v-card-title class="text-body-1">{{ + $t("weekdays." + weekday) + }}</v-card-title> + </v-card> + </div> + <div id="periods"> + <period-card + v-for="(period, index) in periods" + :key="period" + :period="period" + :weekdays="weekdays" + :time-ranges=" + timeRangesByWeekdays( + slotsByPeriods.find( + (periodWithSlots) => periodWithSlots.period === period + ) + ) + " + /> + </div> + <drag-grid + :cols="weekdays.length" + :rows="periods.length" + :value="gridItems" + :loading="gridLoading" + context="timetable" + :disabled-fields="disabledSlots" + @itemChanged="itemMovedToLessons" + grid-id="lessons" + id="timetable" + multiple-items-y + > + <template #item="item"> + <v-menu + open-on-hover + offset-y + :open-on-click="false" + rounded="pill" + bottom + min-width="max-content" + nudge-right="40%" + > + <template #activator="{ attrs, on }"> + <lesson-card + :lesson="item.data" + rounded="lg" + class="d-flex" + v-bind="attrs" + v-on="on" + @click:teacher="teacherClick" + @click:room="roomClick" + /> + </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> + </v-menu> + </template> + <template #loader> + <v-skeleton-loader type="sentences" /> + </template> + <template #highlight> + <v-skeleton-loader + type="image" + boilerplate + height="100%" + id="highlight" + /> + </template> + <template #disabledField="{ isDraggedOver }"> + <v-fade-transition> + <blocking-card v-show="isDraggedOver" /> + </v-fade-transition> + </template> + </drag-grid> + </v-col> + + <v-col cols="12" lg="4" xl="3"> + <v-card> + <v-card-text> + <v-text-field + search + filled + rounded + v-model="courseSearch" + clearable + :label="$t('actions.search_courses')" + :hint="totalLessonRatio" + persistent-hint + /> + <v-data-iterator + :items="selectableCourses" + item-key="key" + :items-per-page="12" + single-expand + :search="courseSearch" + sort-by="data.lessonRatio" + :custom-filter="courseSearchFilter" + > + <template #default="{ items }"> + <drag-grid + :cols="3" + :rows="4" + :value="items" + :loading="$apollo.queries.courses.loading" + no-highlight + context="timetable" + @itemChanged="itemMovedToCourses" + grid-id="courses" + > + <template #item="item"> + <lesson-card + :lesson="item.data" + rounded="lg" + @click:teacher="teacherClick" + @click:room="roomClick" + > + <lesson-ratio-chip :course="item.data" /> + </lesson-card> + </template> + <template #loader> + <v-skeleton-loader type="image" /> + </template> + </drag-grid> + </template> + </v-data-iterator> + </v-card-text> + </v-card> + </v-col> + </v-row> + + <mobile-fullscreen-dialog + v-model="selectedObjectDialogOpen" + max-width="75vw" + > + <v-card> + <v-card-title class="justify-space-between"> + <span> + {{ + $t("lesrooster.timetable_management.timetable_for", { + name: selectedObjectTitle, + }) + }} + </span> + + <v-spacer /> + + <v-tabs + v-model="selectedObjectDialogTab" + color="secondary" + right + v-if="timeGrids && timeGrids.length > 1" + class="width-max-content" + > + <v-tab v-for="timeGrid in timeGrids" :key="timeGrid.id"> + {{ formatTimeGrid(timeGrid) }} + </v-tab> + </v-tabs> + </v-card-title> + <v-card-text> + <v-tabs-items v-model="selectedObjectDialogTab"> + <v-tab-item v-for="timeGrid in timeGrids" :key="timeGrid.id"> + <teacher-time-table + v-if="internalTimeGrid && selectedObjectType === 'teacher'" + :teacher-id="selectedObject" + :time-grid="timeGrid" + class="fill-height" + /> + <room-time-table + v-if="internalTimeGrid && selectedObjectType === 'room'" + :room-id="selectedObject" + :time-grid="timeGrid" + class="fill-height" + /> + </v-tab-item> + </v-tabs-items> + </v-card-text> + </v-card> + </mobile-fullscreen-dialog> + + <delete-dialog + :gql-mutation="deleteMutation" + :gql-query="$apollo.queries.lessonObjects" + v-model="deleteDialog" + :item="itemToDelete" + > + <template #body> + {{ itemToDelete.subject?.name || itemToDelete.course.subject.name }} + </template> + </delete-dialog> + + <dialog-object-form + :is-create="false" + :default-item="lessonEdit.object" + :edit-item="lessonEdit.object" + :fields="lessonEdit.fields" + v-model="lessonEdit.open" + item-title-attribute="course.name" + :gql-patch-mutation="lessonEdit.mutation" + :get-patch-data="lessonEditGetPatchData" + @cancel="lessonEdit.open = false" + @save="handleLessonEditSave" + @error="handleLessonEditError" + @update="handleLessonEditUpdate" + force-model-item-update + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #subject.field="{ attrs, on }"> + <subject-field v-bind="attrs" v-on="on" /> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #teachers.field="{ attrs, on, item }"> + <v-autocomplete + multiple + return-object + :items="getTeacherList(item.subject?.teachers || [])" + item-text="fullName" + item-value="id" + v-bind="attrs" + v-on="on" + :loading="$apollo.queries.persons.loading" + > + <template #item="data"> + <v-list-item-action> + <v-checkbox v-model="data.attrs.inputValue" /> + </v-list-item-action> + <v-list-item-content> + <v-list-item-title>{{ data.item.fullName }}</v-list-item-title> + <v-list-item-subtitle v-if="data.item.shortName">{{ + data.item.shortName + }}</v-list-item-subtitle> + </v-list-item-content> + </template> + </v-autocomplete> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #rooms.field="{ attrs, on }"> + <v-autocomplete + multiple + return-object + :items="rooms" + item-text="name" + item-value="id" + :loading="$apollo.queries.rooms.loading" + v-bind="attrs" + v-on="on" + /> + </template> + </dialog-object-form> + </div> +</template> + +<style> +#highlight > .v-skeleton-loader__image { + height: 100%; +} +</style> + +<style scoped lang="scss"> +.big { + width: 36px; +} + +.spacer { + width: 36px; +} + +.width-max-content { + width: max-content; +} + +#grid { + display: grid; + grid-template: ". weekdays" auto "periods timetable" auto / min-content auto; + gap: 0.5rem; +} + +#weekdays { + grid-area: weekdays; + display: flex; + flex-direction: row; + width: 100%; + justify-content: space-between; + gap: 0.5rem; + + & > * { + width: 100%; + } +} + +#periods { + grid-area: periods; + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + gap: 0.5rem; + + & > * { + height: 100%; + } +} + +#timetable { + grid-area: timetable; + gap: 0.5rem; +} +</style> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql new file mode 100644 index 0000000000000000000000000000000000000000..0a7dc27fd475f58778de605dfa72ac292ba8cf22 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql @@ -0,0 +1,224 @@ +query group($id: ID!) { + groupById(id: $id) { + id + name + } +} + +query gqlGroups($timeGrid: ID!) { + groups: groupsByTimeGrid(timeGrid: $timeGrid) { + id + name + } +} + +query courses($group: ID!, $timeGrid: ID!) { + courses: courseObjectsForGroup(group: $group, timeGrid: $timeGrid) { + id + courseId + name + subject { + id + name + shortName + colourFg + colourBg + teachers { + id + fullName + shortName + } + } + teachers { + id + fullName + shortName + } + groups { + id + name + shortName + } + lessonQuota + } +} + +query lessonObjects($group: ID!, $timeGrid: ID!) { + lessonObjects: lessonObjectsForGroup(group: $group, timeGrid: $timeGrid) { + id + slotStart { + id + period + weekday + } + slotEnd { + id + period + weekday + } + subject { + id + name + shortName + colourFg + colourBg + teachers { + id + fullName + shortName + } + } + teachers { + id + fullName + shortName + } + rooms { + id + name + shortName + } + course { + id + name + subject { + id + name + colourFg + colourBg + teachers { + id + fullName + shortName + } + } + teachers { + id + fullName + shortName + } + groups { + id + name + shortName + } + } + isOptimistic + recurrence + canEdit + canDelete + } +} + +mutation createLesson($input: CreateLessonInput!) { + createLesson(input: $input) { + lesson { + id + slotStart { + id + period + weekday + } + slotEnd { + id + period + weekday + } + subject { + id + name + shortName + colourFg + colourBg + } + teachers { + id + fullName + shortName + } + rooms { + id + name + shortName + } + course { + id + name + subject { + id + name + shortName + colourFg + colourBg + } + teachers { + id + fullName + shortName + } + groups { + id + name + shortName + } + } + isOptimistic + recurrence + canEdit + canDelete + } + } +} + +mutation moveLesson($id: ID!, $input: PatchLessonInput!) { + updateLesson(id: $id, input: $input) { + lesson { + id + slotStart { + id + period + weekday + } + slotEnd { + id + period + weekday + } + isOptimistic + } + } +} + +mutation updateLesson($input: PatchLessonInput!, $id: ID!) { + updateLesson(id: $id, input: $input) { + item: lesson { + id + subject { + id + name + shortName + colourFg + colourBg + } + teachers { + id + fullName + shortName + } + rooms { + id + name + shortName + } + isOptimistic + canEdit + canDelete + } + } +} + +mutation deleteLesson($id: ID!) { + deleteLesson(id: $id) { + ok + } +} diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..a097e292ca8b6172903a26400862833068980a71 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue @@ -0,0 +1,129 @@ +<script> +import { defineComponent } from "vue"; +import { slots } from "../../breaks_and_slots/slot.graphql"; +import LessonCard from "../LessonCard.vue"; +import MessageBox from "aleksis.core/components/generic/MessageBox.vue"; + +export default defineComponent({ + name: "MiniTimeTable", + components: { LessonCard, MessageBox }, + props: { + timeGrid: { + type: Object, + required: true, + }, + }, + data() { + return { + periods: [], + weekdays: [], + }; + }, + apollo: { + slots: { + query: slots, + variables() { + return { + filters: JSON.stringify({ + time_grid: this.timeGrid.id, + }), + }; + }, + skip() { + return this.timeGrid === null; + }, + update: (data) => data.items, + result({ data: { items } }) { + this.weekdays = Array.from( + new Set( + items + .filter((slot) => slot.model === "Slot") + .map((slot) => slot.weekday) + ) + ); + this.periods = Array.from( + new Set( + items + .filter((slot) => slot.model === "Slot") + .map((slot) => slot.period) + ) + ); + }, + }, + }, + computed: { + gridTemplate() { + return ( + "[legend-row] auto " + + this.periods.map((period) => `[period-${period}] auto `).join("") + + "/ [legend-day] auto" + + this.weekdays.map((weekday) => ` [${weekday}] 1fr`).join("") + ); + }, + lessons() { + return []; + }, + }, + methods: { + styleForLesson(lesson) { + return { + gridArea: + `period-${lesson.slotStart.period} / ${lesson.slotStart.weekday} / ` + + `span ${lesson.slotEnd.period - lesson.slotStart.period + 1} / ${ + lesson.slotEnd.weekday + }`, + }; + }, + }, +}); +</script> + +<template> + <div class="timetable"> + <!-- Empty div to fill top-left corner --> + <div></div> + <v-card + v-for="period in periods" + :style="{ + gridColumn: 'legend-day', + gridRow: `period-${period} / span 1`, + }" + :key="'period' + period" + > + <v-card-text>{{ period }}</v-card-text> + </v-card> + <v-card + v-for="weekday in weekdays" + :style="{ gridRow: 'legend-row', gridColumn: `${weekday} / span 1` }" + :key="weekday" + > + <v-card-text>{{ $t("weekdays." + weekday) }}</v-card-text> + </v-card> + <lesson-card + v-for="lesson in lessons" + :lesson="lesson" + :style="styleForLesson(lesson)" + :key="lesson.id" + /> + + <message-box type="info" v-if="!lessons || lessons.length === 0"> + {{ $t("lesrooster.timetable_management.no_lessons") }} + </message-box> + <message-box type="warning" v-if="!slots || slots.length === 0"> + {{ $t("lesrooster.timetable_management.no_slots") }} + </message-box> + </div> +</template> + +<style scoped> +.timetable { + display: grid; + grid-template: v-bind(gridTemplate); + gap: 1em; +} + +.timetable > * { + width: 100%; + height: 100%; +} +</style> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..a50154bbbf49f681223283176652128fa08f4c8e --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue @@ -0,0 +1,35 @@ +<script> +import { defineComponent } from "vue"; +import { lessonsRoom } from "./timetables.graphql"; +import MiniTimeTable from "./MiniTimeTable.vue"; + +export default defineComponent({ + name: "RoomTimeTable", + extends: MiniTimeTable, + props: { + roomId: { + type: String, + required: true, + }, + }, + computed: { + lessons() { + return this.lessonsRoom; + }, + }, + apollo: { + lessonsRoom: { + query: lessonsRoom, + variables() { + return { + timeGrid: this.timeGrid.id, + room: this.roomId, + }; + }, + skip() { + return this.timeGrid === null; + }, + }, + }, +}); +</script> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..5ed3211176f9bb0ad66c654a5fe1c36747ff13b0 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue @@ -0,0 +1,35 @@ +<script> +import { defineComponent } from "vue"; +import { lessonsTeacher } from "./timetables.graphql"; +import MiniTimeTable from "./MiniTimeTable.vue"; + +export default defineComponent({ + name: "TeacherTimeTable", + extends: MiniTimeTable, + props: { + teacherId: { + type: String, + required: true, + }, + }, + computed: { + lessons() { + return this.lessonsTeacher; + }, + }, + apollo: { + lessonsTeacher: { + query: lessonsTeacher, + variables() { + return { + timeGrid: this.timeGrid.id, + teacher: this.teacherId, + }; + }, + skip() { + return this.timeGrid === null; + }, + }, + }, +}); +</script> diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql new file mode 100644 index 0000000000000000000000000000000000000000..3bd1897b5674ad1baf196e8e820a57e5c4afacf2 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql @@ -0,0 +1,112 @@ +query lessonsTeacher($teacher: ID!, $timeGrid: ID!) { + lessonsTeacher: lessonObjectsForTeacher( + teacher: $teacher + timeGrid: $timeGrid + ) { + id + slotStart { + id + period + weekday + } + slotEnd { + id + period + weekday + } + subject { + id + name + colourFg + colourBg + } + teachers { + id + fullName + shortName + } + rooms { + id + name + shortName + } + course { + id + name + subject { + id + name + colourFg + colourBg + } + teachers { + id + fullName + shortName + } + groups { + id + name + shortName + } + } + recurrence + canEdit + canDelete + } +} + +query lessonsRoom($room: ID!, $timeGrid: ID!) { + lessonsRoom: lessonObjectsForRoom(room: $room, timeGrid: $timeGrid) { + id + slotStart { + id + period + weekday + } + slotEnd { + id + period + weekday + } + subject { + id + name + colourFg + colourBg + } + teachers { + id + fullName + shortName + } + rooms { + id + name + shortName + } + course { + id + name + subject { + id + name + colourFg + colourBg + } + teachers { + id + fullName + shortName + } + groups { + id + name + shortName + } + } + recurrence + canEdit + canDelete + } +} diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue new file mode 100644 index 0000000000000000000000000000000000000000..8558207a7503e7609b3247ff8bbec2edf2ceb6a3 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue @@ -0,0 +1,112 @@ +<script> +import { defineComponent } from "vue"; +import { timeGrids } from "./validityRange.graphql"; +import ConfirmDialog from "aleksis.core/components/generic/dialogs/ConfirmDialog.vue"; +import PrimaryActionButton from "aleksis.core/components/generic/buttons/PrimaryActionButton.vue"; + +export default defineComponent({ + name: "CopyFromTimeGridMenu", + components: { ConfirmDialog, PrimaryActionButton }, + apollo: { + timeGrids: { + query: timeGrids, + variables() { + return { + filters: JSON.stringify({ + group: this.groupMatch, + }), + orderBy: ["validity_range__date_start", "validity_range__date_end"], + }; + }, + update: (data) => data.items, + }, + }, + computed: { + grids() { + return this.timeGrids.filter((grid) => !this.denyIds.includes(grid.id)); + }, + }, + props: { + groupMatch: { + required: false, + type: Object, + default: undefined, + }, + denyIds: { + required: false, + default: () => [], + type: Array, + }, + }, + data() { + return { + dialog: false, + gridToCopyFrom: null, + timeGrids: [], + }; + }, + methods: { + openConfirmationDialog(grid) { + this.gridToCopyFrom = grid; + this.dialog = true; + }, + confirm() { + console.log("Confirmed"); + this.$emit("confirm", this.gridToCopyFrom); + this.dialog = false; + }, + cancel() { + console.log("Cancelled"); + this.dialog = false; + this.gridToCopyFrom = null; + }, + formatTimeGrid(item) { + if (item.group === null) { + return this.$t( + "lesrooster.validity_range.time_grid.repr.generic", + item.validityRange + ); + } + return this.$t("lesrooster.validity_range.time_grid.repr.default", [ + item.validityRange.name, + item.group.name, + ]); + }, + }, +}); +</script> + +<template> + <div> + <v-menu offset-y> + <template #activator="{ attrs, on }"> + <slot name="activator" :attrs="attrs" :on="on"> + <primary-action-button + i18n-key="actions.copy_last_configuration" + icon="mdi-content-copy" + /> + </slot> + </template> + <v-list dense> + <v-list-item + v-for="(grid, index) in grids" + @click="openConfirmationDialog(grid)" + :key="index" + > + <v-list-item-title>{{ formatTimeGrid(grid) }}</v-list-item-title> + </v-list-item> + </v-list> + </v-menu> + + <confirm-dialog v-model="dialog" @confirm="confirm" @cancel="cancel"> + <template #title> + {{ $t("actions.confirm_copy_last_configuration") }} + </template> + <template #text> + {{ $t("actions.confirm_copy_last_configuration_message") }} + </template> + </confirm-dialog> + </div> +</template> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridChip.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..32d2e065217f010fc7ce75526098e3cdbf8a035c --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridChip.vue @@ -0,0 +1,34 @@ +<script> +import { defineComponent } from "vue"; + +export default defineComponent({ + name: "TimeGridChip", + props: { + value: { + type: Object, + required: true, + }, + shortName: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + groupName() { + return this.shortName ? "shortName" : "name"; + }, + }, +}); +</script> + +<template> + <v-chip v-bind="$attrs" v-on="$listeners" close> + {{ + value.group?.[groupName] || + $t("lesrooster.validity_range.time_grid.generic") + }} + </v-chip> +</template> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue new file mode 100644 index 0000000000000000000000000000000000000000..212b6104f316fccac4ce80c2130e0d36172098f0 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue @@ -0,0 +1,154 @@ +<script setup> +import ForeignKeyField from "aleksis.core/components/generic/forms/ForeignKeyField.vue"; +import ValidityRangeField from "./ValidityRangeField.vue"; +</script> + +<script> +import { defineComponent } from "vue"; +import { timeGrids, createTimeGrid } from "./validityRange.graphql"; +import { gqlGroups } from "../helper.graphql"; + +export default defineComponent({ + name: "TimeGridField", + apollo: { + groups: { + query: gqlGroups, + }, + }, + data() { + return { + headers: [ + { + text: this.$t( + "lesrooster.validity_range.time_grid.fields.validity_range" + ), + value: "validityRange", + cols: 12, + }, + { + text: this.$t( + "lesrooster.validity_range.time_grid.fields.is_generic" + ), + value: "isGeneric", + }, + { + text: this.$t("lesrooster.validity_range.time_grid.fields.group"), + value: "group", + }, + ], + i18nKey: "lesrooster.validity_range.time_grid", + gqlQuery: timeGrids, + gqlCreateMutation: createTimeGrid, + defaultItem: { + isGeneric: false, + group: null, + validityRange: null, + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, + methods: { + getCreateData(item) { + return { + group: item.group, + validityRange: item.validityRange?.id, + }; + }, + getPatchData(items) {}, + selectableGroups(itemModel) { + if (itemModel.validityRange === null) return []; + + // Filter all groups, so we only take the ones that are not already used in this validityRange + return this.groups?.filter( + (group) => + !this.$refs.field.items.some( + (timeGrid) => + timeGrid.validityRange.id === itemModel.validityRange.id && + timeGrid.group !== null && + timeGrid.group.id === group.id + ) + ); + }, + genericDisabled(itemModel) { + if (itemModel.validityRange === null) return true; + + // Is there a timeGrid that has the same validityRange as we and no group? + return this.$refs.field.items.some( + (timeGrid) => + timeGrid.validityRange.id === itemModel.validityRange.id && + timeGrid.group === null + ); + }, + formatItem(item) { + if (item.group === null) { + return this.$t( + "lesrooster.validity_range.time_grid.repr.generic", + item.validityRange + ); + } + return this.$t("lesrooster.validity_range.time_grid.repr.default", [ + item.validityRange.name, + item.group.name, + ]); + }, + }, +}); +</script> + +<template> + <foreign-key-field + v-bind="$attrs" + v-on="$listeners" + :fields="headers" + create-item-i18n-key="lesrooster.validity_range.time_grid.create_long" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="{}" + :default-item="defaultItem" + :get-create-data="getCreateData" + :get-patch-data="getPatchData" + :item-name="formatItem" + 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"> + <validity-range-field + v-bind="attrs" + v-on="on" + :rules="required" + required + /> + </div> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #isGeneric.field="{ attrs, on, item }"> + <v-switch + v-bind="attrs" + v-on="on" + :disabled="genericDisabled(item)" + ></v-switch> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #group.field="{ attrs, on, item }"> + <v-autocomplete + :items="selectableGroups(item)" + item-text="name" + item-value="id" + v-bind="attrs" + v-on="on" + :disabled="item.isGeneric" + :loading="$apollo.queries.groups.loading" + /> + </template> + </foreign-key-field> +</template> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue new file mode 100644 index 0000000000000000000000000000000000000000..7b59458e6a470af1ecfd435bc982aa535a832dd5 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue @@ -0,0 +1,403 @@ +<script setup> +import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.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"; +import DialogObjectForm from "aleksis.core/components/generic/dialogs/DialogObjectForm.vue"; +import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue"; +import ValidityRangeStatusField from "./ValidityRangeStatusField.vue"; +import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; +</script> + +<template> + <div> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="lesrooster.validity_range.create_validity_range" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :gql-delete-multiple-mutation="gqlDeleteMultipleMutation" + :default-item="defaultItem" + :get-create-data="getCreateData" + :get-patch-data="getPatchData" + filter + show-expand + 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") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateStart.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + :rules="required" + :max="item ? item.dateEnd : undefined" + required + ></date-field> + </div> + </template> + + <template #dateEnd="{ item }"> + {{ $d(new Date(item.dateEnd), "short") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateEnd.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + required + :rules="required" + :min="item ? item.dateStart : undefined" + ></date-field> + </div> + </template> + + <template #filters="{ attrs, on }"> + <date-field + v-bind="attrs('date_end__gte')" + v-on="on('date_end__gte')" + :label="$t('school_term.after')" + /> + + <date-field + v-bind="attrs('date_start__lte')" + v-on="on('date_start__lte')" + :label="$t('school_term.before')" + /> + </template> + + <template #expanded-item="{ item }"> + <v-sheet class="my-4"> + <message-box type="error" v-if="item.timeGrids.length === 0"> + {{ + $t( + "lesrooster.validity_range.time_grid.explanations.none_created" + ) + }} + </message-box> + <message-box + type="info" + v-else-if="item.timeGrids.length === 1 && !item.timeGrids[0].group" + > + {{ + $t( + "lesrooster.validity_range.time_grid.explanations.only_generic" + ) + }} + </message-box> + <message-box type="info" v-else-if="item.timeGrids.length === 1"> + {{ + $t( + "lesrooster.validity_range.time_grid.explanations.only_one_group" + ) + }} + </message-box> + <message-box type="info" v-else> + {{ + $t( + "lesrooster.validity_range.time_grid.explanations.multiple_set" + ) + }} + </message-box> + + <v-slide-x-transition group> + <time-grid-chip + :value="timeGrid" + v-for="timeGrid in item.timeGrids" + :key="timeGrid.id" + @click:close="handleDeleteTimeGridClick(timeGrid, item)" + class="me-2" + /> + </v-slide-x-transition> + + <create-button + i18n-key="lesrooster.validity_range.time_grid.create" + @click="createTimeGridFor(item)" + /> + </v-sheet> + </template> + </inline-c-r-u-d-list> + + <dialog-object-form + is-create + :default-item="timeGrids.object" + :fields="timeGrids.fields" + v-model="timeGrids.open" + item-title-attribute="course.name" + :get-create-data="timeGrids.getCreateDataBuilder(timeGrids.range)" + :gql-create-mutation="timeGrids.mutation" + @cancel="timeGrids.open = false" + @save="handleTimeGridSave" + @error="handleTimeGridError" + @update="handleTimeGridUpdate" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #isGeneric.field="{ attrs, on }"> + <v-switch + v-bind="attrs" + v-on="on" + :disabled="!genericPossible" + ></v-switch> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #group.field="{ attrs, on, item }"> + <v-autocomplete + :items="selectableGroups" + item-text="name" + item-value="id" + v-bind="attrs" + v-on="on" + :disabled="item.isGeneric" + :loading="$apollo.queries.groups.loading" + /> + </template> + </dialog-object-form> + + <delete-dialog + v-model="timeGrids.deleteOpen" + :item="timeGrids.deleteItem" + :gql-mutation="timeGrids.deleteMutation" + @update="updateTimeGridDelete" + > + <template #body> + {{ $t("lesrooster.validity_range.time_grid.confirm_delete_body") }} + </template> + </delete-dialog> + </div> +</template> + +<script> +import { + validityRanges, + createValidityRange, + deleteValidityRange, + deleteValidityRanges, + updateValidityRanges, + createTimeGrid, + deleteTimeGrid, +} from "./validityRange.graphql"; +import { gqlGroups } from "../helper.graphql"; + +export default { + name: "ValidityRange", + apollo: { + groups: { + query: gqlGroups, + }, + }, + data() { + return { + headers: [ + { + text: this.$t("lesrooster.validity_range.name"), + value: "name", + }, + { + text: this.$t("lesrooster.validity_range.status_label"), + value: "status", + }, + { + text: this.$t("school_term.title"), + value: "schoolTerm", + orderKey: "school_term__date_start", + }, + { + text: this.$t("lesrooster.validity_range.date_start"), + value: "dateStart", + }, + { + text: this.$t("lesrooster.validity_range.date_end"), + value: "dateEnd", + }, + ], + i18nKey: "lesrooster.validity_range", + gqlQuery: validityRanges, + gqlCreateMutation: createValidityRange, + gqlPatchMutation: updateValidityRanges, + gqlDeleteMutation: deleteValidityRange, + gqlDeleteMultipleMutation: deleteValidityRanges, + defaultItem: { + name: "", + dateStart: "", + dateEnd: "", + schoolTerm: "", + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + timeGrids: { + open: false, + deleteOpen: false, + deleteItem: null, + deleteMutation: deleteTimeGrid, + range: null, + object: { + isGeneric: false, + group: null, + }, + mutation: createTimeGrid, + fields: [ + { + text: this.$t( + "lesrooster.validity_range.time_grid.fields.is_generic" + ), + value: "isGeneric", + }, + { + text: this.$t("lesrooster.validity_range.time_grid.fields.group"), + value: "group", + }, + ], + getCreateDataBuilder(validityRange) { + return (model) => ({ + group: model.isGeneric ? null : model.group, + validityRange: validityRange.id, + }); + }, + }, + }; + }, + computed: { + selectableGroups() { + return this.groups?.filter( + (group) => + !this.timeGrids.range?.timeGrids + .map((timeGrid) => timeGrid.group?.id) + .includes(group.id) + ); + }, + genericPossible() { + return !this.timeGrids.range.timeGrids.some( + (timeGrid) => timeGrid.group === null + ); + }, + }, + methods: { + getCreateData(item) { + console.log("in getCreateData", item); + return { + ...item, + schoolTerm: item.schoolTerm?.id, + }; + }, + getPatchData(items) { + console.log("patch items", items); + return items.map((item) => ({ + id: item.id, + name: item.name, + dateStart: item.dateStart, + dateEnd: item.dateEnd, + schoolTerm: item.schoolTerm.id, + status: item.status.toLowerCase(), + })); + }, + createTimeGridFor(validityRange) { + this.timeGrids.range = validityRange; + this.timeGrids.open = true; + }, + handleTimeGridSave() { + this.$toastSuccess(); + }, + handleTimeGridError() { + this.$toastError(); + }, + handleTimeGridUpdate(store, timeGrid) { + const query = { + ...this.$refs.crudList.$apollo.queries.items.options, + variables: JSON.parse( + this.$refs.crudList.$apollo.queries.items.previousVariablesJson + ), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const index = storedData.items.findIndex( + (validityRange) => validityRange.id === timeGrid.validityRange.id + ); + storedData.items[index].timeGrids.push(timeGrid); + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + handleDeleteTimeGridClick(timeGrid, validityRange) { + this.timeGrids.deleteItem = timeGrid; + this.timeGrids.range = validityRange; + this.timeGrids.deleteOpen = true; + }, + updateTimeGridDelete(store) { + const query = { + ...this.$refs.crudList.$apollo.queries.items.options, + variables: JSON.parse( + this.$refs.crudList.$apollo.queries.items.previousVariablesJson + ), + }; + // Read the data from cache for query + const storedData = store.readQuery(query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const vrIndex = storedData.items.findIndex( + (validityRange) => validityRange.id === this.timeGrids.range.id + ); + + // Remove item from stored data + const tgIndex = storedData.items[vrIndex].timeGrids.findIndex( + (m) => m.id === this.timeGrids.deleteItem.id + ); + storedData.items[vrIndex].timeGrids.splice(tgIndex, 1); + + // Write data back to the cache + store.writeQuery({ ...query, data: storedData }); + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue new file mode 100644 index 0000000000000000000000000000000000000000..274df9db207462cf7d81edff2f94b7350e5cfd56 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue @@ -0,0 +1,120 @@ +<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> + <foreign-key-field + v-bind="$attrs" + v-on="$listeners" + :fields="headers" + create-item-i18n-key="lesrooster.validity_range.create_validity_range" + :gql-query="gqlQuery" + :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"> + <date-field + v-bind="attrs" + v-on="on" + :rules="required" + :max="item ? item.dateEnd : undefined" + ></date-field> + </div> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateEnd.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + required + :rules="required" + :min="item ? item.dateStart : undefined" + ></date-field> + </div> + </template> + </foreign-key-field> +</template> + +<script> +import { validityRanges, createValidityRange } from "./validityRange.graphql"; + +export default { + name: "ValidityRangeField", + data() { + return { + headers: [ + { + 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", + }, + { + text: this.$t("lesrooster.validity_range.date_end"), + value: "dateEnd", + }, + ], + i18nKey: "lesrooster.validity_range", + gqlQuery: validityRanges, + gqlCreateMutation: createValidityRange, + defaultItem: { + name: "", + dateStart: "", + dateEnd: "", + schoolTerm: "", + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, + methods: { + getCreateData(item) { + console.log("in getCreateData", item); + return { + ...item, + schoolTerm: item.schoolTerm?.id, + }; + }, + getPatchData(items) { + console.log("patch items", items); + return items.map((item) => ({ + id: item.id, + name: item.name, + dateStart: item.dateStart, + dateEnd: item.dateEnd, + schoolTerm: item.schoolTerm.id, + })); + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusChip.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..6cbbe27b840b19723b07034638e7b115a3e0041d --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusChip.vue @@ -0,0 +1,32 @@ +<script> +import { defineComponent } from "vue"; +import validityRangeStatuses from "./validityRangeStatuses"; +export default defineComponent({ + name: "ValidityRangeStatusChip", + props: { + value: { + type: String, + required: true, + }, + }, + data() { + return { + validityRangeStatuses, + }; + }, + computed: { + status() { + return validityRangeStatuses[this.value]; + }, + }, +}); +</script> + +<template> + <v-chip v-bind="$attrs" :color="status.color" outlined> + <v-icon left>{{ status.icon }}</v-icon> + {{ $t(status.textKey) }} + </v-chip> +</template> + +<style scoped></style> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusField.vue new file mode 100644 index 0000000000000000000000000000000000000000..81408add87e970248b378cdc4a052def6037413d --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusField.vue @@ -0,0 +1,24 @@ +<template> + <v-select v-bind="$attrs" v-on="$listeners" :items="items" item-value="value"> + <template #selection="{ item, index }"> + <validity-range-status-chip :value="item.value" /> + </template> + </v-select> +</template> + +<script> +import validityRangeStatuses from "./validityRangeStatuses"; +import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; +export default { + name: "ValidityRangeStatusField", + extends: "v-select", + components: { ValidityRangeStatusChip }, + data() { + return { + items: Object.values(validityRangeStatuses).map((item) => { + return { ...item, text: this.$t(item.textKey) }; + }), + }; + }, +}; +</script> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql new file mode 100644 index 0000000000000000000000000000000000000000..5c15b259d0c77e832b3b11cce0c27c1d57be05ed --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql @@ -0,0 +1,135 @@ +query validityRanges($orderBy: [String], $filters: JSONString) { + items: validityRanges(orderBy: $orderBy, filters: $filters) { + id + name + status + schoolTerm { + id + name + } + timeGrids { + id + group { + id + name + shortName + } + } + dateStart + dateEnd + canEdit + canDelete + } +} + +mutation createValidityRange($input: CreateValidityRangeInput!) { + createValidityRange(input: $input) { + item: validityRange { + id + name + status + schoolTerm { + id + name + } + timeGrids { + id + group { + id + name + shortName + } + } + dateStart + dateEnd + canEdit + canDelete + } + } +} + +mutation deleteValidityRange($id: ID!) { + deleteValidityRange(id: $id) { + ok + } +} + +mutation deleteValidityRanges($ids: [ID]!) { + deleteValidityRanges(ids: $ids) { + deletionCount + } +} + +mutation updateValidityRanges($input: [BatchPatchValidityRangeInput]!) { + batchMutation: updateValidityRanges(input: $input) { + items: validityRanges { + id + name + status + schoolTerm { + id + name + } + timeGrids { + id + group { + id + name + shortName + } + } + dateStart + dateEnd + canEdit + canDelete + } + } +} + +query currentValidityRange { + currentValidityRange { + id + name + dateStart + dateEnd + } +} + +mutation createTimeGrid($input: CreateTimeGridInput!) { + createTimeGrid(input: $input) { + item: timeGrid { + id + group { + id + name + shortName + } + validityRange { + id + } + } + } +} + +mutation deleteTimeGrid($id: ID!) { + deleteTimeGrid(id: $id) { + ok + } +} + +query timeGrids($orderBy: [String], $filters: JSONString) { + items: timeGrids(orderBy: $orderBy, filters: $filters) { + id + group { + id + shortName + name + } + validityRange { + id + name + dateStart + dateEnd + } + } +} diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/validityRangeStatuses.js b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRangeStatuses.js new file mode 100644 index 0000000000000000000000000000000000000000..783ff0a5ff7e78d78c9d10b3048534e698c90602 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRangeStatuses.js @@ -0,0 +1,15 @@ +export default { + DRAFT: { + value: "DRAFT", + textKey: "lesrooster.validity_range.status.draft", + color: "warning", + icon: "mdi-progress-wrench", + }, + + PUBLISHED: { + value: "PUBLISHED", + textKey: "lesrooster.validity_range.status.published", + color: "success", + icon: "mdi-check-circle-outline", + }, +}; diff --git a/aleksis/apps/lesrooster/frontend/index.js b/aleksis/apps/lesrooster/frontend/index.js new file mode 100644 index 0000000000000000000000000000000000000000..1a82aec7706253dd24cc8dd41d0f29c0995abf73 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/index.js @@ -0,0 +1,124 @@ +import { hasPersonValidator } from "aleksis.core/routeValidators"; + +export default { + component: () => import("aleksis.core/components/Parent.vue"), + meta: { + inMenu: true, + titleKey: "lesrooster.menu_title", + icon: "mdi-timetable", + validators: [hasPersonValidator], + permission: "lesrooster.view_lesrooster_menu_rule", + }, + children: [ + { + path: "validity_ranges/", + component: () => import("./components/validity_range/ValidityRange.vue"), + name: "lesrooster.validity_ranges", + meta: { + inMenu: true, + titleKey: "lesrooster.validity_range.menu_title", + icon: "mdi-calendar-expand-horizontal-outline", + permission: "lesrooster.view_validityranges_rule", + }, + }, + { + path: "raster/", + component: () => import("./components/lesson_raster/LessonRaster.vue"), + name: "lesrooster.lesson_raster", + meta: { + inMenu: true, + titleKey: "lesrooster.lesson_raster.menu_title", + toolbarTitle: "lesrooster.lesson_raster.menu_title", + icon: "mdi-grid-large", + permission: "lesrooster.manage_lesson_raster_rule", + }, + }, + { + path: "timebound_course_configs/plan_courses/", + component: () => + import( + "./components/timebound_course_config/TimeboundCourseConfigRaster.vue" + ), + name: "lesrooster.planCourses", + meta: { + inMenu: true, + titleKey: "lesrooster.timebound_course_config.raster_menu_title", + icon: "mdi-clock-edit-outline", + permission: "lesrooster.view_timeboundcourseconfigs_rule", + }, + }, + { + path: "timetable/", + component: () => + import("./components/timetable_management/TimetableManagement.vue"), + name: "lesrooster.timetable_management_select", + meta: { + inMenu: true, + titleKey: "lesrooster.timetable_management.menu_title", + toolbarTitle: "lesrooster.timetable_management.menu_title", + icon: "mdi-magnet", + permission: "lesrooster.plan_timetables_rule", + }, + children: [ + { + path: ":id(\\d+)/", + component: () => + import("./components/timetable_management/TimetableManagement.vue"), + name: "lesrooster.timetable_management", + props: true, + meta: { + permission: "lesrooster.plan_timetables_rule", + }, + }, + ], + }, + { + path: "supervisions/", + component: () => import("./components/supervision/Supervision.vue"), + name: "lesrooster.supervisions", + meta: { + inMenu: true, + titleKey: "lesrooster.supervision.menu_title", + icon: "mdi-seesaw", + permission: "lesrooster.view_supervisions_rule", + }, + }, + { + path: "slots/", + component: () => + import("./components/breaks_and_slots/LesroosterSlot.vue"), + name: "lesrooster.slots", + meta: { + inMenu: true, + titleKey: "lesrooster.slot.menu_title", + icon: "mdi-border-none-variant", + permission: "lesrooster.view_slots_rule", + }, + }, + { + path: "breaks/", + component: () => import("./components/breaks_and_slots/Break.vue"), + name: "lesrooster.breaks", + meta: { + inMenu: true, + titleKey: "lesrooster.break.menu_title", + icon: "mdi-timer-sand-paused", + permission: "lesrooster.view_breakslots_rule", + }, + }, + { + path: "timebound_course_configs/", + component: () => + import( + "./components/timebound_course_config/TimeboundCourseConfigCRUDTable.vue" + ), + name: "lesrooster.timeboundCourseConfigs", + meta: { + inMenu: true, + titleKey: "lesrooster.timebound_course_config.crud_table_menu_title", + icon: "mdi-timetable", + permission: "lesrooster.view_timeboundcourseconfigs_rule", + }, + }, + ], +}; diff --git a/aleksis/apps/lesrooster/frontend/messages/de.json b/aleksis/apps/lesrooster/frontend/messages/de.json new file mode 100644 index 0000000000000000000000000000000000000000..7f151145f424740ff0475eaaf2b34794baee5c2c --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/messages/de.json @@ -0,0 +1,143 @@ +{ + "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" + }, + "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)" + } + } + }, + "slot": { + "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", + "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}" + }, + "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}" + } + }, + "timebound_course_config": { + "crud_table_menu_title": "Kurskonfigurationen", + "raster_menu_title": "Kurse planen", + "title": "Kurskonfiguration", + "title_plural": "Kurskonfigurationen", + "lesson_quota": "Stundenpensum", + "course": "Kurs", + "groups": "Gruppen", + "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" + }, + "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", + "lesson_fields": { + "subject": "Fach", + "rooms": "Räume", + "teachers": "Lehrer" + }, + "snacks": { + "lesson_edit": { + "success": "Stunde erfolgreich bearbeitet.", + "error": "Fehler beim Bearbeiten der Stunde." + }, + "lesson_create": { + "success": "Stunde erfolgreich erstellt.", + "error": "Fehler beim Erstellen der Stunde." + }, + "lesson_move": { + "success": "Stunde erfolgreich verschoben.", + "error": "Fehler beim Verschieben der Stunde." + }, + "lesson_change_length": { + "success": "Stundenlänge erfolgreich angepasst", + "error": "Fehler beim Verändern der Stundenlänge." + } + } + }, + "supervision": { + "menu_title": "Aufsichten", + "title": "Aufsicht", + "title_plural": "Aufsichten", + "create_supervision": "Aufsicht erstellen", + "break_slot": "Pause", + "rooms": "Räume", + "teachers": "Lehrkräfte", + "subject": "Fach" + } + }, + "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 new file mode 100644 index 0000000000000000000000000000000000000000..2a5da55f1a73095e9479c5609a6c4553a80ac5b7 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/messages/en.json @@ -0,0 +1,143 @@ +{ + "lesrooster": { + "menu_title": "Lesson Management", + "validity_range": { + "menu_title": "Validity Ranges", + "title": "Validity Range", + "title_plural": "Validity Ranges", + "name": "Name", + "date_start": "Start Date", + "date_end": "End Date", + "create_validity_range": "Create Validity Range", + "status_label": "Status", + "status": { + "draft": "Draft", + "published": "Published" + }, + "time_grid": { + "generic": "Generic (catch-all)", + "explanations": { + "none_created": "You haven't configured which groups this validity range is valid for, so it cannot be used yet.", + "only_one_group": "The data connected to this validity range (e.g. slots) is only valid for the group below.", + "only_generic": "All data connected to this validity range (e.g. slots) is the same for any group.", + "multiple_set": "Data connected to this validity range (e.g. slots) can be different for the groups below." + }, + "create": "Select group", + "create_long": "Create group-specific validity range", + "fields": { + "is_generic": "Is generic", + "group": "Group" + }, + "confirm_delete_body": "If you remove this group from the validity range, all connected data, like slots and lessons are deleted.", + "repr": { + "default": "{0} ({1})", + "generic": "{name} (generic/catch-all)" + } + } + }, + "slot": { + "menu_title": "Slots", + "title": "Slot", + "title_plural": "Slots", + "name": "Name", + "weekday": "Weekday", + "weekdays": "Weekdays", + "period": "Period", + "period_lte": "Period until", + "period_gte": "Period from", + "time_start": "Start Time", + "time_end": "End Time", + "create_slot": "Create Slot", + "create_items": "Create Slots", + "create_items_success": "The slots were created successfully.", + "create_items_error": "Error creating slots.", + "confirm_delete_multiple_slots": "Do you really want to delete all slots and breaks on {day}?", + "repr": "Slot in period {period}, from {timeStart} to {timeEnd}" + }, + "break": { + "menu_title": "Breaks", + "title": "Break", + "title_plural": "Breaks", + "create_item": "Create Break", + "create_items": "Create Breaks", + "create_items_success": "The breaks where created successfully.", + "create_items_error": "Error creating breaks.", + "repr": { + "default": "Break from {timeStart} to {timeEnd}", + "weekday_short": "{weekday}, {timeStart} to {timeEnd}" + } + }, + "timebound_course_config": { + "crud_table_menu_title": "Timebound course configs", + "raster_menu_title": "Plan courses", + "title": "Timebound course config", + "title_plural": "Timebound course configs", + "lesson_quota": "Scheduled lesson quota", + "course": "Course", + "groups": "Groups", + "teachers": "Teachers", + "teachers_for": "Teachers for", + "subject_teachers": "Teachers for this subject", + "all_teachers": "All teachers", + "no_course_selected": "No course selected", + "create_timebound_course_config": "Create timebound course config", + "subject": "Subject" + }, + "lesson_raster": { + "menu_title": "Lesson Raster" + }, + "timetable_management": { + "menu_title": "Timetable Management", + "for_group": "Timetable management for group {group}", + "back": "Back to school structure", + "timetable_for": "Timetable for {name}", + "no_lessons": "No lessons in this plan", + "no_slots": "There are no slots defined for this plan", + "lessons_used_ratio": "{lessonsUsed}/{lessonQuota}", + "lessons_used_ratio_total": "{lessonsUsed}/{lessonQuota} lessons planned", + "lesson_fields": { + "subject": "Subject", + "rooms": "Rooms", + "teachers": "Teachers" + }, + "snacks": { + "lesson_edit": { + "success": "Lesson updated successfully.", + "error": "Error updating lesson." + }, + "lesson_create": { + "success": "Lesson created successfully", + "error": "Error creating lesson." + }, + "lesson_move": { + "success": "Lesson moved successfully.", + "error": "Error moving lesson." + }, + "lesson_change_length": { + "success": "Lesson length changed successfully.", + "error": "Error changing length of lesson." + } + } + }, + "supervision": { + "menu_title": "Supervisions", + "title": "Supervision", + "title_plural": "Supervisions", + "create_supervision": "Create supervision", + "break_slot": "Break", + "rooms": "Rooms", + "teachers": "Teachers", + "subject": "Subject" + } + }, + "actions": { + "copy_to_day": "Copy to another day", + "search_courses": "Search Courses", + "copy_last_configuration": "Copy from different range", + "confirm_copy_last_configuration": "Do you really want to copy another configuration to this range?", + "confirm_copy_last_configuration_message": "This will overwrite all existing data in this range. This action cannot be undone." + }, + "labels": { + "select_validity_range": "Select Validity Range" + } +} diff --git a/aleksis/apps/lesrooster/managers.py b/aleksis/apps/lesrooster/managers.py index f6cc01f1c3f81ca05fa572b361fb4dd7ca329a36..10703b00c271ed9d9be8e74b639c47aa0d76d390 100644 --- a/aleksis/apps/lesrooster/managers.py +++ b/aleksis/apps/lesrooster/managers.py @@ -9,5 +9,3 @@ class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin): class ValidityRangeManager(AlekSISBaseManagerWithoutMigrations): """Manager for validity ranges.""" - - queryset_class = ValidityRangeQuerySet diff --git a/aleksis/apps/lesrooster/migrations/0001_initial.py b/aleksis/apps/lesrooster/migrations/0001_initial.py index a41304866a4dce9ce84e3778ba6b6c2998ce4a98..1cea6d5a7f046be66b2c49ac31e453d0db445b04 100644 --- a/aleksis/apps/lesrooster/migrations/0001_initial.py +++ b/aleksis/apps/lesrooster/migrations/0001_initial.py @@ -15,8 +15,8 @@ class Migration(migrations.Migration): ("core", "0052_site_related_name"), ("contenttypes", "0002_remove_content_type_name"), ("sites", "0002_alter_domain_unique"), - ("chronos", "0015_managed_by_site"), - ("cursus", "0003_add_course_lesson_quota"), + ("chronos", "0015_add_managed_by_app_label"), + ("cursus", "0001_initial"), ] operations = [ diff --git a/aleksis/apps/lesrooster/migrations/0002_timeboundcourseconfig.py b/aleksis/apps/lesrooster/migrations/0002_timeboundcourseconfig.py new file mode 100644 index 0000000000000000000000000000000000000000..fe6ca47de4f94b0229f70b23b08c42ff998e6e41 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0002_timeboundcourseconfig.py @@ -0,0 +1,96 @@ +# Generated by Django 4.2.3 on 2023-07-31 12:12 + +import aleksis.core.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("core", "0052_site_related_name"), + ("cursus", "0001_initial"), + ("lesrooster", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="TimeboundCourseConfig", + 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)), + ( + "scheduled_slot_count", + models.PositiveSmallIntegerField( + blank=True, + null=True, + verbose_name="Number of slots this course is scheduled to fill per week", + ), + ), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lr_timebound_course_configs", + to="cursus.course", + verbose_name="Course", + ), + ), + ( + "site", + models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + ( + "teachers", + models.ManyToManyField( + related_name="lr_timebound_course_configs", + to="core.person", + verbose_name="Teachers", + ), + ), + ( + "validity_range", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lr_timebound_course_configs", + to="lesrooster.validityrange", + verbose_name="Linked validity range", + ), + ), + ], + options={ + "verbose_name": "Timebound course config", + "verbose_name_plural": "Timebound course configs", + }, + managers=[ + ("objects", aleksis.core.managers.AlekSISBaseManager()), + ], + ), + migrations.AddConstraint( + model_name="timeboundcourseconfig", + constraint=models.UniqueConstraint( + fields=("course", "validity_range"), name="lr_unique_course_config_per_range" + ), + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0003_timegrid.py b/aleksis/apps/lesrooster/migrations/0003_timegrid.py new file mode 100644 index 0000000000000000000000000000000000000000..453e2e4947cc1ff057f1421b726a6981f8fa9589 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0003_timegrid.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.4 on 2023-08-14 18:42 + +import aleksis.core.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0052_site_related_name"), + ("sites", "0002_alter_domain_unique"), + ("lesrooster", "0002_timeboundcourseconfig"), + ] + + operations = [ + migrations.CreateModel( + name="TimeGrid", + 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)), + ( + "group", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="time_grids", + to="core.group", + verbose_name="Group", + ), + ), + ( + "site", + models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + ( + "validity_range", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="time_grids", + to="lesrooster.validityrange", + verbose_name="Linked validity range", + ), + ), + ], + options={ + "verbose_name": "Time Grid", + "verbose_name_plural": "Time Grids", + }, + managers=[ + ("objects", aleksis.core.managers.AlekSISBaseManager()), + ], + ), + migrations.AddConstraint( + model_name="timegrid", + constraint=models.UniqueConstraint( + fields=("validity_range", "group"), name="lr_unique_validity_range_group_time_grid" + ), + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0004_slot_timegrid.py b/aleksis/apps/lesrooster/migrations/0004_slot_timegrid.py new file mode 100644 index 0000000000000000000000000000000000000000..009c5e25e1c2fb3e867df4d4250b77b3ccd84d13 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0004_slot_timegrid.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.4 on 2023-08-14 18:44 + +from django.db import migrations, models +import django.db.models.deletion + + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0003_timegrid"), + ] + + operations = [ + migrations.AlterModelOptions( + name="lesson", + options={ + "ordering": [ + "slot_start__time_grid__validity_range__date_start", + "slot_start__weekday", + "slot_start__time_start", + "subject", + ], + "verbose_name": "Lesson", + "verbose_name_plural": "Lessons", + }, + ), + migrations.AddField( + model_name="slot", + name="time_grid", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="slots", + to="lesrooster.timegrid", + verbose_name="Time Grid", + null=True, + blank=True + ), + preserve_default=False, + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0005_migrate_slot_to_timegrid.py b/aleksis/apps/lesrooster/migrations/0005_migrate_slot_to_timegrid.py new file mode 100644 index 0000000000000000000000000000000000000000..191820bb0d9ebc9776612e8205ffc8fcf054cd5a --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0005_migrate_slot_to_timegrid.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.4 on 2023-08-14 18:44 + +from django.db import migrations, models +import django.db.models.deletion + +def _migrate_values(apps, schema_editor): + TimeGrid = apps.get_model("lesrooster", "TimeGrid") + Slot = apps.get_model("lesrooster", "Slot") + + for slot in Slot.objects.all(): + tgs = TimeGrid.objects.filter(validity_range=slot.validity_range) + if tgs: + tg = tgs.first() + else: + tg = TimeGrid.objects.create(validity_range=slot.validity_range) + slot.time_grid = tg + slot.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0004_slot_timegrid"), + ] + + operations = [ + migrations.RunPython(_migrate_values), + ] diff --git a/aleksis/apps/lesrooster/migrations/0006_slot_drop_validityrange.py b/aleksis/apps/lesrooster/migrations/0006_slot_drop_validityrange.py new file mode 100644 index 0000000000000000000000000000000000000000..6af2f9a1608c649102765b556ba4b79722cf6c2a --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0006_slot_drop_validityrange.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.4 on 2023-08-14 18:44 + +from django.db import migrations, models +import django.db.models.deletion + + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0005_migrate_slot_to_timegrid"), + ] + + operations = [ + migrations.AlterField( + model_name="slot", + name="time_grid", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="slots", + to="lesrooster.timegrid", + verbose_name="Time Grid", + ), + ), + migrations.RemoveConstraint( + model_name="slot", + name="lr_unique_period_per_range", + ), + migrations.RemoveField( + model_name="slot", + name="validity_range", + ), + migrations.AddConstraint( + model_name="slot", + constraint=models.UniqueConstraint( + fields=("weekday", "period", "time_grid"), name="lr_unique_period_per_range" + ), + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0007_rename_scheduled_slot_count_timeboundcourseconfig_lesson_quota.py b/aleksis/apps/lesrooster/migrations/0007_rename_scheduled_slot_count_timeboundcourseconfig_lesson_quota.py new file mode 100644 index 0000000000000000000000000000000000000000..7b5dcd30d82b614a1f8afef90e9eb9c3029f5edf --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0007_rename_scheduled_slot_count_timeboundcourseconfig_lesson_quota.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.10 on 2023-08-14 16:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0006_slot_drop_validityrange"), + ] + + operations = [ + migrations.RenameField( + model_name="timeboundcourseconfig", + old_name="scheduled_slot_count", + new_name="lesson_quota", + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0008_one_default_time_grid.py b/aleksis/apps/lesrooster/migrations/0008_one_default_time_grid.py new file mode 100644 index 0000000000000000000000000000000000000000..0ba71368ff540da131759b2319e4532629a18a64 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0008_one_default_time_grid.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.4 on 2023-08-15 19:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0007_rename_scheduled_slot_count_timeboundcourseconfig_lesson_quota"), + ] + + operations = [ + migrations.AddConstraint( + model_name="timegrid", + constraint=models.UniqueConstraint( + condition=models.Q(("group", None)), + fields=("validity_range",), + name="lr_one_default_time_grid", + ), + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0009_lesroosterglobalpermissions.py b/aleksis/apps/lesrooster/migrations/0009_lesroosterglobalpermissions.py new file mode 100644 index 0000000000000000000000000000000000000000..1a8a7f06fe9588dd791c4e2513041a6d82a92a2a --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0009_lesroosterglobalpermissions.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.4 on 2023-08-15 21:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0008_one_default_time_grid"), + ] + + operations = [ + migrations.CreateModel( + name="LesroosterGlobalPermissions", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ], + options={ + "permissions": ( + ("view_lesson_raster", "Can view lesson raster"), + ("view_timetable_creation", "Can view timetable creation"), + ), + "managed": False, + }, + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0010_multiple_validity_ranges_per_time.py b/aleksis/apps/lesrooster/migrations/0010_multiple_validity_ranges_per_time.py new file mode 100644 index 0000000000000000000000000000000000000000..a974e04f5c75f4a7297f5e487b263b116e4ef7e2 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0010_multiple_validity_ranges_per_time.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.4 on 2023-08-16 18:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0009_lesroosterglobalpermissions"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="validityrange", + name="lr_unique_dates_per_term", + ), + migrations.AddConstraint( + model_name="validityrange", + constraint=models.UniqueConstraint( + condition=models.Q(("status", "published")), + fields=("school_term", "date_start", "date_end"), + name="lr_unique_dates_per_term", + ), + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0011_alter_lesroosterglobalpermissions_options.py b/aleksis/apps/lesrooster/migrations/0011_alter_lesroosterglobalpermissions_options.py new file mode 100644 index 0000000000000000000000000000000000000000..ba2b950c409de3b1e953a6c979cf260c7e7b5619 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0011_alter_lesroosterglobalpermissions_options.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.4 on 2023-08-22 12:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0010_multiple_validity_ranges_per_time"), + ] + + operations = [ + migrations.AlterModelOptions( + name="lesroosterglobalpermissions", + options={ + "managed": False, + "permissions": ( + ("manage_lesson_raster", "Can manage lesson raster"), + ("plan_timetables", "Can plan timetables"), + ), + }, + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0012_alter_timeboundcourseconfig_lesson_quota_and_more.py b/aleksis/apps/lesrooster/migrations/0012_alter_timeboundcourseconfig_lesson_quota_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..2175c10bd3c6c2d9052c78d5c5d1802e003b5b32 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0012_alter_timeboundcourseconfig_lesson_quota_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.4 on 2023-09-16 18:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0011_alter_lesroosterglobalpermissions_options"), + ] + + operations = [ + migrations.AlterField( + model_name="timeboundcourseconfig", + name="lesson_quota", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Number of slots this course is scheduled to fill per week", + null=True, + verbose_name="Lesson quota", + ), + ), + migrations.AddConstraint( + model_name="slot", + constraint=models.CheckConstraint( + check=models.Q(("time_start__lte", models.F("time_end"))), + name="time_start_lte_time_end", + ), + ), + migrations.AddConstraint( + model_name="validityrange", + constraint=models.CheckConstraint( + check=models.Q(("date_start__lte", models.F("date_end"))), + name="date_start_lte_date_end", + ), + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0013_supervision_subject_supervisionsubstitution_subject.py b/aleksis/apps/lesrooster/migrations/0013_supervision_subject_supervisionsubstitution_subject.py new file mode 100644 index 0000000000000000000000000000000000000000..9425e44222147a49549c7bc956c866bad0fea61c --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0013_supervision_subject_supervisionsubstitution_subject.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.4 on 2023-09-13 18:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("cursus", "0001_initial"), + ("lesrooster", "0012_alter_timeboundcourseconfig_lesson_quota_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="supervision", + name="subject", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="lr_supervisions", + to="cursus.subject", + verbose_name="Subject", + ), + ), + migrations.AddField( + model_name="supervisionsubstitution", + name="subject", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="lr_supervision_substitutions", + to="cursus.subject", + verbose_name="Subject", + ), + ), + ] diff --git a/aleksis/apps/lesrooster/migrations/0014_remove_breakslot_period_after.py b/aleksis/apps/lesrooster/migrations/0014_remove_breakslot_period_after.py new file mode 100644 index 0000000000000000000000000000000000000000..24fac30b27ecefdcf94a8bc7e1a6f5d5b8d99e58 --- /dev/null +++ b/aleksis/apps/lesrooster/migrations/0014_remove_breakslot_period_after.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2023-10-26 13:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("lesrooster", "0013_supervision_subject_supervisionsubstitution_subject"), + ] + + operations = [ + migrations.RemoveField( + model_name="breakslot", + name="period_after", + ), + ] diff --git a/aleksis/apps/lesrooster/model_extensions.py b/aleksis/apps/lesrooster/model_extensions.py index 7401fac79572039dfcbee9d422d81c8dabaef301..93a246ff2ab66394942ce82689eac3abfa981300 100644 --- a/aleksis/apps/lesrooster/model_extensions.py +++ b/aleksis/apps/lesrooster/model_extensions.py @@ -1,9 +1,13 @@ from django.utils.translation import gettext as _ -from jsonstore import BooleanField +from jsonstore import BooleanField, IntegerField -from aleksis.core.models import Room +from aleksis.core.models import Person, Room Room.field( is_supervision_area=BooleanField(verbose_name=_("Is supervision area"), null=True, blank=True) ) + +Person.field( + lesson_quota=IntegerField(verbose_name=_("Lesson quota as a teacher"), null=True, blank=True) +) diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py index 5fe0a3f383fb00bca757ac59370b2ed21537aef1..13708570540847e94e4ed9d51148a91c9ef66a8e 100644 --- a/aleksis/apps/lesrooster/models.py +++ b/aleksis/apps/lesrooster/models.py @@ -1,15 +1,15 @@ -from copy import deepcopy from datetime import date, datetime from typing import Optional, Union from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Q, QuerySet +from django.db.models import F, Q, QuerySet from django.utils import timezone from django.utils.formats import date_format, time_format from django.utils.functional import classproperty 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 recurrence.fields import RecurrenceField @@ -17,10 +17,10 @@ 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.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel +from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel, GlobalPermissionModel from aleksis.core.models import Group, Holiday, Person, Room, SchoolTerm -from .managers import ValidityRangeManager +from .managers import ValidityRangeManager, ValidityRangeQuerySet class ValidityRangeStatus(models.TextChoices): @@ -33,7 +33,7 @@ class ValidityRangeStatus(models.TextChoices): class ValidityRange(ExtensibleModel): """A validity range is a date range in which certain data are valid.""" - objects = ValidityRangeManager() + objects = ValidityRangeManager.from_queryset(ValidityRangeQuerySet)() school_term = models.ForeignKey( SchoolTerm, @@ -53,6 +53,10 @@ class ValidityRange(ExtensibleModel): default=ValidityRangeStatus.DRAFT, ) + @property + def published(self): + return self.status == ValidityRangeStatus.PUBLISHED.value + @classmethod def get_current(cls, day: Optional[date] = None) -> Optional["ValidityRange"]: """Get the currently active validity range.""" @@ -80,13 +84,19 @@ class ValidityRange(ExtensibleModel): ): raise ValidationError(_("The validity range must be within the school term.")) - qs = ValidityRange.objects.within_dates(self.date_start, self.date_end) - if self.pk: - qs = qs.exclude(pk=self.pk) - if qs.exists(): - raise ValidationError( - _("There is already a validity range for this time or a part of this time.") + if self.status == ValidityRangeStatus.PUBLISHED.value: + qs = ValidityRange.objects.within_dates(self.date_start, self.date_end).filter( + status=ValidityRangeStatus.PUBLISHED ) + if self.pk: + qs = qs.exclude(pk=self.pk) + if qs.exists(): + raise ValidationError( + _( + "There is already a published validity range " + "for this time or a part of this time." + ) + ) def __str__(self) -> str: return self.name or f"{date_format(self.date_start)}–{date_format(self.date_end)}" @@ -97,7 +107,12 @@ class ValidityRange(ExtensibleModel): constraints = [ # Heads up: Uniqueness per term implies uniqueness per site models.UniqueConstraint( - fields=["school_term", "date_start", "date_end"], name="lr_unique_dates_per_term" + fields=["school_term", "date_start", "date_end"], + condition=Q(status=ValidityRangeStatus.PUBLISHED), + name="lr_unique_dates_per_term", + ), + models.CheckConstraint( + check=Q(date_start__lte=F("date_end")), name="date_start_lte_date_end" ), ] indexes = [ @@ -105,17 +120,52 @@ class ValidityRange(ExtensibleModel): ] +class TimeGrid(ExtensibleModel): + validity_range = models.ForeignKey( + ValidityRange, + on_delete=models.CASCADE, + related_name="time_grids", + verbose_name=_("Linked validity range"), + ) + + group = models.ForeignKey( + Group, + verbose_name=_("Group"), + on_delete=models.SET_NULL, + related_name="time_grids", + blank=True, + null=True, + ) + + def __str__(self): + if self.group: + return f"{self.validity_range}: {self.group}" + return str(self.validity_range) + + class Meta: + verbose_name = _("Time Grid") + verbose_name_plural = _("Time Grids") + constraints = [ + models.UniqueConstraint( + fields=["validity_range", "group"], name="lr_unique_validity_range_group_time_grid" + ), + models.UniqueConstraint( + fields=["validity_range"], condition=Q(group=None), name="lr_one_default_time_grid" + ), + ] + + class Slot(ExtensiblePolymorphicModel): """A slot is a time period in which a lesson can take place.""" WEEKDAY_CHOICES = i18n_day_name_choices_lazy() WEEKDAY_CHOICES_SHORT = i18n_day_abbr_choices_lazy() - validity_range = models.ForeignKey( - ValidityRange, + time_grid = models.ForeignKey( + TimeGrid, on_delete=models.CASCADE, - related_name="lr_slots", - verbose_name=_("Linked validity range"), + related_name="slots", + verbose_name=_("Time Grid"), ) name = models.CharField(verbose_name=_("Name"), max_length=255, blank=True) @@ -151,6 +201,9 @@ class Slot(ExtensiblePolymorphicModel): 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) + 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.""" if isinstance(date_ref, date): @@ -159,11 +212,17 @@ class Slot(ExtensiblePolymorphicModel): 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) + class Meta: constraints = [ # Heads up: Uniqueness per validity range implies validity per site models.UniqueConstraint( - fields=["weekday", "period", "validity_range"], name="lr_unique_period_per_range" + fields=["weekday", "period", "time_grid"], name="lr_unique_period_per_range" + ), + models.CheckConstraint( + check=Q(time_start__lte=F("time_end")), name="time_start_lte_time_end" ), ] ordering = ["weekday", "period"] @@ -248,46 +307,62 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): def clean(self): """Ensure that the slots are in the same validity range.""" - if self.slot_start.validity != self.slot_end.validity: - raise ValidationError(_("The slots must be in the same validity range.")) + if self.slot_start.time_grid != self.slot_end.time_grid: + raise ValidationError(_("The slots must be in the same time grid.")) + + @property + def real_recurrence(self) -> "recurrence.Recurrence": + """Get the real recurrence adjusted to the validity range and including holidays.""" + if not self.recurrence: + return + rrules = self.recurrence.rrules + for rrule in rrules: + rrule.until = self.slot_end.get_last_datetime() + pattern = recurrence.Recurrence( + dtstart=self.slot_start.get_first_datetime(), + rrules=rrules, + ) + pattern.exdates = Holiday.get_ex_dates( + self.slot_start.get_first_datetime(), self.slot_end.get_last_datetime(), pattern + ) + return pattern def sync(self) -> LessonEvent: """Sync the lesson with its lesson event.""" - week_start = CalendarWeek.from_date(self.slot_start.validity_range.date_start) - week_end = CalendarWeek.from_date(self.slot_start.validity_range.date_end) + week_start = CalendarWeek.from_date(self.slot_start.time_grid.validity_range.date_start) + week_end = CalendarWeek.from_date(self.slot_start.time_grid.validity_range.date_end) datetime_start = self.slot_start.get_datetime_start(week_start) datetime_end = self.slot_end.get_datetime_end(week_start) datetime_end_series = self.slot_end.get_datetime_end(week_end) - lesson_event, __ = LessonEvent.objects.update_or_create( - lesson=self, - defaults={ - "course": self.course, - "subject": self.subject, - "datetime_start": datetime_start, - "datetime_end": datetime_end, - }, - ) - if self.recurrence: - lesson_event.recurrences = deepcopy(self.recurrence) - lesson_event.recurrences.exdates += Holiday.get_ex_dates( - datetime_start, datetime_end_series, self.recurrence - ) + if not self.lesson_event: + lesson_event = LessonEvent() + else: + lesson_event = self.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.recurrences = self.real_recurrence + lesson_event.save() + lesson_event.groups.set(self.course.groups.all()) + lesson_event.teachers.set(self.teachers.all()) + lesson_event.rooms.set(self.rooms.all()) + if self.lesson_event != lesson_event: self.lesson_event = lesson_event self.save() - lesson_event.groups.set(self.course.groups.all()) - lesson_event.teachers.set(self.teachers.all()) - lesson_event.rooms.set(self.rooms.all()) return lesson_event class Meta: # Heads up: Link to slot implies uniqueness per site ordering = [ - "slot_start__validity_range__date_start", + "slot_start__time_grid__validity_range__date_start", "slot_start__weekday", "slot_start__time_start", "subject", @@ -299,15 +374,8 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): class BreakSlot(Slot): """A break is a time period that can supervised and in which no lessons take place.""" - period_after = models.IntegerField( - verbose_name=_("Period after"), - ) - def __str__(self) -> str: - return ( - f"{self.period_after - 1}./{self.period_after}. " - f"({time_format(self.time_start)} - {time_format(self.time_end)})" - ) + return f"{time_format(self.time_start)} - {time_format(self.time_end)}" class Meta: verbose_name = _("Break") @@ -342,6 +410,14 @@ class Supervision(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): verbose_name=_("Break Slot"), related_name="lr_supervisions", ) + subject = models.ForeignKey( + Subject, + on_delete=models.CASCADE, + verbose_name=_("Subject"), + related_name="lr_supervisions", + blank=True, + null=True, + ) # Recurrence rules allow to define a series of supervisions # Common examples are weekly or every second week @@ -365,35 +441,51 @@ class Supervision(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): verbose_name = _("Supervision") verbose_name_plural = _("Supervisions") + @property + def real_recurrence(self) -> "recurrence.Recurrence": + """Get the real recurrence adjusted to the validity range and including holidays.""" + if not self.recurrence: + return + rrules = self.recurrence.rrules + for rrule in rrules: + rrule.until = self.break_slot.get_last_datetime() + pattern = recurrence.Recurrence( + dtstart=self.break_slot.get_first_datetime(), + rrules=rrules, + ) + pattern.exdates = Holiday.get_ex_dates( + self.break_slot.get_first_datetime(), self.break_slot.get_last_datetime(), pattern + ) + return pattern + def sync(self) -> SupervisionEvent: """Sync the supervision with its supervision event.""" - week_start = CalendarWeek.from_date(self.break_slot.validity_range.date_start) - week_end = CalendarWeek.from_date(self.break_slot.validity_range.date_end) + week_start = CalendarWeek.from_date(self.break_slot.time_grid.validity_range.date_start) + week_end = CalendarWeek.from_date(self.break_slot.time_grid.validity_range.date_end) datetime_start = self.break_slot.get_datetime_start(week_start) datetime_end = self.break_slot.get_datetime_end(week_start) datetime_end_series = self.break_slot.get_datetime_end(week_end) - supervision_event, __ = SupervisionEvent.objects.update_or_create( - supervision=self, - defaults={ - "datetime_start": datetime_start, - "datetime_end": datetime_end, - }, - ) + if self.supervision_event: + supervision_event = self.supervision_event + else: + supervision_event = SupervisionEvent() + + supervision_event.datetime_start = datetime_start + supervision_event.datetime_end = datetime_end + supervision_event.subject = self.subject + + supervision_event.recurrences = self.real_recurrence - if self.recurrence: - supervision_event.recurrences = deepcopy(self.recurrence) - supervision_event.recurrences.exdates += Holiday.get_ex_dates( - datetime_start, datetime_end_series, self.recurrence - ) supervision_event.save() + supervision_event.teachers.set(self.teachers.all()) + supervision_event.rooms.set(self.rooms.all()) + if self.supervision_event != supervision_event: self.supervision_event = supervision_event self.save() - supervision_event.teachers.set(self.teachers.all()) - supervision_event.rooms.set(self.rooms.all()) return supervision_event @@ -463,26 +555,29 @@ class Substitution(RoomPropertiesMixin, TeacherPropertiesMixin, ExtensibleModel) week = CalendarWeek.from_date(self.date) if not self.lesson.lesson_event: return None - lesson_event, __ = LessonEvent.objects.update_or_create( - substitution=self, - defaults={ - "amends": self.lesson.lesson_event, - "course": self.lesson.course, - "subject": self.subject, - "datetime_start": self.lesson.slot_start.get_datetime_start(week), - "datetime_end": self.lesson.slot_end.get_datetime_end(week), - "cancelled": self.cancelled, - "comment": self.comment, - }, - ) - if self.lesson_event != lesson_event: - self.lesson_event = lesson_event - self.save() + if self.lesson_event: + lesson_event = self.lesson_event + else: + lesson_event = LessonEvent() + + lesson_event.amends = self.lesson.lesson_event + lesson_event.course = self.lesson.course + lesson_event.subject = self.subject + lesson_event.datetime_start = self.lesson.slot_start.get_datetime_start(week) + lesson_event.datetime_end = self.lesson.slot_end.get_datetime_end(week) + lesson_event.cancelled = self.cancelled + lesson_event.comment = self.comment + lesson_event.save() lesson_event.groups.set(self.lesson.course.groups.all()) lesson_event.teachers.set(self.teachers.all()) lesson_event.rooms.set(self.rooms.all()) + + if self.lesson_event != lesson_event: + self.lesson_event = lesson_event + self.save() + return lesson_event class Meta: @@ -531,6 +626,14 @@ class SupervisionSubstitution(TeacherPropertiesMixin, ExtensibleModel): related_name="lr_supervision_substitutions", blank=True, ) + subject = models.ForeignKey( + Subject, + on_delete=models.CASCADE, + verbose_name=_("Subject"), + related_name="lr_supervision_substitutions", + blank=True, + null=True, + ) cancelled = models.BooleanField(default=False, verbose_name=_("Cancelled?")) @@ -554,22 +657,26 @@ class SupervisionSubstitution(TeacherPropertiesMixin, ExtensibleModel): week = CalendarWeek.from_date(self.date) if not self.supervision.supervision_event: return None - supervision_event, __ = SupervisionEvent.objects.update_or_create( - supervision_substitution=self, - defaults={ - "amends": self.supervision.supervision_event, - "datetime_start": self.supervision.break_slot.get_datetime_start(week), - "datetime_end": self.supervision.break_slot.get_datetime_end(week), - "cancelled": self.cancelled, - "comment": self.comment, - }, - ) + + if self.supervision_event: + supervision_event = self.supervision_event + else: + supervision_event = SupervisionEvent() + + supervision_event.amends = self.supervision.supervision_event + supervision_event.datetime_start = self.supervision.break_slot.get_datetime_start(week) + supervision_event.datetime_end = self.supervision.break_slot.get_datetime_end(week) + supervision_event.cancelled = self.cancelled + supervision_event.comment = self.comment + supervision_event.subject = self.subject + supervision_event.save() + + supervision_event.teachers.set(self.teachers.all()) if self.supervision_event != supervision_event: self.supervision_event = supervision_event self.save() - supervision_event.teachers.set(self.teachers.all()) return supervision_event class Meta: @@ -584,3 +691,54 @@ class SupervisionSubstitution(TeacherPropertiesMixin, ExtensibleModel): ] verbose_name = _("Supervision Substitution") verbose_name_plural = _("Supervision Substitutions") + + +class TimeboundCourseConfig(ExtensibleModel): + """A timebound course config is the specific configuration of a course. + + It consists of a course and a validity range. + """ + + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + verbose_name=_("Course"), + related_name="lr_timebound_course_configs", + ) + validity_range = models.ForeignKey( + ValidityRange, + on_delete=models.CASCADE, + verbose_name=_("Linked validity range"), + related_name="lr_timebound_course_configs", + ) + + lesson_quota = models.PositiveSmallIntegerField( + verbose_name=_("Lesson quota"), + help_text=_("Number of slots this course is scheduled to fill per week"), + blank=True, + null=True, + ) + teachers = models.ManyToManyField( + Person, + verbose_name=_("Teachers"), + related_name="lr_timebound_course_configs", + ) + + class Meta: + constraints = [ + # Heads up: Uniqueness per validity range implies validity per site + models.UniqueConstraint( + fields=["course", "validity_range"], name="lr_unique_course_config_per_range" + ), + ] + verbose_name = _("Timebound course config") + verbose_name_plural = _("Timebound course configs") + + +class LesroosterGlobalPermissions(GlobalPermissionModel): + class Meta: + managed = False + permissions = ( + ("manage_lesson_raster", _("Can manage lesson raster")), + ("plan_timetables", _("Can plan timetables")), + ) diff --git a/aleksis/apps/lesrooster/rules.py b/aleksis/apps/lesrooster/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..13146da91f6d9c25587e7f2f2c3b8497771f7ebc --- /dev/null +++ b/aleksis/apps/lesrooster/rules.py @@ -0,0 +1,298 @@ +from rules import add_perm + +from aleksis.core.util.predicates import ( + has_any_object, + has_global_perm, + has_object_perm, + has_person, +) + +from .models import ( + BreakSlot, + Lesson, + Slot, + Supervision, + SupervisionSubstitution, + TimeboundCourseConfig, + TimeGrid, + ValidityRange, +) + +manage_lesson_raster_predicate = has_person & has_global_perm("lesrooster.manage_lesson_raster") +add_perm("lesrooster.manage_lesson_raster_rule", manage_lesson_raster_predicate) + +plan_timetables_predicate = has_person & has_global_perm("lesrooster.plan_timetables") +add_perm("lesrooster.plan_timetables_rule", plan_timetables_predicate) + + +# Slots +view_slots_predicate = has_person & ( + has_global_perm("lesrooster.view_slot") + | has_any_object("lesrooster.view_slot", Slot) + | manage_lesson_raster_predicate + | plan_timetables_predicate +) +add_perm("lesrooster.view_slots_rule", view_slots_predicate) + +view_slot_predicate = has_person & ( + has_global_perm("lesrooster.view_slot") + | has_object_perm("lesrooster.view_slot") + | manage_lesson_raster_predicate + | plan_timetables_predicate +) +add_perm("lesrooster.view_slot_rule", view_slot_predicate) + +create_slot_predicate = has_person & ( + has_global_perm("lesrooster.add_slot") | manage_lesson_raster_predicate +) +add_perm("lesrooster.create_slot_rule", create_slot_predicate) + +edit_slot_predicate = view_slot_predicate & ( + has_global_perm("lesrooster.change_slot") + | has_object_perm("lesrooster.change_slot") + | manage_lesson_raster_predicate +) +add_perm("lesrooster.edit_slot_rule", edit_slot_predicate) + +delete_slot_predicate = view_slot_predicate & ( + has_global_perm("lesrooster.delete_slot") + | has_object_perm("lesrooster.delete_slot") + | manage_lesson_raster_predicate +) +add_perm("lesrooster.delete_slot_rule", delete_slot_predicate) + +# Break slots + +view_break_slots_predicate = has_person & ( + has_global_perm("lesrooster.view_breakslot") + | has_any_object("lesrooster.view_breakslot", BreakSlot) + | manage_lesson_raster_predicate + | plan_timetables_predicate +) +add_perm("lesrooster.view_breakslots_rule", view_break_slots_predicate) + +view_break_slot_predicate = has_person & ( + has_global_perm("lesrooster.view_breakslot") + | has_object_perm("lesrooster.view_breakslot") + | manage_lesson_raster_predicate + | plan_timetables_predicate +) +add_perm("lesrooster.view_breakslot_rule", view_break_slot_predicate) + +create_break_slot_predicate = has_person & ( + has_global_perm("lesrooster.add_breakslot") | manage_lesson_raster_predicate +) +add_perm("lesrooster.create_breakslot_rule", create_break_slot_predicate) + +edit_break_slot_predicate = view_break_slot_predicate & ( + has_global_perm("lesrooster.change_breakslot") + | has_object_perm("lesrooster.change_breakslot") + | manage_lesson_raster_predicate +) +add_perm("lesrooster.edit_breakslot_rule", edit_break_slot_predicate) + +delete_break_slot_predicate = view_break_slot_predicate & ( + has_global_perm("lesrooster.delete_breakslot") + | has_object_perm("lesrooster.delete_breakslot") + | manage_lesson_raster_predicate +) +add_perm("lesrooster.delete_breakslot_rule", delete_break_slot_predicate) + + +# Lessons +view_lessons_predicate = has_person & ( + has_global_perm("lesrooster.view_lesson") + | has_any_object("lesrooster.view_lesson", Lesson) + | plan_timetables_predicate +) +add_perm("lesrooster.view_lessons_rule", view_lessons_predicate) + +view_lesson_predicate = has_person & ( + has_global_perm("lesrooster.view_lesson") + | has_object_perm("lesrooster.view_lesson") + | plan_timetables_predicate +) +add_perm("lesrooster.view_lesson_rule", view_lesson_predicate) + +create_lesson_predicate = has_person & ( + has_global_perm("lesrooster.add_lesson") | plan_timetables_predicate +) +add_perm("lesrooster.create_lesson_rule", create_lesson_predicate) + +edit_lesson_predicate = view_lesson_predicate & ( + has_global_perm("lesrooster.change_lesson") + | has_object_perm("lesrooster.change_lesson") + | plan_timetables_predicate +) +add_perm("lesrooster.edit_lesson_rule", edit_lesson_predicate) + +delete_lesson_predicate = view_lesson_predicate & ( + has_global_perm("lesrooster.delete_lesson") + | has_object_perm("lesrooster.delete_lesson") + | plan_timetables_predicate +) +add_perm("lesrooster.delete_lesson_rule", delete_lesson_predicate) + + +# Supervisions +view_supervisions_predicate = has_person & ( + has_global_perm("lesrooster.view_supervision") + | has_any_object("lesrooster.view_supervision", Supervision) +) +add_perm("lesrooster.view_supervisions_rule", view_supervisions_predicate) + +view_supervision_predicate = has_person & ( + has_global_perm("lesrooster.view_supervision") | has_object_perm("lesrooster.view_supervision") +) +add_perm("lesrooster.view_supervision_rule", view_supervision_predicate) + +create_supervision_predicate = has_person & has_global_perm("lesrooster.add_supervision") +add_perm("lesrooster.create_supervision_rule", create_supervision_predicate) + +edit_supervision_predicate = view_supervision_predicate & ( + has_global_perm("lesrooster.change_supervision") + | has_object_perm("lesrooster.change_supervision") +) +add_perm("lesrooster.edit_supervision_rule", edit_supervision_predicate) + +delete_supervision_predicate = view_supervision_predicate & ( + has_global_perm("lesrooster.delete_supervision") + | has_object_perm("lesrooster.delete_supervision") +) +add_perm("lesrooster.delete_supervision_rule", delete_supervision_predicate) + + +# Supervision substitutions +view_supervision_substitutions_predicate = has_person & ( + has_global_perm("lesrooster.view_supervisionsubstitution") + | has_any_object("lesrooster.view_supervisionsubstitution", SupervisionSubstitution) +) +add_perm("lesrooster.view_supervisionsubstitutions_rule", view_supervision_substitutions_predicate) + +view_supervision_substitution_predicate = has_person & ( + has_global_perm("lesrooster.view_supervisionsubstitution") + | has_object_perm("lesrooster.view_supervisionsubstitution") +) +add_perm("lesrooster.view_supervisionsubstitution_rule", view_supervision_substitution_predicate) + +create_supervision_substitution_predicate = has_person & has_global_perm( + "lesrooster.add_supervisionsubstitution" +) +add_perm( + "lesrooster.create_supervisionsubstitution_rule", create_supervision_substitution_predicate +) + +edit_supervision_substitution_predicate = view_supervision_substitution_predicate & ( + has_global_perm("lesrooster.change_supervisionsubstitution") + | has_object_perm("lesrooster.change_supervisionsubstitution") +) +add_perm("lesrooster.edit_supervisionsubstitution_rule", edit_supervision_substitution_predicate) + +delete_supervision_substitution_predicate = view_supervision_substitution_predicate & ( + has_global_perm("lesrooster.delete_supervisionsubstitution") + | has_object_perm("lesrooster.delete_supervisionsubstitution") +) +add_perm( + "lesrooster.delete_supervisionsubstitution_rule", delete_supervision_substitution_predicate +) + +# Timebound course configs + +view_timebound_course_configs_predicate = has_person & ( + has_global_perm("lesrooster.view_timeboundcourseconfig") + | has_any_object("lesrooster.view_timeboundcourseconfig", TimeboundCourseConfig) +) +add_perm("lesrooster.view_timeboundcourseconfigs_rule", view_timebound_course_configs_predicate) + +view_timebound_course_config_predicate = has_person & ( + has_global_perm("lesrooster.view_timeboundcourseconfig") + | has_object_perm("lesrooster.view_timeboundcourseconfig") + | plan_timetables_predicate +) +add_perm("lesrooster.view_timeboundcourseconfig_rule", view_timebound_course_config_predicate) + +create_timebound_course_config_predicate = has_person & has_global_perm( + "lesrooster.add_timeboundcourseconfig" +) +add_perm("lesrooster.create_timeboundcourseconfig_rule", create_timebound_course_config_predicate) + +edit_timebound_course_config_predicate = view_timebound_course_config_predicate & ( + has_global_perm("lesrooster.change_timeboundcourseconfig") + | has_object_perm("lesrooster.change_timeboundcourseconfig") +) +add_perm("lesrooster.edit_timeboundcourseconfig_rule", edit_timebound_course_config_predicate) + +delete_timebound_course_config_predicate = view_timebound_course_config_predicate & ( + has_global_perm("lesrooster.delete_timeboundcourseconfig") + | has_object_perm("lesrooster.delete_timeboundcourseconfig") +) +add_perm("lesrooster.delete_timeboundcourseconfig_rule", delete_timebound_course_config_predicate) + + +# Validity ranges + +view_validity_ranges_predicate = has_person & ( + has_global_perm("lesrooster.view_validityrange") + | has_any_object("lesrooster.view_validityrange", ValidityRange) +) +add_perm("lesrooster.view_validityranges_rule", view_validity_ranges_predicate) + +view_validity_range_predicate = has_person & ( + has_global_perm("lesrooster.view_validityrange") + | has_object_perm("lesrooster.view_validityrange") + | plan_timetables_predicate +) +add_perm("lesrooster.view_validityrange_rule", view_validity_range_predicate) + +create_validity_range_predicate = has_person & has_global_perm("lesrooster.add_validityrange") +add_perm("lesrooster.create_validityrange_rule", create_validity_range_predicate) + +edit_validity_range_predicate = view_validity_range_predicate & ( + has_global_perm("lesrooster.change_validityrange") + | has_object_perm("lesrooster.change_validityrange") +) +add_perm("lesrooster.edit_validityrange_rule", edit_validity_range_predicate) + +delete_validity_range_predicate = view_validity_range_predicate & ( + has_global_perm("lesrooster.delete_validityrange") + | has_object_perm("lesrooster.delete_validityrange") +) +add_perm("lesrooster.delete_validityrange_rule", delete_validity_range_predicate) + +# Time grids +view_time_grids_predicate = has_person & ( + has_global_perm("lesrooster.view_timegrid") + | has_any_object("lesrooster.view_timegrid", TimeGrid) +) +add_perm("lesrooster.view_timegrids_rule", view_time_grids_predicate) + +view_time_grid_predicate = has_person & ( + has_global_perm("lesrooster.view_timegrid") + | has_object_perm("lesrooster.view_timegrid") + | plan_timetables_predicate +) +add_perm("lesrooster.view_timegrid_rule", view_time_grid_predicate) + +create_time_grid_predicate = has_person & has_global_perm("lesrooster.add_timegrid") +add_perm("lesrooster.create_timegrid_rule", create_time_grid_predicate) + +edit_time_grid_predicate = view_time_grid_predicate & ( + has_global_perm("lesrooster.change_timegrid") | has_object_perm("lesrooster.change_timegrid") +) +add_perm("lesrooster.edit_timegrid_rule", edit_time_grid_predicate) + +delete_time_grid_predicate = view_time_grid_predicate & ( + has_global_perm("lesrooster.delete_timegrid") | has_object_perm("lesrooster.delete_timegrid") +) +add_perm("lesrooster.delete_timegrid_rule", delete_time_grid_predicate) + + +view_lesrooster_menu_predicate = ( + view_validity_ranges_predicate + | view_slots_predicate + | view_break_slots_predicate + | view_timebound_course_configs_predicate + | manage_lesson_raster_predicate + | plan_timetables_predicate +) +add_perm("lesrooster.view_lesrooster_menu_rule", view_lesrooster_menu_predicate) diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..29951626e85c9c7eb5ff6db559cc09ea64ce3aaf --- /dev/null +++ b/aleksis/apps/lesrooster/schema/__init__.py @@ -0,0 +1,294 @@ +from itertools import chain + +from django.db.models import Prefetch, Q + +import graphene +from guardian.shortcuts import get_objects_for_user + +from aleksis.apps.cursus.models import Course, Subject +from aleksis.apps.cursus.schema import CourseInterface +from aleksis.core.models import Group +from aleksis.core.schema.base import FilterOrderList +from aleksis.core.schema.group import GroupType + +from ..models import ( + BreakSlot, + Lesson, + Slot, + Supervision, + TimeboundCourseConfig, + TimeGrid, + ValidityRange, +) +from .break_slot import ( + BreakSlotBatchCreateMutation, + BreakSlotBatchDeleteMutation, + BreakSlotBatchPatchMutation, + BreakSlotCreateMutation, + BreakSlotDeleteMutation, + BreakSlotType, +) +from .lesson import ( + LessonBatchDeleteMutation, + LessonBatchPatchMutation, + LessonCreateMutation, + LessonDeleteMutation, + LessonPatchMutation, + LessonType, +) +from .slot import ( + CarryOverSlotsMutation, + CopySlotsFromDifferentTimeGridMutation, + SlotBatchCreateMutation, + SlotBatchDeleteMutation, + SlotBatchPatchMutation, + SlotCreateMutation, + SlotDeleteMutation, + SlotType, +) +from .supervision import ( + SupervisionBatchDeleteMutation, + SupervisionBatchPatchMutation, + SupervisionCreateMutation, + SupervisionDeleteMutation, + SupervisionType, +) +from .time_grid import ( + TimeGridBatchDeleteMutation, + TimeGridCreateMutation, + TimeGridDeleteMutation, + TimeGridType, +) +from .timebound_course_config import ( + LesroosterExtendedSubjectType, + TimeboundCourseConfigBatchCreateMutation, + TimeboundCourseConfigBatchPatchMutation, + TimeboundCourseConfigCreateMutation, + TimeboundCourseConfigDeleteMutation, + TimeboundCourseConfigType, +) +from .validity_range import ( + ValidityRangeBatchDeleteMutation, + ValidityRangeBatchPatchMutation, + ValidityRangeCreateMutation, + ValidityRangeDeleteMutation, + ValidityRangeType, +) + + +class Query(graphene.ObjectType): + break_slots = FilterOrderList(BreakSlotType) + slots = FilterOrderList(SlotType) + timebound_course_configs = FilterOrderList(TimeboundCourseConfigType) + validity_ranges = FilterOrderList(ValidityRangeType) + time_grids = FilterOrderList(TimeGridType) + lessons = FilterOrderList(LessonType) + supervisions = FilterOrderList(SupervisionType) + + course_objects_for_group = graphene.List( + CourseInterface, + group=graphene.ID(required=True), + time_grid=graphene.ID(required=True), + ) + lesson_objects_for_group = graphene.List( + LessonType, + group=graphene.ID(required=True), + time_grid=graphene.ID(required=True), + ) + lesson_objects_for_teacher = graphene.List( + LessonType, + teacher=graphene.ID(required=True), + time_grid=graphene.ID(required=True), + ) + lesson_objects_for_room = graphene.List( + LessonType, + room=graphene.ID(required=True), + time_grid=graphene.ID(required=True), + ) + + current_validity_range = graphene.Field(ValidityRangeType) + + lesrooster_extended_subjects = FilterOrderList( + LesroosterExtendedSubjectType, groups=graphene.List(graphene.ID) + ) + + groups_by_time_grid = graphene.List(GroupType, time_grid=graphene.ID(required=True)) + + @staticmethod + def resolve_break_slots(root, info): + 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() + + @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() + 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 + + @staticmethod + def resolve_timebound_course_configs(root, info): + tccs = TimeboundCourseConfig.objects.all() + if not info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"): + return get_objects_for_user( + info.context.user, "lesrooster.view_timeboundcourseconfig", tccs + ) + return tccs + + @staticmethod + def resolve_validity_ranges(root, info): + if not info.context.user.has_perm("lesrooster.view_validityrange_rule"): + return get_objects_for_user( + info.context.user, "lesrooster.view_validityrange", ValidityRange + ) + return ValidityRange.objects.all() + + @staticmethod + def resolve_time_grids(root, info): + 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() + + @staticmethod + def resolve_supervisions(root, info): + if not info.context.user.has_perm("lesrooster.view_supervision_rule"): + return get_objects_for_user( + info.context.user, "lesrooster.view_supervision", Supervision + ) + return Supervision.objects.all() + + @staticmethod + def resolve_lesrooster_extended_subjects(root, info, groups): + subjects = Subject.objects.all().prefetch_related( + Prefetch( + "courses", + queryset=get_objects_for_user( + info.context.user, "cursus.view_course", Course.objects.all() + ).filter(groups__in=groups), + ) + ) + if not info.context.user.has_perm("lesrooster.view_subject_rule"): + return get_objects_for_user(info.context.user, "cursus.view_subject", subjects) + return subjects + + @staticmethod + def resolve_current_validity_range(root, info): + validity_range = ValidityRange.current + if info.context.user.has_perm("lesrooster.view_validityrange_rule", validity_range): + return validity_range + + 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) + + if not group: + return [] + + courses = Course.objects.filter( + (Q(groups__in=group.child_groups.all()) | Q(groups=group)) + & Q(lr_timebound_course_configs__isnull=True) + ) + + 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)) + ) + + return list(chain(courses, timebound_course_configs)) + + @staticmethod + def resolve_lesson_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) + + if not group: + return [] + + 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, + ) + + @staticmethod + def resolve_lesson_objects_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, + ) + + @staticmethod + def resolve_lesson_objects_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, + ) + + @staticmethod + def resolve_groups_by_time_grid(root, info, time_grid=None, **kwargs): + if not info.context.user.has_perm("lesrooster.plan_timetables_rule"): + return [] + + return ( + Group.objects.filter(school_term__lr_validity_ranges__time_grids__id=time_grid) + .annotate(has_cg=Q(child_groups__isnull=False)) + .order_by("-has_cg", "name") + ) + + +class Mutation(graphene.ObjectType): + create_break_slot = BreakSlotCreateMutation.Field() + create_break_slots = BreakSlotBatchCreateMutation.Field() + delete_break_slot = BreakSlotDeleteMutation.Field() + delete_break_slots = BreakSlotBatchDeleteMutation.Field() + update_break_slots = BreakSlotBatchPatchMutation.Field() + + create_slot = SlotCreateMutation.Field() + create_slots = SlotBatchCreateMutation.Field() + delete_slot = SlotDeleteMutation.Field() + delete_slots = SlotBatchDeleteMutation.Field() + update_slots = SlotBatchPatchMutation.Field() + + batch_create_timebound_course_config = TimeboundCourseConfigBatchCreateMutation.Field() + create_timebound_course_config = TimeboundCourseConfigCreateMutation.Field() + delete_timebound_course_config = TimeboundCourseConfigDeleteMutation.Field() + update_timebound_course_configs = TimeboundCourseConfigBatchPatchMutation.Field() + carry_over_slots = CarryOverSlotsMutation.Field() + copy_slots_from_grid = CopySlotsFromDifferentTimeGridMutation.Field() + + create_validity_range = ValidityRangeCreateMutation.Field() + delete_validity_range = ValidityRangeDeleteMutation.Field() + delete_validity_ranges = ValidityRangeBatchDeleteMutation.Field() + update_validity_ranges = ValidityRangeBatchPatchMutation.Field() + + create_time_grid = TimeGridCreateMutation.Field() + delete_time_grid = TimeGridDeleteMutation.Field() + delete_time_grids = TimeGridBatchDeleteMutation.Field() + update_time_grids = TimeGridBatchDeleteMutation.Field() + + create_lesson = LessonCreateMutation.Field() + delete_lesson = LessonDeleteMutation.Field() + delete_lessons = LessonBatchDeleteMutation.Field() + update_lesson = LessonPatchMutation.Field() + update_lessons = LessonBatchPatchMutation.Field() + + create_supervision = SupervisionCreateMutation.Field() + delete_supervision = SupervisionDeleteMutation.Field() + delete_supervisions = SupervisionBatchDeleteMutation.Field() + update_supervisions = SupervisionBatchPatchMutation.Field() diff --git a/aleksis/apps/lesrooster/schema/break_slot.py b/aleksis/apps/lesrooster/schema/break_slot.py new file mode 100644 index 0000000000000000000000000000000000000000..fbc54e417fdd85dc7e626fa5cca51a0e207446d2 --- /dev/null +++ b/aleksis/apps/lesrooster/schema/break_slot.py @@ -0,0 +1,107 @@ +import graphene +from graphene_django.types import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchCreateMutation, + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) +from guardian.shortcuts import get_objects_for_user + +from aleksis.core.schema.base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) + +from ..models import BreakSlot +from .slot import slot_filters + +break_filters = slot_filters.copy() + + +class BreakSlotType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): + model = graphene.String(default_value="Break") + + class Meta: + model = BreakSlot + fields = ( + "id", + "time_grid", + "name", + "weekday", + "period", + "time_start", + "time_end", + ) + 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 BreakSlotCreateMutation(DjangoCreateMutation): + class Meta: + model = BreakSlot + return_field_name = "breakSlot" + field_types = {"weekday": graphene.Int()} + only_fields = ( + "id", + "time_grid", + "name", + "weekday", + "period", + "time_start", + "time_end", + ) + permissions = ("lesrooster.create_breakslot_rule",) + + +class BreakSlotDeleteMutation(DeleteMutation): + klass = BreakSlot + permission_required = "lesrooster.delete_breakslot_rule" + + +class BreakSlotBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCreateMutation): + class Meta: + model = BreakSlot + return_field_name = "breakSlots" + field_types = {"weekday": graphene.Int()} + only_fields = ( + "id", + "time_grid", + "name", + "weekday", + "period", + "time_start", + "time_end", + ) + permissions = ("lesrooster.create_breakslot_rule",) + + +class BreakSlotBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = BreakSlot + permissions = ("lesrooster.delete_breakslot_rule",) + + +class BreakSlotBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = BreakSlot + return_field_name = "breakSlots" + field_types = {"weekday": graphene.Int()} + permissions = ("lesrooster.edit_breakslot_rule",) + only_fields = ( + "id", + "time_grid", + "name", + "weekday", + "period", + "time_start", + "time_end", + ) diff --git a/aleksis/apps/lesrooster/schema/lesson.py b/aleksis/apps/lesrooster/schema/lesson.py new file mode 100644 index 0000000000000000000000000000000000000000..de013bc0bc4dcccd286fa3011e5aec775ee025df --- /dev/null +++ b/aleksis/apps/lesrooster/schema/lesson.py @@ -0,0 +1,121 @@ +import graphene +from graphene_django.types import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, + DjangoPatchMutation, +) +from guardian.shortcuts import get_objects_for_user +from recurrence import Recurrence, deserialize, serialize + +from aleksis.core.schema.base import ( + DeleteMutation, + DjangoFilterMixin, + OptimisticResponseTypeMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionPatchMixin, + PermissionsTypeMixin, +) + +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") + 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) + + +class LessonCreateMutation(DjangoCreateMutation): + class Meta: + model = Lesson + only_fields = ( + "id", + "course", + "slot_start", + "slot_end", + "rooms", + "teachers", + "subject", + "recurrence", + ) + field_types = {"recurrence": graphene.String()} + permissions = ("lesrooster.create_lesson_rule",) + + @classmethod + def handle_recurrence(cls, value: str, name, info) -> Recurrence: + return deserialize(value) + + +class LessonDeleteMutation(DeleteMutation): + klass = Lesson + permission_required = "lesrooster.delete_lesson_rule" + + +class LessonBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = Lesson + permissions = ("lesrooster.delete_lesson_rule",) + + +class LessonBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = Lesson + only_fields = ( + "id", + "course", + "slot_start", + "slot_end", + "rooms", + "teachers", + "subject", + "recurrence", + ) + field_types = {"recurrence": graphene.String()} + permissions = ("lesrooster.edit_lesson_rule",) + + @classmethod + def handle_recurrence(cls, value: str, name, info) -> Recurrence: + return deserialize(value) + + +class LessonPatchMutation(PermissionPatchMixin, DjangoPatchMutation): + class Meta: + model = Lesson + only_fields = ( + "id", + "course", + "slot_start", + "slot_end", + "rooms", + "teachers", + "subject", + "recurrence", + ) + field_types = {"recurrence": graphene.String()} + permissions = ("lesrooster.edit_lesson_rule",) + + @classmethod + def handle_recurrence(cls, value: str, name, info) -> Recurrence: + return deserialize(value) diff --git a/aleksis/apps/lesrooster/schema/slot.py b/aleksis/apps/lesrooster/schema/slot.py new file mode 100644 index 0000000000000000000000000000000000000000..6c2de6a501613bd058dd72a141912d644ee01b87 --- /dev/null +++ b/aleksis/apps/lesrooster/schema/slot.py @@ -0,0 +1,196 @@ +from django.core.exceptions import PermissionDenied + +import graphene +from graphene_django.types import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchCreateMutation, + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) +from guardian.shortcuts import get_objects_for_user + +from aleksis.core.schema.base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) + +from ..models import BreakSlot, Slot, TimeGrid + +slot_filters = { + "id": ["exact"], + "name": ["exact", "icontains"], + "weekday": ["exact", "in"], + "period": ["exact", "lt", "lte", "gt", "gte"], + "time_start": ["exact", "lt", "lte", "gt", "gte"], + "time_end": ["exact", "lt", "lte", "gt", "gte"], + "time_grid": ["exact"], +} + + +class SlotType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): + model = graphene.String(default_value="Default") + + class Meta: + model = Slot + 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__ + + +class SlotCreateMutation(DjangoCreateMutation): + class Meta: + model = Slot + field_types = {"weekday": graphene.Int()} + only_fields = ("id", "time_grid", "name", "weekday", "period", "time_start", "time_end") + permissions = ("lesrooster.create_slot_rule",) + + +class SlotDeleteMutation(DeleteMutation): + klass = Slot + permission_required = "lesrooster.delete_slot" + + +class SlotBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCreateMutation): + class Meta: + model = Slot + field_types = {"weekday": graphene.Int()} + only_fields = ("id", "time_grid", "name", "weekday", "period", "time_start", "time_end") + permissions = ("lesrooster.create_slot_rule",) + + +class SlotBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = Slot + permissions = ("lesrooster.delete_slot",) + + +class SlotBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = Slot + field_types = {"weekday": graphene.Int()} + permissions = ("lesrooster.change_slot",) + only_fields = ("id", "time_grid", "name", "weekday", "period", "time_start", "time_end") + + +class CarryOverSlotsMutation(graphene.Mutation): + class Arguments: + time_grid = graphene.ID() + from_day = graphene.Int() + to_day = graphene.Int() + + only = graphene.List(graphene.ID, required=False) + + deleted = graphene.List(graphene.ID) + result = graphene.List(SlotType) + + @classmethod + def mutate(cls, root, info, time_grid, from_day, to_day, only=None): + if not info.context.user.has_perm("lesrooster.edit_slot_rule"): + raise PermissionDenied() + + if only is None: + only = [] + + time_grid = TimeGrid.objects.get(id=time_grid) + + slots_on_day = Slot.objects.filter(weekday=from_day, time_grid=time_grid) + + if only and len(only) > 0: + slots_on_day = slots_on_day.filter(id__in=only) + + result = [] + new_ids = [] + + for slot in slots_on_day: + defaults = {"name": slot.name, "time_start": slot.time_start, "time_end": slot.time_end} + + if slot.period is not None: + new_slot = Slot.objects.non_polymorphic().update_or_create( + weekday=to_day, time_grid=time_grid, period=slot.period, defaults=defaults + )[0] + + else: + new_slot = Slot.objects.non_polymorphic().update_or_create( + weekday=to_day, + time_grid=time_grid, + time_start=slot.time_start, + time_end=slot.time_end, + defaults=defaults, + )[0] + + result.append(new_slot) + new_ids.append(new_slot.pk) + + if not only or not len(only): + objects_to_delete = Slot.objects.filter(weekday=to_day, time_grid=time_grid).exclude( + pk__in=new_ids + ) + objects_to_delete.delete() + + deleted = objects_to_delete.values_list("id", flat=True) + + else: + deleted = [] + + return CarryOverSlotsMutation( + deleted=deleted, + result=result, + ) + + +class CopySlotsFromDifferentTimeGridMutation(graphene.Mutation): + class Arguments: + time_grid = graphene.ID() + from_time_grid = graphene.ID() + + deleted = graphene.List(graphene.ID) + result = graphene.List(SlotType) + + @classmethod + def mutate(cls, root, info, time_grid, from_time_grid): + if not info.context.user.has_perm("lesrooster.edit_slot_rule"): + raise PermissionDenied() + + time_grid = TimeGrid.objects.get(id=time_grid) + from_time_grid = TimeGrid.objects.get(id=from_time_grid) + + # Check for each slot in the from_time_grid if it exists in the time_grid, if not, create it + slots = Slot.objects.filter(time_grid=from_time_grid) + + result = [] + + for slot in slots: + slot: BreakSlot | Slot + klass = slot.get_real_instance_class() + + defaults = {"name": slot.name, "time_start": slot.time_start, "time_end": slot.time_end} + + result.append( + klass.objects.update_or_create( + weekday=slot.weekday, time_grid=time_grid, period=slot.period, defaults=defaults + )[0].id + ) + + # Delete all slots in the time_grid that are not in the from_time_grid + objects_to_delete = Slot.objects.filter(time_grid=time_grid).exclude(id__in=result) + objects_to_delete.delete() + + deleted = objects_to_delete.values_list("id", flat=True) + + return CopySlotsFromDifferentTimeGridMutation( + deleted=deleted, + result=Slot.objects.filter(time_grid=time_grid).non_polymorphic(), + ) diff --git a/aleksis/apps/lesrooster/schema/supervision.py b/aleksis/apps/lesrooster/schema/supervision.py new file mode 100644 index 0000000000000000000000000000000000000000..e028cf8662ccc758f2c4bbbe2574f0bf8a0bc2a7 --- /dev/null +++ b/aleksis/apps/lesrooster/schema/supervision.py @@ -0,0 +1,103 @@ +import graphene +from graphene_django.types import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) +from guardian.shortcuts import get_objects_for_user +from recurrence import Recurrence, deserialize, serialize + +from aleksis.core.schema.base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) + +from ..models import Supervision + +supervision_filters = { + "id": ["exact"], + "rooms": ["in"], + "teachers": ["in"], + "subject": ["exact"], + "break_slot": ["exact"], + "break_slot__time_grid": ["exact"], +} + + +class SupervisionType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): + recurrence = graphene.String() + + class Meta: + model = Supervision + fields = ( + "id", + "rooms", + "teachers", + "subject", + "break_slot", + ) + 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) + + +class SupervisionCreateMutation(DjangoCreateMutation): + class Meta: + model = Supervision + field_types = {"recurrence": graphene.String()} + only_fields = ( + "id", + "rooms", + "teachers", + "subject", + "break_slot", + "recurrence", + ) + permissions = ("lesrooster.create_supervision_rule",) + + @classmethod + def handle_recurrence(cls, value: str, name, info) -> Recurrence: + return deserialize(value) + + +class SupervisionDeleteMutation(DeleteMutation): + klass = Supervision + permission_required = "lesrooster.delete_supervision_rule" + + +class SupervisionBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = Supervision + permissions = ("lesrooster.delete_supervision_rule",) + + +class SupervisionBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = Supervision + only_fields = ( + "id", + "rooms", + "teachers", + "subject", + "break_slot", + "recurrence", + ) + field_types = {"recurrence": graphene.String()} + exclude = ("managed_by_app_label",) + permissions = ("lesrooster.create_supervision_rule",) + + @classmethod + def handle_recurrence(cls, value: str, name, info) -> Recurrence: + return deserialize(value) diff --git a/aleksis/apps/lesrooster/schema/time_grid.py b/aleksis/apps/lesrooster/schema/time_grid.py new file mode 100644 index 0000000000000000000000000000000000000000..906b16ce6d78ab81a991b783506d6d5777569ece --- /dev/null +++ b/aleksis/apps/lesrooster/schema/time_grid.py @@ -0,0 +1,73 @@ +from graphene_django.types import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) +from guardian.shortcuts import get_objects_for_user + +from aleksis.core.schema.base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) + +from ..models import TimeGrid + + +class TimeGridType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): + class Meta: + model = TimeGrid + fields = ( + "id", + "validity_range", + "group", + ) + filter_fields = { + "id": ["exact"], + "group": ["exact", "in"], + "validity_range": ["exact", "in"], + "validity_range__date_start": ["exact", "lt", "lte", "gt", "gte"], + "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 TimeGridCreateMutation(DjangoCreateMutation): + class Meta: + model = TimeGrid + permissions = ("lesrooster.create_timegrid_rule",) + only_fields = ( + "id", + "validity_range", + "group", + ) + + +class TimeGridDeleteMutation(DeleteMutation): + klass = TimeGrid + permission_required = "lesrooster.delete_timegrid_rule" + + +class TimeGridBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = TimeGrid + permissions = ("lesrooster.delete_timegrid_rule",) + + +class TimeGridBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = TimeGrid + permissions = ("lesrooster.edit_timegrid_rule",) + only_fields = ( + "id", + "validity_range", + "group", + ) diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py new file mode 100644 index 0000000000000000000000000000000000000000..dc5e37487829762633197d80cd079fed42d8cb5e --- /dev/null +++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py @@ -0,0 +1,112 @@ +import graphene +from graphene_django.types import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchCreateMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) +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.schema.base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) + +from ..models import TimeboundCourseConfig + +timebound_course_config_filters = {"course": ["in"], "validity_range": ["in"], "teachers": [""]} + + +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", + root.lr_timebound_course_configs.all(), + ) + return queryset + + @staticmethod + def resolve_name(root, info, **kwargs): + return root.course.name + + @staticmethod + def resolve_subject(root, info, **kwargs): + return root.course.subject + + @staticmethod + def resolve_groups(root, info, **kwargs): + return root.course.groups.all() + + @staticmethod + def resolve_lesson_quota(root, info, **kwargs): + return root.lesson_quota + + @staticmethod + def resolve_teachers(root, info, **kwargs): + return root.teachers.all() + + @staticmethod + def resolve_course_id(root, info, **kwargs): + return root.course.id + + +class LesroosterExtendedCourseType(CourseType): + class Meta: + model = Course + + lr_timebound_course_configs = graphene.List(TimeboundCourseConfigType) + + @staticmethod + def resolve_lr_timebound_course_configs(root, info, **kwargs): + if not info.context.user.has_perm("lesrostter.view_timeboundcourseconfig_rule"): + return get_objects_for_user( + info.context.user, + "lesrooster.view_timeboundcourseconfig", + root.lr_timebound_course_configs.all(), + ) + + +class LesroosterExtendedSubjectType(SubjectType): + class Meta: + model = Subject + + courses = graphene.List(LesroosterExtendedCourseType) + + +class TimeboundCourseConfigCreateMutation(DjangoCreateMutation): + class Meta: + model = TimeboundCourseConfig + fields = ("id", "course", "validity_range", "lesson_quota", "teachers") + permissions = ("lesrooster.create_timeboundcourseconfig_rule",) + + +class TimeboundCourseConfigBatchCreateMutation(DjangoBatchCreateMutation): + class Meta: + model = TimeboundCourseConfig + fields = ("id", "course", "validity_range", "lesson_quota", "teachers") + permissions = ("lesrooster.create_timeboundcourseconfig_rule",) + + +class TimeboundCourseConfigDeleteMutation(DeleteMutation): + klass = TimeboundCourseConfig + permission_required = "lesrooster.delete_timeboundcourseconfig_rule" + + +class TimeboundCourseConfigBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = TimeboundCourseConfig + fields = ("id", "course", "validity_range", "lesson_quota", "teachers") + permissions = ("lesrooster.change_timeboundcourseconfig_rule",) diff --git a/aleksis/apps/lesrooster/schema/validity_range.py b/aleksis/apps/lesrooster/schema/validity_range.py new file mode 100644 index 0000000000000000000000000000000000000000..040fc2778846be7d0e32354bd534fef0034b8880 --- /dev/null +++ b/aleksis/apps/lesrooster/schema/validity_range.py @@ -0,0 +1,83 @@ +import graphene +from graphene_django.types import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) +from guardian.shortcuts import get_objects_for_user + +from aleksis.core.schema.base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) + +from ..models import ValidityRange + + +class ValidityRangeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): + class Meta: + model = ValidityRange + fields = ("id", "school_term", "name", "date_start", "date_end", "status", "time_grids") + filter_fields = { + "id": ["exact"], + "school_term": ["exact", "in"], + "status": ["exact"], + "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 ValidityRangeCreateMutation(DjangoCreateMutation): + class Meta: + model = ValidityRange + permissions = ("lesrooster.create_validity_range_rule",) + only_fields = ( + "id", + "school_term", + "name", + "date_start", + "date_end", + "status", + "time_grids", + ) + field_types = {"status": graphene.String()} + + +class ValidityRangeDeleteMutation(DeleteMutation): + klass = ValidityRange + permission_required = "lesrooster.delete_validityrange_rule" + + +class ValidityRangeBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = ValidityRange + permissions = ("lesrooster.delete_validityrange_rule",) + + +class ValidityRangeBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = ValidityRange + permissions = ("lesrooster.change_validityrange",) + only_fields = ( + "id", + "school_term", + "name", + "date_start", + "date_end", + "status", + "time_grids", + ) + field_types = {"status": graphene.String()} diff --git a/aleksis/apps/lesrooster/util/signal_handlers.py b/aleksis/apps/lesrooster/util/signal_handlers.py index e7049055a0a41ace40090229455d8efee3b042ee..d15f674b40912816be283eac885a89f68821c2f5 100644 --- a/aleksis/apps/lesrooster/util/signal_handlers.py +++ b/aleksis/apps/lesrooster/util/signal_handlers.py @@ -13,3 +13,44 @@ def m2m_changed_handler(sender, instance, action, **kwargs): 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}" + ) + del_obj = instance.lesson_event.delete() + elif hasattr(instance, "supervision_event"): + logging.debug( + f"Delete supervision event {instance.supervision_event} " + f"after deletion of lesson {instance}" + ) + del_obj = 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, Substitution, Supervision, SupervisionSubstitution + + # 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)) + + list(Substitution.objects.filter(lesson__slot_start__time_grid__validity_range=instance)) + + list( + SupervisionSubstitution.objects.filter( + supervision__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 f2e32c70f56651ce81d931ebd2a4804452c3df53..44e01f071012e663e54a94cdcf69ff8d46ac816e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,8 @@ priority = "supplemental" [tool.poetry.dependencies] python = "^3.9" AlekSIS-Core = "^4.0.dev0" -AlekSIS-App-Cursus = { path = "../AlekSIS-App-Cursus" } +AlekSIS-App-Chronos = "^4.0.dev1" +AlekSIS-App-Cursus = "^0.1.dev0" django-recurrence = "^1.11.1" [tool.poetry.plugins."aleksis.app"] @@ -50,7 +51,6 @@ safety = "^2.3.5" flake8 = "^6.0.0" flake8-django = "~1.2.0" flake8-fixme = "^1.1.1" -flake8-mypy = "^17.8.0" flake8-bandit = "^4.1.1" flake8-builtins = "^2.0.0" flake8-docstrings = "^1.5.0"