diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue index 14411e9ce9c1c2780402890a80aa907b246262ff..2f15c3fd031c62ff9fc96cf5c35042f2274d3321 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue @@ -1,13 +1,14 @@ <template> - <v-list-item :style="{ scrollMarginTop: '145px' }" two-line> + <v-list-item :style="{ scrollMarginTop: '145px' }" two-line class="px-0"> <v-list-item-content> - <v-subheader class="text-h6">{{ + <v-subheader class="text-h6 px-1">{{ $d(date, "dateWithWeekday") }}</v-subheader> <v-list max-width="100%" class="pt-0 mt-n1"> <v-list-item v-for="doc in docs" :key="'documentation-' + (doc.oldId || doc.id)" + class="px-1" > <documentation-modal :documentation="doc" diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue index e5493b07cda406fddbb3fccc89e5d1b42080ab89..b47ebbfbb1d1cd8219686f05085eb0eeb8f453d3 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue @@ -45,6 +45,10 @@ <script> import { coursesOfPerson, groupsByPerson } from "./coursebook.graphql"; +const TYPENAMES_TO_TYPES = { + CourseType: "course", + GroupType: "group", +}; export default { name: "CoursebookFilters", data() { @@ -73,9 +77,9 @@ export default { selectable() { return [ { header: this.$t("alsijil.coursebook.filter.groups") }, - ...this.groups.map((group) => ({ type: "group", ...group })), + ...this.groups, { header: this.$t("alsijil.coursebook.filter.courses") }, - ...this.courses.map((course) => ({ type: "course", ...course })), + ...this.courses, ]; }, selectLoading() { @@ -86,14 +90,16 @@ export default { }, currentObj() { return this.selectable.find( - (o) => o.type === this.value.objType && o.id === this.value.objId, + (o) => + TYPENAMES_TO_TYPES[o.__typename] === this.value.objType && + o.id === this.value.objId, ); }, }, methods: { selectObject(selection) { this.$emit("input", { - objType: selection ? selection.type : null, + objType: selection ? TYPENAMES_TO_TYPES[selection.__typename] : null, objId: selection ? selection.id : null, }); }, diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue index a5136eb9f0c2535009fd78a02e7481aebacd12b4..52866931e7b2d31bbee85bc754a4a668066e8b73 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue @@ -1,12 +1,12 @@ <template> <div> - <v-list-item v-for="i in numberOfDays" :key="'i-' + i"> + <v-list-item v-for="i in numberOfDays" :key="'i-' + i" class="px-0"> <v-list-item-content> <v-list-item-title> <v-skeleton-loader type="heading" /> </v-list-item-title> <v-list max-width="100%"> - <v-list-item v-for="j in numberOfDocs" :key="'j-' + j"> + <v-list-item v-for="j in numberOfDocs" :key="'j-' + j" class="px-1"> <DocumentationLoader /> </v-list-item> </v-list> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..3af1db58846f37b5e7e7837dba08a4468294269e --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue @@ -0,0 +1,187 @@ +<script> +import AbsenceReasonButtons from "aleksis.apps.kolego/components/AbsenceReasonButtons.vue"; +import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue"; +import AbsenceReasonGroupSelect from "aleksis.apps.kolego/components/AbsenceReasonGroupSelect.vue"; +import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue"; +import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue"; +import mutateMixin from "aleksis.core/mixins/mutateMixin.js"; +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"; + +export default { + name: "ManageStudentsDialog", + extends: MobileFullscreenDialog, + components: { + AbsenceReasonChip, + AbsenceReasonGroupSelect, + AbsenceReasonButtons, + CancelButton, + LessonInformation, + MobileFullscreenDialog, + SlideIterator, + }, + mixins: [documentationPartMixin, mutateMixin], + data() { + return { + dialog: false, + search: "", + loadSelected: false, + selected: [], + isExpanded: false, + }; + }, + props: { + loadingIndicator: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + items() { + return this.documentation.participations; + }, + }, + methods: { + sendToServer(participations, field, value) { + if (field !== "absenceReason") return; + + this.mutate( + updateParticipationStatuses, + { + input: participations.map((participation) => ({ + id: participation.id, + absenceReason: value === "present" ? null : value, + })), + }, + (storedDocumentations, incomingStatuses) => { + const documentation = storedDocumentations.find( + (doc) => doc.id === this.documentation.id, + ); + + incomingStatuses.forEach((newStatus) => { + const participationStatus = documentation.participations.find( + (part) => part.id === newStatus.id, + ); + participationStatus.absenceReason = newStatus.absenceReason; + participationStatus.isOptimistic = newStatus.isOptimistic; + }); + + return storedDocumentations; + }, + ); + }, + handleMultipleAction(absenceReasonId) { + this.loadSelected = true; + this.sendToServer(this.selected, "absenceReason", absenceReasonId); + this.$once("save", this.resetMultipleAction); + }, + resetMultipleAction() { + this.loadSelected = false; + this.$set(this.selected, []); + this.$refs.iterator.selected = []; + }, + }, +}; +</script> + +<template> + <mobile-fullscreen-dialog + scrollable + v-bind="$attrs" + v-on="$listeners" + v-model="dialog" + > + <template #activator="activator"> + <slot name="activator" v-bind="activator" /> + </template> + + <template #title> + <lesson-information v-bind="documentationPartProps" :compact="false" /> + <v-scroll-x-transition leave-absolute> + <v-text-field + v-show="!isExpanded" + type="search" + v-model="search" + clearable + rounded + hide-details + single-line + prepend-inner-icon="$search" + dense + outlined + :placeholder="$t('actions.search')" + class="pt-4 full-width" + /> + </v-scroll-x-transition> + <v-scroll-x-transition> + <div v-show="selected.length > 0" class="full-width mt-4"> + <absence-reason-buttons + allow-empty + empty-value="present" + @input="handleMultipleAction" + /> + </div> + </v-scroll-x-transition> + </template> + <template #content> + <slide-iterator + ref="iterator" + v-model="selected" + :items="items" + :search="search" + :item-key-getter=" + (item) => 'documentation-' + documentation.id + '-student-' + item.id + " + :is-expanded.sync="isExpanded" + :loading="loadingIndicator || loadSelected" + :load-only-selected="loadSelected" + :disabled="loading" + > + <template #listItemContent="{ item }"> + <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> + </template> + + <template #expandedItem="{ item, close }"> + <v-card-title> + <v-tooltip bottom> + <template #activator="{ on, attrs }"> + <v-btn v-bind="attrs" v-on="on" icon @click="close"> + <v-icon>$prev</v-icon> + </v-btn> + </template> + <span v-t="'actions.back_to_overview'" /> + </v-tooltip> + {{ item.person.fullName }} + </v-card-title> + <v-card-text> + <absence-reason-group-select + allow-empty + empty-value="present" + :loadSelectedChip="loading" + :value="item.absenceReason?.id || 'present'" + @input="sendToServer([item], 'absenceReason', $event)" + /> + </v-card-text> + </template> + </slide-iterator> + </template> + + <template #actions> + <cancel-button + @click="dialog = false" + i18n-key="actions.close" + v-show="$vuetify.breakpoint.mobile" + /> + </template> + </mobile-fullscreen-dialog> +</template> + +<style scoped></style> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue new file mode 100644 index 0000000000000000000000000000000000000000..572036c67955b3365bb46eb69f6ab41ee86cf074 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue @@ -0,0 +1,78 @@ +<script> +import { DateTime } from "luxon"; +import ManageStudentsDialog from "./ManageStudentsDialog.vue"; +import documentationPartMixin from "../documentation/documentationPartMixin"; +import { touchDocumentation } from "./participationStatus.graphql"; +import mutateMixin from "aleksis.core/mixins/mutateMixin.js"; + +export default { + name: "ManageStudentsTrigger", + components: { ManageStudentsDialog }, + mixins: [documentationPartMixin, mutateMixin], + data() { + return { + canOpenParticipation: false, + timeout: null, + }; + }, + mounted() { + const lessonStart = DateTime.fromISO(this.documentation.datetimeStart); + const now = DateTime.now(); + this.canOpenParticipation = now >= lessonStart; + + if (!this.canOpenParticipation) { + this.timeout = setTimeout( + () => (this.canOpenParticipation = true), + lessonStart.diff(now).toObject().milliseconds, + ); + } + }, + beforeDestroy() { + if (this.timeout) { + clearTimeout(this.timeout); + } + }, + methods: { + touchDocumentation() { + this.mutate( + touchDocumentation, + { + documentationId: this.documentation.id, + }, + (storedDocumentations, incoming) => { + // ID may be different now + return storedDocumentations.map((doc) => + doc.id === this.documentation.id + ? Object.assign(doc, incoming, { oldId: doc.id }) + : doc, + ); + }, + ); + }, + }, +}; +</script> + +<template> + <manage-students-dialog + v-bind="documentationPartProps" + @update="() => null" + :loading-indicator="loading" + > + <template #activator="{ attrs, on }"> + <v-chip + dense + color="primary" + outlined + :disabled="!canOpenParticipation || loading" + v-bind="attrs" + v-on="on" + @click="touchDocumentation" + > + <v-icon>$edit</v-icon> + </v-chip> + </template> + </manage-students-dialog> +</template> + +<style scoped></style> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql new file mode 100644 index 0000000000000000000000000000000000000000..81a3a5fb1eb3a99bef25eb9938cd254b9068981b --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql @@ -0,0 +1,42 @@ +mutation updateParticipationStatuses( + $input: [BatchPatchParticipationStatusInput]! +) { + updateParticipationStatuses(input: $input) { + items: participationStatuses { + id + isOptimistic + relatedDocumentation { + id + } + absenceReason { + id + name + shortName + colour + } + } + } +} + +mutation touchDocumentation($documentationId: ID!) { + touchDocumentation(documentationId: $documentationId) { + items: documentation { + id + participations { + id + person { + id + firstName + fullName + } + absenceReason { + id + name + shortName + colour + } + isOptimistic + } + } + } +} diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql index 8444f9e35af335080026b221424fda598068c6fc..6348a24f189033fc60e97325c0c69cde5d11fbc9 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql +++ b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql @@ -9,10 +9,6 @@ query coursesOfPerson { courses: coursesOfPerson { id name - groups { - id - name - } } } @@ -70,6 +66,21 @@ query documentationsForCoursebook( colourFg colourBg } + participations { + id + person { + id + firstName + fullName + } + absenceReason { + id + name + shortName + colour + } + isOptimistic + } topic homework groupNote @@ -92,6 +103,21 @@ mutation createOrUpdateDocumentations($input: [DocumentationInputType]!) { homework groupNote oldId + participations { + id + person { + id + firstName + fullName + } + absenceReason { + id + name + shortName + colour + } + isOptimistic + } } } } diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue index 09a04bcb67c6ae618fa0b1546a0171af30323885..652609dccaf430d3a4ab138f80ee2f810b84b4af 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue @@ -61,7 +61,7 @@ import PersonChip from "aleksis.core/components/person/PersonChip.vue"; v-for="teacher in documentation.teachers" :key="documentation.id + '-teacher-' + teacher.id" :person="teacher" - no-link + :no-link="compact" v-bind="compact ? dialogActivator.attrs : {}" v-on="compact ? dialogActivator.on : {}" /> @@ -69,7 +69,7 @@ import PersonChip from "aleksis.core/components/person/PersonChip.vue"; v-for="teacher in amendedTeachers" :key="documentation.id + '-amendedTeacher-' + teacher.id" :person="teacher" - no-link + :no-link="compact" v-bind="compact ? dialogActivator.attrs : {}" v-on="compact ? dialogActivator.on : {}" class="text-decoration-line-through" diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue index f85633f2e6f3864a20db9136f86cee4b51311719..bc0da4a742917e0639a0c1983186fad29764babb 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue @@ -1,45 +1,71 @@ +<script setup> +import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue"; +</script> + <template> <div class="d-flex align-center justify-space-between justify-md-end flex-wrap gap" > - <!-- eslint-disable @intlify/vue-i18n/no-raw-text --> - <v-chip dense color="success"> - <v-chip small dense class="mr-2" color="green darken-3 white--text" - >26</v-chip - > - von 30 anwesend - </v-chip> - <v-chip dense color="warning"> - <v-chip small dense class="mr-2" color="orange darken-3 white--text" - >3</v-chip - > - entschuldigt - </v-chip> - <v-chip dense color="error"> - <v-chip small dense class="mr-2" color="red darken-3 white--text" - >1</v-chip - > - unentschuldigt + <v-chip dense color="success" outlined v-if="total > 0"> + {{ $t("alsijil.coursebook.present_number", { present, total }) }} </v-chip> - <v-chip dense color="grey lighten-1"> - <v-chip small dense class="mr-2" color="grey darken-1 white--text" - >4</v-chip - > - Hausaufgaben vergessen - </v-chip> - <v-chip dense color="primary" outlined> - <v-icon>$edit</v-icon> - </v-chip> - <!-- eslint-enable @intlify/vue-i18n/no-raw-text --> + <absence-reason-chip + v-for="[reasonId, participations] in Object.entries(absences)" + :key="'reason-' + reasonId" + :absence-reason="participations[0].absenceReason" + 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> + </absence-reason-chip> + + <manage-students-trigger v-bind="documentationPartProps" /> </div> </template> <script> import documentationPartMixin from "./documentationPartMixin"; +import ManageStudentsTrigger from "../absences/ManageStudentsTrigger.vue"; export default { name: "LessonNotes", + components: { ManageStudentsTrigger }, mixins: [documentationPartMixin], + computed: { + total() { + return this.documentation.participations.length; + }, + present() { + return this.documentation.participations.filter( + (p) => p.absenceReason === null, + ).length; + }, + absences() { + // Get all course attendants who have an absence reason + return Object.groupBy( + this.documentation.participations.filter( + (p) => p.absenceReason !== null, + ), + ({ absenceReason }) => absenceReason.id, + ); + }, + }, }; </script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js index 165f1d2fd157bb35bf2831fc7973f480b29ccd0a..88a8e852f8cc6e333303034fb5f590d174708886 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js @@ -10,6 +10,13 @@ export default { type: Object, required: true, }, + /** + * The query used by the coursebook. Used to update the store when data changes. + */ + affectedQuery: { + type: Object, + required: true, + }, /** * Whether the documentation is currently in the compact mode (meaning coursebook row) */ @@ -38,6 +45,7 @@ export default { documentation: this.documentation, compact: this.compact, dialogActivator: this.dialogActivator, + affectedQuery: this.affectedQuery, }; }, }, diff --git a/aleksis/apps/alsijil/frontend/messages/de.json b/aleksis/apps/alsijil/frontend/messages/de.json index b193697758ac938f7e92bcfdf90c7bcd5c5bbca7..31ae2d9763a98f946aa896a9ab79d5fc8d514a0f 100644 --- a/aleksis/apps/alsijil/frontend/messages/de.json +++ b/aleksis/apps/alsijil/frontend/messages/de.json @@ -49,13 +49,27 @@ } } }, - "title_plural": "Kursbuch" + "title_plural": "Kursbuch", + "present_number": "{present}/{total} anwesend" }, "excuse_types": { "menu_title": "Entschuldigungsarten" }, "extra_marks": { - "menu_title": "Zusätzliche Markierungen" + "menu_title": "Zusätzliche Markierungen", + "create": "Markierung erstellen", + "name": "Markierung", + "short_name": "Abkürzung", + "colour_fg": "Schriftfarbe", + "colour_bg": "Hintergrundfarbe", + "show_in_coursebook": "In Kursbuch-Übersicht zeigen", + "show_in_coursebook_helptext": "Wenn aktiviert tauchen diese Markierungen in den Zeilen im Kursbuch auf." + }, + "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." }, "group_roles": { "menu_title_assign": "Gruppenrollen zuweisen", @@ -77,5 +91,8 @@ "week": { "menu_title": "Aktuelle Woche" } + }, + "actions": { + "back_to_overview": "Zurück zur Übersicht" } } diff --git a/aleksis/apps/alsijil/frontend/messages/en.json b/aleksis/apps/alsijil/frontend/messages/en.json index acadfea9a598f5edf1952cd205166db7b6ecde62..71a509cfec66659437760a1c2ab2fc41ad35cd3c 100644 --- a/aleksis/apps/alsijil/frontend/messages/en.json +++ b/aleksis/apps/alsijil/frontend/messages/en.json @@ -74,8 +74,12 @@ "courses": "Courses", "filter_for_obj": "Filter for group and course" }, + "present_number": "{present}/{total} present", "no_data": "No lessons for the selected groups and courses in this period", "no_results": "No search results for {search}" } + }, + "actions": { + "back_to_overview": "Back to overview" } } diff --git a/aleksis/apps/alsijil/managers.py b/aleksis/apps/alsijil/managers.py index 2e8b2e558e78c84ac83674840f0a3f8b2eb259c5..7d0130805275359672d1337133eacd1b0a2b62ad 100644 --- a/aleksis/apps/alsijil/managers.py +++ b/aleksis/apps/alsijil/managers.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext as _ from calendarweek import CalendarWeek from aleksis.apps.chronos.managers import DateRangeQuerySetMixin -from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations +from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations, PolymorphicBaseManager if TYPE_CHECKING: from aleksis.core.models import Group @@ -187,3 +187,27 @@ class GroupRoleAssignmentQuerySet(DateRangeQuerySetMixin, QuerySet): def for_group(self, group: "Group"): """Filter all role assignments for a group.""" return self.filter(Q(groups=group) | Q(groups__child_groups=group)) + + +class DocumentationManager(PolymorphicBaseManager): + """Manager adding specific methods to documentations.""" + + def get_queryset(self): + """Ensure often used related data are loaded as well.""" + return ( + super() + .get_queryset() + .select_related( + "course", + "subject", + ) + .prefetch_related("teachers") + ) + + +class ParticipationStatusManager(PolymorphicBaseManager): + """Manager adding specific methods to participation statuses.""" + + def get_queryset(self): + """Ensure often used related data are loaded as well.""" + return super().get_queryset().select_related("person", "absence_reason", "base_absence") diff --git a/aleksis/apps/alsijil/migrations/0021_remove_participationstatus_absent_and_more.py b/aleksis/apps/alsijil/migrations/0021_remove_participationstatus_absent_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..052c763671823d550eb8fa6265cff67fa20daf80 --- /dev/null +++ b/aleksis/apps/alsijil/migrations/0021_remove_participationstatus_absent_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.10 on 2024-04-30 11:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('kolego', '0003_refactor_absence'), + ('alsijil', '0020_documentation_extramark_colour_bg_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='participationstatus', + name='absent', + ), + migrations.AlterField( + model_name='participationstatus', + name='absence_reason', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='kolego.absencereason', verbose_name='Absence Reason'), + ), + ] diff --git a/aleksis/apps/alsijil/migrations/0022_documentation_participation_touched_at.py b/aleksis/apps/alsijil/migrations/0022_documentation_participation_touched_at.py new file mode 100644 index 0000000000000000000000000000000000000000..ef09ddca37a893128571818368982e29bd0c219f --- /dev/null +++ b/aleksis/apps/alsijil/migrations/0022_documentation_participation_touched_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-06-06 09:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alsijil', '0021_remove_participationstatus_absent_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='documentation', + name='participation_touched_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Participation touched at'), + ), + ] diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index 332c428fa7eff2095dc3ec83eb013ee759bab42b..9e68ccd06773dc8b0f6ee65ccd16c81319b94a8b 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -2,13 +2,16 @@ from datetime import date, datetime from typing import Optional, Union from urllib.parse import urlparse +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied from django.db import models from django.db.models import QuerySet from django.db.models.constraints import CheckConstraint from django.db.models.query_utils import Q -from django.http import HttpRequest from django.urls import reverse +from django.utils import timezone from django.utils.formats import date_format +from django.utils.timezone import localdate, localtime, now from django.utils.translation import gettext_lazy as _ from calendarweek import CalendarWeek @@ -22,12 +25,14 @@ from aleksis.apps.alsijil.data_checks import ( PersonalNoteOnHolidaysDataCheck, ) from aleksis.apps.alsijil.managers import ( + DocumentationManager, GroupRoleAssignmentManager, GroupRoleAssignmentQuerySet, GroupRoleManager, GroupRoleQuerySet, LessonDocumentationManager, LessonDocumentationQuerySet, + ParticipationStatusManager, PersonalNoteManager, PersonalNoteQuerySet, ) @@ -40,7 +45,7 @@ from aleksis.apps.kolego.models import Absence as KolegoAbsence from aleksis.apps.kolego.models import AbsenceReason from aleksis.core.data_checks import field_validation_data_check_factory from aleksis.core.mixins import ExtensibleModel, GlobalPermissionModel -from aleksis.core.models import CalendarEvent, Group, SchoolTerm +from aleksis.core.models import CalendarEvent, Group, Person, SchoolTerm from aleksis.core.util.core_helpers import get_site_preferences from aleksis.core.util.model_helpers import ICONS @@ -458,6 +463,8 @@ class Documentation(CalendarEvent): # FIXME: DataCheck + objects = DocumentationManager() + course = models.ForeignKey( Course, models.PROTECT, @@ -483,6 +490,11 @@ class Documentation(CalendarEvent): homework = models.CharField(verbose_name=_("Homework"), max_length=255, blank=True) group_note = models.CharField(verbose_name=_("Group Note"), max_length=255, blank=True) + # Used to track whether participations have been filled in + participation_touched_at = models.DateTimeField( + blank=True, null=True, verbose_name=_("Participation touched at") + ) + def get_subject(self) -> str: if self.subject: return self.subject @@ -515,44 +527,17 @@ class Documentation(CalendarEvent): # which is not possible via constraint, because amends is not local to Documentation @classmethod - def get_for_coursebook( + def get_documentations_for_events( cls, - own: bool, - date_start: datetime, - date_end: datetime, - request: HttpRequest, - obj_type: Optional[str] = None, - obj_id: Optional[str] = None, + events: list, incomplete: Optional[bool] = False, - ) -> list: - """Get all the documentations for an object and a time frame. - - obj_type may be one of TEACHER, GROUP, ROOM, COURSE + ) -> tuple: + """Get all the documentations for the events. + Create dummy documentations if none exist. + Returns a tuple with a list of existing documentations and a list dummy documentations. """ - - # 1. Find all LessonEvents for all Lessons of this Course in this date range - event_params = { - "own": own, - } - if obj_type is not None and obj_id is not None: - event_params.update( - { - "type": obj_type, - "id": obj_id, - } - ) - - events = LessonEvent.get_single_events( - date_start, - date_end, - request, - event_params, - with_reference_object=True, - ) - - # 2. For each lessonEvent → check if there is a documentation - # if so, add the documentation to a list, if not, create a new one docs = [] + dummies = [] for event in events: if incomplete and event["STATUS"] == "CANCELLED": continue @@ -582,7 +567,7 @@ class Documentation(CalendarEvent): else: course, subject = event_reference_obj.course, event_reference_obj.subject - docs.append( + dummies.append( cls( pk=f"DUMMY;{event_reference_obj.id};{event['DTSTART'].dt.isoformat()};{event['DTEND'].dt.isoformat()}", amends=event_reference_obj, @@ -593,7 +578,173 @@ class Documentation(CalendarEvent): ) ) - return docs + return (docs, dummies) + + @classmethod + def get_documentations_for_person( + cls, + person: int, + start: datetime, + end: datetime, + incomplete: Optional[bool] = False, + ) -> tuple: + """Get all the documentations for the person from start to end datetime. + Create dummy documentations if none exist. + Returns a tuple with a list of existing documentations and a list dummy documentations. + """ + event_params = { + "type": "PARTICIPANT", + "obj_id": person, + } + + events = LessonEvent.get_single_events( + start, + end, + None, + event_params, + with_reference_object=True, + ) + + return Documentation.get_documentations_for_events(events, incomplete) + + @classmethod + def parse_dummy( + cls, + _id: str, + ) -> tuple: + """Parse dummy id string into lesson_event, datetime_start, datetime_end.""" + dummy, lesson_event_id, datetime_start_iso, datetime_end_iso = _id.split(";") + lesson_event = LessonEvent.objects.get(id=lesson_event_id) + + datetime_start = datetime.fromisoformat(datetime_start_iso).astimezone( + lesson_event.timezone + ) + datetime_end = datetime.fromisoformat(datetime_end_iso).astimezone(lesson_event.timezone) + return (lesson_event, datetime_start, datetime_end) + + @classmethod + def create_from_lesson_event( + cls, + user: User, + lesson_event: LessonEvent, + datetime_start: datetime, + datetime_end: datetime, + ) -> "Documentation": + """Create a documentation from a lesson_event with start and end datetime. + User is needed for permission checking. + """ + if not user.has_perm( + "alsijil.add_documentation_for_lesson_event_rule", lesson_event + ) or not ( + 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() + + if lesson_event.amends: + course = lesson_event.course if lesson_event.course else lesson_event.amends.course + + subject = lesson_event.subject if lesson_event.subject else lesson_event.amends.subject + + teachers = ( + lesson_event.teachers if lesson_event.teachers else lesson_event.amends.teachers + ) + else: + course, subject, teachers = ( + lesson_event.course, + lesson_event.subject, + lesson_event.teachers, + ) + + obj = cls.objects.create( + datetime_start=datetime_start, + datetime_end=datetime_end, + amends=lesson_event, + course=course, + subject=subject, + ) + obj.teachers.set(teachers.all()) + obj.save() + + # Create Participation Statuses + obj.touch() + + return obj + + @classmethod + def get_or_create_by_id(cls, _id: str | int, user): + if _id.startswith("DUMMY"): + return cls.create_from_lesson_event( + user, + *cls.parse_dummy(_id), + ), True + + return cls.objects.get(id=_id), False + + def touch(self): + """Ensure that participation statuses are created for this documentation.""" + if ( + self.participation_touched_at + or not self.amends + or self.value_start_datetime(self) > now() + ): + # There is no source to update from or it's too early + return + + lesson_event: LessonEvent = self.amends + all_members = lesson_event.all_members + member_pks = [p.pk for p in all_members] + + new_persons = Person.objects.filter(Q(pk__in=member_pks)).prefetch_related("member_of") + + # Get absences from Kolego + events = KolegoAbsence.get_single_events( + self.value_start_datetime(self), + self.value_end_datetime(self), + None, + {"persons": member_pks}, + with_reference_object=True, + ) + kolego_absences_map = {a["REFERENCE_OBJECT"].person: a["REFERENCE_OBJECT"] for a in events} + + new_participations = [] + new_groups_of_person = [] + for person in new_persons: + participation_status = ParticipationStatus( + person=person, + related_documentation=self, + datetime_start=self.datetime_start, + datetime_end=self.datetime_end, + timezone=self.timezone, + ) + + # Take over data from Kolego absence + if person in kolego_absences_map: + participation_status.fill_from_kolego(kolego_absences_map[person]) + + participation_status.save() + + new_groups_of_person += [ + ParticipationStatus.groups_of_person.through( + group=group, participationstatus=participation_status + ) + for group in person.member_of.all() + ] + new_participations.append(participation_status) + ParticipationStatus.groups_of_person.through.objects.bulk_create(new_groups_of_person) + + self.participation_touched_at = timezone.now() + self.save() + + return new_participations class ParticipationStatus(CalendarEvent): @@ -605,6 +756,8 @@ class ParticipationStatus(CalendarEvent): # FIXME: DataChecks + objects = ParticipationStatusManager() + person = models.ForeignKey( "core.Person", models.CASCADE, related_name="participations", verbose_name=_("Person") ) @@ -620,9 +773,12 @@ class ParticipationStatus(CalendarEvent): ) # Absence part - absent = models.BooleanField(verbose_name=_("Absent")) absence_reason = models.ForeignKey( - AbsenceReason, verbose_name=_("Absence Reason"), on_delete=models.PROTECT + AbsenceReason, + verbose_name=_("Absence Reason"), + on_delete=models.PROTECT, + blank=True, + null=True, ) base_absence = models.ForeignKey( @@ -634,8 +790,13 @@ class ParticipationStatus(CalendarEvent): verbose_name=_("Base Absence"), ) + def fill_from_kolego(self, kolego_absence: KolegoAbsence): + """Take over data from a Kolego absence.""" + self.base_absence = kolego_absence + self.absence_reason = kolego_absence.reason + def __str__(self) -> str: - return f"{self.related_documentation}, {self.person}" + return f"{self.related_documentation.id}, {self.person}" class Meta: verbose_name = _("Participation Status") diff --git a/aleksis/apps/alsijil/rules.py b/aleksis/apps/alsijil/rules.py index b7fa4d04a23df031ff479870c17cf5f1a3d53446..9045598fdf362f0bedb57424fd73bb450d80d669 100644 --- a/aleksis/apps/alsijil/rules.py +++ b/aleksis/apps/alsijil/rules.py @@ -12,8 +12,10 @@ from aleksis.core.util.predicates import ( from .util.predicates import ( can_edit_documentation, + can_edit_participation_status, can_view_any_documentation, can_view_documentation, + can_view_participation_status, has_lesson_group_object_perm, has_person_group_object_perm, has_personal_note_group_perm, @@ -24,6 +26,7 @@ from .util.predicates import ( is_group_owner, is_group_role_assignment_group_owner, is_in_allowed_time_range, + is_in_allowed_time_range_for_participation_status, is_lesson_event_group_owner, is_lesson_event_teacher, is_lesson_original_teacher, @@ -414,3 +417,21 @@ edit_documentation_predicate = ( ) add_perm("alsijil.edit_documentation_rule", edit_documentation_predicate) add_perm("alsijil.delete_documentation_rule", edit_documentation_predicate) + +view_participation_status_for_documentation_predicate = has_person & ( + has_global_perm("alsijil.change_participationstatus") | can_view_participation_status +) +add_perm( + "alsijil.view_participation_status_for_documentation_rule", + view_participation_status_for_documentation_predicate, +) + +edit_participation_status_for_documentation_predicate = ( + has_person + & (has_global_perm("alsijil.change_participationstatus") | can_edit_participation_status) + & is_in_allowed_time_range_for_participation_status +) +add_perm( + "alsijil.edit_participation_status_for_documentation_rule", + edit_participation_status_for_documentation_predicate, +) diff --git a/aleksis/apps/alsijil/schema/__init__.py b/aleksis/apps/alsijil/schema/__init__.py index aae1b70603f0b0e5c738d81467d31f3844f13a86..48e1c20c62434cb9154d9e0faa9904dd09c84641 100644 --- a/aleksis/apps/alsijil/schema/__init__.py +++ b/aleksis/apps/alsijil/schema/__init__.py @@ -5,6 +5,7 @@ from django.db.models.query_utils import Q import graphene +from aleksis.apps.chronos.models import LessonEvent from aleksis.apps.cursus.models import Course from aleksis.apps.cursus.schema import CourseType from aleksis.core.models import Group, Person @@ -16,7 +17,10 @@ from ..models import Documentation from .documentation import ( DocumentationBatchCreateOrUpdateMutation, DocumentationType, + LessonsForPersonType, + TouchDocumentationMutation, ) +from .participation_status import ParticipationStatusBatchPatchMutation class Query(graphene.ObjectType): @@ -37,6 +41,13 @@ class Query(graphene.ObjectType): groups_by_person = FilterOrderList(GroupType, person=graphene.ID()) courses_of_person = FilterOrderList(CourseType, person=graphene.ID()) + lessons_for_persons = graphene.List( + LessonsForPersonType, + persons=graphene.List(graphene.ID, required=True), + start=graphene.Date(required=True), + end=graphene.Date(required=True), + ) + 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) @@ -54,9 +65,6 @@ class Query(graphene.ObjectType): incomplete=False, **kwargs, ): - datetime_start = datetime.combine(date_start, datetime.min.time()) - datetime_end = datetime.combine(date_end, datetime.max.time()) - if ( ( obj_type == "COURSE" @@ -79,10 +87,30 @@ class Query(graphene.ObjectType): ): raise PermissionDenied() - return Documentation.get_for_coursebook( - own, datetime_start, datetime_end, info.context, obj_type, obj_id, incomplete + # Find all LessonEvents for all Lessons of this Course in this date range + event_params = { + "own": own, + } + if obj_type is not None and obj_id is not None: + event_params.update( + { + "type": obj_type, + "id": obj_id, + } + ) + + events = LessonEvent.get_single_events( + datetime.combine(date_start, datetime.min.time()), + datetime.combine(date_end, datetime.max.time()), + info.context, + event_params, + with_reference_object=True, ) + # Lookup or create documentations and return them all. + docs, dummies = Documentation.get_documentations_for_events(events, incomplete) + return docs + dummies + @staticmethod def resolve_groups_by_person(root, info, person=None): if person: @@ -116,6 +144,30 @@ class Query(graphene.ObjectType): | Q(groups__parent_groups__owners=person) ) + @staticmethod + def resolve_lessons_for_persons( + root, + info, + persons, + start, + end, + **kwargs, + ): + """Resolve all lesson events for each person in timeframe start to end.""" + lessons_for_person = [] + for person in persons: + docs, dummies = Documentation.get_documentations_for_person( + person, + datetime.combine(start, datetime.min.time()), + datetime.combine(end, datetime.max.time()), + ) + + lessons_for_person.append(id=person, lessons=docs + dummies) + + return lessons_for_person + class Mutation(graphene.ObjectType): create_or_update_documentations = DocumentationBatchCreateOrUpdateMutation.Field() + touch_documentation = TouchDocumentationMutation.Field() + update_participation_statuses = ParticipationStatusBatchPatchMutation.Field() diff --git a/aleksis/apps/alsijil/schema/documentation.py b/aleksis/apps/alsijil/schema/documentation.py index 3225db34e7c146c4eadcf34efcc11936f5df7311..4a0055064abd0a9cc1bb79850390da318a808b4a 100644 --- a/aleksis/apps/alsijil/schema/documentation.py +++ b/aleksis/apps/alsijil/schema/documentation.py @@ -1,15 +1,10 @@ -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 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.chronos.schema import LessonEventType from aleksis.apps.cursus.models import Subject from aleksis.apps.cursus.schema import CourseType, SubjectType @@ -18,9 +13,9 @@ from aleksis.core.schema.base import ( DjangoFilterMixin, PermissionsTypeMixin, ) -from aleksis.core.util.core_helpers import get_site_preferences from ..models import Documentation +from .participation_status import ParticipationStatusType class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): @@ -39,6 +34,7 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp "date_start", "date_end", "teachers", + "participations", ) filter_fields = { "id": ["exact", "lte", "gte"], @@ -48,6 +44,7 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp course = graphene.Field(CourseType, required=False) amends = graphene.Field(lambda: LessonEventType, required=False) subject = graphene.Field(SubjectType, required=False) + participations = graphene.List(ParticipationStatusType, required=False) future_notice = graphene.Boolean(required=False) @@ -71,9 +68,17 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp info.context.user, root ) - @classmethod - def get_queryset(cls, queryset, info): - return get_objects_for_user(info.context.user, "alsijil.view_documentation", queryset) + @staticmethod + def resolve_participations(root: Documentation, info, **kwargs): + if not info.context.user.has_perm( + "alsijil.view_participation_status_for_documentation", root + ): + return [] + + # A dummy documentation will not have any participations + if str(root.pk).startswith("DUMMY") or not hasattr(root, "participations"): + return [] + return root.participations.select_related("absence_reason", "base_absence").all() class DocumentationInputType(graphene.InputObjectType): @@ -87,6 +92,11 @@ class DocumentationInputType(graphene.InputObjectType): group_note = graphene.String(required=False) +class LessonsForPersonType(graphene.ObjectType): + id = graphene.ID() # noqa + lessons = graphene.List(DocumentationType) + + class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation): class Arguments: input = graphene.List(DocumentationInputType) @@ -99,91 +109,25 @@ 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_iso, datetime_end_iso = _id.split(";") - lesson_event = LessonEvent.objects.get(id=lesson_event_id) - - 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() - ) - ): - if lesson_event.amends: - if lesson_event.course: - course = lesson_event.course - else: - course = lesson_event.amends.course - - if lesson_event.subject: - subject = lesson_event.subject - else: - subject = lesson_event.amends.subject - - if lesson_event.teachers: - teachers = lesson_event.teachers - else: - teachers = lesson_event.amends.teachers - else: - course, subject, teachers = ( - lesson_event.course, - lesson_event.subject, - lesson_event.teachers, - ) - - obj = Documentation.objects.create( - datetime_start=datetime_start, - datetime_end=datetime_end, - amends=lesson_event, - course=course, - subject=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(teachers.all()) - obj.save() - return obj - raise PermissionDenied() - else: - obj = Documentation.objects.get(id=_id) + obj, __ = Documentation.get_or_create_by_id(_id, info.context.user) - if not info.context.user.has_perm("alsijil.edit_documentation_rule", obj): - raise PermissionDenied() + if not info.context.user.has_perm("alsijil.edit_documentation_rule", obj): + raise PermissionDenied() - if doc.topic is not None: - obj.topic = doc.topic - if doc.homework is not None: - obj.homework = doc.homework - if doc.group_note is not None: - obj.group_note = doc.group_note + if doc.topic is not None: + obj.topic = doc.topic + if doc.homework is not None: + obj.homework = doc.homework + if doc.group_note is not None: + obj.group_note = doc.group_note - if doc.subject is not None: - obj.subject = Subject.objects.get(pk=doc.subject) - if doc.teachers is not None: - obj.teachers.set(Person.objects.filter(pk__in=doc.teachers)) + if doc.subject is not None: + obj.subject = Subject.objects.get(pk=doc.subject) + if doc.teachers is not None: + obj.teachers.set(Person.objects.filter(pk__in=doc.teachers)) - obj.save() - return obj + obj.save() + return obj @classmethod def mutate(cls, root, info, input): # noqa @@ -193,3 +137,25 @@ class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation): objs = [cls.create_or_update(info, doc) for doc in input] return DocumentationBatchCreateOrUpdateMutation(documentations=objs) + + +class TouchDocumentationMutation(graphene.Mutation): + class Arguments: + documentation_id = graphene.ID(required=True) + + documentation = graphene.Field(DocumentationType) + + def mutate(root, info, documentation_id): + documentation, created = Documentation.get_or_create_by_id( + documentation_id, info.context.user + ) + + if not info.context.user.has_perm( + "alsijil.edit_participation_status_for_documentation_rule", documentation + ): + raise PermissionDenied() + + if not created: + documentation.touch() + + return TouchDocumentationMutation(documentation=documentation) diff --git a/aleksis/apps/alsijil/schema/participation_status.py b/aleksis/apps/alsijil/schema/participation_status.py new file mode 100644 index 0000000000000000000000000000000000000000..246ae52a0aab7e7bf57f102ccd7e6558d4639496 --- /dev/null +++ b/aleksis/apps/alsijil/schema/participation_status.py @@ -0,0 +1,46 @@ +from django.core.exceptions import PermissionDenied + +from graphene_django import DjangoObjectType + +from aleksis.apps.alsijil.models import ParticipationStatus +from aleksis.core.schema.base import ( + BaseBatchPatchMutation, + DjangoFilterMixin, + OptimisticResponseTypeMixin, + PermissionsTypeMixin, +) + + +class ParticipationStatusType( + OptimisticResponseTypeMixin, + PermissionsTypeMixin, + DjangoFilterMixin, + DjangoObjectType, +): + class Meta: + model = ParticipationStatus + fields = ( + "id", + "person", + "absence_reason", + "related_documentation", + "base_absence", + ) + + +class ParticipationStatusBatchPatchMutation(BaseBatchPatchMutation): + class Meta: + model = ParticipationStatus + fields = ("id", "absence_reason") # Only the reason can be updated after creation + return_field_name = "participationStatuses" + + @classmethod + def check_permissions(cls, root, info, input, *args, **kwargs): # noqa: A002 + pass + + @classmethod + def after_update_obj(cls, root, info, input, obj, full_input): # noqa: A002 + if not info.context.user.has_perm( + "alsijil.edit_participation_status_for_documentation_rule", obj.related_documentation + ): + raise PermissionDenied() diff --git a/aleksis/apps/alsijil/util/predicates.py b/aleksis/apps/alsijil/util/predicates.py index fe7746948d807f3afede2a8fd3a11b41358965f5..9f06195e279b6e9bc9564731146d33ea7995498d 100644 --- a/aleksis/apps/alsijil/util/predicates.py +++ b/aleksis/apps/alsijil/util/predicates.py @@ -2,7 +2,7 @@ from typing import Any, Union from django.contrib.auth.models import User from django.db.models import Q -from django.utils.timezone import localdate, localtime +from django.utils.timezone import localdate, now from rules import predicate @@ -420,11 +420,17 @@ def can_view_any_documentation(user: User): """Predicate which checks if the user is allowed to view any documentation.""" allowed_lesson_events = LessonEvent.objects.related_to_person(user.person) - return Documentation.objects.filter( + if allowed_lesson_events.exists(): + return True + + if Documentation.objects.filter( Q(teachers=user.person) | Q(amends__in=allowed_lesson_events) | Q(course__teachers=user.person) - ).exists() + ).exists(): + return True + + return False @predicate @@ -440,6 +446,34 @@ def can_edit_documentation(user: User, obj: Documentation): return False +@predicate +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 is_documentation_teacher(user, obj): + return True + if obj.amends: + return is_lesson_event_teacher(user, obj.amends) | is_lesson_event_group_owner( + user, obj.amends + ) + if obj.course: + return is_course_teacher(user, obj.course) + return False + + +@predicate +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 is_documentation_teacher(user, obj): + return True + if obj.amends: + return is_lesson_event_teacher(user, obj.amends) | is_lesson_event_group_owner( + user, obj.amends + ) + 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.""" @@ -447,12 +481,20 @@ def is_in_allowed_time_range(user: User, obj: Documentation): 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() + and obj.value_start_datetime(obj).date() <= localdate() ) or ( get_site_preferences()["alsijil__allow_edit_future_documentations"] == "current_time" - and obj.datetime_start <= localtime() + and obj.value_start_datetime(obj) <= now() ) ): return True return False + + +@predicate +def is_in_allowed_time_range_for_participation_status(user: User, obj: Documentation): + """Predicate which checks if the documentation is in the allowed time range for editing.""" + if obj and obj.value_start_datetime(obj) <= now(): + return True + return False diff --git a/pyproject.toml b/pyproject.toml index 276683bae087de62a39719ae078ecc0b223a0590..03a0772bee8aa34ffc35dc565a1762b87745abbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ python = "^3.10" aleksis-core = "^4.0.0.dev7" aleksis-app-chronos = "^4.0.0.dev3" aleksis-app-stoelindeling = { version = "^3.0.dev1", optional = true } -aleksis-app-kolego = "^0.1.0.dev0" +aleksis-app-kolego = "^0.1.0.dev2" [tool.poetry.extras] seatingplans = ["aleksis-app-stoelindeling"]