diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 906f44538761575dc686cb0569f9b2fb61145e94..f7b0d177ba0f73ca88d7e3c634459fa7cf0e305c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,15 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to `Semantic Versioning`_. +`4.0.0.dev2`_ - 2024-07-10 +-------------------------- + +Added +~~~~~ + +* Support for entering personal notes for students in the new coursebook interface. +* Support for entering tardiness for students in the new coursebook interface. + `4.0.0.dev1`_ - 2024-06-13 -------------------------- @@ -41,6 +50,14 @@ Fixed * Migrating failed due to an incorrect field reference. +`3.0.1`_ - 2023-09-02 +------------------- + +Fixed +~~~~~ + +* Migrations failed on empty database + `3.0`_ - 2023-05-15 ------------------- @@ -132,7 +149,7 @@ Changed ~~~~~~~ * Use start date of current SchoolTerm as default value for PersonalNote filter in overview. - +Julia ist eine höhere Programmiersprache, die vor allem für numerisches und wissenschaftliches Rechnen entwickelt wurde und auch als Allzweck-Programmiersprache verwendet werden kann, bei gleichzeitiger Wahrung einer hohen Ausführungsgeschwindigkeit. Wikipedia Fixed ~~~~~ @@ -351,4 +368,7 @@ Fixed .. _2.1.1: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/2.1.1 .. _3.0b0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/3.0b0 .. _3.0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/3.0 +.. _3.0.1: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/3.0.1 .. _4.0.0.dev0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/4.0.0.dev0 +.. _4.0.0.dev1: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/4.0.0.dev1 +.. _4.0.0.dev2: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/4.0.0.dev2 diff --git a/README.rst b/README.rst index 61e80f73c24aa9077218aa86a2427463f75f1f0f..53fbc493f0f1acfd35a0f893c816cfb117a27572 100644 --- a/README.rst +++ b/README.rst @@ -34,10 +34,11 @@ Licence Copyright © 2019, 2021 Dominik George <dominik.george@teckids.org> Copyright © 2019, 2020 Tom Teichler <tom.teichler@teckids.org> Copyright © 2019 mirabilos <thorsten.glaser@teckids.org> - Copyright © 2020, 2021, 2022 Jonathan Weth <dev@jonathanweth.de> - Copyright © 2020, 2021 Julian Leucker <leuckeju@katharineum.de> - Copyright © 2020, 2022 Hangzhi Yu <yuha@katharineum.de> + Copyright © 2020, 2021, 2022, 2024 Jonathan Weth <dev@jonathanweth.de> + Copyright © 2020, 2021, 2024 Julian Leucker <leuckeju@katharineum.de> + Copyright © 2020, 2022, 2023, 2024 Hangzhi Yu <yuha@katharineum.de> Copyright © 2021 Lloyd Meins <meinsll@katharineum.de> + Copyright © 2024 Michael Bauer <michael-bauer@posteo.de> Licenced under the EUPL, version 1.2 or later, by Teckids e.V. (Bonn, Germany). diff --git a/aleksis/apps/alsijil/apps.py b/aleksis/apps/alsijil/apps.py index b523b38afa08c965628afbfbe61327e8b03f7067..ab0877f2f457658463c034e09139d8098df5b8d3 100644 --- a/aleksis/apps/alsijil/apps.py +++ b/aleksis/apps/alsijil/apps.py @@ -13,8 +13,9 @@ class AlsijilConfig(AppConfig): ([2019, 2021], "Dominik George", "dominik.george@teckids.org"), ([2019, 2020], "Tom Teichler", "tom.teichler@teckids.org"), ([2019], "mirabilos", "thorsten.glaser@teckids.org"), - ([2020, 2021, 2022], "Jonathan Weth", "dev@jonathanweth.de"), - ([2020, 2021], "Julian Leucker", "leuckeju@katharineum.de"), - ([2020, 2022], "Hangzhi Yu", "yuha@katharineum.de"), + ([2020, 2021, 2022, 2024], "Jonathan Weth", "dev@jonathanweth.de"), + ([2020, 2021, 2024], "Julian Leucker", "leuckeju@katharineum.de"), + ([2020, 2022, 2023, 2024], "Hangzhi Yu", "yuha@katharineum.de"), ([2021], "Lloyd Meins", "meinsll@katharineum.de"), + ([2024], "Michael Bauer", "michael-bauer@posteo.de"), ) diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue index 11a0c85c853d0d7a175017d88c3bf8a21df936a8..7c1b9ac900da53ff7ab97a5b459e9be70891521f 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue @@ -35,6 +35,7 @@ @init="transition" :key="'day-' + date" ref="days" + :extra-marks="extraMarks" /> <coursebook-loader /> @@ -78,6 +79,7 @@ import { documentationsForCoursebook } from "./coursebook.graphql"; import CoursebookFilters from "./CoursebookFilters.vue"; import CoursebookLoader from "./CoursebookLoader.vue"; import CoursebookEmptyMessage from "./CoursebookEmptyMessage.vue"; +import { extraMarks } from "../extra_marks/extra_marks.graphql"; import AbsenceCreationDialog from "./absences/AbsenceCreationDialog.vue"; @@ -140,8 +142,15 @@ export default { initDate: false, currentDate: "", hashUpdater: false, + extraMarks: [], }; }, + apollo: { + extraMarks: { + query: extraMarks, + update: (data) => data.items, + }, + }, computed: { // Assertion: Should only fire on page load or selection change. // Resets date range. diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue index 2f15c3fd031c62ff9fc96cf5c35042f2274d3321..c4a677c9f5f72227537f7d842baa51902d9859ac 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue @@ -12,6 +12,7 @@ > <documentation-modal :documentation="doc" + :extra-marks="extraMarks" :affected-query="lastQuery" /> </v-list-item> @@ -45,6 +46,10 @@ export default { required: false, default: false, }, + extraMarks: { + type: Array, + required: true, + }, }, emits: ["init"], methods: { diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue index b47ebbfbb1d1cd8219686f05085eb0eeb8f453d3..00ff6b02fe2420d21396c500d20eca9a0f4678b1 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue @@ -3,6 +3,7 @@ <v-autocomplete :items="selectable" item-text="name" + :item-value="(item) => `${item.__typename}-${item.id}`" clearable return-object filled diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue index 3af1db58846f37b5e7e7837dba08a4468294269e..c0207c87ae5f44a5e36549474bfe02f146202e63 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue @@ -9,18 +9,26 @@ import documentationPartMixin from "../documentation/documentationPartMixin"; import LessonInformation from "../documentation/LessonInformation.vue"; import { updateParticipationStatuses } from "./participationStatus.graphql"; import SlideIterator from "aleksis.core/components/generic/SlideIterator.vue"; +import PersonalNotes from "../personal_notes/PersonalNotes.vue"; +import ExtraMarkChip from "../../extra_marks/ExtraMarkChip.vue"; +import TardinessChip from "./TardinessChip.vue"; +import TardinessField from "./TardinessField.vue"; export default { name: "ManageStudentsDialog", extends: MobileFullscreenDialog, components: { + TardinessChip, + ExtraMarkChip, AbsenceReasonChip, AbsenceReasonGroupSelect, AbsenceReasonButtons, + PersonalNotes, CancelButton, LessonInformation, MobileFullscreenDialog, SlideIterator, + TardinessField, }, mixins: [documentationPartMixin, mutateMixin], data() { @@ -46,14 +54,27 @@ export default { }, methods: { sendToServer(participations, field, value) { - if (field !== "absenceReason") return; + let fieldValue; + + if (field === "absenceReason") { + fieldValue = { + absenceReason: value === "present" ? null : value, + }; + } else if (field === "tardiness") { + fieldValue = { + tardiness: value, + }; + } else { + console.error(`Wrong field '${field}' for sendToServer`); + return; + } this.mutate( updateParticipationStatuses, { input: participations.map((participation) => ({ id: participation.id, - absenceReason: value === "present" ? null : value, + ...fieldValue, })), }, (storedDocumentations, incomingStatuses) => { @@ -66,6 +87,7 @@ export default { (part) => part.id === newStatus.id, ); participationStatus.absenceReason = newStatus.absenceReason; + participationStatus.tardiness = newStatus.tardiness; participationStatus.isOptimistic = newStatus.isOptimistic; }); @@ -144,8 +166,43 @@ export default { <v-list-item-title> {{ item.person.fullName }} </v-list-item-title> - <v-list-item-subtitle v-if="item.absenceReason"> - <absence-reason-chip small :absence-reason="item.absenceReason" /> + <v-list-item-subtitle + v-if=" + item.absenceReason || + item.notesWithNote?.length > 0 || + item.notesWithExtraMark?.length > 0 || + item.tardiness + " + class="d-flex flex-wrap gap" + > + <absence-reason-chip + v-if="item.absenceReason" + small + :absence-reason="item.absenceReason" + /> + <v-chip + v-for="note in item.notesWithNote" + :key="'text-note-note-overview-' + note.id" + small + > + <v-avatar left> + <v-icon small>mdi-note-outline</v-icon> + </v-avatar> + <span class="text-truncate" style="max-width: 30ch"> + {{ note.note }} + </span> + </v-chip> + <extra-mark-chip + v-for="note in item.notesWithExtraMark" + :key="'extra-mark-note-overview-' + note.id" + :extra-mark="extraMarks.find((e) => e.id === note.extraMark.id)" + small + /> + <tardiness-chip + v-if="item.tardiness" + :tardiness="item.tardiness" + small + /> </v-list-item-subtitle> </template> @@ -169,6 +226,23 @@ export default { :value="item.absenceReason?.id || 'present'" @input="sendToServer([item], 'absenceReason', $event)" /> + <tardiness-field + v-bind="documentationPartProps" + :loading="loading" + :disabled="loading" + :participation="item" + :value="item.tardiness" + @input="sendToServer([item], 'tardiness', $event)" + /> + </v-card-text> + <v-divider /> + <v-card-text> + <personal-notes + v-bind="documentationPartProps" + :participation=" + documentation.participations.find((p) => p.id === item.id) + " + /> </v-card-text> </template> </slide-iterator> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue index 572036c67955b3365bb46eb69f6ab41ee86cf074..a7854c9ae41c9fe73a7d011f60f1abd09ad42145 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue @@ -58,6 +58,7 @@ export default { v-bind="documentationPartProps" @update="() => null" :loading-indicator="loading" + v-if="!documentation.amends?.cancelled" > <template #activator="{ attrs, on }"> <v-chip diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/TardinessChip.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/TardinessChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..6be1fea3942fed64d37d2a68dde699344e55c35b --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/TardinessChip.vue @@ -0,0 +1,34 @@ +<script> +export default { + name: "TardinessChip", + props: { + tardiness: { + type: Number, + required: false, + default: 0, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + extends: "v-chip", +}; +</script> + +<template> + <v-chip dense outlined v-bind="$attrs" v-on="$listeners"> + <v-avatar left> + <v-icon small>mdi-clock-alert-outline</v-icon> + </v-avatar> + <slot name="prepend" /> + <slot> + {{ $tc("alsijil.personal_notes.minutes_late", tardiness) }} + </slot> + <slot name="append" /> + <v-avatar right v-if="loading"> + <v-progress-circular indeterminate :size="16" :width="2" /> + </v-avatar> + </v-chip> +</template> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/TardinessField.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/TardinessField.vue new file mode 100644 index 0000000000000000000000000000000000000000..74a85ae09018fcc810950e6b10a52a8e5a948607 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/TardinessField.vue @@ -0,0 +1,109 @@ +<script> +import { DateTime } from "luxon"; +import documentationPartMixin from "../documentation/documentationPartMixin"; +import ConfirmDialog from "aleksis.core/components/generic/dialogs/ConfirmDialog.vue"; +import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue"; + +export default { + name: "TardinessField", + components: { ConfirmDialog, PositiveSmallIntegerField }, + mixins: [documentationPartMixin], + props: { + value: { + type: Number, + default: null, + required: false, + }, + participation: { + type: Object, + required: true, + }, + }, + computed: { + lessonLength() { + const lessonStart = DateTime.fromISO(this.documentation.datetimeStart); + const lessonEnd = DateTime.fromISO(this.documentation.datetimeEnd); + + let diff = lessonEnd.diff(lessonStart, "minutes"); + return diff.toObject().minutes; + }, + }, + methods: { + lessonLengthRule(time) { + return ( + time == null || + time <= this.lessonLength || + this.$t("alsijil.personal_notes.lesson_length_exceeded") + ); + }, + saveValue(value) { + this.$emit("input", value); + this.previousValue = value; + }, + confirm() { + this.saveValue(0); + }, + cancel() { + this.saveValue(this.previousValue); + }, + set(newValue) { + if (!newValue) { + // this is a DELETE action, show the dialog, ... + this.showDeleteConfirm = true; + return; + } + + this.saveValue(newValue); + }, + }, + data() { + return { + showDeleteConfirm: false, + previousValue: 0, + }; + }, + mounted() { + this.previousValue = this.value; + }, +}; +</script> + +<template> + <positive-small-integer-field + outlined + class="mt-1" + prepend-inner-icon="mdi-clock-alert-outline" + :suffix="$t('time.minutes')" + :label="$t('alsijil.personal_notes.tardiness')" + :rules="[lessonLengthRule]" + :value="value" + @change="set($event)" + v-bind="$attrs" + > + <template #append> + <confirm-dialog + v-model="showDeleteConfirm" + @confirm="confirm" + @cancel="cancel" + > + <template #title> + {{ $t("alsijil.personal_notes.confirm_delete") }} + </template> + <template #text> + {{ + $t("alsijil.personal_notes.confirm_delete_tardiness", { + tardiness: previousValue, + name: participation.person.fullName, + }) + }} + </template> + </confirm-dialog> + </template> + </positive-small-integer-field> +</template> + +<style scoped> +.mt-n1-5 { + margin-top: -6px; +} +</style> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql index 81a3a5fb1eb3a99bef25eb9938cd254b9068981b..dd50495972c020550dec310081f0c8c149473d9e 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql @@ -14,6 +14,7 @@ mutation updateParticipationStatuses( shortName colour } + tardiness } } } @@ -35,6 +36,18 @@ mutation touchDocumentation($documentationId: ID!) { shortName colour } + notesWithExtraMark { + id + extraMark { + id + showInCoursebook + } + } + notesWithNote { + id + note + } + tardiness isOptimistic } } diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql index 6348a24f189033fc60e97325c0c69cde5d11fbc9..73f0dc3c281a5e03cfbcd7d29dff9f8dc2e61d2b 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql +++ b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql @@ -35,8 +35,10 @@ query documentationsForCoursebook( } amends { id + title amends { id + title teachers { id shortName @@ -79,6 +81,18 @@ query documentationsForCoursebook( shortName colour } + notesWithExtraMark { + id + extraMark { + id + showInCoursebook + } + } + notesWithNote { + id + note + } + tardiness isOptimistic } topic @@ -116,6 +130,7 @@ mutation createOrUpdateDocumentations($input: [DocumentationInputType]!) { shortName colour } + tardiness isOptimistic } } diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue index 460f39f97fc15b61d0476dd99ed33d29cf43029c..630085bd593f145e5a8a15c50d686a2e48cf6a20 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue @@ -4,7 +4,12 @@ <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" + :extra-marks="extraMarks" + /> </template> <!-- dialog view -> deactivate dialog --> <!-- cancel | save (through lesson-summary) --> @@ -27,5 +32,11 @@ export default { popup: false, }; }, + props: { + extraMarks: { + type: Array, + required: true, + }, + }, }; </script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue index 652609dccaf430d3a4ab138f80ee2f810b84b4af..890e557e162c64868de3a0362b248cea4c2604d8 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue @@ -24,7 +24,11 @@ import PersonChip from "aleksis.core/components/person/PersonChip.vue"; 'font-weight-medium': largeGrid, }" > - {{ documentation.course?.name }} + {{ + documentation.course?.name || + documentation.amends.title || + documentation.amends.amends.title + }} </span> <div :class="{ @@ -38,6 +42,10 @@ import PersonChip from "aleksis.core/components/person/PersonChip.vue"; :subject="documentation.subject" v-bind="compact ? dialogActivator.attrs : {}" v-on="compact ? dialogActivator.on : {}" + :class="{ + 'text-decoration-line-through': documentation.amends?.cancelled, + }" + :disabled="documentation.amends?.cancelled" /> <subject-chip v-if=" diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue index bc0da4a742917e0639a0c1983186fad29764babb..64bbb59cb8a4bffdabf9b0d641f2a6e415a2ba69 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue @@ -1,5 +1,7 @@ <script setup> import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue"; +import ExtraMarkChip from "../../extra_marks/ExtraMarkChip.vue"; +import TardinessChip from "../absences/TardinessChip.vue"; </script> <template> @@ -35,6 +37,56 @@ import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip. </template> </absence-reason-chip> + <extra-mark-chip + v-for="[markId, [mark, ...participations]] in Object.entries( + extraMarkChips, + )" + :key="'extra-mark-' + markId" + :extra-mark="mark" + dense + > + <template #append> + <span + >: + <span> + {{ + participations + .slice(0, 5) + .map((participation) => participation.person.firstName) + .join(", ") + }} + </span> + <span v-if="participations.length > 5"> + <!-- eslint-disable @intlify/vue-i18n/no-raw-text --> + +{{ participations.length - 5 }} + <!-- eslint-enable @intlify/vue-i18n/no-raw-text --> + </span> + </span> + </template> + </extra-mark-chip> + + <tardiness-chip v-if="tardyParticipations.length > 0"> + {{ $t("alsijil.personal_notes.late") }} + + <template #append> + <span + >: + {{ + tardyParticipations + .slice(0, 5) + .map((participation) => participation.person.firstName) + .join(", ") + }} + + <span v-if="tardyParticipations.length > 5"> + <!-- eslint-disable @intlify/vue-i18n/no-raw-text --> + +{{ tardyParticipations.length - 5 }} + <!-- eslint-enable @intlify/vue-i18n/no-raw-text --> + </span> + </span> + </template> + </tardiness-chip> + <manage-students-trigger v-bind="documentationPartProps" /> </div> </template> @@ -51,13 +103,18 @@ export default { total() { return this.documentation.participations.length; }, + /** + * Return the number of present people. + */ present() { return this.documentation.participations.filter( (p) => p.absenceReason === null, ).length; }, + /** + * Get all course attendants who have an absence reason, grouped by that reason. + */ absences() { - // Get all course attendants who have an absence reason return Object.groupBy( this.documentation.participations.filter( (p) => p.absenceReason !== null, @@ -65,6 +122,42 @@ export default { ({ absenceReason }) => absenceReason.id, ); }, + /** + * Parse and combine all extraMark notes. + * + * Notes with extraMarks are grouped by ExtraMark. ExtraMarks with the showInCoursebook property set to false are ignored. + * @return An object where the keys are extraMark IDs and the values have the structure [extraMark, note1, note2, ..., noteN] + */ + extraMarkChips() { + // Apply the inner function to each participation, with value being the resulting object + return this.documentation.participations.reduce((value, p) => { + // Go through every extra mark of this participation + for (const { extraMark } of p.notesWithExtraMark) { + // Only proceed if the extraMark should be displayed here + if (!extraMark.showInCoursebook) { + continue; + } + + // value[extraMark.id] is an Array with the structure [extraMark, note1, note2, ..., noteN] + if (value[extraMark.id]) { + value[extraMark.id].push(p); + } else { + value[extraMark.id] = [ + this.extraMarks.find((e) => e.id === extraMark.id), + p, + ]; + } + } + + return value; + }, {}); + }, + /** + * Return a list Participations with a set tardiness + */ + tardyParticipations() { + return this.documentation.participations.filter((p) => p.tardiness); + }, }, }; </script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js index 88a8e852f8cc6e333303034fb5f590d174708886..35243ee3ca5e055c0d2fd5375fba614bbbd6e246 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js @@ -33,6 +33,13 @@ export default { required: false, default: () => ({ attrs: {}, on: {} }), }, + /** + * Once loaded list of all extra marks to avoid excessive network and database queries + */ + extraMarks: { + type: Array, + required: true, + }, }, computed: { @@ -46,6 +53,7 @@ export default { compact: this.compact, dialogActivator: this.dialogActivator, affectedQuery: this.affectedQuery, + extraMarks: this.extraMarks, }; }, }, diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/ExtraMarkNoteCheckbox.vue b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/ExtraMarkNoteCheckbox.vue new file mode 100644 index 0000000000000000000000000000000000000000..1f31f9ee1cf6c557ee2fe7b86134793629f9063f --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/ExtraMarkNoteCheckbox.vue @@ -0,0 +1,104 @@ +<script> +import { + createPersonalNotes, + deletePersonalNotes, +} from "./personal_notes.graphql"; +import personalNoteRelatedMixin from "./personalNoteRelatedMixin"; +import mutateMixin from "aleksis.core/mixins/mutateMixin.js"; + +export default { + name: "ExtraMarkNoteCheckbox", + mixins: [mutateMixin, personalNoteRelatedMixin], + props: { + personalNote: { + type: Object, + default: null, + }, + /** + * Extra Mark + */ + value: { + type: Object, + required: true, + }, + }, + computed: { + model: { + get() { + return !!this.personalNote?.id; + }, + set(newValue) { + if (newValue && !this.personalNote) { + // CREATE new personal note + this.mutate( + createPersonalNotes, + { + input: [ + { + documentation: this.documentation.id, + person: this.participation.person.id, + extraMark: this.value.id, + }, + ], + }, + (storedDocumentations, incomingPersonalNotes) => { + const note = incomingPersonalNotes[0]; + const documentation = storedDocumentations.find( + (doc) => doc.id === this.documentation.id, + ); + const participationStatus = documentation.participations.find( + (part) => part.id === this.participation.id, + ); + participationStatus.notesWithExtraMark.push(note); + + return storedDocumentations; + }, + ); + } else { + // DELETE personal note + this.mutate( + deletePersonalNotes, + { + ids: [this.personalNote.id], + }, + (storedDocumentations) => { + const documentation = storedDocumentations.find( + (doc) => doc.id === this.documentation.id, + ); + const participationStatus = documentation.participations.find( + (part) => part.id === this.participation.id, + ); + const index = participationStatus.notesWithExtraMark.findIndex( + (n) => n.id === this.personalNote.id, + ); + participationStatus.notesWithExtraMark.splice(index, 1); + + return storedDocumentations; + }, + ); + } + }, + }, + }, +}; +</script> + +<template> + <v-checkbox + :label="value.name" + :value="value.id" + v-model="model" + :disabled="loading" + :true-value="true" + :false-value="false" + > + <template #append> + <v-progress-circular + v-if="loading" + indeterminate + :size="16" + :width="2" + ></v-progress-circular> + </template> + </v-checkbox> +</template> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/ExtraMarksNote.vue b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/ExtraMarksNote.vue new file mode 100644 index 0000000000000000000000000000000000000000..177b21e803f073e7965815662a00727b8b8a24e4 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/ExtraMarksNote.vue @@ -0,0 +1,29 @@ +<script> +import { extraMarks } from "../../extra_marks/extra_marks.graphql"; +import ExtraMarkNoteCheckbox from "./ExtraMarkNoteCheckbox.vue"; +import personalNoteRelatedMixin from "./personalNoteRelatedMixin"; + +export default { + name: "ExtraMarksNote", + components: { ExtraMarkNoteCheckbox }, + mixins: [personalNoteRelatedMixin], + props: { + value: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <extra-mark-note-checkbox + v-for="extraMark in extraMarks" + :key="'checkbox-extramark-' + extraMark.id" + v-bind="personalNoteRelatedProps" + :value="extraMark" + :personal-note="value.find((pn) => pn.extraMark.id === extraMark.id)" + /> + </div> +</template> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/PersonalNotes.vue b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/PersonalNotes.vue new file mode 100644 index 0000000000000000000000000000000000000000..2a3d953f03484ef2af2f1ac7a8f876bcb658f8bf --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/PersonalNotes.vue @@ -0,0 +1,28 @@ +<script setup> +import ExtraMarksNote from "./ExtraMarksNote.vue"; +import TextNotes from "./TextNotes.vue"; +</script> +<script> +import personalNoteRelatedMixin from "./personalNoteRelatedMixin"; + +export default { + name: "PersonalNotes", + mixins: [personalNoteRelatedMixin], +}; +</script> + +<template> + <div> + <text-notes + v-bind="personalNoteRelatedProps" + :value="participation.notesWithNote" + /> + + <extra-marks-note + v-bind="personalNoteRelatedProps" + :value="participation.notesWithExtraMark" + /> + </div> +</template> + +<style scoped></style> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/TextNote.vue b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/TextNote.vue new file mode 100644 index 0000000000000000000000000000000000000000..43175c7402100c5ef9e4b84c153a8e79a9bb7f76 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/TextNote.vue @@ -0,0 +1,164 @@ +<script> +import { + createPersonalNotes, + deletePersonalNotes, + updatePersonalNotes, +} from "./personal_notes.graphql"; +import personalNoteRelatedMixin from "./personalNoteRelatedMixin"; +import mutateMixin from "aleksis.core/mixins/mutateMixin.js"; +import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue"; + +export default { + name: "TextNote", + components: { DeleteDialog }, + mixins: [mutateMixin, personalNoteRelatedMixin], + props: { + value: { + type: Object, + required: true, + }, + }, + computed: { + model: { + get() { + return this.value.note; + }, + set(newValue) { + if (!newValue) { + // this is a DELETE action, show the dialog, ... + this.showDeleteConfirm = true; + return; + } + const create = !this.value.id; + + this.mutate( + create ? createPersonalNotes : updatePersonalNotes, + this.getInput( + newValue, + create + ? { + documentation: this.documentation.id, + person: this.participation.person.id, + extraMark: null, + } + : { + id: this.value.id, + }, + ), + this.getUpdater(create ? "create" : "update"), + ); + }, + }, + }, + methods: { + getInput(newValue, extras) { + return { + input: [ + { + note: newValue, + ...extras, + }, + ], + }; + }, + getUpdater(mode) { + return (storedDocumentations, incomingPersonalNotes) => { + const note = incomingPersonalNotes?.[0] || undefined; + const documentation = storedDocumentations.find( + (doc) => doc.id === this.documentation.id, + ); + const participationStatus = documentation.participations.find( + (part) => part.id === this.participation.id, + ); + switch (mode.toLowerCase()) { + case "update": + case "delete": { + const updateIndex = participationStatus.notesWithNote.findIndex( + (n) => n.id === this.value.id, + ); + if (mode === "update") { + participationStatus.notesWithNote.splice(updateIndex, 1, note); + } else { + participationStatus.notesWithNote.splice(updateIndex, 1); + } + + break; + } + + case "create": + participationStatus.notesWithNote.push(note); + + this.$emit("create"); + break; + + default: + console.error("Invalid mode in getUpdater():", mode); + break; + } + + return storedDocumentations; + }; + }, + }, + data() { + return { + showDeleteConfirm: false, + deletePersonalNotes, + }; + }, +}; +</script> + +<template> + <v-textarea + auto-grow + :rows="3" + outlined + hide-details + class="mb-4" + :label="$t('alsijil.personal_notes.note')" + :value="model" + @change="model = $event" + :loading="loading" + > + <template #append> + <v-slide-x-reverse-transition> + <v-btn + v-if="!!model" + icon + @click="showDeleteConfirm = true" + class="mt-n1-5" + > + <v-icon> $deleteContent </v-icon> + </v-btn> + </v-slide-x-reverse-transition> + + <delete-dialog + v-model="showDeleteConfirm" + :gql-delete-mutation="deletePersonalNotes" + :affected-query="affectedQuery" + item-attribute="fullName" + :items="[value]" + :custom-update="getUpdater('delete')" + > + <template #title> + {{ $t("alsijil.personal_notes.confirm_delete") }} + </template> + <template #body> + {{ + $t("alsijil.personal_notes.confirm_delete_explanation", { + note: value.note, + name: participation.person.fullName, + }) + }} + </template> + </delete-dialog> + </template> + </v-textarea> +</template> + +<style scoped> +.mt-n1-5 { + margin-top: -6px; +} +</style> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/TextNotes.vue b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/TextNotes.vue new file mode 100644 index 0000000000000000000000000000000000000000..dd32164db251b16acdc8b61daa29559b9a776fde --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/TextNotes.vue @@ -0,0 +1,48 @@ +<script setup> +import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue"; +import TextNote from "./TextNote.vue"; +</script> +<script> +import personalNoteRelatedMixin from "./personalNoteRelatedMixin"; + +export default { + name: "TextNotes", + mixins: [personalNoteRelatedMixin], + props: { + value: { + type: Array, + required: true, + }, + }, + data() { + return { + showNewNote: true, + }; + }, + computed: { + notes() { + return this.showNewNote ? [...this.value, { note: "" }] : this.value; + }, + }, +}; +</script> + +<template> + <div> + <text-note + v-for="note in notes" + :key="note.id || -1" + v-bind="personalNoteRelatedProps" + :value="note" + @create="showNewNote = false" + /> + + <secondary-action-button + i18n-key="alsijil.personal_notes.create_personal_note" + icon-text="$plus" + class="full-width" + @click="showNewNote = true" + :disabled="showNewNote" + /> + </div> +</template> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/personalNoteRelatedMixin.js b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/personalNoteRelatedMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..0eda6c4ca625afb16742d9ea8e585254d041e970 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/personalNoteRelatedMixin.js @@ -0,0 +1,19 @@ +import documentationPartMixin from "../documentation/documentationPartMixin"; + +export default { + mixins: [documentationPartMixin], + props: { + participation: { + type: Object, + required: true, + }, + }, + computed: { + personalNoteRelatedProps() { + return { + ...this.documentationPartProps, + participation: this.participation, + }; + }, + }, +}; diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/personal_notes.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/personal_notes.graphql new file mode 100644 index 0000000000000000000000000000000000000000..014178c1326960f02dee24cf92f8eb5c679298f5 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/personal_notes/personal_notes.graphql @@ -0,0 +1,45 @@ +query personalNotes($orderBy: [String], $filters: JSONString) { + items: personalNotes(orderBy: $orderBy, filters: $filters) { + id + note + extraMark { + id + } + canEdit + canDelete + } +} + +mutation createPersonalNotes($input: [BatchCreatePersonalNoteInput]!) { + createPersonalNotes(input: $input) { + items: personalNotes { + id + note + extraMark { + id + } + canEdit + canDelete + } + } +} + +mutation deletePersonalNotes($ids: [ID]!) { + deletePersonalNotes(ids: $ids) { + deletionCount + } +} + +mutation updatePersonalNotes($input: [BatchPatchPersonalNoteInput]!) { + updatePersonalNotes(input: $input) { + items: personalNotes { + id + note + extraMark { + id + } + canEdit + canDelete + } + } +} diff --git a/aleksis/apps/alsijil/frontend/components/extra_marks/ExtraMarkChip.vue b/aleksis/apps/alsijil/frontend/components/extra_marks/ExtraMarkChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..da5cd4182d6f25965374934a31d64691dbdf852f --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/extra_marks/ExtraMarkChip.vue @@ -0,0 +1,49 @@ +<script> +import CounterChip from "aleksis.core/components/generic/chips/CounterChip.vue"; + +export default { + name: "ExtraMarkChip", + components: { CounterChip }, + props: { + extraMark: { + type: Object, + required: true, + }, + short: { + type: Boolean, + required: false, + default: false, + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + extends: CounterChip, + computed: { + text() { + return this.short ? this.extraMark.shortName : this.extraMark.name; + }, + }, +}; +</script> + +<template> + <counter-chip + :color="extraMark.colourBg" + :text-color="extraMark.colourFg" + :value="extraMark.id" + :count="count" + :outlined="false" + v-bind="$attrs" + v-on="$listeners" + > + <slot name="prepend" /> + {{ text }} + <slot name="append" /> + <v-avatar right v-if="loading"> + <v-progress-circular indeterminate :size="16" :width="2" /> + </v-avatar> + </counter-chip> +</template> diff --git a/aleksis/apps/alsijil/frontend/components/extra_marks/ExtraMarks.vue b/aleksis/apps/alsijil/frontend/components/extra_marks/ExtraMarks.vue new file mode 100644 index 0000000000000000000000000000000000000000..9468ac1dba5d291abd251659586d24aa95fe0278 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/extra_marks/ExtraMarks.vue @@ -0,0 +1,138 @@ +<script setup> +import ColorField from "aleksis.core/components/generic/forms/ColorField.vue"; +import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; +</script> + +<template> + <v-container> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="alsijil.extra_marks.create" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :default-item="defaultItem" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #name.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + :rules="$rules().required.build()" + /> + </div> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #shortName.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + :rules="$rules().required.build()" + /> + </div> + </template> + + <template #colourFg="{ item }"> + <v-chip :color="item.colourFg" outlined v-if="item.colourFg"> + {{ item.colourFg }} + </v-chip> + <span v-else>–</span> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #colourFg.field="{ attrs, on }"> + <color-field v-bind="attrs" v-on="on" /> + </template> + + <template #colourBg="{ item }"> + <v-chip :color="item.colourBg" outlined v-if="item.colourBg"> + {{ item.colourBg }} + </v-chip> + <span v-else>–</span> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #colourBg.field="{ attrs, on }"> + <color-field v-bind="attrs" v-on="on" /> + </template> + + <template #showInCoursebook="{ item }"> + <v-switch + :input-value="item.showInCoursebook" + disabled + inset + :false-value="false" + :true-value="true" + /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #showInCoursebook.field="{ attrs, on }"> + <v-switch + v-bind="attrs" + v-on="on" + inset + :false-value="false" + :true-value="true" + :hint="$t('alsijil.extra_marks.show_in_coursebook_helptext')" + persistent-hint + /> + </template> + </inline-c-r-u-d-list> + </v-container> +</template> + +<script> +import formRulesMixin from "aleksis.core/mixins/formRulesMixin.js"; +import { + extraMarks, + createExtraMarks, + deleteExtraMarks, + updateExtraMarks, +} from "./extra_marks.graphql"; + +export default { + name: "ExtraMarks", + mixins: [formRulesMixin], + data() { + return { + headers: [ + { + text: this.$t("alsijil.extra_marks.short_name"), + value: "shortName", + }, + { + text: this.$t("alsijil.extra_marks.name"), + value: "name", + }, + { + text: this.$t("alsijil.extra_marks.colour_fg"), + value: "colourFg", + }, + { + text: this.$t("alsijil.extra_marks.colour_bg"), + value: "colourBg", + }, + { + text: this.$t("alsijil.extra_marks.show_in_coursebook"), + value: "showInCoursebook", + }, + ], + i18nKey: "alsijil.extra_marks", + gqlQuery: extraMarks, + gqlCreateMutation: createExtraMarks, + gqlPatchMutation: updateExtraMarks, + gqlDeleteMutation: deleteExtraMarks, + defaultItem: { + shortName: "", + name: "", + colourFg: "", + colourBg: "", + showInCoursebook: true, + }, + }; + }, +}; +</script> diff --git a/aleksis/apps/alsijil/frontend/components/extra_marks/extra_marks.graphql b/aleksis/apps/alsijil/frontend/components/extra_marks/extra_marks.graphql new file mode 100644 index 0000000000000000000000000000000000000000..73e8ba4121c215dc4c3968b3ed2021b71b5ecfc1 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/extra_marks/extra_marks.graphql @@ -0,0 +1,48 @@ +query extraMarks($orderBy: [String], $filters: JSONString) { + items: extraMarks(orderBy: $orderBy, filters: $filters) { + id + shortName + name + colourFg + colourBg + showInCoursebook + canEdit + canDelete + } +} + +mutation createExtraMarks($input: [BatchCreateExtraMarkInput]!) { + createExtraMarks(input: $input) { + items: extraMarks { + id + shortName + name + colourFg + colourBg + showInCoursebook + canEdit + canDelete + } + } +} + +mutation deleteExtraMarks($ids: [ID]!) { + deleteExtraMarks(ids: $ids) { + deletionCount + } +} + +mutation updateExtraMarks($input: [BatchPatchExtraMarkInput]!) { + updateExtraMarks(input: $input) { + items: extraMarks { + id + shortName + name + colourFg + colourBg + showInCoursebook + canEdit + canDelete + } + } +} diff --git a/aleksis/apps/alsijil/frontend/index.js b/aleksis/apps/alsijil/frontend/index.js index 1ec79280e06ef377c4be215c27b5291c51043522..e73b19d83631d0953a269f037fc064257bb187d4 100644 --- a/aleksis/apps/alsijil/frontend/index.js +++ b/aleksis/apps/alsijil/frontend/index.js @@ -16,45 +16,6 @@ export default { byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, }, children: [ - { - path: "extra_marks/", - component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"), - name: "alsijil.extraMarks", - meta: { - inMenu: true, - titleKey: "alsijil.extra_marks.menu_title", - icon: "mdi-label-variant-outline", - iconActive: "mdi-label-variant", - permission: "alsijil.view_extramarks_rule", - }, - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - }, - { - path: "extra_marks/create/", - component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"), - name: "alsijil.createExtraMark", - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - }, - { - path: "extra_marks/:pk(\\d+)/edit/", - component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"), - name: "alsijil.editExtraMark", - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - }, - { - path: "extra_marks/:pk(\\d+)/delete/", - component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"), - name: "alsijil.deleteExtraMark", - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - }, { path: "coursebook/", component: () => import("./components/coursebook/Coursebook.vue"), @@ -91,5 +52,17 @@ export default { }, ], }, + { + path: "extra_marks/", + component: () => import("./components/extra_marks/ExtraMarks.vue"), + name: "alsijil.extraMarks", + meta: { + inMenu: true, + titleKey: "alsijil.extra_marks.menu_title", + icon: "mdi-label-variant-outline", + iconActive: "mdi-label-variant", + permission: "alsijil.view_extramarks_rule", + }, + }, ], }; diff --git a/aleksis/apps/alsijil/frontend/messages/de.json b/aleksis/apps/alsijil/frontend/messages/de.json index 321fddf6acbb2da81cbad7752733859e0323ca0e..e47b2b78a0b7e9b9de790156b3182fbe6c4b3226 100644 --- a/aleksis/apps/alsijil/frontend/messages/de.json +++ b/aleksis/apps/alsijil/frontend/messages/de.json @@ -64,6 +64,8 @@ }, "extra_marks": { "menu_title": "Zusätzliche Markierungen", + "title": "Zusätzliche Markierung", + "title_plural": "Zusätzliche Markierungen", "create": "Markierung erstellen", "name": "Markierung", "short_name": "Abkürzung", @@ -75,8 +77,13 @@ "personal_notes": { "note": "Notiz", "create_personal_note": "Weitere Notiz", - "confirm_delete": "Notiz wirklich löschen?", - "confirm_delete_explanation": "Die Notiz \"{note}\" für {name} wird entfernt." + "tardiness": "Verspätung", + "late": "Verspätet", + "minutes_late": "pünktlich | eine Minute verspätet | {n} Minuten zu spät", + "lesson_length_exceeded": "Die Verspätung überschreitet die Stundenlänge.", + "confirm_delete": "Anmerkung wirklich löschen?", + "confirm_delete_explanation": "Die Notiz \"{note}\" für {name} wird entfernt.", + "confirm_delete_tardiness": "Die Verspätung von {name} in Höhe von {tardiness} Minuten wird entfernt." }, "group_roles": { "menu_title_assign": "Gruppenrollen zuweisen", @@ -101,5 +108,8 @@ }, "actions": { "back_to_overview": "Zurück zur Übersicht" + }, + "time": { + "minutes": "Minuten" } } diff --git a/aleksis/apps/alsijil/frontend/messages/en.json b/aleksis/apps/alsijil/frontend/messages/en.json index ccbda8b436044c8505397c005ebf3452c01a9b94..3ad99b40cadee92070cf5a2b1ab287343b2a8e6f 100644 --- a/aleksis/apps/alsijil/frontend/messages/en.json +++ b/aleksis/apps/alsijil/frontend/messages/en.json @@ -19,7 +19,16 @@ "menu_title": "My overview" }, "extra_marks": { - "menu_title": "Extra marks" + "menu_title": "Extra marks", + "title": "Extra mark", + "title_plural": "Extra marks", + "create": "Create Extra mark", + "name": "Mark", + "short_name": "Abbreviation", + "colour_fg": "Foreground Colour", + "colour_bg": "Background Colour", + "show_in_coursebook": "Show in Coursebook Overview", + "show_in_coursebook_helptext": "When checked, this extra mark will be displayed in the lesson summary in the coursebook" }, "excuse_types": { "menu_title": "Excuse types" @@ -85,9 +94,23 @@ "success": "The absences were registered successfully.", "warning": "The following lessons are in the selected time period. Please check that you want to register the absences for these lessons before confirming." } + }, + "personal_notes": { + "note": "Note", + "create_personal_note": "Add another note", + "tardiness": "Tardiness", + "late": "Late", + "minutes_late": "on time | one minute late | {n} minutes late", + "lesson_length_exceeded": "The tardiness exceeds the length of the lesson", + "confirm_delete": "Delete note?", + "confirm_delete_explanation": "The note \"{note}\" for {name} will be removed.", + "confirm_delete_tardiness": "The tardiness of {tardiness} minutes will be removed for {name}." } }, "actions": { "back_to_overview": "Back to overview" + }, + "time": { + "minutes": "minutes" } } diff --git a/aleksis/apps/alsijil/migrations/0023_add_tardiness_and_rework_constraints.py b/aleksis/apps/alsijil/migrations/0023_add_tardiness_and_rework_constraints.py new file mode 100644 index 0000000000000000000000000000000000000000..3ca24f5865ddf5d3fb02acbd0aa7522bbc8bda13 --- /dev/null +++ b/aleksis/apps/alsijil/migrations/0023_add_tardiness_and_rework_constraints.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.10 on 2024-07-01 13:44 +# Updated for more custom logic + +from django.db import migrations, models + + +def forwards__unique_extra_mark_documentation(apps, schema_editor): + NewPersonalNote = apps.get_model("alsijil", "NewPersonalNote") # noqa + db_alias = schema_editor.connection.alias + + duplicates = (NewPersonalNote.objects.using(db_alias) + .values("documentation", "extra_mark", "person") + .annotate(count=models.Count("id")) + .filter(count__gt=1, extra_mark__isnull=False)) + + # Iterate over duplicates and delete the extra instances + for duplicate in duplicates: + pks = (NewPersonalNote + .objects + .using(db_alias) + .filter(person=duplicate["person"], documentation=duplicate["documentation"], extra_mark=duplicate["extra_mark"]) + .values_list("pk", flat=True) + )[1:] + NewPersonalNote.objects.using(db_alias).filter(pk__in=pks).delete() + + +def reverse__unique_extra_mark_documentation(apps, schema_editor): + # Nothing to do, we cannot bring back the deleted objects, but they were duplicate data, so they are not needed anyway. + pass + + +class Migration(migrations.Migration): + dependencies = [ + ('alsijil', '0022_documentation_participation_touched_at'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='newpersonalnote', + name='unique_absence_per_documentation', + ), + migrations.AddField( + model_name='participationstatus', + name='tardiness', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Tardiness'), + ), + migrations.AlterField( + model_name='newpersonalnote', + name='note', + field=models.TextField(blank=True, default='', verbose_name='Note'), + ), + migrations.AddConstraint( + model_name='newpersonalnote', + constraint=models.CheckConstraint( + check=models.Q(models.Q(('note', ''), _negated=True), ('extra_mark__isnull', False), _connector='OR'), + name='either_note_or_extra_mark_per_note'), + ), + migrations.RunPython(forwards__unique_extra_mark_documentation, reverse__unique_extra_mark_documentation), + migrations.AddConstraint( + model_name='newpersonalnote', + constraint=models.UniqueConstraint( + condition=models.Q(('extra_mark', None), _negated=True), + fields=('person', 'documentation', 'extra_mark'), + name='unique_person_documentation_extra_mark', + violation_error_message='A person got assigned the same extra mark multiple times per documentation.'), + ), + ] diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index 6be1734a116c928e11a8a9e8933470d6c2683965..cca46c9cb755c4a116c3e157484b265b8f4f4ae4 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -708,6 +708,7 @@ class Documentation(CalendarEvent): self.participation_touched_at or not self.amends or self.value_start_datetime(self) > now() + or self.amends.cancelled ): # There is no source to update from or it's too early return @@ -803,6 +804,8 @@ class ParticipationStatus(CalendarEvent): verbose_name=_("Base Absence"), ) + tardiness = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name=_("Tardiness")) + @classmethod def get_objects( cls, request: HttpRequest | None = None, params: dict[str, any] | None = None @@ -869,7 +872,7 @@ class NewPersonalNote(ExtensibleModel): null=True, ) - note = models.TextField(blank=True, verbose_name=_("Note")) + note = models.TextField(blank=True, default="", verbose_name=_("Note")) extra_mark = models.ForeignKey( ExtraMark, on_delete=models.PROTECT, blank=True, null=True, verbose_name=_("Extra Mark") ) @@ -881,9 +884,18 @@ class NewPersonalNote(ExtensibleModel): verbose_name = _("Personal Note") verbose_name_plural = _("Personal Notes") constraints = [ + # This constraint could be dropped in future scenarios models.CheckConstraint( check=~Q(note="") | Q(extra_mark__isnull=False), - name="unique_absence_per_documentation", + name="either_note_or_extra_mark_per_note", + ), + models.UniqueConstraint( + fields=["person", "documentation", "extra_mark"], + name="unique_person_documentation_extra_mark", + violation_error_message=_( + "A person got assigned the same extra mark multiple times per documentation." + ), + condition=~Q(extra_mark=None), ), ] diff --git a/aleksis/apps/alsijil/rules.py b/aleksis/apps/alsijil/rules.py index 836c7383f2aa37314a2d31e975dfa81c02bf7936..c1ffafbe856ffa64c91a17e3eaf062942debbe40 100644 --- a/aleksis/apps/alsijil/rules.py +++ b/aleksis/apps/alsijil/rules.py @@ -13,9 +13,11 @@ from aleksis.core.util.predicates import ( from .util.predicates import ( can_edit_documentation, can_edit_participation_status, + can_edit_personal_note, can_view_any_documentation, can_view_documentation, can_view_participation_status, + can_view_personal_note, has_lesson_group_object_perm, has_person_group_object_perm, has_personal_note_group_perm, @@ -278,6 +280,10 @@ add_perm("alsijil.delete_excusetype_rule", delete_excusetype_predicate) view_extramarks_predicate = has_person & has_global_perm("alsijil.view_extramark") add_perm("alsijil.view_extramarks_rule", view_extramarks_predicate) +# Fetch all extra marks +fetch_extramarks_predicate = has_person +add_perm("alsijil.fetch_extramarks_rule", fetch_extramarks_predicate) + # Add extra mark add_extramark_predicate = view_extramarks_predicate & has_global_perm("alsijil.add_extramark") add_perm("alsijil.add_extramark_rule", add_extramark_predicate) @@ -432,3 +438,21 @@ add_perm( "alsijil.edit_participation_status_for_documentation_rule", edit_participation_status_for_documentation_predicate, ) + +view_personal_note_predicate = has_person & ( + has_global_perm("alsijil.change_newpersonalnote") | can_view_personal_note +) +add_perm( + "alsijil.view_personal_note_rule", + view_personal_note_predicate, +) + +edit_personal_note_predicate = ( + has_person + & (has_global_perm("alsijil.change_newpersonalnote") | can_edit_personal_note) + & is_in_allowed_time_range +) +add_perm( + "alsijil.edit_personal_note_rule", + edit_personal_note_predicate, +) diff --git a/aleksis/apps/alsijil/schema/__init__.py b/aleksis/apps/alsijil/schema/__init__.py index ec8a9b025067e53126b3642a295caa4b10c492bd..9b715583e32a391f0888b0d54d1cb29c8d5e4f79 100644 --- a/aleksis/apps/alsijil/schema/__init__.py +++ b/aleksis/apps/alsijil/schema/__init__.py @@ -24,7 +24,18 @@ from .documentation import ( LessonsForPersonType, TouchDocumentationMutation, ) +from .extra_marks import ( + ExtraMarkBatchCreateMutation, + ExtraMarkBatchDeleteMutation, + ExtraMarkBatchPatchMutation, + ExtraMarkType, +) from .participation_status import ParticipationStatusBatchPatchMutation +from .personal_note import ( + PersonalNoteBatchCreateMutation, + PersonalNoteBatchDeleteMutation, + PersonalNoteBatchPatchMutation, +) class Query(graphene.ObjectType): @@ -53,6 +64,8 @@ class Query(graphene.ObjectType): end=graphene.Date(required=True), ) + extra_marks = FilterOrderList(ExtraMarkType) + def resolve_documentations_by_course_id(root, info, course_id, **kwargs): documentations = Documentation.objects.filter( Q(course__pk=course_id) | Q(amends__course__pk=course_id) @@ -132,8 +145,10 @@ class Query(graphene.ObjectType): else: raise PermissionDenied() - return Group.objects.filter( - Q(members=person) | Q(owners=person) | Q(parent_groups__owners=person) + return ( + Group.objects.for_current_school_term_or_all() + .filter(Q(members=person) | Q(owners=person) | Q(parent_groups__owners=person)) + .distinct() ) @staticmethod @@ -146,13 +161,15 @@ class Query(graphene.ObjectType): person = info.context.user.person else: raise PermissionDenied() - return Course.objects.filter( - Q(teachers=person) - | Q(groups__members=person) - | Q(groups__owners=person) - | Q(groups__parent_groups__owners=person) - ) + ( + Q(teachers=person) + | Q(groups__members=person) + | Q(groups__owners=person) + | Q(groups__parent_groups__owners=person) + ) + & Q(groups__in=Group.objects.for_current_school_term_or_all()) + ).distinct() @staticmethod def resolve_absence_creation_persons(root, info, **kwargs): @@ -188,3 +205,11 @@ class Mutation(graphene.ObjectType): touch_documentation = TouchDocumentationMutation.Field() update_participation_statuses = ParticipationStatusBatchPatchMutation.Field() create_absences_for_persons = AbsencesForPersonsCreateMutation.Field() + + create_extra_marks = ExtraMarkBatchCreateMutation.Field() + update_extra_marks = ExtraMarkBatchPatchMutation.Field() + delete_extra_marks = ExtraMarkBatchDeleteMutation.Field() + + create_personal_notes = PersonalNoteBatchCreateMutation.Field() + update_personal_notes = PersonalNoteBatchPatchMutation.Field() + delete_personal_notes = PersonalNoteBatchDeleteMutation.Field() diff --git a/aleksis/apps/alsijil/schema/extra_marks.py b/aleksis/apps/alsijil/schema/extra_marks.py new file mode 100644 index 0000000000000000000000000000000000000000..2b1e3723d2c559e70ddd6793f5f2a89d9c1e6abe --- /dev/null +++ b/aleksis/apps/alsijil/schema/extra_marks.py @@ -0,0 +1,66 @@ +from django.core.exceptions import PermissionDenied + +from graphene_django import DjangoObjectType + +from aleksis.apps.alsijil.models import ExtraMark +from aleksis.core.schema.base import ( + BaseBatchCreateMutation, + BaseBatchDeleteMutation, + BaseBatchPatchMutation, + DjangoFilterMixin, + OptimisticResponseTypeMixin, + PermissionsTypeMixin, +) + + +class ExtraMarkType( + OptimisticResponseTypeMixin, + PermissionsTypeMixin, + DjangoFilterMixin, + DjangoObjectType, +): + class Meta: + model = ExtraMark + fields = ("id", "short_name", "name", "colour_fg", "colour_bg", "show_in_coursebook") + + @classmethod + def get_queryset(cls, queryset, info): + if info.context.user.has_perm("alsijil.fetch_extramarks_rule"): + return queryset + raise PermissionDenied() + + +class ExtraMarkBatchCreateMutation(BaseBatchCreateMutation): + class Meta: + model = ExtraMark + fields = ("short_name", "name", "colour_fg", "colour_bg", "show_in_coursebook") + optional_fields = ("name",) + + @classmethod + def check_permissions(cls, root, info, input): # noqa + if info.context.user.has_perm("alsijil.create_extramark_rule"): + return + raise PermissionDenied() + + +class ExtraMarkBatchDeleteMutation(BaseBatchDeleteMutation): + class Meta: + model = ExtraMark + + @classmethod + def check_permissions(cls, root, info, input): # noqa + if info.context.user.has_perm("alsijil.delete_extramark_rule"): + return + raise PermissionDenied() + + +class ExtraMarkBatchPatchMutation(BaseBatchPatchMutation): + class Meta: + model = ExtraMark + fields = ("id", "short_name", "name", "colour_fg", "colour_bg", "show_in_coursebook") + + @classmethod + def check_permissions(cls, root, info, input): # noqa + if info.context.user.has_perm("alsijil.edit_extramark_rule"): + return + raise PermissionDenied() diff --git a/aleksis/apps/alsijil/schema/participation_status.py b/aleksis/apps/alsijil/schema/participation_status.py index 246ae52a0aab7e7bf57f102ccd7e6558d4639496..335f5bc8d1c22682725563d2c3c83b88032fbc38 100644 --- a/aleksis/apps/alsijil/schema/participation_status.py +++ b/aleksis/apps/alsijil/schema/participation_status.py @@ -1,8 +1,10 @@ from django.core.exceptions import PermissionDenied +import graphene from graphene_django import DjangoObjectType -from aleksis.apps.alsijil.models import ParticipationStatus +from aleksis.apps.alsijil.models import NewPersonalNote, ParticipationStatus +from aleksis.apps.alsijil.schema.personal_note import PersonalNoteType from aleksis.core.schema.base import ( BaseBatchPatchMutation, DjangoFilterMixin, @@ -25,13 +27,37 @@ class ParticipationStatusType( "absence_reason", "related_documentation", "base_absence", + "tardiness", + ) + + notes_with_extra_mark = graphene.List(PersonalNoteType) + notes_with_note = graphene.List(PersonalNoteType) + + @staticmethod + def resolve_notes_with_extra_mark(root: ParticipationStatus, info, **kwargs): + return NewPersonalNote.objects.filter( + person=root.person, + documentation=root.related_documentation, + extra_mark__isnull=False, + ) + + @staticmethod + def resolve_notes_with_note(root: ParticipationStatus, info, **kwargs): + return NewPersonalNote.objects.filter( + person=root.person, + documentation=root.related_documentation, + note__isnull=False, ) class ParticipationStatusBatchPatchMutation(BaseBatchPatchMutation): class Meta: model = ParticipationStatus - fields = ("id", "absence_reason") # Only the reason can be updated after creation + fields = ( + "id", + "absence_reason", + "tardiness", + ) # Only the reason and tardiness can be updated after creation return_field_name = "participationStatuses" @classmethod diff --git a/aleksis/apps/alsijil/schema/personal_note.py b/aleksis/apps/alsijil/schema/personal_note.py new file mode 100644 index 0000000000000000000000000000000000000000..dfe36359d87345a0b51992e689627065638fcd4e --- /dev/null +++ b/aleksis/apps/alsijil/schema/personal_note.py @@ -0,0 +1,50 @@ +from graphene_django import DjangoObjectType + +from aleksis.apps.alsijil.models import NewPersonalNote +from aleksis.core.schema.base import ( + BaseBatchCreateMutation, + BaseBatchDeleteMutation, + BaseBatchPatchMutation, + DjangoFilterMixin, + OptimisticResponseTypeMixin, + PermissionsTypeMixin, +) + + +class PersonalNoteType( + OptimisticResponseTypeMixin, + PermissionsTypeMixin, + DjangoFilterMixin, + DjangoObjectType, +): + class Meta: + model = NewPersonalNote + fields = ( + "id", + "note", + "extra_mark", + ) + + +class PersonalNoteBatchCreateMutation(BaseBatchCreateMutation): + class Meta: + model = NewPersonalNote + type_name = "BatchCreatePersonalNoteInput" + return_field_name = "personalNotes" + fields = ("note", "extra_mark", "documentation", "person") + permissions = ("alsijil.edit_personal_note_rule",) + + +class PersonalNoteBatchPatchMutation(BaseBatchPatchMutation): + class Meta: + model = NewPersonalNote + type_name = "BatchPatchPersonalNoteInput" + return_field_name = "personalNotes" + fields = ("id", "note", "extra_mark", "documentation", "person") + permissions = ("alsijil.edit_personal_note_rule",) + + +class PersonalNoteBatchDeleteMutation(BaseBatchDeleteMutation): + class Meta: + model = NewPersonalNote + permissions = ("alsijil.edit_personal_note_rule",) diff --git a/aleksis/apps/alsijil/tables.py b/aleksis/apps/alsijil/tables.py index a0ef1734e5eea326fd9a8fd0f858b71a89089082..f17a214930f2d7f2e40a1662b491eab587bda0ad 100644 --- a/aleksis/apps/alsijil/tables.py +++ b/aleksis/apps/alsijil/tables.py @@ -11,26 +11,6 @@ from aleksis.core.util.tables import SelectColumn from .models import PersonalNote -class ExtraMarkTable(tables.Table): - class Meta: - attrs = {"class": "highlight"} - - name = tables.LinkColumn("edit_extra_mark", args=[A("id")]) - short_name = tables.Column() - edit = tables.LinkColumn( - "edit_extra_mark", - args=[A("id")], - text=_("Edit"), - attrs={"a": {"class": "btn-flat waves-effect waves-orange orange-text"}}, - ) - delete = tables.LinkColumn( - "delete_extra_mark", - args=[A("id")], - text=_("Delete"), - attrs={"a": {"class": "btn-flat waves-effect waves-red red-text"}}, - ) - - class ExcuseTypeTable(tables.Table): class Meta: attrs = {"class": "highlight"} diff --git a/aleksis/apps/alsijil/templates/alsijil/extra_mark/create.html b/aleksis/apps/alsijil/templates/alsijil/extra_mark/create.html deleted file mode 100644 index d0ee3a9055561df1f468692f79d794404a60c1d1..0000000000000000000000000000000000000000 --- a/aleksis/apps/alsijil/templates/alsijil/extra_mark/create.html +++ /dev/null @@ -1,17 +0,0 @@ - {# -*- engine:django -*- #} - - {% extends "core/base.html" %} - {% load material_form i18n %} - - {% block browser_title %}{% blocktrans %}Create extra mark{% endblocktrans %}{% endblock %} - {% block page_title %}{% blocktrans %}Create extra mark{% endblocktrans %}{% endblock %} - - {% block content %} - - <form method="post"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% include "core/partials/save_button.html" %} - </form> - - {% endblock %} diff --git a/aleksis/apps/alsijil/templates/alsijil/extra_mark/edit.html b/aleksis/apps/alsijil/templates/alsijil/extra_mark/edit.html deleted file mode 100644 index 7adee30a1cfd30256d70b2a823827384de331e05..0000000000000000000000000000000000000000 --- a/aleksis/apps/alsijil/templates/alsijil/extra_mark/edit.html +++ /dev/null @@ -1,17 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} -{% load material_form i18n %} - -{% block browser_title %}{% blocktrans %}Edit extra mark{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Edit extra mark{% endblocktrans %}{% endblock %} - -{% block content %} - - <form method="post"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% include "core/partials/save_button.html" %} - </form> - -{% endblock %} diff --git a/aleksis/apps/alsijil/templates/alsijil/extra_mark/list.html b/aleksis/apps/alsijil/templates/alsijil/extra_mark/list.html deleted file mode 100644 index 9eeb63b1a81162e6490072ca7184059be7a5193a..0000000000000000000000000000000000000000 --- a/aleksis/apps/alsijil/templates/alsijil/extra_mark/list.html +++ /dev/null @@ -1,18 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} - -{% load i18n %} -{% load render_table from django_tables2 %} - -{% block browser_title %}{% blocktrans %}Extra marks{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Extra marks{% endblocktrans %}{% endblock %} - -{% block content %} - <a class="btn green waves-effect waves-light" href="{% url 'create_extra_mark' %}"> - <i class="material-icons iconify left" data-icon="mdi:plus"></i> - {% trans "Create extra mark" %} - </a> - - {% render_table table %} -{% endblock %} diff --git a/aleksis/apps/alsijil/urls.py b/aleksis/apps/alsijil/urls.py index edc0b4fa81cfb50e1f268d9dd21b3d9dda2c033a..cd7367ce84973bb4eb62791e9ce5b2c7eb3a5f85 100644 --- a/aleksis/apps/alsijil/urls.py +++ b/aleksis/apps/alsijil/urls.py @@ -3,22 +3,6 @@ from django.urls import path from . import views urlpatterns = [ - path("extra_marks/", views.ExtraMarkListView.as_view(), name="extra_marks"), - path( - "extra_marks/create/", - views.ExtraMarkCreateView.as_view(), - name="create_extra_mark", - ), - path( - "extra_marks/<int:pk>/edit/", - views.ExtraMarkEditView.as_view(), - name="edit_extra_mark", - ), - path( - "extra_marks/<int:pk>/delete/", - views.ExtraMarkDeleteView.as_view(), - name="delete_extra_mark", - ), path("group_roles/", views.GroupRoleListView.as_view(), name="group_roles"), path("group_roles/create/", views.GroupRoleCreateView.as_view(), name="create_group_role"), path( diff --git a/aleksis/apps/alsijil/util/predicates.py b/aleksis/apps/alsijil/util/predicates.py index 6dea3844a318c6572e63b254e54c846505b8fb4e..e1d8f8a02760b846d35672ffddeeb73d161ece72 100644 --- a/aleksis/apps/alsijil/util/predicates.py +++ b/aleksis/apps/alsijil/util/predicates.py @@ -12,7 +12,7 @@ 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 +from ..models import Documentation, NewPersonalNote, PersonalNote @predicate @@ -445,6 +445,8 @@ def can_edit_documentation(user: User, obj: Documentation): def can_view_participation_status(user: User, obj: Documentation): """Predicate which checks if the user is allowed to view participation for a documentation.""" if obj: + if obj.amends and obj.amends.cancelled: + return False if is_documentation_teacher(user, obj): return True if obj.amends: @@ -460,6 +462,8 @@ def can_view_participation_status(user: User, obj: Documentation): def can_edit_participation_status(user: User, obj: Documentation): """Predicate which checks if the user is allowed to edit participation for a documentation.""" if obj: + if obj.amends and obj.amends.cancelled: + return False if is_documentation_teacher(user, obj): return True if obj.amends: @@ -470,8 +474,14 @@ def can_edit_participation_status(user: User, obj: Documentation): @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.""" +def is_in_allowed_time_range(user: User, obj: Union[Documentation, NewPersonalNote]): + """Predicate for documentations or new personal notes with linked documentation. + + Predicate which checks if the given documentation or the documentation linked + to the given NewPersonalNote is in the allowed time range for editing. + """ + if isinstance(obj, NewPersonalNote): + obj = obj.documentation if obj and ( get_site_preferences()["alsijil__allow_edit_future_documentations"] == "all" or ( @@ -493,3 +503,31 @@ def is_in_allowed_time_range_for_participation_status(user: User, obj: Documenta if obj and obj.value_start_datetime(obj) <= now(): return True return False + + +@predicate +def can_view_personal_note(user: User, obj: NewPersonalNote): + """Predicate which checks if the user is allowed to view a personal note.""" + if obj.documentation: + if is_documentation_teacher(user, obj.documentation): + return True + if obj.documentation.amends: + return is_lesson_event_teacher( + user, obj.documentation.amends + ) | is_lesson_event_group_owner(user, obj.documentation.amends) + if obj.documentation.course: + return is_course_teacher(user, obj.documentation.course) + return False + + +@predicate +def can_edit_personal_note(user: User, obj: NewPersonalNote): + """Predicate which checks if the user is allowed to edit a personal note.""" + if obj.documentation: + if is_documentation_teacher(user, obj.documentation): + return True + if obj.documentation.amends: + return is_lesson_event_teacher( + user, obj.documentation.amends + ) | is_lesson_event_group_owner(user, obj.documentation.amends) + return False diff --git a/aleksis/apps/alsijil/views.py b/aleksis/apps/alsijil/views.py index dd010383b83ae7e28c6d98fd7de34bf9aa5432ba..0395a8bfbc54fcab81dfea092f431045d15ae134 100644 --- a/aleksis/apps/alsijil/views.py +++ b/aleksis/apps/alsijil/views.py @@ -48,7 +48,6 @@ from .filters import PersonalNoteFilter from .forms import ( AssignGroupRoleForm, ExcuseTypeForm, - ExtraMarkForm, FilterRegisterObjectForm, GroupRoleAssignmentEditForm, GroupRoleForm, @@ -62,7 +61,6 @@ from .forms import ( from .models import ExcuseType, ExtraMark, GroupRole, GroupRoleAssignment, PersonalNote from .tables import ( ExcuseTypeTable, - ExtraMarkTable, GroupRoleTable, PersonalNoteTable, RegisterObjectSelectTable, @@ -1052,51 +1050,6 @@ class DeletePersonalNoteView(PermissionRequiredMixin, DetailView): return redirect("overview_person", note.person.pk) -@method_decorator(pwa_cache, "dispatch") -class ExtraMarkListView(PermissionRequiredMixin, SingleTableView): - """Table of all extra marks.""" - - model = ExtraMark - table_class = ExtraMarkTable - permission_required = "alsijil.view_extramarks_rule" - template_name = "alsijil/extra_mark/list.html" - - -@method_decorator(never_cache, name="dispatch") -class ExtraMarkCreateView(PermissionRequiredMixin, AdvancedCreateView): - """Create view for extra marks.""" - - model = ExtraMark - form_class = ExtraMarkForm - permission_required = "alsijil.add_extramark_rule" - template_name = "alsijil/extra_mark/create.html" - success_url = reverse_lazy("extra_marks") - success_message = _("The extra mark has been created.") - - -@method_decorator(never_cache, name="dispatch") -class ExtraMarkEditView(PermissionRequiredMixin, AdvancedEditView): - """Edit view for extra marks.""" - - model = ExtraMark - form_class = ExtraMarkForm - permission_required = "alsijil.edit_extramark_rule" - template_name = "alsijil/extra_mark/edit.html" - success_url = reverse_lazy("extra_marks") - success_message = _("The extra mark has been saved.") - - -@method_decorator(never_cache, name="dispatch") -class ExtraMarkDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView): - """Delete view for extra marks.""" - - model = ExtraMark - permission_required = "alsijil.delete_extramark_rule" - template_name = "core/pages/delete.html" - success_url = reverse_lazy("extra_marks") - success_message = _("The extra mark has been deleted.") - - @method_decorator(pwa_cache, "dispatch") class ExcuseTypeListView(PermissionRequiredMixin, SingleTableView): """Table of all excuse types."""