diff --git a/aleksis/apps/chronos/frontend/components/AmendLessonOverview.vue b/aleksis/apps/chronos/frontend/components/AmendLessonOverview.vue new file mode 100644 index 0000000000000000000000000000000000000000..15b32e035f04e5eb78bdd0877fb646636ca9aedc --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/AmendLessonOverview.vue @@ -0,0 +1,153 @@ +<script setup> +import AmendedLessonCard from "./AmendedLessonCard.vue"; +import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue"; +import DateField from "aleksis.core/components/generic/forms/DateField.vue"; +import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; + +import { DateTime } from "luxon"; + +import { amendedLessonsFromAbsences, batchPatchAmendLessons, gqlGroups } from "./amendLesson.graphql"; +</script> + +<template> + <c-r-u-d-iterator + :gql-query="gqlQuery" + :gql-patch-mutation="gqlPatchMutation" + :get-patch-data="gqlGetPatchData" + :gql-filters="gqlFilters" + i18n-key="test" + :enable-search="false" + :enable-filter="true" + :enable-create="false" + :show-create="false" + :enable-delete="false" + :enable-edit="true" + :headers="headers" + :force-model-item-update="true" + @lastQuery="lastQuery = $event" + > + <template #default="{ items }"> + <v-list-item v-for="day in groupAmendedLessonsByDay(items)" two-line> + <v-list-item-content> + <v-list-item-title>{{ $d(day[0], "short") }}</v-list-item-title> + <v-list> + <v-list-item v-for="amendedLesson in day.slice(1)"> + <amended-lesson-card :amended-lesson="amendedLesson" :affected-query="lastQuery" :is-create="false" :gql-patch-mutation="batchPatchAmendLessons" /> + </v-list-item> + </v-list> + </v-list-item-content> + </v-list-item> + </template> + + <!--<template #groups="{ item }">--> + <!-- <lesson-related-object-chip--> + <!-- v-for="group in item.realAmends.groups"--> + <!-- :key="group.id"--> + <!-- >--> + <!-- {{ group.shortName }}--> + <!-- </lesson-related-object-chip--> + <!-- >--> + <!--</template>--> + + <!--<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>--> + + <template #filters="{ attrs, on }"> + <date-field + v-bind="attrs('date_start')" + v-on="on('date_start')" + :label="$t('start')" + /> + + <date-field + v-bind="attrs('date_end')" + v-on="on('date_end')" + :label="$t('end')" + /> + + <v-autocomplete + v-bind="attrs('group_id')" + v-on="on('group_id')" + :label="$t('group')" + :items="groups" + item-text="shortName" + item-value="id" + /> + </template> + </c-r-u-d-iterator> +</template> + +<script> + export default { + props: { + }, + data() { + return { + gqlQuery: amendedLessonsFromAbsences, + gqlPatchMutation: batchPatchAmendLessons, + gqlFilters: { + group_id: 2, + }, + headers: [ + { + text: "date & time start", + value: "datetimeStart", + disableEdit: true, + }, + { + text: "date & time end", + value: "datetimeEnd", + disableEdit: true, + }, + { + text: "subject", + value: "subject", + disableEdit: true, + }, + { + text: "groups", + value: "groups", + disableEdit: true, + }, + { + text: "teachers", + value: "teachers", + cols: 12, + } + ], + lastQuery: null, + }; + }, + methods: { + groupAmendedLessonsByDay(amendedLessons) { + const byDay = amendedLessons.reduce((byDay, amendedLesson) => { + const day = DateTime.fromISO(amendedLesson.datetimeStart).startOf("day"); + byDay[day] ??= [day]; + byDay[day].push(amendedLesson); + return byDay; + }, {}); + + return Object.keys(byDay) + .sort() + .map((key) => byDay[key]); + }, + gqlGetPatchData(item) { + return { id: item.id, teachers: item.teachers } + }, + }, + apollo: { + groups: gqlGroups, + } + }; +</script> diff --git a/aleksis/apps/chronos/frontend/components/AmendedLessonCard.vue b/aleksis/apps/chronos/frontend/components/AmendedLessonCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..a29a183853f384cd9e3c02d61b542f5fa662f589 --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/AmendedLessonCard.vue @@ -0,0 +1,106 @@ +<script setup> +import DeleteButton from "aleksis.core/components/generic/buttons/DeleteButton.vue"; +import LessonInformation from "./LessonInformation.vue"; +import LessonRelatedObjectChip from "./LessonRelatedObjectChip.vue"; + +import { gqlPersons } from "./amendLesson.graphql"; + +import createOrPatchMixin from "aleksis.core/mixins/createOrPatchMixin.js"; +</script> + +<template> + <v-card class="my-2 full-width"> + <!-- flex-md-row zeile ab medium --> + <!-- align-stretch - stretch full-width --> + <div class="full-width d-flex flex-md-row flex-column align-center justify-space-between"> + <lesson-information + class="flex-grow-1" + :lesson="$attrs['amended-lesson']" + /> + + <v-autocomplete + v-model="teachers" + multiple + chips + deletable-chips + :items="amendableTeachers" + item-text="fullName" + item-value="id" + class="flex-grow-1 flex-shrink-0 mx-2" + @input="save" + > + <template #prepend-inner> + <v-chip v-for="teacher in teachersWithStatus($attrs['amended-lesson']).filter((t) => t.status === 'removed')" class="text-decoration-line-through text--secondary mb-2">{{ teacher.fullName }}</v-chip> + </template> + </v-autocomplete> + + <delete-button class="flex-grow-1 mx-2" color="red white--text" @click="toggleCancel">{{ $attrs['amended-lesson'].cancelled ? "de-cancel" : "cancel" }}</delete-button> + </div> + <v-divider/> + <!--<v-card-actions>--> + <!-- <v-spacer/>--> + <!-- <cancel-button @click="$emit('close')" :disabled="loading" />--> + <!-- <save-button--> + <!-- @click="save"--> + <!-- :loading="loading"--> + <!-- />--> + <!--</v-card-actions>--> + </v-card> +</template> + +<script> +//import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue"; +//import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue"; +//import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue"; + +//import { createOrUpdateDocumentations } from "../coursebook.graphql"; + +export default { + name: "AmendedLessonCard", + emits: ["open", "close"], + mixins: [createOrPatchMixin], + data() { + return { + loading: false, + teachers: [], + }; + }, + methods: { + teachersWithStatus(lesson) { + let oldIds = lesson.realAmends.teachers.map((teacher) => teacher.id); + let newIds = lesson.teachers.map((teacher) => teacher.id); + let teachersWithStatus = lesson.realAmends.teachers.concat(lesson.teachers).map((teacher) => { + let status = "regular"; + if (newIds.includes(teacher.id) && !oldIds.includes(teacher.id)) { + status = "new"; + } else if ( + !newIds.includes(teacher.id) && + oldIds.includes(teacher.id) + ) { + status = "removed"; + } + return { ...teacher, status: status }; + }); + return teachersWithStatus; + }, + save() { + this.createOrPatch([{ + id: this.$attrs["amended-lesson"].id, + teachers: this.teachers, + }]); + }, + toggleCancel() { + this.createOrPatch([{ + id: this.$attrs["amended-lesson"].id, + cancelled: !this.$attrs["amended-lesson"].cancelled, + }]); + }, + }, + apollo: { + amendableTeachers: gqlPersons, + }, + mounted() { + this.teachers = this.$attrs["amended-lesson"].teachers.map((teacher) => teacher.id); + }, +}; +</script> diff --git a/aleksis/apps/chronos/frontend/components/LessonInformation.vue b/aleksis/apps/chronos/frontend/components/LessonInformation.vue new file mode 100644 index 0000000000000000000000000000000000000000..a43ab8b9bd7fcc67203af6f2b9742b68e1545b31 --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/LessonInformation.vue @@ -0,0 +1,52 @@ +<script setup> +import CancelledCalendarStatusChip from "aleksis.core/components/calendar/CancelledCalendarStatusChip.vue"; +import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; + +import { DateTime } from "luxon"; +</script> + +<template> + <v-card-text> + <cancelled-calendar-status-chip + v-if="lesson.cancelled" + class="mr-2" + /> + <div :class="{ 'text-decoration-line-through': lesson.cancelled, 'text--secondary': lesson.cancelled }"> + {{ $d(toDateTime(lesson.datetimeStart), "shortTime") }} – + {{ $d(toDateTime(lesson.datetimeEnd), "shortTime") }} + {{ getCourse(lesson)?.name }} + </div> + <subject-chip + v-if="getSubject(lesson)" + :subject="getSubject(lesson)" + /> + </v-card-text> +</template> + +<script> +export default { + name: "LessonInformation", + props: { + lesson: { + type: Object, + required: true, + }, + cancelled: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + toDateTime(dateString) { + return DateTime.fromISO(dateString); + }, + getSubject(lesson) { + return lesson.subject ? lesson.subject : lesson.course?.subject ? lesson.course.subject : lesson.realAmends?.subject ? lesson.realAmends.subject : undefined; + }, + getCourse(lesson) { + return lesson.course ? lesson.course : lesson.realAmends?.course ? lesson.realAmends.course : undefined; + }, + }, +}; +</script> diff --git a/aleksis/apps/chronos/frontend/components/amendLesson.graphql b/aleksis/apps/chronos/frontend/components/amendLesson.graphql index a93e89186df05efdfa77473b7f1a1751eb614c24..1868fca441804614354dbd7e2996f8d3013a6816 100644 --- a/aleksis/apps/chronos/frontend/components/amendLesson.graphql +++ b/aleksis/apps/chronos/frontend/components/amendLesson.graphql @@ -19,6 +19,14 @@ query gqlRooms { } } +query gqlGroups { + groups: groups { + id + name + shortName + } +} + mutation createAmendLesson($input: CreateLessonEventInput!) { createAmendLesson(input: $input) { lessonEvent { @@ -68,8 +76,133 @@ mutation patchAmendLesson($input: PatchLessonEventInput!, $id: ID!) { } } +mutation batchPatchAmendLessons($input: [BatchPatchLessonEventInput]!) { + batchPatchAmendLessons(input: $input) { + items: lessonEvents { + id + subject { + id + shortName + name + colourFg + colourBg + } + teachers { + id + shortName + fullName + } + groups { + id + } + rooms { + id + } + course { + id + subject { + id + shortName + name + colourFg + colourBg + } + } + realAmends { + id + teachers { + id + shortName + fullName + } + subject { + id + shortName + name + colourFg + colourBg + } + groups { + id + shortName + } + course { + id + name + } + } + datetimeStart + datetimeEnd + cancelled + comment + } + } +} + mutation deleteAmendLesson($id: ID!) { deleteAmendLesson(id: $id) { ok } } + +query amendedLessonsFromAbsences($filters: JSONString!) { + items: amendedLessonsFromAbsences(filters: $filters) { + id + subject { + id + shortName + name + colourFg + colourBg + } + teachers { + id + shortName + fullName + } + groups { + id + } + course { + id + subject { + id + shortName + name + colourFg + colourBg + } + name + } + rooms { + id + } + realAmends { + id + teachers { + id + shortName + fullName + } + subject { + id + shortName + name + colourFg + colourBg + } + groups { + id + shortName + } + course { + id + name + } + } + datetimeStart + datetimeEnd + cancelled + comment + } +} diff --git a/aleksis/apps/chronos/frontend/index.js b/aleksis/apps/chronos/frontend/index.js index 30451f93c2f0dfcb769a4ab92385b50fe1bfe61c..494a0e90ee48dd92cb7747c9f304bb3460124228 100644 --- a/aleksis/apps/chronos/frontend/index.js +++ b/aleksis/apps/chronos/frontend/index.js @@ -1,5 +1,6 @@ import { hasPersonValidator } from "aleksis.core/routeValidators"; import Timetable from "./components/Timetable.vue"; +import AmendLessonOverview from "./components/AmendLessonOverview.vue" export default { meta: { @@ -31,5 +32,15 @@ export default { permission: "chronos.view_timetable_overview_rule", }, }, + { + path: "amend_lesson_overview/", + component: AmendLessonOverview, + name: "chronos.amendLessonOverview", + meta: { + inMenu: true, + titleKey: "chronos.amendLessonOverview.menu_title", + icon: "mdi-grid", + }, + }, ], }; diff --git a/aleksis/apps/chronos/managers.py b/aleksis/apps/chronos/managers.py index a2fc76acb785ea78c9bb709137852b5c65c0e1c9..ba620386bfcb59d31049ff4b5c7a2d89f7f01fd2 100644 --- a/aleksis/apps/chronos/managers.py +++ b/aleksis/apps/chronos/managers.py @@ -895,3 +895,16 @@ class LessonEventQuerySet(PolymorphicQuerySet): return self.filter( Q(teachers=person) | Q(groups__members=person) | Q(pk__in=amended) ).distinct() + + def affected_by_absences(self, datetime_start: datetime, datetime_end: datetime): + return self.filter( + ((Q(teachers__kolego_absences__datetime_start__gte=datetime_start) + & Q(teachers__kolego_absences__datetime_start__lte=datetime_end)) + | (Q(teachers__kolego_absences__datetime_end__gte=datetime_start) + & Q(teachers__kolego_absences__datetime_end__lte=datetime_end)) + ) + & Q(teachers__kolego_absences__datetime_start__lte=F("datetime_end")) + & Q(teachers__kolego_absences__datetime_end__gte=F("datetime_start")) + & Q(amends__isnull=True) + & Q(amended_by__isnull=True) + ) diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py index 0587381d7139025f6a4d4dc2c55225429819f180..c501552c2ce8a450500af60c85ba8d5ece857aa1 100644 --- a/aleksis/apps/chronos/models.py +++ b/aleksis/apps/chronos/models.py @@ -11,7 +11,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models -from django.db.models import Max, Min, Q +from django.db.models import F, Max, Min, Q from django.db.models.functions import Coalesce from django.dispatch import receiver from django.forms import Media @@ -1424,7 +1424,7 @@ class LessonEvent(CalendarEvent): @property def subject_name_with_amends(self: LessonEvent) -> str: - my_subject = self.subject.name + my_subject = self.subject.name if self.subject else "" amended_subject = self.real_amends.subject.name if self.amends else "" if my_subject and amended_subject: @@ -1543,6 +1543,10 @@ class LessonEvent(CalendarEvent): if params: obj_id = int(params.get("id", 0)) type_ = params.get("type", None) + prefetch_absences = params.get("prefetch_absences", False) + + if prefetch_absences: + objs = objs.prefetch_related("teachers__kolego_absences") if type_ and obj_id: if type_ == "TEACHER": @@ -1553,6 +1557,42 @@ class LessonEvent(CalendarEvent): return objs.for_room(obj_id) return objs.for_person(request.user.person) + @classmethod + def get_for_substitution_overview(cls, obj_type: str, obj_id: str, date_start: datetime, date_end: datetime, request: HttpRequest) -> list: + """Get all the amended lessons for an object and a time frame. + + obj_type may be one of TEACHER, GROUP, ROOM, COURSE + """ + + # 1. Find all LessonEvents for all Lessons of this Group in this date range and which are not themselves amending another lessonEvent + + events = LessonEvent.get_single_events(date_start, date_end, request, {"type": obj_type, "id": obj_id, "not_amending": True, "prefetch_absences": True}, with_reference_object=True) + # (1.5 filter them by permissions) + ... + + # 2. For each lessonEvent → check if there are any teachers with absences that overlap the lesson & if yes, check if there is already an amendment for that lesson + # if so, add it to a list, if not, create a new one (no dummy creation here possible since teachers is a m2m field) + + amended_lessons = [] + + for event in events: + reference_obj = event["REFERENCE_OBJECT"] + + affected_teachers = reference_obj.teachers.filter(Q(kolego_absences__datetime_start__lte=event["DTEND"].dt) + & Q(kolego_absences__datetime_end__gte=event["DTSTART"].dt)) + + if affected_teachers.exists(): + obj, created = LessonEvent.objects.update_or_create( + amends=reference_obj, + datetime_start=event["DTSTART"].dt, + datetime_end=event["DTEND"].dt, + ) + if created: + obj.teachers.set(reference_obj.teachers.exclude(pk__in=affected_teachers)) + amended_lessons.append(obj) + + return amended_lessons + class Meta: verbose_name = _("Lesson Event") verbose_name_plural = _("Lesson Events") diff --git a/aleksis/apps/chronos/schema/__init__.py b/aleksis/apps/chronos/schema/__init__.py index f7cc25ff46c7fe7018876cc782fb6ad4e92e4eca..c2a3e6fd4d33b2ee7298edeb7a6fa9e948f84016 100644 --- a/aleksis/apps/chronos/schema/__init__.py +++ b/aleksis/apps/chronos/schema/__init__.py @@ -1,12 +1,18 @@ -from datetime import timezone +from datetime import date, datetime, timezone + +from functools import reduce +from operator import and_ +from django.db.models import F, ManyToManyField, OuterRef, Subquery, Q, Prefetch import graphene from graphene_django import DjangoObjectType -from graphene_django_cud.mutations import DjangoCreateMutation, DjangoPatchMutation +from graphene_django_cud.mutations import DjangoBatchPatchMutation, DjangoCreateMutation, DjangoPatchMutation from aleksis.core.models import CalendarEvent, Group, Person, Room from aleksis.core.schema.base import DeleteMutation +from aleksis.apps.kolego.models import Absence + from ..models import LessonEvent from ..util.chronos_helpers import get_classes, get_rooms, get_teachers @@ -53,11 +59,32 @@ class LessonEventType(DjangoObjectType): "teachers", "groups", "rooms", + "course", "cancelled", "comment", ) +class LessonEventTypeWithRealAmends(DjangoObjectType): + class Meta: + model = LessonEvent + fields = ( + "id", + "amends", + "datetime_start", + "datetime_end", + "subject", + "teachers", + "groups", + "rooms", + "course", + "cancelled", + "comment", + ) + + real_amends = graphene.Field(LessonEventType, required=False) + + class DatetimeTimezoneMixin: """Handle datetimes for mutations with CalendarEvent objects. @@ -114,6 +141,13 @@ class AmendLessonPatchMutation(DatetimeTimezoneMixin, DjangoPatchMutation): only_fields = ("subject", "teachers", "groups", "rooms", "cancelled", "comment") +class AmendLessonBatchPatchMutation(DjangoBatchPatchMutation): + class Meta: + model = LessonEvent + permissions = ("chronos.edit_substitution_rule",) + only_fields = ("id", "subject", "teachers", "groups", "rooms", "cancelled", "comment") + + class AmendLessonDeleteMutation(DeleteMutation): klass = LessonEvent permission_required = "chronos.edit_substitution_rule" @@ -145,6 +179,11 @@ class Query(graphene.ObjectType): timetable_rooms = graphene.List(TimetableRoomType) available_timetables = graphene.List(TimetableObjectType) + amended_lessons_from_absences = graphene.List( + LessonEventTypeWithRealAmends, + filters=graphene.JSONString(required=True), + ) + def resolve_timetable_teachers(self, info, **kwargs): return get_teachers(info.context.user) @@ -186,8 +225,21 @@ class Query(graphene.ObjectType): return all_timetables + def resolve_amended_lessons_from_absences(root, info, filters, **kwargs): + if isinstance(filters, str): + filters = json.loads(filters) + + datetime_start = datetime.combine(date.fromisoformat(filters.get("date_start", datetime.now().date().isoformat())) , datetime.min.time()) + datetime_end = datetime.combine(date.fromisoformat(filters.get("date_end", datetime.now().date().isoformat())), datetime.max.time()) + + group_id = filters.get("group_id") + + # TODO: later on, allow getting amended lessons for other types than courses, e.g. groups or persons + return LessonEvent.get_for_substitution_overview("GROUP", group_id, datetime_start, datetime_end, info.context) + class Mutation(graphene.ObjectType): create_amend_lesson = AmendLessonCreateMutation.Field() patch_amend_lesson = AmendLessonPatchMutation.Field() + batch_patch_amend_lessons = AmendLessonBatchPatchMutation.Field() delete_amend_lesson = AmendLessonDeleteMutation.Field() diff --git a/pyproject.toml b/pyproject.toml index dc673aaad89e0b5f31ef65f6cc18422889cd9a19..aa02408ec2a8205863904e845e1e5dcdcdd5e48e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ calendarweek = "^0.5.0" aleksis-core = "^4.0.0.dev2" aleksis-app-resint = "^4.0.0.dev1" aleksis-app-cursus = "^0.1.dev0" +aleksis-app-kolego = "^0.1.dev0" [tool.poetry.plugins."aleksis.app"]