Skip to content
Snippets Groups Projects
Commit f8f366cb authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Merge branch '261-add-absence-management-to-course-book-student-dialog' into 'master'

Resolve "Add absence management to course book student dialog"

Closes #261

See merge request !360
parents 84768a91 901d9465
No related branches found
No related tags found
1 merge request!360Resolve "Add absence management to course book student dialog"
Pipeline #189742 passed with warnings
Showing
with 888 additions and 181 deletions
<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"
......
......@@ -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,
});
},
......
<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>
......
<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>
<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>
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
}
}
}
}
......@@ -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
}
}
}
}
......@@ -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"
......
<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>
......
......@@ -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,
};
},
},
......
......@@ -49,13 +49,27 @@
}
}
},
"title_plural": "Kursbuch"
"title_plural": "Kursbuch",
"present_number": "{present}/{total} anwesend"
},
"excuse_types": {
"menu_title": "Entschuldigungsarten"
},
"extra_marks": {
"menu_title": "Zusätzliche Markierungen"
"menu_title": "Zusätzliche Markierungen",
"create": "Markierung erstellen",
"name": "Markierung",
"short_name": "Abkürzung",
"colour_fg": "Schriftfarbe",
"colour_bg": "Hintergrundfarbe",
"show_in_coursebook": "In Kursbuch-Übersicht zeigen",
"show_in_coursebook_helptext": "Wenn aktiviert tauchen diese Markierungen in den Zeilen im Kursbuch auf."
},
"personal_notes": {
"note": "Notiz",
"create_personal_note": "Weitere Notiz",
"confirm_delete": "Notiz wirklich löschen?",
"confirm_delete_explanation": "Die Notiz \"{note}\" für {name} wird entfernt."
},
"group_roles": {
"menu_title_assign": "Gruppenrollen zuweisen",
......@@ -77,5 +91,8 @@
"week": {
"menu_title": "Aktuelle Woche"
}
},
"actions": {
"back_to_overview": "Zurück zur Übersicht"
}
}
......@@ -74,8 +74,12 @@
"courses": "Courses",
"filter_for_obj": "Filter for group and course"
},
"present_number": "{present}/{total} present",
"no_data": "No lessons for the selected groups and courses in this period",
"no_results": "No search results for {search}"
}
},
"actions": {
"back_to_overview": "Back to overview"
}
}
......@@ -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")
# Generated by Django 4.2.10 on 2024-04-30 11:14
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('kolego', '0003_refactor_absence'),
('alsijil', '0020_documentation_extramark_colour_bg_and_more'),
]
operations = [
migrations.RemoveField(
model_name='participationstatus',
name='absent',
),
migrations.AlterField(
model_name='participationstatus',
name='absence_reason',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='kolego.absencereason', verbose_name='Absence Reason'),
),
]
# 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'),
),
]
......@@ -2,13 +2,16 @@ from datetime import date, datetime
from typing import Optional, Union
from urllib.parse import urlparse
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.db import models
from django.db.models import QuerySet
from django.db.models.constraints import CheckConstraint
from django.db.models.query_utils import Q
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.timezone import localdate, localtime, now
from django.utils.translation import gettext_lazy as _
from calendarweek import CalendarWeek
......@@ -22,12 +25,14 @@ from aleksis.apps.alsijil.data_checks import (
PersonalNoteOnHolidaysDataCheck,
)
from aleksis.apps.alsijil.managers import (
DocumentationManager,
GroupRoleAssignmentManager,
GroupRoleAssignmentQuerySet,
GroupRoleManager,
GroupRoleQuerySet,
LessonDocumentationManager,
LessonDocumentationQuerySet,
ParticipationStatusManager,
PersonalNoteManager,
PersonalNoteQuerySet,
)
......@@ -40,7 +45,7 @@ from aleksis.apps.kolego.models import Absence as KolegoAbsence
from aleksis.apps.kolego.models import AbsenceReason
from aleksis.core.data_checks import field_validation_data_check_factory
from aleksis.core.mixins import ExtensibleModel, GlobalPermissionModel
from aleksis.core.models import CalendarEvent, Group, SchoolTerm
from aleksis.core.models import CalendarEvent, Group, Person, SchoolTerm
from aleksis.core.util.core_helpers import get_site_preferences
from aleksis.core.util.model_helpers import ICONS
......@@ -458,6 +463,8 @@ class Documentation(CalendarEvent):
# FIXME: DataCheck
objects = DocumentationManager()
course = models.ForeignKey(
Course,
models.PROTECT,
......@@ -483,6 +490,11 @@ class Documentation(CalendarEvent):
homework = models.CharField(verbose_name=_("Homework"), max_length=255, blank=True)
group_note = models.CharField(verbose_name=_("Group Note"), max_length=255, blank=True)
# Used to track whether participations have been filled in
participation_touched_at = models.DateTimeField(
blank=True, null=True, verbose_name=_("Participation touched at")
)
def get_subject(self) -> str:
if self.subject:
return self.subject
......@@ -515,44 +527,17 @@ class Documentation(CalendarEvent):
# which is not possible via constraint, because amends is not local to Documentation
@classmethod
def get_for_coursebook(
def get_documentations_for_events(
cls,
own: bool,
date_start: datetime,
date_end: datetime,
request: HttpRequest,
obj_type: Optional[str] = None,
obj_id: Optional[str] = None,
events: list,
incomplete: Optional[bool] = False,
) -> list:
"""Get all the documentations for an object and a time frame.
obj_type may be one of TEACHER, GROUP, ROOM, COURSE
) -> tuple:
"""Get all the documentations for the events.
Create dummy documentations if none exist.
Returns a tuple with a list of existing documentations and a list dummy documentations.
"""
# 1. Find all LessonEvents for all Lessons of this Course in this date range
event_params = {
"own": own,
}
if obj_type is not None and obj_id is not None:
event_params.update(
{
"type": obj_type,
"id": obj_id,
}
)
events = LessonEvent.get_single_events(
date_start,
date_end,
request,
event_params,
with_reference_object=True,
)
# 2. For each lessonEvent → check if there is a documentation
# if so, add the documentation to a list, if not, create a new one
docs = []
dummies = []
for event in events:
if incomplete and event["STATUS"] == "CANCELLED":
continue
......@@ -582,7 +567,7 @@ class Documentation(CalendarEvent):
else:
course, subject = event_reference_obj.course, event_reference_obj.subject
docs.append(
dummies.append(
cls(
pk=f"DUMMY;{event_reference_obj.id};{event['DTSTART'].dt.isoformat()};{event['DTEND'].dt.isoformat()}",
amends=event_reference_obj,
......@@ -593,7 +578,173 @@ class Documentation(CalendarEvent):
)
)
return docs
return (docs, dummies)
@classmethod
def get_documentations_for_person(
cls,
person: int,
start: datetime,
end: datetime,
incomplete: Optional[bool] = False,
) -> tuple:
"""Get all the documentations for the person from start to end datetime.
Create dummy documentations if none exist.
Returns a tuple with a list of existing documentations and a list dummy documentations.
"""
event_params = {
"type": "PARTICIPANT",
"obj_id": person,
}
events = LessonEvent.get_single_events(
start,
end,
None,
event_params,
with_reference_object=True,
)
return Documentation.get_documentations_for_events(events, incomplete)
@classmethod
def parse_dummy(
cls,
_id: str,
) -> tuple:
"""Parse dummy id string into lesson_event, datetime_start, datetime_end."""
dummy, lesson_event_id, datetime_start_iso, datetime_end_iso = _id.split(";")
lesson_event = LessonEvent.objects.get(id=lesson_event_id)
datetime_start = datetime.fromisoformat(datetime_start_iso).astimezone(
lesson_event.timezone
)
datetime_end = datetime.fromisoformat(datetime_end_iso).astimezone(lesson_event.timezone)
return (lesson_event, datetime_start, datetime_end)
@classmethod
def create_from_lesson_event(
cls,
user: User,
lesson_event: LessonEvent,
datetime_start: datetime,
datetime_end: datetime,
) -> "Documentation":
"""Create a documentation from a lesson_event with start and end datetime.
User is needed for permission checking.
"""
if not user.has_perm(
"alsijil.add_documentation_for_lesson_event_rule", lesson_event
) or not (
get_site_preferences()["alsijil__allow_edit_future_documentations"] == "all"
or (
get_site_preferences()["alsijil__allow_edit_future_documentations"] == "current_day"
and datetime_start.date() <= localdate()
)
or (
get_site_preferences()["alsijil__allow_edit_future_documentations"]
== "current_time"
and datetime_start <= localtime()
)
):
raise PermissionDenied()
if lesson_event.amends:
course = lesson_event.course if lesson_event.course else lesson_event.amends.course
subject = lesson_event.subject if lesson_event.subject else lesson_event.amends.subject
teachers = (
lesson_event.teachers if lesson_event.teachers else lesson_event.amends.teachers
)
else:
course, subject, teachers = (
lesson_event.course,
lesson_event.subject,
lesson_event.teachers,
)
obj = cls.objects.create(
datetime_start=datetime_start,
datetime_end=datetime_end,
amends=lesson_event,
course=course,
subject=subject,
)
obj.teachers.set(teachers.all())
obj.save()
# Create Participation Statuses
obj.touch()
return obj
@classmethod
def get_or_create_by_id(cls, _id: str | int, user):
if _id.startswith("DUMMY"):
return cls.create_from_lesson_event(
user,
*cls.parse_dummy(_id),
), True
return cls.objects.get(id=_id), False
def touch(self):
"""Ensure that participation statuses are created for this documentation."""
if (
self.participation_touched_at
or not self.amends
or self.value_start_datetime(self) > now()
):
# There is no source to update from or it's too early
return
lesson_event: LessonEvent = self.amends
all_members = lesson_event.all_members
member_pks = [p.pk for p in all_members]
new_persons = Person.objects.filter(Q(pk__in=member_pks)).prefetch_related("member_of")
# Get absences from Kolego
events = KolegoAbsence.get_single_events(
self.value_start_datetime(self),
self.value_end_datetime(self),
None,
{"persons": member_pks},
with_reference_object=True,
)
kolego_absences_map = {a["REFERENCE_OBJECT"].person: a["REFERENCE_OBJECT"] for a in events}
new_participations = []
new_groups_of_person = []
for person in new_persons:
participation_status = ParticipationStatus(
person=person,
related_documentation=self,
datetime_start=self.datetime_start,
datetime_end=self.datetime_end,
timezone=self.timezone,
)
# Take over data from Kolego absence
if person in kolego_absences_map:
participation_status.fill_from_kolego(kolego_absences_map[person])
participation_status.save()
new_groups_of_person += [
ParticipationStatus.groups_of_person.through(
group=group, participationstatus=participation_status
)
for group in person.member_of.all()
]
new_participations.append(participation_status)
ParticipationStatus.groups_of_person.through.objects.bulk_create(new_groups_of_person)
self.participation_touched_at = timezone.now()
self.save()
return new_participations
class ParticipationStatus(CalendarEvent):
......@@ -605,6 +756,8 @@ class ParticipationStatus(CalendarEvent):
# FIXME: DataChecks
objects = ParticipationStatusManager()
person = models.ForeignKey(
"core.Person", models.CASCADE, related_name="participations", verbose_name=_("Person")
)
......@@ -620,9 +773,12 @@ class ParticipationStatus(CalendarEvent):
)
# Absence part
absent = models.BooleanField(verbose_name=_("Absent"))
absence_reason = models.ForeignKey(
AbsenceReason, verbose_name=_("Absence Reason"), on_delete=models.PROTECT
AbsenceReason,
verbose_name=_("Absence Reason"),
on_delete=models.PROTECT,
blank=True,
null=True,
)
base_absence = models.ForeignKey(
......@@ -634,8 +790,13 @@ class ParticipationStatus(CalendarEvent):
verbose_name=_("Base Absence"),
)
def fill_from_kolego(self, kolego_absence: KolegoAbsence):
"""Take over data from a Kolego absence."""
self.base_absence = kolego_absence
self.absence_reason = kolego_absence.reason
def __str__(self) -> str:
return f"{self.related_documentation}, {self.person}"
return f"{self.related_documentation.id}, {self.person}"
class Meta:
verbose_name = _("Participation Status")
......
......@@ -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,
)
......@@ -5,6 +5,7 @@ from django.db.models.query_utils import Q
import graphene
from aleksis.apps.chronos.models import LessonEvent
from aleksis.apps.cursus.models import Course
from aleksis.apps.cursus.schema import CourseType
from aleksis.core.models import Group, Person
......@@ -16,7 +17,10 @@ from ..models import Documentation
from .documentation import (
DocumentationBatchCreateOrUpdateMutation,
DocumentationType,
LessonsForPersonType,
TouchDocumentationMutation,
)
from .participation_status import ParticipationStatusBatchPatchMutation
class Query(graphene.ObjectType):
......@@ -37,6 +41,13 @@ class Query(graphene.ObjectType):
groups_by_person = FilterOrderList(GroupType, person=graphene.ID())
courses_of_person = FilterOrderList(CourseType, person=graphene.ID())
lessons_for_persons = graphene.List(
LessonsForPersonType,
persons=graphene.List(graphene.ID, required=True),
start=graphene.Date(required=True),
end=graphene.Date(required=True),
)
def resolve_documentations_by_course_id(root, info, course_id, **kwargs):
documentations = Documentation.objects.filter(
Q(course__pk=course_id) | Q(amends__course__pk=course_id)
......@@ -54,9 +65,6 @@ class Query(graphene.ObjectType):
incomplete=False,
**kwargs,
):
datetime_start = datetime.combine(date_start, datetime.min.time())
datetime_end = datetime.combine(date_end, datetime.max.time())
if (
(
obj_type == "COURSE"
......@@ -79,10 +87,30 @@ class Query(graphene.ObjectType):
):
raise PermissionDenied()
return Documentation.get_for_coursebook(
own, datetime_start, datetime_end, info.context, obj_type, obj_id, incomplete
# Find all LessonEvents for all Lessons of this Course in this date range
event_params = {
"own": own,
}
if obj_type is not None and obj_id is not None:
event_params.update(
{
"type": obj_type,
"id": obj_id,
}
)
events = LessonEvent.get_single_events(
datetime.combine(date_start, datetime.min.time()),
datetime.combine(date_end, datetime.max.time()),
info.context,
event_params,
with_reference_object=True,
)
# Lookup or create documentations and return them all.
docs, dummies = Documentation.get_documentations_for_events(events, incomplete)
return docs + dummies
@staticmethod
def resolve_groups_by_person(root, info, person=None):
if person:
......@@ -116,6 +144,30 @@ class Query(graphene.ObjectType):
| Q(groups__parent_groups__owners=person)
)
@staticmethod
def resolve_lessons_for_persons(
root,
info,
persons,
start,
end,
**kwargs,
):
"""Resolve all lesson events for each person in timeframe start to end."""
lessons_for_person = []
for person in persons:
docs, dummies = Documentation.get_documentations_for_person(
person,
datetime.combine(start, datetime.min.time()),
datetime.combine(end, datetime.max.time()),
)
lessons_for_person.append(id=person, lessons=docs + dummies)
return lessons_for_person
class Mutation(graphene.ObjectType):
create_or_update_documentations = DocumentationBatchCreateOrUpdateMutation.Field()
touch_documentation = TouchDocumentationMutation.Field()
update_participation_statuses = ParticipationStatusBatchPatchMutation.Field()
from datetime import datetime
from django.core.exceptions import PermissionDenied
from django.utils.timezone import localdate, localtime
import graphene
from graphene_django.types import DjangoObjectType
from guardian.shortcuts import get_objects_for_user
from reversion import create_revision, set_comment, set_user
from aleksis.apps.alsijil.util.predicates import can_edit_documentation, is_in_allowed_time_range
from aleksis.apps.chronos.models import LessonEvent
from aleksis.apps.chronos.schema import LessonEventType
from aleksis.apps.cursus.models import Subject
from aleksis.apps.cursus.schema import CourseType, SubjectType
......@@ -18,9 +13,9 @@ from aleksis.core.schema.base import (
DjangoFilterMixin,
PermissionsTypeMixin,
)
from aleksis.core.util.core_helpers import get_site_preferences
from ..models import Documentation
from .participation_status import ParticipationStatusType
class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
......@@ -39,6 +34,7 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp
"date_start",
"date_end",
"teachers",
"participations",
)
filter_fields = {
"id": ["exact", "lte", "gte"],
......@@ -48,6 +44,7 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp
course = graphene.Field(CourseType, required=False)
amends = graphene.Field(lambda: LessonEventType, required=False)
subject = graphene.Field(SubjectType, required=False)
participations = graphene.List(ParticipationStatusType, required=False)
future_notice = graphene.Boolean(required=False)
......@@ -71,9 +68,17 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp
info.context.user, root
)
@classmethod
def get_queryset(cls, queryset, info):
return get_objects_for_user(info.context.user, "alsijil.view_documentation", queryset)
@staticmethod
def resolve_participations(root: Documentation, info, **kwargs):
if not info.context.user.has_perm(
"alsijil.view_participation_status_for_documentation", root
):
return []
# A dummy documentation will not have any participations
if str(root.pk).startswith("DUMMY") or not hasattr(root, "participations"):
return []
return root.participations.select_related("absence_reason", "base_absence").all()
class DocumentationInputType(graphene.InputObjectType):
......@@ -87,6 +92,11 @@ class DocumentationInputType(graphene.InputObjectType):
group_note = graphene.String(required=False)
class LessonsForPersonType(graphene.ObjectType):
id = graphene.ID() # noqa
lessons = graphene.List(DocumentationType)
class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation):
class Arguments:
input = graphene.List(DocumentationInputType)
......@@ -99,91 +109,25 @@ class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation):
# Sadly, we can't use the update_or_create method since create_defaults
# is only introduced in Django 5.0
if _id.startswith("DUMMY"):
dummy, lesson_event_id, datetime_start_iso, datetime_end_iso = _id.split(";")
lesson_event = LessonEvent.objects.get(id=lesson_event_id)
datetime_start = datetime.fromisoformat(datetime_start_iso).astimezone(
lesson_event.timezone
)
datetime_end = datetime.fromisoformat(datetime_end_iso).astimezone(
lesson_event.timezone
)
if info.context.user.has_perm(
"alsijil.add_documentation_for_lesson_event_rule", lesson_event
) and (
get_site_preferences()["alsijil__allow_edit_future_documentations"] == "all"
or (
get_site_preferences()["alsijil__allow_edit_future_documentations"]
== "current_day"
and datetime_start.date() <= localdate()
)
or (
get_site_preferences()["alsijil__allow_edit_future_documentations"]
== "current_time"
and datetime_start <= localtime()
)
):
if lesson_event.amends:
if lesson_event.course:
course = lesson_event.course
else:
course = lesson_event.amends.course
if lesson_event.subject:
subject = lesson_event.subject
else:
subject = lesson_event.amends.subject
if lesson_event.teachers:
teachers = lesson_event.teachers
else:
teachers = lesson_event.amends.teachers
else:
course, subject, teachers = (
lesson_event.course,
lesson_event.subject,
lesson_event.teachers,
)
obj = Documentation.objects.create(
datetime_start=datetime_start,
datetime_end=datetime_end,
amends=lesson_event,
course=course,
subject=subject,
topic=doc.topic or "",
homework=doc.homework or "",
group_note=doc.group_note or "",
)
if doc.teachers is not None:
obj.teachers.add(*doc.teachers)
else:
obj.teachers.set(teachers.all())
obj.save()
return obj
raise PermissionDenied()
else:
obj = Documentation.objects.get(id=_id)
obj, __ = Documentation.get_or_create_by_id(_id, info.context.user)
if not info.context.user.has_perm("alsijil.edit_documentation_rule", obj):
raise PermissionDenied()
if not info.context.user.has_perm("alsijil.edit_documentation_rule", obj):
raise PermissionDenied()
if doc.topic is not None:
obj.topic = doc.topic
if doc.homework is not None:
obj.homework = doc.homework
if doc.group_note is not None:
obj.group_note = doc.group_note
if doc.topic is not None:
obj.topic = doc.topic
if doc.homework is not None:
obj.homework = doc.homework
if doc.group_note is not None:
obj.group_note = doc.group_note
if doc.subject is not None:
obj.subject = Subject.objects.get(pk=doc.subject)
if doc.teachers is not None:
obj.teachers.set(Person.objects.filter(pk__in=doc.teachers))
if doc.subject is not None:
obj.subject = Subject.objects.get(pk=doc.subject)
if doc.teachers is not None:
obj.teachers.set(Person.objects.filter(pk__in=doc.teachers))
obj.save()
return obj
obj.save()
return obj
@classmethod
def mutate(cls, root, info, input): # noqa
......@@ -193,3 +137,25 @@ class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation):
objs = [cls.create_or_update(info, doc) for doc in input]
return DocumentationBatchCreateOrUpdateMutation(documentations=objs)
class TouchDocumentationMutation(graphene.Mutation):
class Arguments:
documentation_id = graphene.ID(required=True)
documentation = graphene.Field(DocumentationType)
def mutate(root, info, documentation_id):
documentation, created = Documentation.get_or_create_by_id(
documentation_id, info.context.user
)
if not info.context.user.has_perm(
"alsijil.edit_participation_status_for_documentation_rule", documentation
):
raise PermissionDenied()
if not created:
documentation.touch()
return TouchDocumentationMutation(documentation=documentation)
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()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment