diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b6443616ef0e981041c82f7c2c11be428f85b898..b1000af70c49f45a1adf510bc3e0be703292bea6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ If you're upgrading from 3.x, there is now a migration path to use. Therefore, please install ``AlekSIS-App-Lesrooster`` which now includes parts of the legacy Chronos and the migration path. +Added +~~~~~ + +* Printout with person overview including all statistics. + `4.0.0.dev9`_ - 2024-12-07 -------------------------- diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookPrintDialog.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookPrintDialog.vue index 8a9058d6d1a8391d6b72db9bb239e66afcefa9a8..52444d0e5b5b03a72bbcb631bc7d75ed28427fb1 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookPrintDialog.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookPrintDialog.vue @@ -157,7 +157,7 @@ export default { }, print() { this.$router.push({ - name: "alsijil.coursebook_print", + name: "alsijil.coursebookPrintGroups", params: { groupIds: this.selectedGroups, }, diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue index c9c8bcd3ce8d762c9fe091f343adda5307cbfe8b..84e63a49529e9536c550616d4738f856232db104 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/statistics/StatisticsForPersonPage.vue @@ -257,8 +257,16 @@ v-model="$root.activeSchoolTerm" color="secondary" /> - <!-- TODO: add functionality --> - <v-btn v-if="toolbar" icon color="primary" disabled> + <v-btn + v-if="toolbar" + icon + color="primary" + :to="{ + name: 'alsijil.coursebookPrintPerson', + params: { id: personId }, + }" + target="_blank" + > <v-icon>$print</v-icon> </v-btn> <FabButton v-else icon-text="$print" i18n-key="actions.print" disabled /> diff --git a/aleksis/apps/alsijil/frontend/index.js b/aleksis/apps/alsijil/frontend/index.js index 18e0f68eaca178cbaf3b358a8065257cc3e745b3..13967281fe1de32919000b48ecadef95304e2ef3 100644 --- a/aleksis/apps/alsijil/frontend/index.js +++ b/aleksis/apps/alsijil/frontend/index.js @@ -95,7 +95,15 @@ export default { { path: "print/groups/:groupIds+/", component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"), - name: "alsijil.coursebook_print", + name: "alsijil.coursebookPrintGroups", + props: { + byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, + }, + }, + { + path: "print/person/:id(\\d+)?/", + component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"), + name: "alsijil.coursebookPrintPerson", props: { byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, }, diff --git a/aleksis/apps/alsijil/managers.py b/aleksis/apps/alsijil/managers.py index 681e3e562be5f8b26c4fd31fb543dcadbbe5fb29..ff5decdb8a04468ba718b364dc23a9a4ebef27a0 100644 --- a/aleksis/apps/alsijil/managers.py +++ b/aleksis/apps/alsijil/managers.py @@ -8,13 +8,16 @@ from django.db.models.query_utils import Q from calendarweek import CalendarWeek +from aleksis.apps.chronos.models import LessonEvent from aleksis.core.managers import ( AlekSISBaseManagerWithoutMigrations, RecurrencePolymorphicManager, ) if TYPE_CHECKING: - from aleksis.core.models import Group + from aleksis.core.models import Group, SchoolTerm + + from .models import Documentation class GroupRoleManager(AlekSISBaseManagerWithoutMigrations): @@ -73,6 +76,36 @@ class GroupRoleAssignmentQuerySet(QuerySet): class DocumentationManager(RecurrencePolymorphicManager): """Manager adding specific methods to documentations.""" + def for_school_term(self, school_term: "SchoolTerm") -> QuerySet["Documentation"]: + """Filter documentations by school term.""" + return self.filter( + datetime_start__date__gte=school_term.date_start, + datetime_end__date__lte=school_term.date_end, + ) + + def all_for_group(self, group: "Group") -> QuerySet["Documentation"]: + """Filter documentations by group.""" + qs = self.for_school_term(group.school_term) if group.school_term else self + return qs.filter( + pk__in=self.filter(course__groups=group) + .values_list("pk", flat=True) + .union(self.filter(course__groups__parent_groups=group).values_list("pk", flat=True)) + .union( + self.filter( + amends__in=LessonEvent.objects.filter(LessonEvent.objects.for_group_q(group)) + ).values_list("pk", flat=True) + ) + ) + + def all_planned_for_group(self, group: "Group") -> QuerySet["Documentation"]: + """Filter documentations by group, but only planned lessons.""" + qs = self.for_school_term(group.school_term) if group.school_term else self + return qs.filter( + pk__in=self.filter( + amends__in=LessonEvent.objects.filter(LessonEvent.objects.for_group_q(group)) + ).values_list("pk", flat=True) + ) + class ParticipationStatusManager(RecurrencePolymorphicManager): """Manager adding specific methods to participation statuses.""" diff --git a/aleksis/apps/alsijil/model_extensions.py b/aleksis/apps/alsijil/model_extensions.py index e1a2bd0ef2a2c49d0bdf124b21a6f550d303176c..b3186f6103de2d0f6781d03d9562e468124e80f3 100644 --- a/aleksis/apps/alsijil/model_extensions.py +++ b/aleksis/apps/alsijil/model_extensions.py @@ -1,12 +1,6 @@ -from django.db.models import FilteredRelation, Q, QuerySet, Value -from django.db.models.aggregates import Count, Sum from django.utils.translation import gettext as _ -from aleksis.apps.chronos.models import LessonEvent -from aleksis.apps.kolego.models import AbsenceReason -from aleksis.core.models import Group, Person, SchoolTerm - -from .models import Documentation, ExtraMark +from aleksis.core.models import Group, Person # Dynamically add extra permissions to Group and Person models in core # Note: requires migrate afterwards @@ -32,112 +26,3 @@ Group.add_permission( ) Group.add_permission("assign_grouprole", _("Can assign a group role for this group")) Person.add_permission("register_absence_person", _("Can register an absence for a person")) - - -def annotate_person_statistics( - persons: QuerySet[Person], - participations_filter: Q, - personal_notes_filter: Q, - *, - ignore_filters: bool = False, -) -> QuerySet[Person]: - """Annotate a queryset of persons with class register statistics.""" - - if ignore_filters: - persons = persons.annotate( - absence_count=Value(0), - filtered_participation_statuses=FilteredRelation( - "participations", - condition=Q(pk=None), - ), - filtered_personal_notes=FilteredRelation( - "new_personal_notes", - condition=Q(pk=None), - ), - participation_count=Value(0), - tardiness_count=Value(0), - tardiness_sum=Value(0), - ) - else: - persons = persons.annotate( - filtered_participation_statuses=FilteredRelation( - "participations", - condition=(participations_filter), - ), - filtered_personal_notes=FilteredRelation( - "new_personal_notes", - condition=(personal_notes_filter), - ), - ).annotate( - participation_count=Count( - "filtered_participation_statuses", - filter=Q(filtered_participation_statuses__absence_reason__isnull=True), - distinct=True, - ), - absence_count=Count( - "filtered_participation_statuses", - filter=Q(filtered_participation_statuses__absence_reason__count_as_absent=True), - distinct=True, - ), - tardiness_sum=Sum("filtered_participation_statuses__tardiness", distinct=True), - tardiness_count=Count( - "filtered_participation_statuses", - filter=Q(filtered_participation_statuses__tardiness__gt=0), - distinct=True, - ), - ) - - persons = persons.order_by("last_name", "first_name") - - for absence_reason in AbsenceReason.objects.all(): - persons = persons.annotate( - **{ - absence_reason.count_label: Count( - "filtered_participation_statuses", - filter=Q( - filtered_participation_statuses__absence_reason=absence_reason, - ), - distinct=True, - ) - } - ) - - for extra_mark in ExtraMark.objects.all(): - persons = persons.annotate( - **{ - extra_mark.count_label: Count( - "filtered_personal_notes", - filter=Q(filtered_personal_notes__extra_mark=extra_mark), - distinct=True, - ) - } - ) - - return persons - - -def annotate_person_statistics_from_documentations( - persons: QuerySet[Person], docs: QuerySet[Documentation] -) -> QuerySet[Person]: - """Annotate a queryset of persons with class register statistics from documentations.""" - docs = list(docs.values_list("pk", flat=True)) - return annotate_person_statistics( - persons, - Q(participations__related_documentation__in=docs), - Q(new_personal_notes__documentation__in=docs), - ignore_filters=len(docs) == 0, - ) - - -def annotate_person_statistics_for_school_term( - persons: QuerySet[Person], school_term: SchoolTerm, group: Group | None = None -) -> QuerySet[Person]: - """Annotate a queryset of persons with class register statistics for a school term.""" - documentations = Documentation.objects.filter( - datetime_start__date__gte=school_term.date_start, - datetime_end__date__lte=school_term.date_end, - ) - if group: - lesson_events = LessonEvent.objects.filter(LessonEvent.objects.for_group_q(group)) - documentations = documentations.filter(amends__in=lesson_events) - return annotate_person_statistics_from_documentations(persons, documentations) diff --git a/aleksis/apps/alsijil/schema/__init__.py b/aleksis/apps/alsijil/schema/__init__.py index 1614cf0f3000c95915a1fdef92d114b443525e03..821ff026fdbb5b925445d48b8925a716c2dce951 100644 --- a/aleksis/apps/alsijil/schema/__init__.py +++ b/aleksis/apps/alsijil/schema/__init__.py @@ -23,8 +23,8 @@ from aleksis.core.util.core_helpers import ( has_person, ) -from ..model_extensions import annotate_person_statistics_for_school_term from ..models import Documentation, ExtraMark, NewPersonalNote, ParticipationStatus +from ..util.statistics import StatisticsBuilder from .absences import ( AbsencesForPersonsClearMutation, AbsencesForPersonsCreateMutation, @@ -310,10 +310,14 @@ class Query(graphene.ObjectType): if not info.context.user.has_perm("alsijil.view_person_statistics_rule", person): return None school_term = get_active_school_term(info.context) + statistics = ( + StatisticsBuilder(Person.objects.filter(id=person.id)) + .use_from_school_term(school_term) + .annotate_statistics() + .build() + ) return graphene_django_optimizer.query( - annotate_person_statistics_for_school_term( - Person.objects.filter(id=person.id), school_term - ).first(), + statistics.first(), info, ) @@ -358,9 +362,13 @@ class Query(graphene.ObjectType): school_term = get_active_school_term(info.context) members = group.members.all() - return graphene_django_optimizer.query( - annotate_person_statistics_for_school_term(members, school_term, group=group), info + statistics = ( + StatisticsBuilder(members) + .use_from_group(group, school_term=school_term) + .annotate_statistics() + .build() ) + return graphene_django_optimizer.query(statistics, info) @staticmethod def resolve_periods_by_day(root, info): diff --git a/aleksis/apps/alsijil/tasks.py b/aleksis/apps/alsijil/tasks.py index 71de0d315e6a2d7fbb66b99d5298d21b52502429..f7aa866d33ba06f23d709a40af8b380d62a30c0d 100644 --- a/aleksis/apps/alsijil/tasks.py +++ b/aleksis/apps/alsijil/tasks.py @@ -7,19 +7,22 @@ from django.utils.translation import gettext as _ from celery.result import allow_join_result from celery.states import SUCCESS -from aleksis.apps.chronos.models import LessonEvent from aleksis.apps.cursus.models import Course from aleksis.apps.kolego.models.absence import AbsenceReason -from aleksis.core.models import Group, PDFFile +from aleksis.core.models import Group, PDFFile, Person, SchoolTerm from aleksis.core.util.celery_progress import ProgressRecorder, recorded_task from aleksis.core.util.pdf import generate_pdf_from_template -from .model_extensions import annotate_person_statistics_from_documentations from .models import Documentation, ExtraMark, NewPersonalNote, ParticipationStatus +from .util.statistics import StatisticsBuilder + + +class PDFGenerationError(Exception): + """Error in PDF generation.""" @recorded_task -def generate_full_register_printout( +def generate_groups_register_printout( groups: list[int], file_object: int, recorder: ProgressRecorder, @@ -32,33 +35,6 @@ def generate_full_register_printout( ): """Generate a configurable register printout as PDF for a group.""" - def prefetch_notable_participations(select_related=None, prefetch_related=None): - if not select_related: - select_related = [] - if not prefetch_related: - prefetch_related = [] - return Prefetch( - "participations", - to_attr="notable_participations", - queryset=ParticipationStatus.objects.filter( - Q(absence_reason__tags__short_name="class_register") | Q(tardiness__isnull=False) - ) - .select_related("absence_reason", *select_related) - .prefetch_related(*prefetch_related), - ) - - def prefetch_personal_notes(name, select_related=None, prefetch_related=None): - if not select_related: - select_related = [] - if not prefetch_related: - prefetch_related = [] - return Prefetch( - name, - queryset=NewPersonalNote.objects.filter(Q(note__gt="") | Q(extra_mark__isnull=False)) - .select_related("extra_mark", *select_related) - .prefetch_related(*prefetch_related), - ) - context = {} context["include_cover"] = include_cover @@ -107,52 +83,44 @@ def generate_full_register_printout( 2 + i, _number_of_steps, _(f"Loading group {group.short_name or group.name} ...") ) - if include_members_table or include_person_overviews or include_coursebook: - documentations = Documentation.objects.filter( - Q(datetime_start__date__gte=group.school_term.date_start) - & Q(datetime_end__date__lte=group.school_term.date_end) - & Q( - pk__in=Documentation.objects.filter(course__groups=group) - .values_list("pk", flat=True) - .union( - Documentation.objects.filter( - course__groups__parent_groups=group - ).values_list("pk", flat=True) - ) - .union( - Documentation.objects.filter( - amends__in=LessonEvent.objects.filter( - LessonEvent.objects.for_group_q(group) - ) - ).values_list("pk", flat=True) - ) - ) - ) - if include_members_table or include_person_overviews: - group.members_with_stats = annotate_person_statistics_from_documentations( - group.members.all(), documentations + doc_query_set = Documentation.objects.select_related("subject").prefetch_related( + "teachers" ) - if include_person_overviews: - doc_query_set = documentations.select_related("subject").prefetch_related("teachers") - group.members_with_stats = group.members_with_stats.prefetch_related( - prefetch_notable_participations( - prefetch_related=[Prefetch("related_documentation", queryset=doc_query_set)] - ), - prefetch_personal_notes( - "new_personal_notes", - prefetch_related=[Prefetch("documentation", queryset=doc_query_set)], - ), + members_with_statistics = ( + StatisticsBuilder(group.members.all()).use_from_group(group).annotate_statistics() ) + if include_person_overviews: + members_with_statistics = members_with_statistics.prefetch_relevant_participations( + documentation_with_details=doc_query_set + ).prefetch_relevant_personal_notes(documentation_with_details=doc_query_set) + members_with_statistics = members_with_statistics.build() + group.members_with_stats = members_with_statistics if include_teachers_and_subjects_table: group.as_list = [group] if include_coursebook: - group.documentations = documentations.order_by("datetime_start").prefetch_related( - prefetch_notable_participations(select_related=["person"]), - prefetch_personal_notes("personal_notes", select_related=["person"]), + group.documentations = ( + Documentation.objects.all_for_group(group) + .order_by("datetime_start") + .prefetch_related( + Prefetch( + "participations", + to_attr="relevant_participations", + queryset=ParticipationStatus.objects.filter( + Q(absence_reason__isnull=False) | Q(tardiness__isnull=False) + ).select_related("absence_reason", "person"), + ), + Prefetch( + "personal_notes", + to_attr="relevant_personal_notes", + queryset=NewPersonalNote.objects.filter( + Q(note__gt="") | Q(extra_mark__isnull=False) + ).select_related("extra_mark", "person"), + ), + ) ) context["groups"] = groups @@ -170,7 +138,66 @@ def generate_full_register_printout( with allow_join_result(): result.wait() file_object.refresh_from_db() - if not result.status == SUCCESS and file_object.file: - raise Exception(_("PDF generation failed")) + if not (result.status == SUCCESS and file_object.file): + raise PDFGenerationError(_("PDF generation failed")) recorder.set_progress(5 + len(groups), _number_of_steps) + + +@recorded_task +def generate_person_register_printout( + person: int, + school_term: int, + file_object: int, + recorder: ProgressRecorder, +): + """Generate a register printout as PDF for a person.""" + + context = {} + + recorder.set_progress(1, 4, _("Loading data ...")) + + person = Person.objects.get(pk=person) + school_term = SchoolTerm.objects.get(pk=school_term) + + doc_query_set = Documentation.objects.select_related("subject").prefetch_related("teachers") + + statistics = ( + StatisticsBuilder(Person.objects.filter(id=person.id)) + .use_from_school_term(school_term) + .annotate_statistics() + .prefetch_relevant_participations(documentation_with_details=doc_query_set) + .prefetch_relevant_personal_notes(documentation_with_details=doc_query_set) + .build() + .first() + ) + + context["person"] = statistics + + context["school_term"] = school_term + + context["absence_reasons"] = AbsenceReason.objects.filter( + tags__short_name="class_register", count_as_absent=True + ) + context["absence_reasons_not_counted"] = AbsenceReason.objects.filter( + tags__short_name="class_register", count_as_absent=False + ) + context["extra_marks"] = ExtraMark.objects.all() + + recorder.set_progress(2, 4, _("Generating template ...")) + + file_object, result = generate_pdf_from_template( + "alsijil/print/register_for_person.html", + context, + file_object=PDFFile.objects.get(pk=file_object), + ) + + recorder.set_progress(3, 4, _("Generating PDF ...")) + + with allow_join_result(): + result.wait() + file_object.refresh_from_db() + if not (result.status == SUCCESS and file_object.file): + raise PDFGenerationError(_("PDF generation failed")) + + recorder.set_progress(4, 4) diff --git a/aleksis/apps/alsijil/templates/alsijil/print/partials/person_overview.html b/aleksis/apps/alsijil/templates/alsijil/print/partials/person_overview.html index 6c91c34730b10116e190b4892bb26415a4ceed20..f8cd4eaddfe981f022e39dd466899b5f7fe733de 100644 --- a/aleksis/apps/alsijil/templates/alsijil/print/partials/person_overview.html +++ b/aleksis/apps/alsijil/templates/alsijil/print/partials/person_overview.html @@ -94,7 +94,7 @@ </thead> <tbody> - {% for participation in person.notable_participations %} + {% for participation in person.relevant_participations %} <tr> <td>{{ participation.related_documentation.datetime_start }}</td> <td> @@ -122,7 +122,7 @@ </thead> <tbody> - {% for note in person.new_personal_notes.all %} + {% for note in person.relevant_personal_notes.all %} <tr> <td>{{ note.documentation.datetime_start }}</td> <td> diff --git a/aleksis/apps/alsijil/templates/alsijil/print/partials/register_coursebook.html b/aleksis/apps/alsijil/templates/alsijil/print/partials/register_coursebook.html index ab626b7dd389ae6fa77732f5dd34328b31f3946a..95fb8e8f3977623346aa637d6f4986a1e85029bc 100644 --- a/aleksis/apps/alsijil/templates/alsijil/print/partials/register_coursebook.html +++ b/aleksis/apps/alsijil/templates/alsijil/print/partials/register_coursebook.html @@ -53,7 +53,7 @@ <td class="lesson-homework">{{ doc.homework }}</td> <td class="lesson-notes"> {{ documentation.group_note }} - {% for participation in doc.notable_participations %} + {% for participation in doc.relevant_participations %} {% if participation.absence_reason %} <span class="lesson-note-absent"> {{ participation.person.full_name }} @@ -69,7 +69,7 @@ </span> {% endif %} {% endfor %} - {% for personal_note in doc.personal_notes.all %} + {% for personal_note in doc.relevant_personal_notes.all %} {% if personal_note.extra_mark %} <span> {{ personal_note.person.full_name }} diff --git a/aleksis/apps/alsijil/templates/alsijil/print/register_for_group.html b/aleksis/apps/alsijil/templates/alsijil/print/register_for_group.html index 6c1d5aa9f2de7e93d9922c6daf587452f5e04c21..8b1ea0afb698d94c8df131dfadd932ec54409cca 100644 --- a/aleksis/apps/alsijil/templates/alsijil/print/register_for_group.html +++ b/aleksis/apps/alsijil/templates/alsijil/print/register_for_group.html @@ -42,7 +42,7 @@ {% if include_person_overviews %} {% for person in group.members_with_stats %} - {% include "alsijil/print/partials/person_overview.html" with person=person group=group %} + {% include "alsijil/print/partials/person_overview.html" with person=person %} <div class="page-break"> </div> {% endfor %} {% endif %} diff --git a/aleksis/apps/alsijil/templates/alsijil/print/register_for_person.html b/aleksis/apps/alsijil/templates/alsijil/print/register_for_person.html new file mode 100644 index 0000000000000000000000000000000000000000..2cc2b5a4d063cca38ddce21852ae52d94295c227 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/print/register_for_person.html @@ -0,0 +1,15 @@ +{% extends "core/base_print.html" %} + +{% load static i18n %} + +{% block page_title %} + {% trans "Class Register" %} · {{ school_term.name }} +{% endblock %} + +{% block extra_head %} + <link rel="stylesheet" href="{% static 'css/alsijil/full_register.css' %}"/> +{% endblock %} + +{% block content %} + {% include "alsijil/print/partials/person_overview.html" with person=person %} +{% endblock %} diff --git a/aleksis/apps/alsijil/urls.py b/aleksis/apps/alsijil/urls.py index 8017db1c94d063930ed462be021846841bce3d40..e0dfc61b044cdbe28dac8d03680dc132e8b4665d 100644 --- a/aleksis/apps/alsijil/urls.py +++ b/aleksis/apps/alsijil/urls.py @@ -3,7 +3,10 @@ from django.urls import path from . import views urlpatterns = [ - path("print/groups/<path:ids>/", views.full_register_for_group, name="full_register_for_group"), + path( + "print/groups/<path:ids>/", views.groups_register_printout, name="full_register_for_group" + ), + path("print/person/<int:pk>/", views.person_register_printout, name="full_register_for_person"), path("group_roles/", views.GroupRoleListView.as_view(), name="group_roles"), path("group_roles/create/", views.GroupRoleCreateView.as_view(), name="create_group_role"), path( diff --git a/aleksis/apps/alsijil/util/statistics.py b/aleksis/apps/alsijil/util/statistics.py new file mode 100644 index 0000000000000000000000000000000000000000..158a3efd3a316435973229f0db751cb3491f2153 --- /dev/null +++ b/aleksis/apps/alsijil/util/statistics.py @@ -0,0 +1,271 @@ +from django.db.models import FilteredRelation, Prefetch, Q, QuerySet, Value +from django.db.models.aggregates import Count, Sum + +from aleksis.apps.chronos.models import LessonEvent +from aleksis.apps.kolego.models import AbsenceReason +from aleksis.core.models import Group, Person, SchoolTerm + +from ..models import Documentation, ExtraMark, NewPersonalNote, ParticipationStatus + + +class BuilderError(Exception): + """Error in building statistics using the StatisticsBuilder.""" + + pass + + +class StatisticsBuilder: + """Builder class for building queries with annotated statistics on persons. + + To build queries, you can combine `use_` with multiple `annotate` or `prefetch` + methods. At the end, call `build` to get the actual queryset. + + >>> StatisticsBuilder(person_qs).use_from_school_term(school_term).annotate_statistics().build() + """ + + def __init__(self, persons: QuerySet[Person]) -> None: + """Intialize the builder with a persons queryset.""" + self.qs: QuerySet[Person] = persons + self.participations_filter: Q | None = None + self.personal_notes_filter: Q | None = None + self.empty: bool = False + self._order() + + def _order(self) -> "StatisticsBuilder": + """Order by last and first names.""" + self.qs = self.qs.order_by("last_name", "first_name") + return self + + def use_participations( + self, + participations_filter: Q, + ) -> "StatisticsBuilder": + """Set a filter for participations.""" + self.participations_filter = participations_filter + return self + + def use_personal_notes( + self, + personal_notes_filter: Q, + ) -> "StatisticsBuilder": + """Set a filter for personal notes.""" + self.personal_notes_filter = personal_notes_filter + return self + + def use_from_documentations( + self, documentations: QuerySet[Documentation] + ) -> "StatisticsBuilder": + """Set a filter for participations and personal notes from documentations.""" + docs = list(documentations.values_list("pk", flat=True)) + if len(docs) == 0: + self.empty = True + self.use_participations(Q(participations__related_documentation__in=docs)) + self.use_personal_notes(Q(new_personal_notes__documentation__in=docs)) + return self + + def use_from_school_term(self, school_term: SchoolTerm) -> "StatisticsBuilder": + """Set a filter for participations and personal notes from school term.""" + documentations = Documentation.objects.for_school_term(school_term) + self.use_from_documentations(documentations) + return self + + def use_from_group( + self, group: Group, school_term: SchoolTerm | None = None + ) -> "StatisticsBuilder": + """Set a filter for participations and personal notes from group.""" + school_term = school_term or group.school_term + if not school_term: + documentations = Documentation.objects.none() + else: + lesson_events = LessonEvent.objects.filter(LessonEvent.objects.for_group_q(group)) + documentations = Documentation.objects.for_school_term(school_term).filter( + amends__in=lesson_events + ) + self.use_from_documentations(documentations) + return self + + def _annotate_filtered_participations(self, condition: Q | None = None) -> "StatisticsBuilder": + """Annotate a filtered relation for participations.""" + if not self.participations_filter and not condition: + raise BuilderError("Annotation of participations needs a participation filter.") + self.qs = self.qs.annotate( + filtered_participation_statuses=FilteredRelation( + "participations", + condition=condition or self.participations_filter, + ) + ) + return self + + def _annotate_filtered_personal_notes(self, condition: Q | None = None) -> "StatisticsBuilder": + """Annotate a filtered relation for personal notes.""" + if not self.personal_notes_filter and not condition: + raise BuilderError("Annotation of personal notes needs a participation filter.") + self.qs = self.qs.annotate( + filtered_personal_notes=FilteredRelation( + "new_personal_notes", + condition=condition or self.personal_notes_filter, + ), + ) + return self + + def annotate_participation_statistics(self) -> "StatisticsBuilder": + """Annotate statistics for participations.""" + if self.empty: + self.annotate_empty_participation_statistics() + return self + self._annotate_filtered_participations() + + self.qs = self.qs.annotate( + participation_count=Count( + "filtered_participation_statuses", + filter=Q(filtered_participation_statuses__absence_reason__isnull=True), + distinct=True, + ), + absence_count=Count( + "filtered_participation_statuses", + filter=Q(filtered_participation_statuses__absence_reason__count_as_absent=True), + distinct=True, + ), + tardiness_sum=Sum("filtered_participation_statuses__tardiness", distinct=True), + tardiness_count=Count( + "filtered_participation_statuses", + filter=Q(filtered_participation_statuses__tardiness__gt=0), + distinct=True, + ), + ) + + for absence_reason in AbsenceReason.objects.all(): + self.qs = self.qs.annotate( + **{ + absence_reason.count_label: Count( + "filtered_participation_statuses", + filter=Q( + filtered_participation_statuses__absence_reason=absence_reason, + ), + distinct=True, + ) + } + ) + + return self + + def annotate_personal_note_statistics(self) -> "StatisticsBuilder": + """Annotate statistics for personal notes.""" + if self.empty: + self.annotate_empty_personal_note_statistics() + return self + self._annotate_filtered_personal_notes() + + for extra_mark in ExtraMark.objects.all(): + self.qs = self.qs.annotate( + **{ + extra_mark.count_label: Count( + "filtered_personal_notes", + filter=Q(filtered_personal_notes__extra_mark=extra_mark), + distinct=True, + ) + } + ) + + return self + + def annotate_statistics(self) -> "StatisticsBuilder": + """Annotate statistics for participations and personal notes.""" + self.annotate_participation_statistics() + self.annotate_personal_note_statistics() + + return self + + def annotate_empty_participation_statistics(self) -> "StatisticsBuilder": + """Annotate with empty participation statistics.""" + self.qs = self.qs.annotate( + absence_count=Value(0), + participation_count=Value(0), + tardiness_count=Value(0), + tardiness_sum=Value(0), + ) + for absence_reason in AbsenceReason.objects.all(): + self.qs = self.qs.annotate(**{absence_reason.count_label: Value(0)}) + + return self + + def annotate_empty_personal_note_statistics(self) -> "StatisticsBuilder": + """Annotate with empty personal note statistics.""" + for extra_mark in ExtraMark.objects.all(): + self.qs = self.qs.annotate(**{extra_mark.count_label: Value(0)}) + + return self + + def annotate_empty_statistics(self) -> "StatisticsBuilder": + """Annotate with empty statistics.""" + self.annotate_empty_participation_statistics() + self.annotate_empty_personal_note_statistics() + + return self + + def prefetch_relevant_participations( + self, + select_related: list | None = None, + prefetch_related: list | None = None, + documentation_with_details: QuerySet | None = None, + ) -> "StatisticsBuilder": + """Prefetch relevant participations.""" + if not select_related: + select_related = [] + if not prefetch_related: + prefetch_related = [] + + if documentation_with_details: + prefetch_related.append( + Prefetch("related_documentation", queryset=documentation_with_details) + ) + else: + select_related.append("related_documentation") + self.qs = self.qs.prefetch_related( + Prefetch( + "participations", + to_attr="relevant_participations", + queryset=ParticipationStatus.objects.filter( + Q(absence_reason__isnull=False) | Q(tardiness__isnull=False) + ) + .select_related("absence_reason", *select_related) + .prefetch_related(*prefetch_related), + ) + ) + + return self + + def prefetch_relevant_personal_notes( + self, + select_related: list | None = None, + prefetch_related: list | None = None, + documentation_with_details: QuerySet | None = None, + ) -> "StatisticsBuilder": + """Prefetch relevant personal notes.""" + if not select_related: + select_related = [] + if not prefetch_related: + prefetch_related = [] + + if documentation_with_details: + prefetch_related.append(Prefetch("documentation", queryset=documentation_with_details)) + else: + select_related.append("documentation") + + self.qs = self.qs.prefetch_related( + Prefetch( + "new_personal_notes", + to_attr="relevant_personal_notes", + queryset=NewPersonalNote.objects.filter( + Q(note__gt="") | Q(extra_mark__isnull=False) + ) + .select_related("extra_mark", *select_related) + .prefetch_related(*prefetch_related), + ) + ) + + return self + + def build(self) -> QuerySet[Person]: + """Build annotated queryset with statistics.""" + return self.qs diff --git a/aleksis/apps/alsijil/views.py b/aleksis/apps/alsijil/views.py index 71b4cbdd6ee85c3eb280c9f15549e31736b060ab..3e68392033b4bb56e241583f704657563222f108 100644 --- a/aleksis/apps/alsijil/views.py +++ b/aleksis/apps/alsijil/views.py @@ -22,10 +22,10 @@ from aleksis.core.mixins import ( AdvancedEditView, SuccessNextMixin, ) -from aleksis.core.models import Group, PDFFile +from aleksis.core.models import Group, PDFFile, Person from aleksis.core.util import messages from aleksis.core.util.celery_progress import render_progress_page -from aleksis.core.util.core_helpers import has_person +from aleksis.core.util.core_helpers import get_active_school_term, has_person from .forms import ( AssignGroupRoleForm, @@ -36,10 +36,10 @@ from .models import GroupRole, GroupRoleAssignment from .tables import ( GroupRoleTable, ) -from .tasks import generate_full_register_printout +from .tasks import generate_groups_register_printout, generate_person_register_printout -def full_register_for_group(request: HttpRequest, ids: str) -> HttpResponse: +def groups_register_printout(request: HttpRequest, ids: str) -> HttpResponse: """Show a configurable register printout as PDF for a group.""" def parse_get_param(name): @@ -65,7 +65,7 @@ def full_register_for_group(request: HttpRequest, ids: str) -> HttpResponse: redirect_url = f"/pdfs/{file_object.pk}" - result = generate_full_register_printout.delay( + result = generate_groups_register_printout.delay( groups=ids, file_object=file_object.pk, include_cover=parse_get_param("cover"), @@ -95,6 +95,43 @@ def full_register_for_group(request: HttpRequest, ids: str) -> HttpResponse: ) +def person_register_printout(request: HttpRequest, pk: int) -> HttpResponse: + """Show a statistics printout as PDF for a person.""" + + person = get_object_or_404(Person, pk=pk) + school_term = get_active_school_term(request) + if not request.user.has_perm("alsijil.view_person_statistics_rule", person) or not school_term: + raise PermissionDenied() + + file_object = PDFFile.objects.create() + file_object.person = request.user.person + file_object.save() + + redirect_url = f"/pdfs/{file_object.pk}" + + result = generate_person_register_printout.delay( + person=person.id, + school_term=school_term.id, + file_object=file_object.pk, + ) + + back_url = request.GET.get("back", "") + + return render_progress_page( + request, + result, + title=_(f"Generate register printout for {person.full_name}"), + progress_title=_("Generate register printout …"), + success_message=_("The printout has been generated successfully."), + error_message=_("There was a problem while generating the printout."), + redirect_on_success_url=redirect_url, + back_url=back_url, + button_title=_("Download PDF"), + button_url=redirect_url, + button_icon="picture_as_pdf", + ) + + @method_decorator(pwa_cache, "dispatch") class GroupRoleListView(PermissionRequiredMixin, SingleTableView): """Table of all group roles."""