diff --git a/aleksis/apps/chronos/frontend/components/AmendLesson.vue b/aleksis/apps/chronos/frontend/components/AmendLesson.vue new file mode 100644 index 0000000000000000000000000000000000000000..2d9f8b4c9d2798ba0afcedeb4711debbfa1ae0fc --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/AmendLesson.vue @@ -0,0 +1,207 @@ +<template> + <v-card-actions v-if="checkPermission('chronos.edit_substitution_rule')"> + <edit-button + i18n-key="chronos.event.amend.edit_button" + @click="edit = true" + /> + <delete-button + v-if="selectedEvent.meta.amended" + i18n-key="chronos.event.amend.delete_button" + @click="deleteEvent = true" + /> + <dialog-object-form + v-model="edit" + :fields="fields" + :is-create="!selectedEvent.meta.amended" + create-item-i18n-key="chronos.event.amend.title" + :gql-create-mutation="gqlCreateMutation" + :get-create-data="transformCreateData" + :default-item="defaultItem" + edit-item-i18n-key="chronos.event.amend.title" + :gql-patch-mutation="gqlPatchMutation" + :get-patch-data="transformPatchData" + :edit-item="initPatchData" + @cancel="open = false" + @save="updateOnSave()" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #subject.field="{ attrs, on, item }"> + <v-autocomplete + :disabled="item.cancelled" + :items="amendableSubjects" + item-text="name" + item-value="id" + v-bind="attrs" + v-on="on" + /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #teachers.field="{ attrs, on, item }"> + <v-autocomplete + :disabled="item.cancelled" + multiple + :items="amendableTeachers" + item-text="fullName" + item-value="id" + v-bind="attrs" + v-on="on" + chips + deletable-chips + /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #rooms.field="{ attrs, on, item }"> + <v-autocomplete + :disabled="item.cancelled" + multiple + :items="amendableRooms" + item-text="name" + item-value="id" + v-bind="attrs" + v-on="on" + chips + deletable-chips + /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #cancelled.field="{ attrs, on }"> + <v-checkbox v-bind="attrs" v-on="on" /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #comment.field="{ attrs, on }"> + <v-textarea v-bind="attrs" v-on="on" /> + </template> + </dialog-object-form> + <delete-dialog + delete-success-message-i18n-key="chronos.event.amend.delete_success" + :gql-delete-mutation="gqlDeleteMutation" + v-model="deleteEvent" + :items="[selectedEvent.meta]" + :get-name-of-item="getLessonDeleteText" + @save="updateOnSave()" + > + <template #title> + {{ $t("chronos.event.amend.delete_dialog") }} + </template> + </delete-dialog> + </v-card-actions> +</template> + +<script> +import permissionsMixin from "aleksis.core/mixins/permissions.js"; +import EditButton from "aleksis.core/components/generic/buttons/EditButton.vue"; +import DialogObjectForm from "aleksis.core/components/generic/dialogs/DialogObjectForm.vue"; +import DeleteButton from "aleksis.core/components/generic/buttons/DeleteButton.vue"; +import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue"; +import { + gqlSubjects, + gqlPersons, + gqlRooms, + createAmendLessons, + patchAmendLessons, + deleteAmendLessons, +} from "./amendLesson.graphql"; + +export default { + name: "AmendLesson", + components: { + EditButton, + DialogObjectForm, + DeleteButton, + DeleteDialog, + }, + mixins: [permissionsMixin], + props: { + selectedEvent: { + type: Object, + required: true, + }, + }, + data() { + return { + edit: false, + fields: [ + { + text: this.$t("chronos.event.amend.subject"), + value: "subject", + }, + { + text: this.$t("chronos.event.amend.teachers"), + value: "teachers", + }, + { + text: this.$t("chronos.event.amend.rooms"), + value: "rooms", + }, + { + text: this.$t("chronos.event.amend.cancelled"), + value: "cancelled", + }, + { + text: this.$t("chronos.event.amend.comment"), + value: "comment", + }, + ], + defaultItem: { + cancelled: this.selectedEvent.meta.cancelled, + comment: this.selectedEvent.meta.comment, + }, + gqlCreateMutation: createAmendLessons, + gqlPatchMutation: patchAmendLessons, + deleteEvent: false, + gqlDeleteMutation: deleteAmendLessons, + }; + }, + methods: { + transformCreateData(item) { + return { + ...item, + amends: this.selectedEvent.meta.id, + datetimeStart: this.selectedEvent.startDateTime.toUTC().toISO(), + datetimeEnd: this.selectedEvent.endDateTime.toUTC().toISO(), + }; + }, + transformPatchData(item) { + let { id, __typename, cancelled, ...patchItem } = item; + return { + ...patchItem, + // Normalize cancelled, v-checkbox returns null & does not + // honor false-value. + cancelled: cancelled ? true : false, + }; + }, + updateOnSave() { + this.$emit("refreshCalendar"); + this.model = false; + }, + getLessonDeleteText(item) { + return `${this.selectedEvent.name} · ${this.$d( + this.selectedEvent.start, + "shortDateTime", + )} – ${this.$d(this.selectedEvent.end, "shortTime")}`; + }, + }, + computed: { + initPatchData() { + return { + id: this.selectedEvent.meta.id, + subject: this.selectedEvent.meta.subject?.id.toString(), + teachers: this.selectedEvent.meta.teachers.map((teacher) => + teacher.id.toString(), + ), + rooms: this.selectedEvent.meta.rooms.map((room) => room.id.toString()), + cancelled: this.selectedEvent.meta.cancelled, + comment: this.selectedEvent.meta.comment, + }; + }, + }, + apollo: { + amendableSubjects: gqlSubjects, + amendableTeachers: gqlPersons, + amendableRooms: gqlRooms, + }, + mounted() { + this.addPermissions(["chronos.edit_substitution_rule"]); + }, +}; +</script> diff --git a/aleksis/apps/chronos/frontend/components/amendLesson.graphql b/aleksis/apps/chronos/frontend/components/amendLesson.graphql new file mode 100644 index 0000000000000000000000000000000000000000..5142b0b84c694333edf8ffce8b905fc4e622b1f3 --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/amendLesson.graphql @@ -0,0 +1,75 @@ +query gqlSubjects { + amendableSubjects: subjects { + id + name + } +} + +query gqlPersons { + amendableTeachers: persons { + id + fullName + } +} + +query gqlRooms { + amendableRooms: rooms { + id + name + } +} + +mutation createAmendLessons($input: [BatchCreateLessonEventInput]!) { + createAmendLessons(input: $input) { + items: lessonEvents { + id + amends { + id + } + datetimeStart + datetimeEnd + subject { + id + } + teachers { + id + } + groups { + id + } + rooms { + id + } + cancelled + comment + } + } +} + +mutation patchAmendLessons($input: [BatchPatchLessonEventInput]!) { + patchAmendLessons(input: $input) { + items: lessonEvents { + id + subject { + id + } + teachers { + id + } + groups { + id + } + rooms { + id + } + cancelled + comment + } + } +} + +mutation deleteAmendLessons($ids: [ID]!) { + deleteAmendLessons(ids: $ids) { + deletionCount + } +} diff --git a/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue b/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue index cd91c0cd15cc77b01fd735a6b75b22043209bb2b..627dd2fe2e8395da29d8d6c4162e29b99b2f1d1e 100644 --- a/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue +++ b/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue @@ -100,6 +100,11 @@ </v-list-item-title> </v-list-item-content> </v-list-item> + <amend-lesson + v-if="selectedEvent" + :selected-event="selectedEvent" + @refreshCalendar="$emit('refreshCalendar')" + /> </template> </base-calendar-feed-details> </template> @@ -111,8 +116,12 @@ import CalendarStatusChip from "aleksis.core/components/calendar/CalendarStatusC import CancelledCalendarStatusChip from "aleksis.core/components/calendar/CancelledCalendarStatusChip.vue"; import LessonRelatedObjectChip from "../../LessonRelatedObjectChip.vue"; + import lessonEvent from "../mixins/lessonEvent"; import LessonEventSubject from "../../LessonEventSubject.vue"; + +import AmendLesson from "../../AmendLesson.vue"; + export default { name: "LessonDetails", components: { @@ -121,6 +130,7 @@ export default { BaseCalendarFeedDetails, CalendarStatusChip, CancelledCalendarStatusChip, + AmendLesson, }, mixins: [calendarFeedDetailsMixin, lessonEvent], }; diff --git a/aleksis/apps/chronos/frontend/messages/en.json b/aleksis/apps/chronos/frontend/messages/en.json index 32b0b58d39c05d6366b33677a1d084a1b09f013f..e38a4e03df2f494032be278d85d1c4fcec880984 100644 --- a/aleksis/apps/chronos/frontend/messages/en.json +++ b/aleksis/apps/chronos/frontend/messages/en.json @@ -32,7 +32,19 @@ "event": { "no_teacher": "No teacher", "no_room": "No room", - "current_changes": "Current changes" + "current_changes": "Current changes", + "amend": { + "edit_button": "Change", + "delete_button": "Reset", + "delete_dialog": "Are you sure you want to delete this substitution?", + "delete_success": "The substitution was deleted successfully.", + "title": "Change lesson", + "subject": "Subject", + "teachers": "Teachers", + "rooms": "Rooms", + "cancelled": "Cancelled", + "comment": "Comment" + } } } } diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py index bf4b3f86078615e0475c27209991dbe06ad10d0a..7f01d7f67c9bff1458804e93a538daa340e014b3 100644 --- a/aleksis/apps/chronos/models.py +++ b/aleksis/apps/chronos/models.py @@ -1437,7 +1437,7 @@ class LessonEvent(CalendarEvent): def subject_name_with_amends(self: LessonEvent) -> str: """Get formatted subject name (including amends).""" my_subject = self.subject.name if self.subject else "" - amended_subject = self.amends.subject.name if self.amends else "" + amended_subject = self.amends.subject.name if self.amends and self.amends.subject else "" if my_subject and amended_subject: return _("{} (instead of {})").format(my_subject, amended_subject) @@ -1461,8 +1461,10 @@ class LessonEvent(CalendarEvent): elif request: title += " · " + reference_object.teacher_names_with_amends else: - title += f" · {reference_object.group_names} · {reference_object.teacher_names}" - + title += ( + f" · {reference_object.group_names} · " + + f"{reference_object.teacher_names_with_amends}" + ) if reference_object.rooms.all().exists(): title += " · " + reference_object.room_names_with_amends return title @@ -1522,6 +1524,7 @@ class LessonEvent(CalendarEvent): """ return { + "id": reference_object.id, "amended": bool(reference_object.amends), "amends": cls.value_meta(reference_object.amends, request) if reference_object.amends diff --git a/aleksis/apps/chronos/schema/__init__.py b/aleksis/apps/chronos/schema/__init__.py index b746fc21e8344af3367d53d5d0d7abfff79cd708..a0682ef5e5fa98ee2c36be2cd33801afa4732dda 100644 --- a/aleksis/apps/chronos/schema/__init__.py +++ b/aleksis/apps/chronos/schema/__init__.py @@ -1,5 +1,12 @@ +from datetime import timezone + import graphene from graphene_django import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchCreateMutation, + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, +) from aleksis.core.models import Group, Person, Room @@ -28,6 +35,90 @@ class TimetableRoomType(DjangoObjectType): skip_registry = True +class LessonEventType(DjangoObjectType): + class Meta: + model = LessonEvent + fields = ( + "id", + "amends", + "datetime_start", + "datetime_end", + "subject", + "teachers", + "groups", + "rooms", + "cancelled", + "comment", + ) + filter_fields = { + "id": ["exact", "lte", "gte"], + } + + amends = graphene.Field(lambda: LessonEventType, required=False) + + +class DatetimeTimezoneMixin: + """Handle datetimes for mutations with CalendarEvent objects. + + This is necessary because the client sends timezone information as + ISO string which only includes an offset (+00:00 UTC) and an + offset is not a valid timezone. Instead we set UTC as timezone + here directly. + """ + + @classmethod + def handle_datetime_start(cls, value, name, info) -> int: + value = value.replace(tzinfo=timezone.utc) + return value + + @classmethod + def handle_datetime_end(cls, value, name, info) -> int: + value = value.replace(tzinfo=timezone.utc) + return value + + +class AmendLessonBatchCreateMutation(DatetimeTimezoneMixin, DjangoBatchCreateMutation): + class Meta: + model = LessonEvent + permissions = ("chronos.edit_substitution_rule",) + only_fields = ( + "amends", + "datetime_start", + "datetime_end", + "subject", + "teachers", + "groups", + "rooms", + "cancelled", + "comment", + ) + + @classmethod + def before_save(cls, root, info, input, created_objects): # noqa: A002 + for obj in created_objects: + obj.timezone = obj.amends.timezone + return created_objects + + +class AmendLessonBatchPatchMutation(DatetimeTimezoneMixin, DjangoBatchPatchMutation): + class Meta: + model = LessonEvent + permissions = ("chronos.edit_substitution_rule",) + only_fields = ("subject", "teachers", "groups", "rooms", "cancelled", "comment") + + @classmethod + def before_save(cls, root, info, input, updated_objects): # noqa: A002 + for obj in updated_objects: + obj.timezone = obj.amends.timezone + return updated_objects + + +class AmendLessonBatchDeleteMutation(DjangoBatchDeleteMutation): + class Meta: + model = LessonEvent + permissions = ("chronos.delete_substitution_rule",) + + class TimetableType(graphene.Enum): TEACHER = "teacher" GROUP = "group" @@ -48,15 +139,6 @@ class TimetableObjectType(graphene.ObjectType): return f"{root.type.value}-{root.id}" -class LessonEventType(DjangoObjectType): - class Meta: - model = LessonEvent - fields = ("id",) - filter_fields = { - "id": ["exact", "lte", "gte"], - } - - class Query(graphene.ObjectType): timetable_teachers = graphene.List(TimetablePersonType) timetable_groups = graphene.List(TimetableGroupType) @@ -103,3 +185,9 @@ class Query(graphene.ObjectType): ) return all_timetables + + +class Mutation(graphene.ObjectType): + create_amend_lessons = AmendLessonBatchCreateMutation.Field() + patch_amend_lessons = AmendLessonBatchPatchMutation.Field() + delete_amend_lessons = AmendLessonBatchDeleteMutation.Field() diff --git a/aleksis/apps/chronos/templates/chronos/partials/subs/room.html b/aleksis/apps/chronos/templates/chronos/partials/subs/room.html index a80afb98487308b20733e1f23cee8a29821b1eb0..94f2d3574992d23dffe3d6f9b03ffdd3e75cb042 100644 --- a/aleksis/apps/chronos/templates/chronos/partials/subs/room.html +++ b/aleksis/apps/chronos/templates/chronos/partials/subs/room.html @@ -1,6 +1,6 @@ {% if type == "substitution" %} {% if el.cancelled or el.cancelled_for_teachers %} - {# Canceled lesson: no room #} + {# Cancelled lesson: no room #} {% elif el.room and el.lesson_period.room %} {# New and old room available #} <span class="tooltipped" data-position="bottom"