From 89930f5d13503cc79b9947058bc103103e5313e6 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Sat, 25 Jan 2025 15:58:02 +0100
Subject: [PATCH 1/6] Refactor generation of statistics to statistics builder

---
 aleksis/apps/alsijil/managers.py              |  28 +-
 aleksis/apps/alsijil/model_extensions.py      | 117 +-------
 aleksis/apps/alsijil/schema/__init__.py       |  20 +-
 aleksis/apps/alsijil/tasks.py                 |  96 ++-----
 .../alsijil/partials/person_overview.html     |   4 +-
 .../alsijil/partials/register_coursebook.html |   4 +-
 aleksis/apps/alsijil/util/statistics.py       | 260 ++++++++++++++++++
 7 files changed, 336 insertions(+), 193 deletions(-)
 create mode 100644 aleksis/apps/alsijil/util/statistics.py

diff --git a/aleksis/apps/alsijil/managers.py b/aleksis/apps/alsijil/managers.py
index 681e3e562..6f097bc62 100644
--- a/aleksis/apps/alsijil/managers.py
+++ b/aleksis/apps/alsijil/managers.py
@@ -8,13 +8,14 @@ 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
 
 
 class GroupRoleManager(AlekSISBaseManagerWithoutMigrations):
@@ -73,6 +74,31 @@ class GroupRoleAssignmentQuerySet(QuerySet):
 class DocumentationManager(RecurrencePolymorphicManager):
     """Manager adding specific methods to documentations."""
 
+    def for_school_term(self, school_term: "SchoolTerm"):
+        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"):
+        return self.for_school_term(group.school_term).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"):
+        return self.for_school_term(group.school_term).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 e1a2bd0ef..b3186f610 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 f337be4f8..975b8930e 100644
--- a/aleksis/apps/alsijil/schema/__init__.py
+++ b/aleksis/apps/alsijil/schema/__init__.py
@@ -21,8 +21,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,
@@ -295,10 +295,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,
         )
 
@@ -343,9 +347,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)
 
 
 class Mutation(graphene.ObjectType):
diff --git a/aleksis/apps/alsijil/tasks.py b/aleksis/apps/alsijil/tasks.py
index 71de0d315..355396dcb 100644
--- a/aleksis/apps/alsijil/tasks.py
+++ b/aleksis/apps/alsijil/tasks.py
@@ -7,15 +7,14 @@ 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.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
 
 
 @recorded_task
@@ -32,33 +31,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 +79,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
diff --git a/aleksis/apps/alsijil/templates/alsijil/partials/person_overview.html b/aleksis/apps/alsijil/templates/alsijil/partials/person_overview.html
index 6c91c3473..f8cd4eadd 100644
--- a/aleksis/apps/alsijil/templates/alsijil/partials/person_overview.html
+++ b/aleksis/apps/alsijil/templates/alsijil/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/partials/register_coursebook.html b/aleksis/apps/alsijil/templates/alsijil/partials/register_coursebook.html
index ab626b7dd..95fb8e8f3 100644
--- a/aleksis/apps/alsijil/templates/alsijil/partials/register_coursebook.html
+++ b/aleksis/apps/alsijil/templates/alsijil/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/util/statistics.py b/aleksis/apps/alsijil/util/statistics.py
new file mode 100644
index 000000000..ee7cb3057
--- /dev/null
+++ b/aleksis/apps/alsijil/util/statistics.py
@@ -0,0 +1,260 @@
+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):
+    pass
+
+
+class StatisticsBuilder:
+    def __init__(self, persons: QuerySet[Person]) -> None:
+        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
+        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
+        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
-- 
GitLab


From 4822847b541f99d4177b4e5e8cc379073811eebb Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Sat, 25 Jan 2025 16:25:48 +0100
Subject: [PATCH 2/6] Add coursebook printout for single persons

---
 CHANGELOG.rst                                 |  5 ++
 .../coursebook/CoursebookPrintDialog.vue      |  2 +-
 .../statistics/StatisticsForPersonPage.vue    | 12 +++-
 aleksis/apps/alsijil/frontend/index.js        | 10 ++-
 aleksis/apps/alsijil/tasks.py                 | 67 ++++++++++++++++++-
 .../alsijil/print/register_for_group.html     |  2 +-
 .../alsijil/print/register_for_person.html    | 15 +++++
 aleksis/apps/alsijil/urls.py                  |  5 +-
 aleksis/apps/alsijil/views.py                 | 47 +++++++++++--
 9 files changed, 152 insertions(+), 13 deletions(-)
 create mode 100644 aleksis/apps/alsijil/templates/alsijil/print/register_for_person.html

diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index b6443616e..b1000af70 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 8a9058d6d..52444d0e5 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 c9c8bcd3c..84e63a495 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 18e0f68ea..13967281f 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/tasks.py b/aleksis/apps/alsijil/tasks.py
index 355396dcb..47fd9074c 100644
--- a/aleksis/apps/alsijil/tasks.py
+++ b/aleksis/apps/alsijil/tasks.py
@@ -9,7 +9,7 @@ from celery.states import SUCCESS
 
 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
 
@@ -18,7 +18,7 @@ from .util.statistics import StatisticsBuilder
 
 
 @recorded_task
-def generate_full_register_printout(
+def generate_groups_register_printout(
     groups: list[int],
     file_object: int,
     recorder: ProgressRecorder,
@@ -138,3 +138,66 @@ def generate_full_register_printout(
             raise Exception(_("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 = {}
+
+    _number_of_steps = 4
+
+    recorder.set_progress(1, _number_of_steps, _("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, _number_of_steps, _("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, _number_of_steps, _("Generating PDF ..."))
+
+    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"))
+
+    recorder.set_progress(4, _number_of_steps)
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 225763327..8e395b723 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/partials/person_overview.html" with person=person group=group %}
+        {% include "alsijil/partials/person_overview.html" with person=person %}
         <div class="page-break">&nbsp;</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 000000000..f22d62060
--- /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/partials/person_overview.html" with person=person %}
+{% endblock %}
diff --git a/aleksis/apps/alsijil/urls.py b/aleksis/apps/alsijil/urls.py
index 8017db1c9..e0dfc61b0 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/views.py b/aleksis/apps/alsijil/views.py
index 71b4cbdd6..3e6839203 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."""
-- 
GitLab


From 52a77a8bf4185a56ac5269e3aaff523ba7cdfef0 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 27 Jan 2025 18:12:42 +0100
Subject: [PATCH 3/6] Add some docstrings and types for statistics builder

---
 aleksis/apps/alsijil/managers.py        | 17 ++++++++++++-----
 aleksis/apps/alsijil/util/statistics.py | 11 +++++++++++
 2 files changed, 23 insertions(+), 5 deletions(-)

diff --git a/aleksis/apps/alsijil/managers.py b/aleksis/apps/alsijil/managers.py
index 6f097bc62..ff5decdb8 100644
--- a/aleksis/apps/alsijil/managers.py
+++ b/aleksis/apps/alsijil/managers.py
@@ -17,6 +17,8 @@ from aleksis.core.managers import (
 if TYPE_CHECKING:
     from aleksis.core.models import Group, SchoolTerm
 
+    from .models import Documentation
+
 
 class GroupRoleManager(AlekSISBaseManagerWithoutMigrations):
     pass
@@ -74,14 +76,17 @@ class GroupRoleAssignmentQuerySet(QuerySet):
 class DocumentationManager(RecurrencePolymorphicManager):
     """Manager adding specific methods to documentations."""
 
-    def for_school_term(self, school_term: "SchoolTerm"):
+    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"):
-        return self.for_school_term(group.school_term).filter(
+    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))
@@ -92,8 +97,10 @@ class DocumentationManager(RecurrencePolymorphicManager):
             )
         )
 
-    def all_planned_for_group(self, group: "Group"):
-        return self.for_school_term(group.school_term).filter(
+    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)
diff --git a/aleksis/apps/alsijil/util/statistics.py b/aleksis/apps/alsijil/util/statistics.py
index ee7cb3057..f9d13f846 100644
--- a/aleksis/apps/alsijil/util/statistics.py
+++ b/aleksis/apps/alsijil/util/statistics.py
@@ -9,11 +9,22 @@ from ..models import Documentation, ExtraMark, NewPersonalNote, ParticipationSta
 
 
 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 one `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
-- 
GitLab


From 6978f1c5a3c2bb31267951ad96f1bfb6bb664227 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 27 Jan 2025 19:00:34 +0100
Subject: [PATCH 4/6] Fix PDF generation

---
 aleksis/apps/alsijil/tasks.py | 22 ++++++++++++----------
 1 file changed, 12 insertions(+), 10 deletions(-)

diff --git a/aleksis/apps/alsijil/tasks.py b/aleksis/apps/alsijil/tasks.py
index 47fd9074c..7216716a7 100644
--- a/aleksis/apps/alsijil/tasks.py
+++ b/aleksis/apps/alsijil/tasks.py
@@ -17,6 +17,10 @@ from .models import Documentation, ExtraMark, NewPersonalNote, ParticipationStat
 from .util.statistics import StatisticsBuilder
 
 
+class PDFGenerationError(Exception):
+    """Error in PDF generation."""
+
+
 @recorded_task
 def generate_groups_register_printout(
     groups: list[int],
@@ -134,8 +138,8 @@ def generate_groups_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)
 
@@ -151,9 +155,7 @@ def generate_person_register_printout(
 
     context = {}
 
-    _number_of_steps = 4
-
-    recorder.set_progress(1, _number_of_steps, _("Loading data ..."))
+    recorder.set_progress(1, 4, _("Loading data ..."))
 
     person = Person.objects.get(pk=person)
     school_term = SchoolTerm.objects.get(pk=school_term)
@@ -184,7 +186,7 @@ def generate_person_register_printout(
     )
     context["extra_marks"] = ExtraMark.objects.all()
 
-    recorder.set_progress(2, _number_of_steps, _("Generating template ..."))
+    recorder.set_progress(2, 4, _("Generating template ..."))
 
     file_object, result = generate_pdf_from_template(
         "alsijil/print/register_for_person.html",
@@ -192,12 +194,12 @@ def generate_person_register_printout(
         file_object=PDFFile.objects.get(pk=file_object),
     )
 
-    recorder.set_progress(3, _number_of_steps, _("Generating PDF ..."))
+    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 Exception(_("PDF generation failed"))
+        if not (result.status == SUCCESS and file_object.file):
+            raise PDFGenerationError(_("PDF generation failed"))
 
-    recorder.set_progress(4, _number_of_steps)
+    recorder.set_progress(4, 4)
-- 
GitLab


From a4b9dfea25f939598d540813889c779bd0e19ce6 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 27 Jan 2025 20:33:52 +0100
Subject: [PATCH 5/6] Improve statistics builder

---
 aleksis/apps/alsijil/tasks.py           | 8 +++-----
 aleksis/apps/alsijil/util/statistics.py | 6 +++---
 2 files changed, 6 insertions(+), 8 deletions(-)

diff --git a/aleksis/apps/alsijil/tasks.py b/aleksis/apps/alsijil/tasks.py
index 7216716a7..f7aa866d3 100644
--- a/aleksis/apps/alsijil/tasks.py
+++ b/aleksis/apps/alsijil/tasks.py
@@ -163,11 +163,9 @@ def generate_person_register_printout(
     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()
-        )
+        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()
diff --git a/aleksis/apps/alsijil/util/statistics.py b/aleksis/apps/alsijil/util/statistics.py
index f9d13f846..158a3efd3 100644
--- a/aleksis/apps/alsijil/util/statistics.py
+++ b/aleksis/apps/alsijil/util/statistics.py
@@ -17,7 +17,7 @@ class BuilderError(Exception):
 class StatisticsBuilder:
     """Builder class for building queries with annotated statistics on persons.
 
-    To build queries, you can combine one `use_` with multiple `annotate` or `prefetch`
+    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()
@@ -87,7 +87,7 @@ class StatisticsBuilder:
     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
+            raise BuilderError("Annotation of participations needs a participation filter.")
         self.qs = self.qs.annotate(
             filtered_participation_statuses=FilteredRelation(
                 "participations",
@@ -99,7 +99,7 @@ class StatisticsBuilder:
     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
+            raise BuilderError("Annotation of personal notes needs a participation filter.")
         self.qs = self.qs.annotate(
             filtered_personal_notes=FilteredRelation(
                 "new_personal_notes",
-- 
GitLab


From 7c8f8e7f26ba61e713152b14a7fa303ff42ab033 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 3 Feb 2025 20:13:51 +0100
Subject: [PATCH 6/6] Fix full register for persons after changing template
 paths

---
 .../alsijil/templates/alsijil/print/register_for_person.html    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/aleksis/apps/alsijil/templates/alsijil/print/register_for_person.html b/aleksis/apps/alsijil/templates/alsijil/print/register_for_person.html
index f22d62060..2cc2b5a4d 100644
--- a/aleksis/apps/alsijil/templates/alsijil/print/register_for_person.html
+++ b/aleksis/apps/alsijil/templates/alsijil/print/register_for_person.html
@@ -11,5 +11,5 @@
 {% endblock %}
 
 {% block content %}
-      {% include "alsijil/partials/person_overview.html" with person=person %}
+      {% include "alsijil/print/partials/person_overview.html" with person=person %}
 {% endblock %}
-- 
GitLab