diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue index 1403a38bb524c7fa407ba94f8193cbda00e8b6ba..7c1b9ac900da53ff7ab97a5b459e9be70891521f 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue @@ -1,68 +1,73 @@ <template> - <c-r-u-d-iterator - i18n-key="alsijil.coursebook" - :gql-query="gqlQuery" - :gql-additional-query-args="gqlQueryArgs" - :enable-create="false" - :enable-edit="false" - :elevated="false" - @lastQuery="lastQuery = $event" - ref="iterator" - fixed-header - disable-pagination - hide-default-footer - use-deep-search - > - <template #additionalActions="{ attrs, on }"> - <coursebook-filters v-model="filters" /> - </template> - <template #default="{ items }"> - <coursebook-loader /> - <coursebook-day - v-for="{ date, docs, first, last } in groupDocsByDay(items)" - v-intersect="{ - handler: intersectHandler(date, first, last), - options: { - rootMargin: '-' + topMargin + 'px 0px 0px 0px', - threshold: [0, 1], - }, - }" - :date="date" - :docs="docs" - :lastQuery="lastQuery" - :focus-on-mount="initDate && initDate.toMillis() === date.toMillis()" - @init="transition" - :key="'day-' + date" - ref="days" - :extra-marks="extraMarks" - /> - <coursebook-loader /> + <div> + <c-r-u-d-iterator + i18n-key="alsijil.coursebook" + :gql-query="gqlQuery" + :gql-additional-query-args="gqlQueryArgs" + :enable-create="false" + :enable-edit="false" + :elevated="false" + @lastQuery="lastQuery = $event" + ref="iterator" + fixed-header + disable-pagination + hide-default-footer + use-deep-search + > + <template #additionalActions="{ attrs, on }"> + <coursebook-filters v-model="filters" /> + </template> + <template #default="{ items }"> + <coursebook-loader /> + <coursebook-day + v-for="{ date, docs, first, last } in groupDocsByDay(items)" + v-intersect="{ + handler: intersectHandler(date, first, last), + options: { + rootMargin: '-' + topMargin + 'px 0px 0px 0px', + threshold: [0, 1], + }, + }" + :date="date" + :docs="docs" + :lastQuery="lastQuery" + :focus-on-mount="initDate && initDate.toMillis() === date.toMillis()" + @init="transition" + :key="'day-' + date" + ref="days" + :extra-marks="extraMarks" + /> + <coursebook-loader /> - <date-select-footer - :value="currentDate" - @input="gotoDate" - @prev="gotoPrev" - @next="gotoNext" - /> - </template> - <template #loading> - <coursebook-loader :number-of-days="10" :number-of-docs="5" /> - </template> + <date-select-footer + :value="currentDate" + @input="gotoDate" + @prev="gotoPrev" + @next="gotoNext" + /> + </template> + <template #loading> + <coursebook-loader :number-of-days="10" :number-of-docs="5" /> + </template> - <template #no-data> - <CoursebookEmptyMessage icon="mdi-book-off-outline"> - {{ $t("alsijil.coursebook.no_data") }} - </CoursebookEmptyMessage> - </template> + <template #no-data> + <CoursebookEmptyMessage icon="mdi-book-off-outline"> + {{ $t("alsijil.coursebook.no_data") }} + </CoursebookEmptyMessage> + </template> - <template #no-results> - <CoursebookEmptyMessage icon="mdi-book-alert-outline"> - {{ - $t("alsijil.coursebook.no_results", { search: $refs.iterator.search }) - }} - </CoursebookEmptyMessage> - </template> - </c-r-u-d-iterator> + <template #no-results> + <CoursebookEmptyMessage icon="mdi-book-alert-outline"> + {{ + $t("alsijil.coursebook.no_results", { + search: $refs.iterator.search, + }) + }} + </CoursebookEmptyMessage> + </template> + </c-r-u-d-iterator> + <absence-creation-dialog /> + </div> </template> <script> @@ -76,6 +81,8 @@ import CoursebookLoader from "./CoursebookLoader.vue"; import CoursebookEmptyMessage from "./CoursebookEmptyMessage.vue"; import { extraMarks } from "../extra_marks/extra_marks.graphql"; +import AbsenceCreationDialog from "./absences/AbsenceCreationDialog.vue"; + export default { name: "Coursebook", components: { @@ -85,6 +92,7 @@ export default { CRUDIterator, DateSelectFooter, CoursebookDay, + AbsenceCreationDialog, }, props: { filterType: { diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationDialog.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..dbb504c393b0dc03f43b647ca305f614fd7df1da --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationDialog.vue @@ -0,0 +1,174 @@ +<template> + <mobile-fullscreen-dialog v-model="popup" persistent> + <template #activator="activator"> + <fab-button + color="secondary" + @click="popup = true" + :disabled="popup" + :class="{ + 'd-none': !checkPermission('alsijil.view_register_absence_rule'), + }" + icon-text="$plus" + i18n-key="alsijil.coursebook.absences.button" + > + <v-icon>$plus</v-icon> + </fab-button> + </template> + <template #title> + <div> + {{ $t("alsijil.coursebook.absences.title") }} + </div> + <span v-if="!form" class="px-2">·</span> + <div v-if="!form"> + {{ $t("alsijil.coursebook.absences.summary") }} + </div> + </template> + <template #content> + <absence-creation-form + :persons="persons" + :start-date="startDate" + :end-date="endDate" + :comment="comment" + :absence-reason="absenceReason" + @valid="formValid = $event" + @persons="persons = $event" + @start-date="startDate = $event" + @end-date="endDate = $event" + @comment="comment = $event" + @absence-reason="absenceReason = $event" + :class="{ + 'd-none': !form, + }" + /> + <absence-creation-summary + v-if="!form" + :persons="persons" + :start-date="startDate" + :end-date="endDate" + @loading="handleLoading" + /> + </template> + <template #actionsLeft> + <cancel-button @click="cancel" /> + </template> + <template #actions> + <!-- secondary --> + <secondary-action-button + @click="form = true" + v-if="!form" + :disabled="loading" + i18n-key="actions.back" + > + <v-icon left>$prev</v-icon> + {{ $t("actions.back") }} + </secondary-action-button> + <!-- primary --> + <save-button + v-if="form" + @click="form = false" + :loading="loading" + :disabled="!formValid" + > + {{ $t("actions.continue") }} + <v-icon right>$next</v-icon> + </save-button> + <save-button + v-else + i18n-key="actions.confirm" + @click="confirm" + :loading="loading" + /> + </template> + </mobile-fullscreen-dialog> +</template> + +<script> +import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue"; +import AbsenceCreationForm from "./AbsenceCreationForm.vue"; +import AbsenceCreationSummary from "./AbsenceCreationSummary.vue"; +import FabButton from "aleksis.core/components/generic/buttons/FabButton.vue"; +import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue"; +import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue"; +import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue"; +import loadingMixin from "aleksis.core/mixins/loadingMixin.js"; +import permissionsMixin from "aleksis.core/mixins/permissions.js"; +import mutateMixin from "aleksis.core/mixins/mutateMixin.js"; + +import { createAbsencesForPersons } from "./absenceCreation.graphql"; + +export default { + name: "AbsenceCreationDialog", + components: { + MobileFullscreenDialog, + AbsenceCreationForm, + AbsenceCreationSummary, + CancelButton, + SaveButton, + SecondaryActionButton, + FabButton, + }, + mixins: [loadingMixin, mutateMixin, permissionsMixin], + data() { + return { + popup: false, + form: true, + formValid: false, + persons: [], + startDate: "", + endDate: "", + comment: "", + absenceReason: "", + }; + }, + mounted() { + this.addPermissions(["alsijil.view_register_absence_rule"]); + }, + methods: { + cancel() { + this.popup = false; + this.form = true; + this.clearForm(); + }, + clearForm() { + this.persons = []; + this.startDate = ""; + this.endDate = ""; + this.comment = ""; + this.absenceReason = ""; + }, + confirm() { + this.handleLoading(true); + this.mutate( + createAbsencesForPersons, + { + persons: this.persons.map((p) => p.id), + start: this.startDate, + end: this.endDate, + comment: this.comment, + reason: this.absenceReason, + }, + (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; + }, + ); + this.$once("save", this.handleSave); + }, + handleSave() { + this.cancel(); + this.$toastSuccess("alsijil.coursebook.absences.success"); + }, + }, +}; +</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationForm.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..559824bdf4cb8bb214d1be3acebba38c08c836cd --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationForm.vue @@ -0,0 +1,117 @@ +<template> + <v-form @input="$emit('valid', $event)"> + <v-container> + <v-row> + <div aria-required="true" class="full-width"> + <!-- FIXME Vue 3: clear-on-select --> + <v-autocomplete + :label="$t('forms.labels.persons')" + :items="allPersons" + item-text="fullName" + return-object + multiple + chips + deletable-chips + :rules=" + $rules().build([ + (value) => value.length > 0 || $t('forms.errors.required'), + ]) + " + :value="persons" + :loading="$apollo.queries.allPersons.loading" + @input="$emit('persons', $event)" + /> + </div> + </v-row> + <v-row> + <v-col cols="12" :sm="6" class="pl-0"> + <div aria-required="true"> + <date-field + :label="$t('forms.labels.start')" + :max="endDate" + :rules="$rules().required.build()" + :value="startDate" + @input="$emit('start-date', $event)" + /> + </div> + </v-col> + <v-col cols="12" :sm="6" class="pr-0"> + <div aria-required="true"> + <date-field + :label="$t('forms.labels.end')" + :min="startDate" + :rules="$rules().required.build()" + :value="endDate" + @input="$emit('end-date', $event)" + /> + </div> + </v-col> + </v-row> + <v-row> + <v-text-field + :label="$t('forms.labels.comment')" + :value="comment" + @input="$emit('comment', $event)" + /> + </v-row> + <v-row> + <div aria-required="true"> + <absence-reason-group-select + :rules="$rules().required.build()" + :value="absenceReason" + @input="$emit('absence-reason', $event)" + /> + </div> + </v-row> + </v-container> + </v-form> +</template> + +<script> +import AbsenceReasonGroupSelect from "aleksis.apps.kolego/components/AbsenceReasonGroupSelect.vue"; +import DateField from "aleksis.core/components/generic/forms/DateField.vue"; +import { persons } from "./absenceCreation.graphql"; +import formRulesMixin from "aleksis.core/mixins/formRulesMixin.js"; + +export default { + name: "AbsenceCreationForm", + components: { + AbsenceReasonGroupSelect, + DateField, + }, + mixins: [formRulesMixin], + emits: [ + "valid", + "persons", + "start-date", + "end-date", + "comment", + "absence-reason", + ], + apollo: { + allPersons: persons, + }, + props: { + persons: { + type: Array, + required: true, + }, + startDate: { + type: String, + required: true, + }, + endDate: { + type: String, + required: true, + }, + comment: { + type: String, + required: true, + }, + absenceReason: { + type: String, + required: true, + }, + }, +}; +</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationSummary.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationSummary.vue new file mode 100644 index 0000000000000000000000000000000000000000..17a7795e697caeed9346c3aa13fe6b10e2f54c28 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationSummary.vue @@ -0,0 +1,123 @@ +<template> + <div> + <message-box dense type="warning" class="mt-5"> + {{ $t("alsijil.coursebook.absences.warning") }} + </message-box> + <!-- MAYBE introduce a minimal variant of CRUDIterator --> + <!-- with most features disabled for this list usecase --> + <c-r-u-d-iterator + i18n-key="" + :gql-query="gqlQuery" + :gql-additional-query-args="gqlArgs" + :enable-search="false" + :enable-create="false" + :enable-edit="false" + :elevated="false" + disable-pagination + hide-default-footer + @loading="handleLoading" + > + <template #default="{ items }"> + <v-expansion-panels> + <v-expansion-panel v-for="person in items" :key="person.id"> + <v-expansion-panel-header> + <div> + {{ persons.find((p) => p.id === person.id).fullName }} + </div> + <v-spacer /> + <div> + {{ + $tc( + "alsijil.coursebook.absences.lessons", + person.lessons.length, + { count: person.lessons.length }, + ) + }} + </div> + </v-expansion-panel-header> + <v-expansion-panel-content> + <v-list-item + v-for="lesson in person.lessons" + class="px-0" + :key="lesson.id" + > + <v-row> + <!-- TODO: We should extract this display & share it --> + <v-col cols="3"> + <time :datetime="lesson.datetimeStart" class="text-no-wrap"> + {{ + $d( + $parseISODate(lesson.datetimeStart), + "shortWithWeekday", + ) + }} + </time> + </v-col> + <v-col cols="3"> + <time :datetime="lesson.datetimeStart" class="text-no-wrap"> + {{ $d($parseISODate(lesson.datetimeStart), "shortTime") }} + </time> + <span> - </span> + <time :datetime="lesson.datetimeEnd" class="text-no-wrap"> + {{ $d($parseISODate(lesson.datetimeEnd), "shortTime") }} + </time> + </v-col> + <v-col cols="3"> + {{ lesson.course?.name }} + </v-col> + <v-col cols="3"> + <subject-chip :subject="lesson.subject" /> + </v-col> + </v-row> + </v-list-item> + </v-expansion-panel-content> + </v-expansion-panel> + </v-expansion-panels> + </template> + </c-r-u-d-iterator> + </div> +</template> + +<script> +import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue"; +import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; +import { lessonsForPersons } from "./absenceCreation.graphql"; +import loadingMixin from "aleksis.core/mixins/loadingMixin.js"; + +export default { + name: "AbsenceCreationSummary", + components: { + CRUDIterator, + SubjectChip, + }, + mixins: [loadingMixin], + props: { + persons: { + type: Array, + required: true, + }, + startDate: { + type: String, + required: true, + }, + endDate: { + type: String, + required: true, + }, + }, + data() { + return { + gqlQuery: lessonsForPersons, + }; + }, + computed: { + gqlArgs() { + return { + persons: this.persons.map((person) => person.id), + start: this.startDate, + end: this.endDate, + }; + }, + }, +}; +</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/absenceCreation.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/absences/absenceCreation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..21ce30d0b871bcfbd9a5b40b671a8594948a2569 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/absenceCreation.graphql @@ -0,0 +1,61 @@ +# Uses core persons query +query persons { + allPersons: absenceCreationPersons { + id + fullName + } +} + +query lessonsForPersons($persons: [ID]!, $start: Date!, $end: Date!) { + items: lessonsForPersons(persons: $persons, start: $start, end: $end) { + id + lessons { + id + datetimeStart + datetimeEnd + course { + id + name + } + subject { + id + name + shortName + colourFg + colourBg + } + } + } +} + +# Use absencesInputType? +mutation createAbsencesForPersons( + $persons: [ID]! + $start: Date! + $end: Date! + $comment: String + $reason: ID! +) { + createAbsencesForPersons( + persons: $persons + start: $start + end: $end + comment: $comment + reason: $reason + ) { + ok + items: participationStatuses { + id + isOptimistic + relatedDocumentation { + id + } + absenceReason { + id + name + shortName + colour + } + } + } +} diff --git a/aleksis/apps/alsijil/frontend/messages/de.json b/aleksis/apps/alsijil/frontend/messages/de.json index f1d6ecf6452596d6ab97e467cf0a06e6c6c98773..e47b2b78a0b7e9b9de790156b3182fbe6c4b3226 100644 --- a/aleksis/apps/alsijil/frontend/messages/de.json +++ b/aleksis/apps/alsijil/frontend/messages/de.json @@ -50,7 +50,14 @@ } }, "title_plural": "Kursbuch", - "present_number": "{present}/{total} anwesend" + "present_number": "{present}/{total} anwesend", + "absences": { + "title": "Abwesenheiten erfassen", + "summary": "Zusammenfassung", + "lessons": "Keine Stunden | 1 Stunde | {count} Stunden", + "success": "Die Abwesenheiten wurden erfolgreich erfasst.", + "warning": "Die folgenden Stunden liegen im ausgewählten Zeitraum. Bitte überprüfen Sie vor dem Bestätigen, ob Sie die Abwesenheiten für diese Stunden erfassen möchten." + } }, "excuse_types": { "menu_title": "Entschuldigungsarten" diff --git a/aleksis/apps/alsijil/frontend/messages/en.json b/aleksis/apps/alsijil/frontend/messages/en.json index c74ae97abc9d412152123eb11bf74e5d75b70e93..3ad99b40cadee92070cf5a2b1ab287343b2a8e6f 100644 --- a/aleksis/apps/alsijil/frontend/messages/en.json +++ b/aleksis/apps/alsijil/frontend/messages/en.json @@ -85,7 +85,15 @@ }, "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}" + "no_results": "No search results for {search}", + "absences": { + "title": "Register absences", + "button": "Register absences", + "summary": "Summary", + "lessons": "No lessons | 1 lesson | {count} lessons", + "success": "The absences were registered successfully.", + "warning": "The following lessons are in the selected time period. Please check that you want to register the absences for these lessons before confirming." + } }, "personal_notes": { "note": "Note", diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index 2036260dca1a134571315d6b0e3e4a23ac720d92..cca46c9cb755c4a116c3e157484b265b8f4f4ae4 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -8,6 +8,7 @@ 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 @@ -529,6 +530,8 @@ class Documentation(CalendarEvent): @classmethod def get_documentations_for_events( cls, + datetime_start: datetime, + datetime_end: datetime, events: list, incomplete: Optional[bool] = False, ) -> tuple: @@ -538,18 +541,28 @@ class Documentation(CalendarEvent): """ docs = [] dummies = [] + + # Prefetch existing documentations to speed things up + existing_documentations = Documentation.objects.filter( + datetime_start__lte=datetime_end, + datetime_end__gte=datetime_start, + amends__in=[e["REFERENCE_OBJECT"] for e in events], + ).prefetch_related("participations") + for event in events: if incomplete and event["STATUS"] == "CANCELLED": continue event_reference_obj = event["REFERENCE_OBJECT"] - existing_documentations = event_reference_obj.amended_by.filter( - datetime_start=event["DTSTART"].dt, - datetime_end=event["DTEND"].dt, + existing_documentations_event = filter( + lambda d: ( + d.datetime_start == event["DTSTART"].dt and d.datetime_end == event["DTEND"].dt + ), + existing_documentations, ) - if existing_documentations.exists(): - doc = existing_documentations.first() + doc = next(existing_documentations_event, None) + if doc: if incomplete and doc.topic: continue docs.append(doc) @@ -578,7 +591,7 @@ class Documentation(CalendarEvent): ) ) - return (docs, dummies) + return docs, dummies @classmethod def get_documentations_for_person( @@ -594,7 +607,7 @@ class Documentation(CalendarEvent): """ event_params = { "type": "PARTICIPANT", - "obj_id": person, + "id": person, } events = LessonEvent.get_single_events( @@ -605,7 +618,7 @@ class Documentation(CalendarEvent): with_reference_object=True, ) - return Documentation.get_documentations_for_events(events, incomplete) + return Documentation.get_documentations_for_events(start, end, events, incomplete) @classmethod def parse_dummy( @@ -793,6 +806,34 @@ class ParticipationStatus(CalendarEvent): tardiness = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name=_("Tardiness")) + @classmethod + def get_objects( + cls, request: HttpRequest | None = None, params: dict[str, any] | None = None + ) -> QuerySet: + qs = super().get_objects(request, params).select_related("person", "absence_reason") + if params: + if params.get("person"): + qs = qs.filter(person=params["person"]) + elif params.get("persons"): + qs = qs.filter(person__in=params["persons"]) + elif params.get("group"): + qs = qs.filter(groups_of_person__in=params.get("group")) + return qs + + @classmethod + def value_title( + cls, reference_object: "ParticipationStatus", request: HttpRequest | None = None + ) -> str: + """Return the title of the calendar event.""" + return f"{reference_object.person} ({reference_object.absence_reason})" + + @classmethod + def value_description( + cls, reference_object: "ParticipationStatus", request: HttpRequest | None = None + ) -> str: + """Return the title of the calendar event.""" + return "" + def fill_from_kolego(self, kolego_absence: KolegoAbsence): """Take over data from a Kolego absence.""" self.base_absence = kolego_absence diff --git a/aleksis/apps/alsijil/rules.py b/aleksis/apps/alsijil/rules.py index 99222b8f5d102c940d4ec0ef89f75ca94fbfd954..c1ffafbe856ffa64c91a17e3eaf062942debbe40 100644 --- a/aleksis/apps/alsijil/rules.py +++ b/aleksis/apps/alsijil/rules.py @@ -171,19 +171,16 @@ add_perm("alsijil.view_week_personalnote_rule", view_week_personal_notes_predica # Register absence view_register_absence_predicate = has_person & ( - ( - is_person_group_owner - & is_site_preference_set("alsijil", "register_absence_as_primary_group_owner") - ) - | has_global_perm("alsijil.register_absence") + is_person_group_owner | has_global_perm("alsijil.register_absence") ) +add_perm("alsijil.view_register_absence_rule", view_register_absence_predicate) register_absence_predicate = has_person & ( - view_register_absence_predicate + is_group_owner + | has_global_perm("alsijil.register_absence") | has_object_perm("core.register_absence_person") | has_person_group_object_perm("core.register_absence_group") ) -add_perm("alsijil.view_register_absence_rule", view_register_absence_predicate) add_perm("alsijil.register_absence_rule", register_absence_predicate) # View full register for group diff --git a/aleksis/apps/alsijil/schema/__init__.py b/aleksis/apps/alsijil/schema/__init__.py index 9a58c6fec23a53173cefd960951f7411cb8c80af..9b715583e32a391f0888b0d54d1cb29c8d5e4f79 100644 --- a/aleksis/apps/alsijil/schema/__init__.py +++ b/aleksis/apps/alsijil/schema/__init__.py @@ -11,9 +11,13 @@ 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.schema.person import PersonType from aleksis.core.util.core_helpers import has_person from ..models import Documentation +from .absences import ( + AbsencesForPersonsCreateMutation, +) from .documentation import ( DocumentationBatchCreateOrUpdateMutation, DocumentationType, @@ -52,6 +56,7 @@ class Query(graphene.ObjectType): groups_by_person = FilterOrderList(GroupType, person=graphene.ID()) courses_of_person = FilterOrderList(CourseType, person=graphene.ID()) + absence_creation_persons = graphene.List(PersonType) lessons_for_persons = graphene.List( LessonsForPersonType, persons=graphene.List(graphene.ID, required=True), @@ -121,7 +126,12 @@ class Query(graphene.ObjectType): ) # Lookup or create documentations and return them all. - docs, dummies = Documentation.get_documentations_for_events(events, incomplete) + docs, dummies = Documentation.get_documentations_for_events( + datetime.combine(date_start, datetime.min.time()), + datetime.combine(date_end, datetime.max.time()), + events, + incomplete, + ) return docs + dummies @staticmethod @@ -161,6 +171,12 @@ class Query(graphene.ObjectType): & Q(groups__in=Group.objects.for_current_school_term_or_all()) ).distinct() + @staticmethod + def resolve_absence_creation_persons(root, info, **kwargs): + if not info.context.user.has_perm("alsijil.register_absence"): + return Person.objects.filter(member_of__owners=info.context.user.person) + return Person.objects.all() + @staticmethod def resolve_lessons_for_persons( root, @@ -179,7 +195,7 @@ class Query(graphene.ObjectType): datetime.combine(end, datetime.max.time()), ) - lessons_for_person.append(id=person, lessons=docs + dummies) + lessons_for_person.append(LessonsForPersonType(id=person, lessons=docs + dummies)) return lessons_for_person @@ -188,6 +204,7 @@ class Mutation(graphene.ObjectType): create_or_update_documentations = DocumentationBatchCreateOrUpdateMutation.Field() touch_documentation = TouchDocumentationMutation.Field() update_participation_statuses = ParticipationStatusBatchPatchMutation.Field() + create_absences_for_persons = AbsencesForPersonsCreateMutation.Field() create_extra_marks = ExtraMarkBatchCreateMutation.Field() update_extra_marks = ExtraMarkBatchPatchMutation.Field() diff --git a/aleksis/apps/alsijil/schema/absences.py b/aleksis/apps/alsijil/schema/absences.py new file mode 100644 index 0000000000000000000000000000000000000000..d05ac55214a36430808bd17ec22cc2f4c6767313 --- /dev/null +++ b/aleksis/apps/alsijil/schema/absences.py @@ -0,0 +1,59 @@ +from datetime import datetime + +from django.core.exceptions import PermissionDenied + +import graphene + +from aleksis.apps.kolego.models import Absence +from aleksis.core.models import Person + +from ..models import ParticipationStatus +from .participation_status import ParticipationStatusType + + +class AbsencesForPersonsCreateMutation(graphene.Mutation): + class Arguments: + persons = graphene.List(graphene.ID, required=True) + start = graphene.Date(required=True) + end = graphene.Date(required=True) + comment = graphene.String(required=False) + reason = graphene.ID(required=True) + + ok = graphene.Boolean() + participation_statuses = graphene.List(ParticipationStatusType) + + @classmethod + def mutate(cls, root, info, persons, start, end, comment, reason): # noqa + participation_statuses = [] + + persons = Person.objects.filter(pk__in=persons) + + for person in persons: + if not info.context.user.has_perm("alsijil.register_absence_rule", person): + raise PermissionDenied() + kolego_absence, __ = Absence.objects.get_or_create( + date_start=start, + date_end=end, + reason_id=reason, + person=person, + defaults={"comment": comment}, + ) + + events = ParticipationStatus.get_single_events( + datetime.combine(start, datetime.min.time()), + datetime.combine(end, datetime.max.time()), + None, + {"person": person}, + with_reference_object=True, + ) + + for event in events: + participation_status = event["REFERENCE_OBJECT"] + participation_status.absence_reason_id = reason + participation_status.base_absence = kolego_absence + participation_status.save() + participation_statuses.append(participation_status) + + return AbsencesForPersonsCreateMutation( + ok=True, participation_statuses=participation_statuses + ) diff --git a/aleksis/apps/alsijil/schema/documentation.py b/aleksis/apps/alsijil/schema/documentation.py index 63aecfdfc0e5115250e2e4d59c34d41c8375530e..39eed04a4a688c4c8c0aa0dc333fc02e852ab2a4 100644 --- a/aleksis/apps/alsijil/schema/documentation.py +++ b/aleksis/apps/alsijil/schema/documentation.py @@ -34,7 +34,6 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp "date_start", "date_end", "teachers", - "participations", ) filter_fields = { "id": ["exact", "lte", "gte"], @@ -78,7 +77,7 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp # 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() + return root.participations.all() class DocumentationInputType(graphene.InputObjectType): diff --git a/aleksis/apps/alsijil/util/predicates.py b/aleksis/apps/alsijil/util/predicates.py index eff4c8e7fd31314cebd62b8301867ebdecb3f3b3..e1d8f8a02760b846d35672ffddeeb73d161ece72 100644 --- a/aleksis/apps/alsijil/util/predicates.py +++ b/aleksis/apps/alsijil/util/predicates.py @@ -93,19 +93,14 @@ def is_group_owner(user: User, obj: Union[Group, Person]) -> bool: @predicate -def is_person_group_owner(user: User, obj: Person) -> bool: +def is_person_group_owner(user: User, obj) -> bool: """ Predicate for group owners of any group. Checks whether the person linked to the user is the owner of any group of the given person. """ - if obj: - for group in use_prefetched(obj, "member_of"): - if user.person in use_prefetched(group, "owners"): - return True - return False - return False + return Group.objects.filter(owners=user.person).exists() def use_prefetched(obj, attr):