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/AbsenceCreationDialog.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationDialog.vue index f4de6f18e8f84208baf4b1abf1e79b05550c27d2..51ac72d2873661f5247dfbb197f547dc5f73f5ff 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationDialog.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationDialog.vue @@ -5,20 +5,17 @@ <!-- -> popup = true --> </template> <template #title> - <!-- Abwesenheit/Entschuldigung erfassen --> - <!-- Abwesenheit/Entschuldigung Zusammenfassung --> + <!-- Abwesenheit/Entschuldigung erfassen --> + <!-- Abwesenheit/Entschuldigung Zusammenfassung --> </template> <template #content> - <absence-form v-if="form" /> - <absence-summary v-else /> + <absence-form v-if="form" /> + <absence-summary v-else /> </template> <template #actions> <!-- secondary --> <!-- TODO: Return to form on cancel? form=true --> - <cancel-button - @click="popup = false" - disabled="loading" - /> + <cancel-button @click="popup = false" disabled="loading" /> <!-- primary --> <save-button v-if="form" diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationForm.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationForm.vue index 11e226bfb5ed523289a87862fa18d0e7aebbfcf9..09cf3ea5adbb59e39ed3b782bc5a114a22a123da 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationForm.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationForm.vue @@ -6,28 +6,22 @@ </v-row> <v-row> <!-- Start --> - <v-col - cols="12" - :sm="6" - > + <v-col cols="12" :sm="6"> <date-field :value="value" @input="$emit('input', $event)" :label="$t('date_select.label')" :disabled="loading" - /> + /> </v-col> <!-- End --> - <v-col - cols="12" - :sm="6" - > + <v-col cols="12" :sm="6"> <date-field :value="value" @input="$emit('input', $event)" :label="$t('date_select.label')" :disabled="loading" - /> + /> </v-col> </v-row> <v-row> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationSummary.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationSummary.vue index e255df5a069f3135d6dcd5030ab58a9ce2cfefc8..569bc64903ecb8740ba166f457956cc64ca09496 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationSummary.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationSummary.vue @@ -6,7 +6,7 @@ :enable-create="false" :enable-edit="false" :elevated="false" - > + > <template #default="{ items }"> <!-- expandable card per person --> </template> 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/absenceCreation.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/absences/absenceCreation.graphql index 79178bfd835e5c6dbe726d53d6e00b11c7c8f61f..5bc9ee2d62125800ccb43fa0ec677956b9343e32 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/absenceCreation.graphql +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/absenceCreation.graphql @@ -7,16 +7,8 @@ query persons { } } -query lessonsForPersons( - $persons: [ID!]! - $start: Date! - $end: Date! -) { - items: lessonsForPersons( - persons: $persons - start: $start - end: $end - ) { +query lessonsForPersons($persons: [ID!]!, $start: Date!, $end: Date!) { + items: lessonsForPersons(persons: $persons, start: $start, end: $end) { id lessons { id 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/components/coursebook/statistics/MockPerson.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/MockPerson.vue index 23ff3da5ff379f3ee29afed54f3a5461d19e7a43..98a346ea0b916640a50795c5336018d7cfb9b01e 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/MockPerson.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/MockPerson.vue @@ -1,7 +1,5 @@ <template> - <statistics-for-person-card - :person="{ id: 100 }" - /> + <statistics-for-person-card :person="{ id: 100 }" /> </template> <script> @@ -10,7 +8,7 @@ import StatisticsForPersonCard from "./StatisticsForPersonCard.vue"; export default { name: "MockPerson", components: { - StatisticsForPersonCard + StatisticsForPersonCard, }, extends: "StatisticsForPersonCard", }; diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsAbsencesCard.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsAbsencesCard.vue index 0c7fda2a44d68f6d7b7ce3a8ce095d171ced4a47..e2dd2205e6975e7e2d02c6ec5e96eea38ddb6696 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsAbsencesCard.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsAbsencesCard.vue @@ -1,9 +1,7 @@ <template> - <v-skeleton-loader v-if="loading" - type="card" - /> + <v-skeleton-loader v-if="loading" type="card" /> <v-card v-else> - <v-card-text class="d-flex flex-column" style="gap: .5em"> + <v-card-text class="d-flex flex-column" style="gap: 0.5em"> <absence-reason-chip v-for="absenceReason in absenceReasons" :absenceReason="absenceReason.absenceReason" @@ -19,7 +17,7 @@ import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip. export default { name: "StatisticsAbsencesCard", components: { - AbsenceReasonChip + AbsenceReasonChip, }, props: { absenceReasons: { diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsExtraMarksCard.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsExtraMarksCard.vue index 2473b600dbb3e40c0f71482ee587eb5dcf4b4dbb..e7ae371559623adb49fb96ee2280d0f303247870 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsExtraMarksCard.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsExtraMarksCard.vue @@ -1,9 +1,7 @@ <template> - <v-skeleton-loader v-if="loading" - type="card" - /> + <v-skeleton-loader v-if="loading" type="card" /> <v-card v-else> - <v-card-text class="d-flex flex-column" style="gap: .5em"> + <v-card-text class="d-flex flex-column" style="gap: 0.5em"> <counter-chip v-for="extraMark in extraMarks" :value="extraMark.extraMark.id" diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonCard.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonCard.vue index 800d3090752dd3c193ae70acfadc8e93d2bb1a85..86ff284806830d9ddb15c33441d618369ca31271 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonCard.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonCard.vue @@ -1,8 +1,6 @@ <template> <v-card> - <v-skeleton-loader v-if="$apollo.loading" - type="card-heading" - /> + <v-skeleton-loader v-if="$apollo.loading" type="card-heading" /> <v-card-title v-else-if="compact"> {{ $t("alsijil.coursebook.statistics.person_compact.title") }} <v-spacer /> @@ -36,7 +34,8 @@ :loading="$apollo.loading" /> </div> - <statistics-personal-notes-list v-if="compact" + <statistics-personal-notes-list + v-if="compact" class="flex-grow-1" :loading="$apollo.loading" /> @@ -85,9 +84,7 @@ export default { statistics: { query: statisticsByPerson, variables() { - const term = this.schoolTerm - ? { term: this.schoolTerm.id } - : {}; + const term = this.schoolTerm ? { term: this.schoolTerm.id } : {}; return { person: this.person.id, diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue index e1e8177551ae4fc53c2eb8f69c49e8817ba0aff8..b4fe3e012d2a2235312b802fdab9aca895805ccd 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue @@ -13,7 +13,7 @@ :enable-create="false" :enable-edit="false" :elevated="false" - > + > <template #item="{ item }"> <v-list-item :key="item.id"> <v-list-item-content> @@ -30,10 +30,7 @@ {{ $d(DateTime.fromISO(item.datetimeEnd), "shortTime") }} </time> <!-- teacher --> - <person-chip - :person="item.teacher" - no-link - /> + <person-chip :person="item.teacher" no-link /> <!-- group --> <div> {{ item.groupShortName }} @@ -54,13 +51,11 @@ > {{ extraMark.name }} </v-chip> - </v-list-item-title> - <v-list-item-subtitle> - item.personalNote - </v-list-item-subtitle> - </v-list-item-content> - </v-list-item> - <v-divider></v-divider> + </v-list-item-title> + <v-list-item-subtitle> item.personalNote </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + <v-divider></v-divider> </template> </c-r-u-d-iterator> </div> @@ -69,7 +64,7 @@ :compact="false" :person="{ id: personId }" :school-term="{ id: schoolTerm }" - /> + /> </div> </template> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsPersonalNotesList.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsPersonalNotesList.vue index 5c3e70487584394b37d49331b0e3008796e182d0..5bb820d8c0a7d2b24d6ce7c7754cf51c1c1bb310 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsPersonalNotesList.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsPersonalNotesList.vue @@ -1,32 +1,20 @@ <template> - <v-skeleton-loader v-if="loading" - type="card" - /> + <v-skeleton-loader v-if="loading" type="card" /> <v-card v-else> - <v-virtual-scroll - :items="personalNotes" - height="150" - item-height="75" - > + <v-virtual-scroll :items="personalNotes" height="150" item-height="75"> <template v-slot:default="{ item }"> <v-list-item :key="item"> <v-list-item-content> <v-list-item-title class="d-flex"> <!-- new_personal_note.documentation.course.groups.FORALL.shortName --> - <div> - 5a - </div> + <div>5a</div> <!-- new_personal_note.documentation.subject/amends.subject --> <!-- TODO: In subject-chip --> - <div> - Ma - </div> + <div>Ma</div> <v-spacer /> <!-- new_personal_note.documentation.datetimeStart.toDate() --> <div> - <v-list-item-subtitle> - 01.01.2031 - </v-list-item-subtitle> + <v-list-item-subtitle> 01.01.2031 </v-list-item-subtitle> </div> </v-list-item-title> <v-list-item-subtitle> @@ -37,7 +25,7 @@ </v-list-item> <v-divider></v-divider> </template> - </v-virtual-scroll> + </v-virtual-scroll> </v-card> </template> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsTardinessCard.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsTardinessCard.vue index 5374b602801b598543a4ef3258b8bfb4f4d471e0..8921bcf059d0871e9682317eec8e3418893c62e7 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsTardinessCard.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsTardinessCard.vue @@ -1,7 +1,5 @@ <template> - <v-skeleton-loader v-if="loading" - type="card" - /> + <v-skeleton-loader v-if="loading" type="card" /> <v-card v-else class="text-center"> <v-card-text> <div class="text-h2"> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/statistics.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/statistics.graphql index 9414ec16f7efdb4069a076d1f462a08da5d7cece..821f961c3efe23c47be9cd7583082ba67395aa86 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/statistics.graphql +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/statistics.graphql @@ -27,26 +27,14 @@ fragment statistics on StatisticsByPersonType { } } -query statisticsByPerson ( - $person: ID! - $term: ID -) { - statistics: statisticsByPerson( - person: $person - term: $term - ) { - ...statistics +query statisticsByPerson($person: ID!, $term: ID) { + statistics: statisticsByPerson(person: $person, term: $term) { + ...statistics } } -query documentationsByPerson ( - $person: ID! - $term: ID -) { - documentations: documentationsByPerson( - person: $person - term: $term - ) { +query documentationsByPerson($person: ID!, $term: ID) { + documentations: documentationsByPerson(person: $person, term: $term) { id datetimeStart datetimeEnd @@ -83,18 +71,12 @@ query documentationsByPerson ( } } -query statisticsByGroup ( - $group: ID! - $term: ID -) { - statistics: statisticsByGroup( - group: $group - term: $term - ) { - persons { - id - fullName - ...statistics +query statisticsByGroup($group: ID!, $term: ID) { + statistics: statisticsByGroup(group: $group, term: $term) { + persons { + id + fullName + ...statistics } } } diff --git a/aleksis/apps/alsijil/frontend/index.js b/aleksis/apps/alsijil/frontend/index.js index fac7721f62b0cdd0efa2410d53e7760d7a2c4385..d1f4bcc16b5defe71695e4f99574be55aa2e5195 100644 --- a/aleksis/apps/alsijil/frontend/index.js +++ b/aleksis/apps/alsijil/frontend/index.js @@ -93,7 +93,8 @@ export default { }, { path: "stats/", - component: () => import("./components/coursebook/statistics/MockPerson.vue"), + component: () => + import("./components/coursebook/statistics/MockPerson.vue"), name: "alsijil.coursebook_stats", meta: { inMenu: true, @@ -106,7 +107,10 @@ export default { }, { path: "statistics/:personId/:schoolTermId/", - component: () => import("./components/coursebook/statistics/StatisticsForPersonPage.vue"), + component: () => + import( + "./components/coursebook/statistics/StatisticsForPersonPage.vue" + ), name: "alsijil.coursebook_statistics", props: true, meta: { diff --git a/aleksis/apps/alsijil/frontend/messages/de.json b/aleksis/apps/alsijil/frontend/messages/de.json index 6d0fccd933e910b0f2e30c28d510da1819037137..034f1482cb55c852f8cfbc0456cdc992a33e0b67 100644 --- a/aleksis/apps/alsijil/frontend/messages/de.json +++ b/aleksis/apps/alsijil/frontend/messages/de.json @@ -49,6 +49,8 @@ } } }, + "title_plural": "Kursbuch", + "present_number": "{present}/{total} anwesend", "statistics": { "person_compact": { "title": "Kursbuch · Statistiken" @@ -56,14 +58,26 @@ "person_page": { "title": "Statistiken" } - }, - "title_plural": "Kursbuch" + } }, "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", @@ -88,5 +102,8 @@ "tardiness": { "plural": "Verspätungen" } + }, + "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 0ccd1c78d9d3f3c8c373fcd533af2062b156be15..808126e52166aa25fca3ffa2e897b3e0322f0bfc 100644 --- a/aleksis/apps/alsijil/frontend/messages/en.json +++ b/aleksis/apps/alsijil/frontend/messages/en.json @@ -77,6 +77,7 @@ "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}", "statistics": { @@ -88,5 +89,8 @@ } } } + }, + "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/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 2bf3b78c3909ab9f5ca90e82f1e3f1d090231d07..9e68ccd06773dc8b0f6ee65ccd16c81319b94a8b 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -2,17 +2,17 @@ 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 +from django.utils.timezone import localdate, localtime, now from django.utils.translation import gettext_lazy as _ -from django.contrib.auth.models import User from calendarweek import CalendarWeek from colorfield.fields import ColorField @@ -25,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, ) @@ -43,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 @@ -461,6 +463,8 @@ class Documentation(CalendarEvent): # FIXME: DataCheck + objects = DocumentationManager() + course = models.ForeignKey( Course, models.PROTECT, @@ -486,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 @@ -519,9 +528,9 @@ class Documentation(CalendarEvent): @classmethod def get_documentations_for_events( - cls, - events: list, - incomplete: Optional[bool] = False, + cls, + events: list, + incomplete: Optional[bool] = False, ) -> tuple: """Get all the documentations for the events. Create dummy documentations if none exist. @@ -573,11 +582,11 @@ class Documentation(CalendarEvent): @classmethod def get_documentations_for_person( - cls, - person: int, - start: datetime, - end: datetime, - incomplete: Optional[bool] = False, + 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. @@ -600,65 +609,54 @@ class Documentation(CalendarEvent): @classmethod def parse_dummy( - cls, - _id: str, + cls, + _id: str, ) -> tuple: - """Parse dummy id string into lesson_event, datetime_start, datetime_end. - """ + """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 - ) + 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, + 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. + """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() - ) - ): + "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: - if lesson_event.course: - course = lesson_event.course - else: - course = lesson_event.amends.course + course = lesson_event.course if lesson_event.course else lesson_event.amends.course - if lesson_event.subject: - subject = lesson_event.subject - else: - subject = lesson_event.amends.subject + subject = lesson_event.subject if lesson_event.subject else lesson_event.amends.subject - if lesson_event.teachers: - teachers = lesson_event.teachers - else: - teachers = lesson_event.amends.teachers + teachers = ( + lesson_event.teachers if lesson_event.teachers else lesson_event.amends.teachers + ) else: course, subject, teachers = ( lesson_event.course, @@ -672,41 +670,81 @@ class Documentation(CalendarEvent): amends=lesson_event, course=course, subject=subject, - topic="", - homework="", - group_note="", ) obj.teachers.set(teachers.all()) obj.save() # Create Participation Statuses - # Cannot use djangos bulk_create method, as then the save method of the - # superclass wouldn't be called - - for member in lesson_event.all_members: - # TODO: Check for preexisting absences in kolego - # TODO: maybe only create if the lesson start is in the past - status = ParticipationStatus.objects.create( - person=member, - related_documentation=obj, - datetime_start=datetime_start, - datetime_end=datetime_end, - timezone=lesson_event.timezone, - ) - status.groups_of_person.set(member.member_of.all()) - status.save() + obj.touch() return obj @classmethod - def get_or_create_by_id(cls, _id: str|int, user): + 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, ) - return cls.objects.get(id=_id) + # 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): @@ -718,6 +756,8 @@ class ParticipationStatus(CalendarEvent): # FIXME: DataChecks + objects = ParticipationStatusManager() + person = models.ForeignKey( "core.Person", models.CASCADE, related_name="participations", verbose_name=_("Person") ) @@ -750,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 14725b342e721e5e9ddc79eda43c774adb789f98..ef274760f7a772748bbdd0664e74cb347d76df12 100644 --- a/aleksis/apps/alsijil/schema/__init__.py +++ b/aleksis/apps/alsijil/schema/__init__.py @@ -5,28 +5,31 @@ 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 from aleksis.core.schema.base import FilterOrderList from aleksis.core.schema.group import GroupType from aleksis.core.util.core_helpers import has_person -from aleksis.apps.chronos.models import LessonEvent from ..models import Documentation +from .absences import ( + AbsencesBatchCreateMutation, +) from .documentation import ( DocumentationBatchCreateOrUpdateMutation, DocumentationType, -) -from .absences import ( LessonsForPersonType, - AbsencesBatchCreateMutation, + TouchDocumentationMutation, ) +from .participation_status import ParticipationStatusBatchPatchMutation from .statistics import ( - StatisticsByPersonType, DocumentationByPersonType, + StatisticsByPersonType, ) + class Query(graphene.ObjectType): documentations = FilterOrderList(DocumentationType) documentations_by_course_id = FilterOrderList( @@ -173,8 +176,7 @@ class Query(graphene.ObjectType): end, **kwargs, ): - """Resolve all lesson events for each person in timeframe start to end. - """ + """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( @@ -183,10 +185,7 @@ class Query(graphene.ObjectType): datetime.combine(end, datetime.max.time()), ) - lessons_for_person.append( - id=person, - lessons=docs + dummies - ) + lessons_for_person.append(id=person, lessons=docs + dummies) return lessons_for_person @@ -208,4 +207,6 @@ class Query(graphene.ObjectType): class Mutation(graphene.ObjectType): create_or_update_documentations = DocumentationBatchCreateOrUpdateMutation.Field() + touch_documentation = TouchDocumentationMutation.Field() + update_participation_statuses = ParticipationStatusBatchPatchMutation.Field() create_absences = AbsencesBatchCreateMutation.Field() diff --git a/aleksis/apps/alsijil/schema/absences.py b/aleksis/apps/alsijil/schema/absences.py index 663455671de7b8f2c420bb4ab8d332ffda3c61d2..cd79f6863a688b763993e556d1651937576508a6 100644 --- a/aleksis/apps/alsijil/schema/absences.py +++ b/aleksis/apps/alsijil/schema/absences.py @@ -1,14 +1,11 @@ -import graphene from datetime import datetime +import graphene + from aleksis.apps.kolego.models import Absence -from .documentation import DocumentationType from ..models import Documentation, ParticipationStatus -class LessonsForPersonType(graphene.ObjectType): - id = graphene.ID() # noqa - lessons = graphene.List(DocumentationType) class AbsencesBatchCreateMutation(graphene.Mutation): class Arguments: diff --git a/aleksis/apps/alsijil/schema/documentation.py b/aleksis/apps/alsijil/schema/documentation.py index e8443965d140c31977ffbe3770cb870d471ba907..4a0055064abd0a9cc1bb79850390da318a808b4a 100644 --- a/aleksis/apps/alsijil/schema/documentation.py +++ b/aleksis/apps/alsijil/schema/documentation.py @@ -1,11 +1,7 @@ -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 @@ -19,6 +15,7 @@ from aleksis.core.schema.base import ( ) from ..models import Documentation +from .participation_status import ParticipationStatusType class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): @@ -37,6 +34,7 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp "date_start", "date_end", "teachers", + "participations", ) filter_fields = { "id": ["exact", "lte", "gte"], @@ -46,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) @@ -69,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): @@ -85,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) @@ -97,7 +109,7 @@ class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation): # Sadly, we can't use the update_or_create method since create_defaults # is only introduced in Django 5.0 - obj = Documentation.get_or_create_by_id(_id, info.context.user) + 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() @@ -125,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/schema/personal_note.py b/aleksis/apps/alsijil/schema/personal_note.py index 689bb5b7f73c08c3d8961dcc9230df677e20b383..afd44904e69ac9998fc49444c9d1422fae74e68e 100644 --- a/aleksis/apps/alsijil/schema/personal_note.py +++ b/aleksis/apps/alsijil/schema/personal_note.py @@ -3,12 +3,12 @@ from guardian.shortcuts import get_objects_for_user from aleksis.core.schema.base import ( DjangoFilterMixin, - PermissionBatchPatchMixin, PermissionsTypeMixin, ) from ..models import ExtraMark + class ExtraMarkType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = ExtraMark diff --git a/aleksis/apps/alsijil/schema/statistics.py b/aleksis/apps/alsijil/schema/statistics.py index 070379d1b893a1048f3b716b8de1514539e00eb6..4b9188a7a1e192707f783928a191b4b900b1cd2c 100644 --- a/aleksis/apps/alsijil/schema/statistics.py +++ b/aleksis/apps/alsijil/schema/statistics.py @@ -1,12 +1,15 @@ import graphene -from aleksis.core.models import Person -from aleksis.core.schema.person import PersonType + from aleksis.apps.cursus.schema import SubjectType from aleksis.apps.kolego.models.absence import AbsenceReason from aleksis.apps.kolego.schema.absence import AbsenceReasonType +from aleksis.core.models import Person +from aleksis.core.schema.person import PersonType + from ..models import ExtraMark from .personal_note import ExtraMarkType + class AbsenceReasonWithCountType(graphene.ObjectType): absence_reason = graphene.Field(AbsenceReasonType) count = graphene.Int() 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"]