diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue index e2f56912c65ac40dc7cecc9da5342ed8ff02928e..650e1aeb90810a9fa5f3366e5e9db59a00daca58 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue @@ -6,37 +6,54 @@ :enable-create="false" :enable-edit="false" :elevated="false" + :items-per-page="-1" @lastQuery="lastQuery = $event" ref="iterator" hide-default-footer > <template #additionalActions="{ attrs, on }"> - <v-autocomplete - :items="selectable" - item-text="name" - clearable - return-object - filled - dense - hide-details - :placeholder="$t('alsijil.coursebook.filter.filter_for_obj')" - :loading="selectLoading" - :value="currentObj" - @input="changeSelection" - @click:clear="changeSelection" - /> - <v-switch - :loading="selectLoading" - :label="$t('alsijil.coursebook.filter.own')" - :input-value="filterType === 'my'" - @change=" - changeSelection({ - filterType: $event ? 'my' : 'all', - type: objType, - id: objId, - }) - " - /> + <div class="d-flex flex-grow-1 justify-end"> + <v-autocomplete + :items="selectable" + item-text="name" + clearable + return-object + filled + dense + hide-details + :placeholder="$t('alsijil.coursebook.filter.filter_for_obj')" + :loading="selectLoading" + :value="currentObj" + @input="changeSelection" + @click:clear="changeSelection" + class="max-width" + /> + <div class="ml-6"> + <v-switch + :loading="selectLoading" + :label="$t('alsijil.coursebook.filter.own')" + :input-value="filterType === 'my'" + @change=" + changeSelection({ + filterType: $event ? 'my' : 'all', + type: objType, + id: objId, + }) + " + dense + inset + hide-details + /> + <v-switch + :loading="selectLoading" + :label="$t('alsijil.coursebook.filter.missing')" + v-model="incomplete" + dense + inset + hide-details + /> + </div> + </div> </template> <template #default="{ items }"> <v-list-item @@ -45,11 +62,13 @@ :key="'day-' + day[0]" > <v-list-item-content :id="'documentation_' + day[0].toISODate()"> - <v-subheader class="text-h6">{{ $d(day[0], "dateWithWeekday") }}</v-subheader> + <v-subheader class="text-h6">{{ + $d(day[0], "dateWithWeekday") + }}</v-subheader> <v-list max-width="100%" class="pt-0 mt-n1"> <v-list-item v-for="doc in day.slice(1)" - :key="'documentation-' + doc.id" + :key="'documentation-' + (doc.oldId || doc.id)" > <documentation-modal :documentation="doc" @@ -137,6 +156,7 @@ export default { courses: [], dateStart: null, dateEnd: null, + incomplete: false, }; }, apollo: { @@ -158,6 +178,7 @@ export default { dateEnd: this.dateEnd ?? DateTime.fromISO(this.date).plus({ weeks: 1 }).toISODate(), + incomplete: !!this.incomplete, }; }, selectable() { @@ -279,3 +300,9 @@ export default { }, }; </script> + +<style> +.max-width { + max-width: 25rem; +} +</style> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDateSelect.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDateSelect.vue index 486f2d23438fdbf79b767ed1da7eb21a0e44ff35..933bcb29ba3b63798dd0f853dee58f5a921bae22 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDateSelect.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDateSelect.vue @@ -49,12 +49,7 @@ export default { </script> <template> - <v-footer - app - inset - padless - id="date-select-footer" - > + <v-footer app inset padless id="date-select-footer"> <v-card tile class="full-width"> <v-card-title id="content"> <div class="d-flex align-center justify-center full-width"> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql index c47203625ce2e9ad1e2dd0a5e828eb7b0d29ce7f..c73f273af1451a5f102d3b91dc66dadce57901ee 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql +++ b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql @@ -22,6 +22,7 @@ query documentationsForCoursebook( $objType: String $dateStart: Date! $dateEnd: Date! + $incomplete: Boolean ) { items: documentationsForCoursebook( own: $own @@ -29,6 +30,7 @@ query documentationsForCoursebook( objType: $objType dateStart: $dateStart dateEnd: $dateEnd + incomplete: $incomplete ) { id course { @@ -37,6 +39,10 @@ query documentationsForCoursebook( } lessonEvent { id + amends { + id + } + cancelled } teachers { id @@ -58,7 +64,9 @@ query documentationsForCoursebook( datetimeEnd dateStart dateEnd + oldId canEdit + futureNotice canDelete } } @@ -70,6 +78,7 @@ mutation createOrUpdateDocumentations($input: [DocumentationInputType]!) { topic homework groupNote + oldId } } } diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/Documentation.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/Documentation.vue index 0f767b923cddbed44f1bf7e3e48abc6b192f9f86..5bbc21c0de53b613986c30360e853ee6720526b5 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/Documentation.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/Documentation.vue @@ -1,12 +1,15 @@ <template> - <v-card :class="{'my-1 full-width': true, 'd-flex flex-column': !compact }"> + <v-card :class="{ 'my-1 full-width': true, 'd-flex flex-column': !compact }"> <v-card-title v-if="!compact"> <lesson-information v-bind="documentationPartProps" /> </v-card-title> <v-card-text class="full-width main-body" - :class="{ 'vertical': !compact || $vuetify.breakpoint.mobile, 'pa-2': compact }" + :class="{ + vertical: !compact || $vuetify.breakpoint.mobile, + 'pa-2': compact, + }" > <lesson-information v-if="compact" v-bind="documentationPartProps" /> <lesson-summary @@ -24,9 +27,21 @@ <v-divider /> <v-card-actions v-if="!compact"> <v-spacer /> - <cancel-button v-if="documentation.canEdit" @click="$emit('close')" :disabled="loading" /> - <save-button v-if="documentation.canEdit" @click="save" :loading="loading" /> - <cancel-button v-if="!documentation.canEdit" i18n-key="actions.close" @click="$emit('close')"/> + <cancel-button + v-if="documentation.canEdit" + @click="$emit('close')" + :disabled="loading" + /> + <save-button + v-if="documentation.canEdit" + @click="save" + :loading="loading" + /> + <cancel-button + v-if="!documentation.canEdit" + i18n-key="actions.close" + @click="$emit('close')" + /> </v-card-actions> </v-card> </template> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationCompactDetails.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationCompactDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..9160bd79522900613c4ac195b9bcf85d818fb9d3 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationCompactDetails.vue @@ -0,0 +1,30 @@ +<template> + <v-card outlined dense rounded="lg" v-bind="$attrs" v-on="$listeners"> + <div class="font-weight-medium mr-2"> + {{ $t("alsijil.coursebook.summary.topic") }}: + </div> + <div class="text-truncate">{{ documentation.topic || "–" }}</div> + + <div class="font-weight-medium mr-2"> + {{ $t("alsijil.coursebook.summary.homework.label") }}: + </div> + <div class="text-truncate">{{ documentation.homework || "–" }}</div> + + <div class="font-weight-medium mr-2"> + {{ $t("alsijil.coursebook.summary.group_note.label") }}: + </div> + <div class="text-truncate">{{ documentation.groupNote || "–" }}</div> + </v-card> +</template> + +<script> +export default { + name: "DocumentationCompactDetails", + props: { + documentation: { + type: Object, + required: true, + }, + }, +}; +</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationFullDetails.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationFullDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..ddb7ae2764771ac59eb5e69f6e2ffb5ee6c7725b --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationFullDetails.vue @@ -0,0 +1,34 @@ +<template> + <div v-bind="$attrs" v-on="$listeners"> + <v-card outlined dense rounded="lg" class="mb-2"> + <v-card-title class="text-subtitle-2 pb-1 font-weight-medium"> + {{ $t("alsijil.coursebook.summary.topic") }} + </v-card-title> + <v-card-text>{{ documentation.topic || "–" }}</v-card-text> + </v-card> + <v-card outlined dense rounded="lg" class="mb-2"> + <v-card-title class="text-subtitle-2 pb-1 font-weight-medium"> + {{ $t("alsijil.coursebook.summary.homework.label") }} + </v-card-title> + <v-card-text>{{ documentation.homework || "–" }}</v-card-text> + </v-card> + <v-card outlined dense rounded="lg"> + <v-card-title class="text-subtitle-2 pb-1 font-weight-medium"> + {{ $t("alsijil.coursebook.summary.group_note.label") }} + </v-card-title> + <v-card-text>{{ documentation.groupNote || "–" }}</v-card-text> + </v-card> + </div> +</template> + +<script> +export default { + name: "DocumentationFullDetails", + props: { + documentation: { + type: Object, + required: true, + }, + }, +}; +</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue index 08f5b0ea04ff60677dbf5bc415d120f5721dca8a..460f39f97fc15b61d0476dd99ed33d29cf43029c 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue @@ -4,11 +4,7 @@ <mobile-fullscreen-dialog v-model="popup" max-width="500px"> <template #activator="activator"> <!-- list view -> activate dialog --> - <documentation - compact - v-bind="$attrs" - :dialog-activator="activator" - /> + <documentation compact v-bind="$attrs" :dialog-activator="activator" /> </template> <!-- dialog view -> deactivate dialog --> <!-- cancel | save (through lesson-summary) --> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationStatus.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationStatus.vue index d9c3950e40ff821fdc3cdd3df4b54382b35ecd41..4b44fa537a25f9d0d9848014e8f3580ab330ff4d 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationStatus.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationStatus.vue @@ -1,7 +1,13 @@ <template> <v-tooltip bottom> <template v-slot:activator="{ on, attrs }"> - <v-icon :color="currentStatus?.color" class="mr-md-4" v-on="on" v-bind="attrs">{{ currentStatus?.icon }}</v-icon> + <v-icon + :color="currentStatus?.color" + class="mr-md-4" + v-on="on" + v-bind="attrs" + >{{ currentStatus?.icon }}</v-icon + > </template> <span>{{ currentStatus?.text }}</span> </v-tooltip> @@ -45,7 +51,7 @@ export default { { name: "cancelled", text: this.$t("alsijil.coursebook.status.cancelled"), - icon: "$cancel", + icon: "mdi-cancel", color: "error", }, { @@ -72,33 +78,55 @@ export default { }, methods: { updateStatus() { - if (!this.documentation.id.startsWith("DUMMY")) { + if (this.documentation?.lessonEvent.cancelled) { + this.currentStatusName = "cancelled"; + } else if (this.documentation.topic) { this.currentStatusName = "available"; + } else if (DateTime.now() > this.documentationDateTimeEnd) { + this.currentStatusName = "missing"; + } else if (this.documentation?.lessonEvent.amends) { + this.currentStatusName = "substitution"; + } else if ( + DateTime.now() > this.documentationDateTimeStart && + DateTime.now() < this.documentationDateTimeEnd + ) { + this.currentStatusName = "running"; } else { - if (DateTime.now() > this.documentationDateTimeEnd) { - this.currentStatusName = "missing"; - } else if (DateTime.now() > this.documentationDateTimeStart && DateTime.now() < this.documentationDateTimeEnd) { - this.currentStatusName = "running"; - } else { - if (this.documentation?.lessonEvent.amends) { - if (this.documentation.lessonEvent.amends.cancelled) { - this.currentStatusName = "cancelled"; - } else { - this.currentStatusName = "substitution"; - } - } else { - this.currentStatusName = "pending"; - } - } + this.currentStatusName = "pending"; } }, }, + watch: { + documentation: { + handler() { + this.updateStatus(); + }, + deep: true, + }, + }, mounted() { this.updateStatus(); - this.statusTimeout = setTimeout(this.updateStatus, this.documentationDateTimeStart.diff(DateTime.now(), "seconds").toObject().seconds); + + if (DateTime.now() < this.documentationDateTimeStart) { + this.statusTimeout = setTimeout( + this.updateStatus, + this.documentationDateTimeStart + .diff(DateTime.now(), "seconds") + .toObject(), + ); + } else if (DateTime.now() < this.documentationDateTimeEnd) { + this.statusTimeout = setTimeout( + this.updateStatus, + this.documentationDateTimeEnd + .diff(DateTime.now(), "seconds") + .toObject(), + ); + } }, beforeDestroy() { - clearTimeout(this.statusTimeout); + if (this.statusTimeout) { + clearTimeout(this.statusTimeout); + } }, -} +}; </script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue index 747b666eab1e59cf05be75a2e6012a5e59699e31..6e709e24493049028ecc99ea55b874bab417da26 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue @@ -17,7 +17,13 @@ import PersonChip from "aleksis.core/components/person/PersonChip.vue"; </time> </div> </div> - <span :class="{ 'text-right': !largeGrid }"> + <span + :class="{ + 'text-right': !largeGrid, + 'text-subtitle-1': largeGrid, + 'font-weight-medium': largeGrid, + }" + > {{ documentation.course?.name }} </span> <subject-chip @@ -57,7 +63,7 @@ export default { computed: { largeGrid() { return this.compact && !this.$vuetify.breakpoint.mobile; - } + }, }, }; </script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonSummary.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonSummary.vue index ad9a626490f3c6fe8dc39342f21222dee70cc9ab..47bca3b28fbc36b092112147c317ded8c6e50eb0 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonSummary.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonSummary.vue @@ -5,8 +5,45 @@ class="d-flex flex-column flex-md-row align-stretch align-md-center gap justify-start fill-height" v-if="compact" > + <documentation-compact-details + v-bind="dialogActivator.attrs" + v-on="dialogActivator.on" + v-if=" + !documentation.canEdit && + (documentation.topic || + documentation.homework || + documentation.groupNote) + " + :documentation="documentation" + @click="$emit('open')" + :class="{ + 'flex-grow-1 min-width pa-1 read-only-grid': true, + 'full-width': $vuetify.breakpoint.mobile, + }" + /> + <v-alert + v-else-if="documentation.futureNotice" + type="warning" + outlined + class="min-width flex-grow-1 mb-0" + > + {{ $t("alsijil.coursebook.notices.future") }} + </v-alert> + <v-alert + v-else-if="!documentation.canEdit" + type="info" + outlined + class="min-width flex-grow-1 mb-0" + > + {{ $t("alsijil.coursebook.notices.no_entry") }} + </v-alert> + <v-text-field - :class="{ 'flex-grow-1 min-width': true, 'full-width': $vuetify.breakpoint.mobile }" + v-if="documentation.canEdit" + :class="{ + 'flex-grow-1 min-width': true, + 'full-width': $vuetify.breakpoint.mobile, + }" hide-details outlined :label="$t('alsijil.coursebook.summary.topic')" @@ -15,9 +52,22 @@ @focusout="save" @keydown.enter="saveAndBlur" :loading="loading" - :readonly="!documentation.canEdit" - /> - <div :class="{ 'flex-grow-1 max-width': true, 'full-width': $vuetify.breakpoint.mobile }"> + > + <template #append> + <v-scroll-x-transition> + <v-icon v-if="appendIcon" :color="appendIconColor">{{ + appendIcon + }}</v-icon> + </v-scroll-x-transition> + </template> + </v-text-field> + <div + :class="{ + 'flex-grow-1 max-width': true, + 'full-width': $vuetify.breakpoint.mobile, + }" + v-if="documentation.canEdit" + > <v-card v-bind="dialogActivator.attrs" v-on="dialogActivator.on" @@ -46,37 +96,44 @@ <!-- Are focusout & enter enough trigger? --> <v-text-field filled - v-if="!compact" + v-if="!compact && documentation.canEdit" :label="$t('alsijil.coursebook.summary.topic')" :value="documentation.topic" @input="topic = $event" - :readonly="!documentation.canEdit" /> <v-textarea filled auto-grow rows="3" clearable - v-if="!compact" + v-if="!compact && documentation.canEdit" :label="$t('alsijil.coursebook.summary.homework.label')" :value="documentation.homework" @input="homework = $event" - :readonly="!documentation.canEdit" /> <v-textarea filled auto-grow rows="3" clearable - v-if="!compact" + v-if="!compact && documentation.canEdit" :label="$t('alsijil.coursebook.summary.group_note.label')" :value="documentation.groupNote" @input="groupNote = $event" - :readonly="!documentation.canEdit" + /> + + <documentation-full-details + v-if="!compact && !documentation.canEdit" + :documentation="documentation" /> </div> </template> +<script setup> +import DocumentationCompactDetails from "./DocumentationCompactDetails.vue"; +import DocumentationFullDetails from "./DocumentationFullDetails.vue"; +</script> + <script> import createOrPatchMixin from "aleksis.core/mixins/createOrPatchMixin.js"; import documentationPartMixin from "./documentationPartMixin"; @@ -90,6 +147,7 @@ export default { topic: null, homework: null, groupNote: null, + appendIcon: null, }; }, methods: { @@ -102,16 +160,36 @@ export default { (o) => o[itemId] === this.documentation.id, ); // merged with the incoming partial documentation - cached[index] = { ...this.documentation, ...object }; + // if creation of proper documentation from dummy one, set ID of documentation currently being edited as oldID so that key in coursebook doesn't change + cached[index] = { + ...this.documentation, + ...object, + oldId: + this.documentation.id !== object.id + ? this.documentation.id + : this.documentation.oldId, + }; } return cached; }; }, + handleAppendIconSuccess() { + this.appendIcon = "$success"; + setTimeout(() => { + this.appendIcon = ""; + }, 3000); + }, save() { - if (this.topic !== null || this.homework !== null || this.groupNote !== null) { + if ( + this.topic !== null || + this.homework !== null || + this.groupNote !== null + ) { const topic = this.topic !== null ? { topic: this.topic } : {}; - const homework = this.homework !== null ? { homework: this.homework } : {}; - const groupNote = this.groupNote !== null ? { groupNote: this.groupNote } : {}; + const homework = + this.homework !== null ? { homework: this.homework } : {}; + const groupNote = + this.groupNote !== null ? { groupNote: this.groupNote } : {}; this.createOrPatch([ { @@ -131,6 +209,9 @@ export default { this.save(); event.target.blur(); }, + handleError() { + this.appendIcon = "$error"; + }, }, computed: { homeworkIcon() { @@ -158,7 +239,15 @@ export default { }, maxWidth() { return this.$vuetify.breakpoint.mobile ? "100%" : "20ch"; - } + }, + appendIconColor() { + return ( + { $success: "success", $error: "error" }[this.appendIcon] || "primary" + ); + }, + }, + mounted() { + this.$on("save", this.handleAppendIconSuccess); }, }; </script> @@ -169,7 +258,7 @@ export default { } .max-width { - max-width: v-bind(maxWidth) + max-width: v-bind(maxWidth); } .gap { @@ -180,4 +269,10 @@ export default { display: grid; grid-template-columns: auto min-content; } + +.read-only-grid { + display: grid; + grid-template-columns: min-content auto; + grid-template-rows: auto; +} </style> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js index 099878f82cbf3feb0aef8bd588b3aa49144fd8eb..165f1d2fd157bb35bf2831fc7973f480b29ccd0a 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js @@ -2,43 +2,43 @@ * Mixin to provide common fields for all components specific to a singular documentation inside the coursebook */ export default { - props: { - /** - * The documentation in question - */ - documentation: { - type: Object, - required: true, - }, - /** - * Whether the documentation is currently in the compact mode (meaning coursebook row) - */ - compact: { - type: Boolean, - required: false, - default: false, - }, - /** - * Activator attributes and event listeners to open documentation dialog in different places - */ - dialogActivator: { - type: Object, - required: false, - default: () => ({ attrs: {}, on: {} }), - }, + props: { + /** + * The documentation in question + */ + documentation: { + type: Object, + required: true, }, + /** + * Whether the documentation is currently in the compact mode (meaning coursebook row) + */ + compact: { + type: Boolean, + required: false, + default: false, + }, + /** + * Activator attributes and event listeners to open documentation dialog in different places + */ + dialogActivator: { + type: Object, + required: false, + default: () => ({ attrs: {}, on: {} }), + }, + }, - computed: { - /** - * All necessary props bundled together to easily pass to child components - * @returns {{compact: Boolean, documentation: Object, dialogActivator: Object<{attrs: Object, on: Object}>}} - */ - documentationPartProps() { - return { - documentation: this.documentation, - compact: this.compact, - dialogActivator: this.dialogActivator, - } - } - } + computed: { + /** + * All necessary props bundled together to easily pass to child components + * @returns {{compact: Boolean, documentation: Object, dialogActivator: Object<{attrs: Object, on: Object}>}} + */ + documentationPartProps() { + return { + documentation: this.documentation, + compact: this.compact, + dialogActivator: this.dialogActivator, + }; + }, + }, }; diff --git a/aleksis/apps/alsijil/frontend/messages/de.json b/aleksis/apps/alsijil/frontend/messages/de.json index 586d59fefd034f6008b4ca16c3844d56e0925d3a..614c3142e8d9d59c02b1ed79ead092690a2480b4 100644 --- a/aleksis/apps/alsijil/frontend/messages/de.json +++ b/aleksis/apps/alsijil/frontend/messages/de.json @@ -59,9 +59,10 @@ }, "filter": { "own": "Nur eigene Stunden anzeigen", - "groups": "Klassen", + "missing": "Nur unvollständige Stunden anzeigen", + "groups": "Gruppen", "courses": "Kurse", - "filter_for_obj": "Nach Klasse und Kurs filtern" + "filter_for_obj": "Nach Gruppe und Kurs filtern" }, "no_data": "Keine Stunden der ausgewählten Gruppen und Kurse im aktuellen Zeitraum", "no_results": "Keine Suchergebnisse für {search}" diff --git a/aleksis/apps/alsijil/frontend/messages/en.json b/aleksis/apps/alsijil/frontend/messages/en.json index 68a3290e6e293307a0294dca198a5d81d36faf29..679044209b95c578e4e89cb439f27745b5e3f9b2 100644 --- a/aleksis/apps/alsijil/frontend/messages/en.json +++ b/aleksis/apps/alsijil/frontend/messages/en.json @@ -57,11 +57,16 @@ "empty": "No group note" } }, + "notices": { + "future": "Editing this lesson isn't allowed as this lesson is in the future.", + "no_entry": "There is no entry for this lesson yet." + }, "filter": { "own": "Only show own lessons", - "groups": "School classes", + "missing": "Only show incomplete lessons", + "groups": "Groups", "courses": "Courses", - "filter_for_obj": "Filter for school class and course" + "filter_for_obj": "Filter for group and course" }, "no_data": "No lessons for the selected groups and courses in this period", "no_results": "No search results for {search}" diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index 163e837cbc5cb9949e63b7a13c422369b4ca1e2f..067818d1857c3a4054f9fbb0cc141c0fb0eae1a5 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -1,4 +1,4 @@ -from datetime import date, datetime, timezone +from datetime import date, datetime from typing import Optional, Union from urllib.parse import urlparse @@ -481,7 +481,11 @@ class Documentation(CalendarEvent): ) teachers = models.ManyToManyField( - "core.Person", related_name="documentations_as_teacher", blank=True, null=True, verbose_name=_("Teachers") + "core.Person", + related_name="documentations_as_teacher", + blank=True, + null=True, + verbose_name=_("Teachers"), ) topic = models.CharField(verbose_name=_("Lesson Topic"), max_length=255, blank=True) @@ -530,8 +534,9 @@ class Documentation(CalendarEvent): date_start: datetime, date_end: datetime, request: HttpRequest, - obj_type: Optional[str], - obj_id: Optional[str], + obj_type: Optional[str] = None, + obj_id: Optional[str] = None, + incomplete: Optional[bool] = False, ) -> list: """Get all the documentations for an object and a time frame. @@ -541,7 +546,6 @@ class Documentation(CalendarEvent): # 1. Find all LessonEvents for all Lessons of this Course in this date range event_params = { "own": own, - "not_amended": True, } if obj_type is not None and obj_id is not None: event_params.update( @@ -558,33 +562,38 @@ class Documentation(CalendarEvent): event_params, with_reference_object=True, ) - # (1.5 filter them by permissions) - ... # 2. For each lessonEvent → check if there is a documentation # if so, add the documentation to a list, if not, create a new one - return [ - ( - existing_documentations.first() - if ( - existing_documentations := ( - event_reference_obj := event["REFERENCE_OBJECT"] - ).documentation.filter( - datetime_start=event["DTSTART"].dt.replace(tzinfo=timezone.utc), - datetime_end=event["DTEND"].dt.replace(tzinfo=timezone.utc), + docs = [] + for event in events: + if incomplete and event["STATUS"] == "CANCELLED": + continue + + event_reference_obj = event["REFERENCE_OBJECT"] + existing_documentations = event_reference_obj.documentation.filter( + datetime_start=event["DTSTART"].dt, + datetime_end=event["DTEND"].dt, + ) + + if existing_documentations.exists(): + doc = existing_documentations.first() + if incomplete and doc.topic: + continue + docs.append(doc) + else: + docs.append( + cls( + pk=f"DUMMY;{event_reference_obj.id};{event['DTSTART'].dt.isoformat()};{event['DTEND'].dt.isoformat()}", + lesson_event=event_reference_obj, + course=event_reference_obj.course, + subject=event_reference_obj.subject, + datetime_start=event["DTSTART"].dt, + datetime_end=event["DTEND"].dt, ) - ).exists() - else cls( - pk=f"DUMMY;{event_reference_obj.id};{event['DTSTART'].dt.isoformat()};{event['DTEND'].dt.isoformat()}", - lesson_event=event_reference_obj, - course=event_reference_obj.course, - subject=event_reference_obj.subject, - datetime_start=event["DTSTART"].dt, - datetime_end=event["DTEND"].dt, ) - ) - for event in events - ] + + return docs class ParticipationStatus(ExtensibleModel): diff --git a/aleksis/apps/alsijil/preferences.py b/aleksis/apps/alsijil/preferences.py index 2fd34fa7fc4f802a52ead1d2d0291789be4fb9ce..b00d9277e507e284130340b84cbbca8c07597d1f 100644 --- a/aleksis/apps/alsijil/preferences.py +++ b/aleksis/apps/alsijil/preferences.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from dynamic_preferences.preferences import Section -from dynamic_preferences.types import BooleanPreference, IntegerPreference +from dynamic_preferences.types import BooleanPreference, ChoicePreference, IntegerPreference from aleksis.core.registries import person_preferences_registry, site_preferences_registry @@ -157,3 +157,27 @@ class DefaultLessonDocumentationFilter(BooleanPreference): name = "default_lesson_documentation_filter" default = True verbose_name = _("Filter lessons by existence of their lesson documentation on default") + + +@site_preferences_registry.register +class AllowEditFutureDocumentations(ChoicePreference): + """Time range for which documentations may be edited.""" + + section = alsijil + name = "allow_edit_future_documentations" + default = "current_day" + choices = ( + ("all", _("Allow editing of all future documentations")), + ( + "current_day", + _("Allow editing of all documentations up to and including those on the current day"), + ), + ( + "current_time", + _( + "Allow editing of all documentations up to and " + "including those on the current date and time" + ), + ), + ) + verbose_name = _("Set time range for which documentations may be edited") diff --git a/aleksis/apps/alsijil/rules.py b/aleksis/apps/alsijil/rules.py index 015e47e59ade466adca78e065f1767beeb6bd5be..f48bf498db59bb86fc8c890cd1c802b46e698da6 100644 --- a/aleksis/apps/alsijil/rules.py +++ b/aleksis/apps/alsijil/rules.py @@ -22,6 +22,7 @@ from .util.predicates import ( is_group_member, is_group_owner, is_group_role_assignment_group_owner, + is_in_allowed_time_range, is_lesson_event_group_owner, is_lesson_event_teacher, is_lesson_original_teacher, @@ -400,8 +401,10 @@ add_perm( "alsijil.add_documentation_for_lesson_event_rule", add_documentation_for_lesson_event_predicate ) -edit_documentation_predicate = has_person & ( - has_global_perm("alsijil.change_documentation") | can_edit_documentation +edit_documentation_predicate = ( + has_person + & (has_global_perm("alsijil.change_documentation") | can_edit_documentation) + & is_in_allowed_time_range ) add_perm("alsijil.edit_documentation_rule", edit_documentation_predicate) add_perm("alsijil.delete_documentation_rule", edit_documentation_predicate) diff --git a/aleksis/apps/alsijil/schema/__init__.py b/aleksis/apps/alsijil/schema/__init__.py index afc8fd8d2c1835b69a9ace6c92d30312faaa665c..d762a9874e9ad85f98de86a2adff2fdd4af388fd 100644 --- a/aleksis/apps/alsijil/schema/__init__.py +++ b/aleksis/apps/alsijil/schema/__init__.py @@ -14,11 +14,7 @@ from aleksis.core.util.core_helpers import has_person from ..models import Documentation from .documentation import ( - DocumentationBatchCreateMutation, DocumentationBatchCreateOrUpdateMutation, - DocumentationBatchPatchMutation, - DocumentationCreateMutation, - DocumentationDeleteMutation, DocumentationType, ) @@ -35,6 +31,7 @@ class Query(graphene.ObjectType): obj_id=graphene.ID(required=False), date_start=graphene.Date(required=True), date_end=graphene.Date(required=True), + incomplete=graphene.Boolean(required=False), ) groups_by_person = FilterOrderList(GroupType, person=graphene.ID()) @@ -47,7 +44,15 @@ class Query(graphene.ObjectType): return documentations def resolve_documentations_for_coursebook( - root, info, own, date_start, date_end, obj_type=None, obj_id=None, **kwargs + root, + info, + own, + date_start, + date_end, + obj_type=None, + obj_id=None, + incomplete=False, + **kwargs, ): datetime_start = datetime.combine(date_start, datetime.min.time()) datetime_end = datetime.combine(date_end, datetime.max.time()) @@ -75,7 +80,7 @@ class Query(graphene.ObjectType): raise PermissionDenied() return Documentation.get_for_coursebook( - own, datetime_start, datetime_end, info.context, obj_type, obj_id + own, datetime_start, datetime_end, info.context, obj_type, obj_id, incomplete ) @staticmethod @@ -113,9 +118,4 @@ class Query(graphene.ObjectType): class Mutation(graphene.ObjectType): - create_documentation = DocumentationCreateMutation.Field() - create_documentations = DocumentationBatchCreateMutation.Field() - delete_documentation = DocumentationDeleteMutation.Field() - update_documentations = DocumentationBatchPatchMutation.Field() - create_or_update_documentations = DocumentationBatchCreateOrUpdateMutation.Field() diff --git a/aleksis/apps/alsijil/schema/documentation.py b/aleksis/apps/alsijil/schema/documentation.py index 43e958e910194be5a5b9adff9727c933093591b1..442f2156a92778861d5d5a7584e34a12ddae9767 100644 --- a/aleksis/apps/alsijil/schema/documentation.py +++ b/aleksis/apps/alsijil/schema/documentation.py @@ -1,26 +1,23 @@ -from datetime import datetime, timezone +from datetime import datetime from django.core.exceptions import PermissionDenied +from django.utils.timezone import localdate, localtime 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 reversion import create_revision, set_comment, set_user +from aleksis.apps.alsijil.util.predicates import can_edit_documentation, is_in_allowed_time_range from aleksis.apps.chronos.models import LessonEvent from aleksis.apps.cursus.models import Subject from aleksis.apps.cursus.schema import CourseType, SubjectType from aleksis.core.models import Person from aleksis.core.schema.base import ( - DeleteMutation, DjangoFilterMixin, - PermissionBatchPatchMixin, PermissionsTypeMixin, ) +from aleksis.core.util.core_helpers import get_site_preferences from ..models import Documentation @@ -50,90 +47,28 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp course = graphene.Field(CourseType, required=False) subject = graphene.Field(SubjectType, required=False) + future_notice = graphene.Boolean(required=False) + + old_id = graphene.ID(required=False) + @staticmethod def resolve_teachers(root: Documentation, info, **kwargs): if not str(root.pk).startswith("DUMMY") and hasattr(root, "teachers"): - return root.teachers + return root.teachers return root.lesson_event.teachers + @staticmethod + def resolve_future_notice(root: Documentation, info, **kwargs): + """Show whether the user can't edit the documentation because it's in the future.""" + return not is_in_allowed_time_range(info.context.user, root) and can_edit_documentation( + info.context.user, root + ) + @classmethod def get_queryset(cls, queryset, info): return get_objects_for_user(info.context.user, "alsijil.view_documentation", queryset) -class DocumentationCreateMutation(DjangoCreateMutation): - class Meta: - model = Documentation - only_fields = ( - "course", - "lesson_event", - "subject", - "topic", - "homework", - "group_note", - "datetime_start", - "datetime_end", - "date_start", - "date_end", - ) - optional_fields = ( - "course", - "lesson_event", - "subject", - "topic", - "homework", - "group_note", - "datetime_start", - "datetime_end", - "date_start", - "date_end", - ) - permissions = ("alsijil.add_documentation",) # FIXME - - -class DocumentationBatchCreateMutation(DjangoBatchCreateMutation): - class Meta: - model = Documentation - only_fields = ( - "id", - "course", - "lesson_event", - "subject", - "topic", - "homework", - "group_note", - "datetime_start", - "datetime_end", - "date_start", - "date_end", - ) - permissions = ("alsijil.add_documentation",) # FIXME - - -class DocumentationDeleteMutation(DeleteMutation): - klass = Documentation - permission_required = "alsijil.delete_documentation_rule" # FIXME - - -class DocumentationBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): - class Meta: - model = Documentation - only_fields = ( - "id", - "course", - "lesson_event", - "subject", - "topic", - "homework", - "group_note", - "datetime_start", - "datetime_end", - "date_start", - "date_end", - ) - permissions = ("alsijil.edit_documentation_rule",) # FIXME - - class DocumentationInputType(graphene.InputObjectType): id = graphene.ID(required=True) course = graphene.ID(required=False) @@ -158,33 +93,48 @@ class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation): # Sadly, we can't use the update_or_create method since create_defaults # is only introduced in Django 5.0 if _id.startswith("DUMMY"): - dummy, lesson_event_id, datetime_start, datetime_end = _id.split(";") + dummy, lesson_event_id, datetime_start_iso, datetime_end_iso = _id.split(";") lesson_event = LessonEvent.objects.get(id=lesson_event_id) - if not info.context.user.has_perm( + datetime_start = datetime.fromisoformat(datetime_start_iso).astimezone( + lesson_event.timezone + ) + datetime_end = datetime.fromisoformat(datetime_end_iso).astimezone( + lesson_event.timezone + ) + + if info.context.user.has_perm( "alsijil.add_documentation_for_lesson_event_rule", lesson_event + ) and ( + get_site_preferences()["alsijil__allow_edit_future_documentations"] == "all" + or ( + get_site_preferences()["alsijil__allow_edit_future_documentations"] + == "current_day" + and datetime_start.date() <= localdate() + ) + or ( + get_site_preferences()["alsijil__allow_edit_future_documentations"] + == "current_time" + and datetime_start <= localtime() + ) ): - raise PermissionDenied() - - # Timezone removal is necessary due to ISO style offsets are no valid timezones. - # Instead, we take the timezone from the lesson_event and save it in a dedicated field. - obj = Documentation.objects.create( - datetime_start=datetime.fromisoformat(datetime_start).replace(tzinfo=timezone.utc), - datetime_end=datetime.fromisoformat(datetime_end).replace(tzinfo=timezone.utc), - timezone=lesson_event.timezone, - lesson_event=lesson_event, - course=lesson_event.course, - subject=lesson_event.subject, - topic=doc.topic or "", - homework=doc.homework or "", - group_note=doc.group_note or "", - ) - if doc.teachers is not None: - obj.teachers.add(*doc.teachers) - else: - obj.teachers.set(lesson_event.teachers.all()) - obj.save() - return obj + obj = Documentation.objects.create( + datetime_start=datetime_start, + datetime_end=datetime_end, + lesson_event=lesson_event, + course=lesson_event.course, + subject=lesson_event.subject, + topic=doc.topic or "", + homework=doc.homework or "", + group_note=doc.group_note or "", + ) + if doc.teachers is not None: + obj.teachers.add(*doc.teachers) + else: + obj.teachers.set(lesson_event.teachers.all()) + obj.save() + return obj + raise PermissionDenied() else: obj = Documentation.objects.get(id=_id) @@ -208,6 +158,9 @@ class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation): @classmethod def mutate(cls, root, info, input): # noqa - objs = [cls.create_or_update(info, doc) for doc in input] + with create_revision(): + set_user(info.context.user) + set_comment("Updated in coursebook") + objs = [cls.create_or_update(info, doc) for doc in input] return DocumentationBatchCreateOrUpdateMutation(documentations=objs) diff --git a/aleksis/apps/alsijil/util/predicates.py b/aleksis/apps/alsijil/util/predicates.py index e337043174025ffd81d4f9d2a426f87ec10c47b7..b1d9c45e5a22edf54e3e1eef8befcd8d0602ce69 100644 --- a/aleksis/apps/alsijil/util/predicates.py +++ b/aleksis/apps/alsijil/util/predicates.py @@ -1,12 +1,14 @@ from typing import Any, Union from django.contrib.auth.models import User +from django.utils.timezone import localdate, localtime from rules import predicate from aleksis.apps.chronos.models import Event, ExtraLesson, LessonEvent, LessonPeriod from aleksis.apps.cursus.models import Course from aleksis.core.models import Group, Person +from aleksis.core.util.core_helpers import get_site_preferences from aleksis.core.util.predicates import check_object_permission from ..models import Documentation, PersonalNote @@ -390,9 +392,7 @@ def can_view_documentation(user: User, obj: Documentation): | is_lesson_event_group_owner(user, obj.lesson_event) ) if obj.course: - return ( - is_course_teacher(user, obj.course) - ) + return is_course_teacher(user, obj.course) return False @@ -407,3 +407,21 @@ def can_edit_documentation(user: User, obj: Documentation): if obj.course: return is_course_teacher(user, obj.course) return False + + +@predicate +def is_in_allowed_time_range(user: User, obj: Documentation): + """Predicate which checks if the documentation is in the allowed time range for editing.""" + if obj and ( + get_site_preferences()["alsijil__allow_edit_future_documentations"] == "all" + or ( + get_site_preferences()["alsijil__allow_edit_future_documentations"] == "current_day" + and obj.datetime_start.date() <= localdate() + ) + or ( + get_site_preferences()["alsijil__allow_edit_future_documentations"] == "current_time" + and obj.datetime_start <= localtime() + ) + ): + return True + return False