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..f4de6f18e8f84208baf4b1abf1e79b05550c27d2 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationDialog.vue @@ -0,0 +1,69 @@ +<template> + <mobile-fullscreen-dialog v-model="popup"> + <template #activator="activator"> + <!-- button +? --> + <!-- -> popup = true --> + </template> + <template #title> + <!-- Abwesenheit/Entschuldigung erfassen --> + <!-- Abwesenheit/Entschuldigung Zusammenfassung --> + </template> + <template #content> + <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" + /> + <!-- primary --> + <save-button + v-if="form" + i18n-key="actions.continue" + @click="form = false" + :loading="loading" + /> + <save-button + v-if="form" + 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 AbsenceForm from "./AbsenceForm.vue"; +import AbsenceSummary from "./AbsenceSummary.vue"; +import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue"; +import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue"; + +export default { + name: "AbsenceDialog", + components: { + MobileFullscreenDialog, + AbsenceForm, + AbsenceSummary, + CancelButton, + SaveButton, + }, + data() { + return { + popup: false, + form: true, + loading: false, + }; + }, + methods: { + confirm() { + // TODO: Send mutation (shown in absence-summary) + popup = false, + }, + }, +}; +</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..11e226bfb5ed523289a87862fa18d0e7aebbfcf9 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationForm.vue @@ -0,0 +1,50 @@ +<template> + <v-container> + <v-row> + <!-- persons --> + <!-- v-autocomplete --> + </v-row> + <v-row> + <!-- Start --> + <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" + > + <date-field + :value="value" + @input="$emit('input', $event)" + :label="$t('date_select.label')" + :disabled="loading" + /> + </v-col> + </v-row> + <v-row> + <!-- comment --> + </v-row> + <v-row> + <!-- TODO: Component from Julian --> + </v-row> + </v-container> +</template> + +<script> +import DateField from "aleksis.core/components/generic/forms/DateField.vue"; +export default { + name: "AbsenceForm", + components: { + DateField, + }, +}; +</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..e255df5a069f3135d6dcd5030ab58a9ce2cfefc8 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/AbsenceCreationSummary.vue @@ -0,0 +1,25 @@ +<template> + <!-- TODO: Hide header --> + <c-r-u-d-iterator + :gql-query="" + :gql-additional-query-args="FROM FORM" + :enable-create="false" + :enable-edit="false" + :elevated="false" + > + <template #default="{ items }"> + <!-- expandable card per person --> + </template> + </c-r-u-d-iterator> +</template> + +<script> +import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue"; + +export default { + name: "AbsenceSummary", + components: { + CRUDIterator, + }, +}; +</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..79178bfd835e5c6dbe726d53d6e00b11c7c8f61f --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/absenceCreation.graphql @@ -0,0 +1,59 @@ +# Uses core persons query +query persons { + persons: persons { + id + firstName + lastName + } +} + +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 createAbsences( + $persons: [ID!]! + $start: Date! + $end: Date! + $comment: String + $reason: ID! +) { + createAbsences( + person: $persons + start: $start + end: $end + comment: $comment + reason: $reason + ) { + items: absences { + ok + } + } +} diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index a73a7d7b925360ce6283cdf4d3161cd78b4de4bb..ad6aa5c4f7a5a93b40fe1e37e53837be344571e1 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -2,6 +2,7 @@ from datetime import date, datetime from typing import Optional, Union from urllib.parse import urlparse +from django.core.exceptions import PermissionDenied from django.db import models from django.db.models import QuerySet from django.db.models.constraints import CheckConstraint @@ -9,7 +10,9 @@ from django.db.models.query_utils import Q from django.http import HttpRequest from django.urls import reverse from django.utils.formats import date_format +from django.utils.timezone import localdate, localtime from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.models import User from calendarweek import CalendarWeek from colorfield.fields import ColorField @@ -515,44 +518,17 @@ class Documentation(CalendarEvent): # which is not possible via constraint, because amends is not local to Documentation @classmethod - def get_for_coursebook( - cls, - own: bool, - date_start: datetime, - date_end: datetime, - request: HttpRequest, - obj_type: Optional[str] = None, - obj_id: Optional[str] = None, - incomplete: Optional[bool] = False, - ) -> list: - """Get all the documentations for an object and a time frame. - - obj_type may be one of TEACHER, GROUP, ROOM, COURSE + def get_documentations_for_events( + cls, + events: list, + incomplete: Optional[bool] = False, + ) -> 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 +558,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 +569,116 @@ 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: + 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 = cls.objects.create( + datetime_start=datetime_start, + datetime_end=datetime_end, + amends=lesson_event, + course=course, + subject=subject, + topic="", + homework="", + group_note="", + ) + obj.teachers.set(teachers.all()) + obj.save() + return obj class ParticipationStatus(CalendarEvent): diff --git a/aleksis/apps/alsijil/schema/__init__.py b/aleksis/apps/alsijil/schema/__init__.py index e4e48be10e717b4f105330a7f942861794aca12c..5871a80b09fa0cc461dda9ff5b05604ddb9475fe 100644 --- a/aleksis/apps/alsijil/schema/__init__.py +++ b/aleksis/apps/alsijil/schema/__init__.py @@ -11,12 +11,17 @@ 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 .documentation import ( DocumentationBatchCreateOrUpdateMutation, DocumentationType, ) +from .absences import ( + LessonsForPersonType, + AbsencesBatchCreateMutation, +) from .participation_status import ParticipationStatusBatchPatchMutation @@ -38,6 +43,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) @@ -55,9 +67,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" @@ -80,10 +89,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: @@ -117,7 +146,34 @@ 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() + create_absences = AbsencesBatchCreateMutation.Field() update_participation_statuses = ParticipationStatusBatchPatchMutation.Field() diff --git a/aleksis/apps/alsijil/schema/absences.py b/aleksis/apps/alsijil/schema/absences.py new file mode 100644 index 0000000000000000000000000000000000000000..663455671de7b8f2c420bb4ab8d332ffda3c61d2 --- /dev/null +++ b/aleksis/apps/alsijil/schema/absences.py @@ -0,0 +1,81 @@ +import graphene +from datetime import datetime + +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: + persons = graphene.List(graphene.ID) + start = graphene.Date() + end = graphene.Date() + comment = graphene.String() + reason = graphene.ID() + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, persons, start, end, comment, reason): # noqa + # TODO: Check permissions for ParticipationStatus & KolegoAbsence + # See MR 356 + + # DocumentationBatchCreateOrUpdateMutation.create_or_update + # at least already checks permissions. + + for person in persons: + # Get all documentations for this person between start & end + docs, dummies = Documentation.get_documentations_for_person( + person, + datetime.combine(start, datetime.min.time()), + datetime.combine(end, datetime.max.time()), + ) + + # Create doc for dummies that are already in the past + future = False + for dummy in dummies: + lesson_event, dummy_start, dummy_end = Documentation.parse_dummy(dummy.id) + + if dummy_start < datetime.now(): + # In the past -> Create a Documentation + docs.append( + Documentation.create_from_lesson_event( + info.context.user, + lesson_event, + dummy_start, + dummy_end, + ) + ) + else: + future = True + + # Create a ParticipationStatus for each documentation + for doc in docs: + # Set person & absence_reason directly from id + ParticipationStatus.objects.create( + person_id=person, + related_documentation=doc, + absence_reason_id=reason, + ) + + # If there are still dummy documentations in the future + # create a Kolego Absence + if future: + # TODO: Are date_start & date_end from CalendarEvent enough + # or more needed? + # Set reason & person directly from id + Absence.objects.create( + date_start=datetime.now().date(), + date_end=end, + reason_id=reason, + person_id=person, + comment=comment, + ) + + # Return ok=True if everything went well. + return AbsencesBatchCreateMutation(ok=True) diff --git a/aleksis/apps/alsijil/schema/documentation.py b/aleksis/apps/alsijil/schema/documentation.py index e9ece98b8619a134441884d0a332e533e90a872c..24029c245376a0d6a7fa8d35a6d4d5d8adebb17b 100644 --- a/aleksis/apps/alsijil/schema/documentation.py +++ b/aleksis/apps/alsijil/schema/documentation.py @@ -9,7 +9,6 @@ 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,7 +17,6 @@ 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 @@ -112,90 +110,30 @@ 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 + obj = Documentation.create_from_lesson_event( + info.context.user, + *Documentation.parse_dummy(_id), ) - - 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) - 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