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">&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 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."""