From 75e5746822bf4d25a8cd5d0c0a291c8a75645383 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Thu, 29 Oct 2020 21:23:27 +0100
Subject: [PATCH] Implement detailed students list and include it on several
 positions

- My groups
- My students
- Links in week view
---
 aleksis/apps/alsijil/model_extensions.py      |  78 +++++++++++-
 aleksis/apps/alsijil/rules.py                 |   8 ++
 .../alsijil/static/css/alsijil/alsijil.css    |  23 ++++
 .../alsijil/class_register/groups.html        |  87 +++++++++++--
 .../alsijil/class_register/persons.html       |  62 ++++++++--
 .../alsijil/class_register/students_list.html |  49 ++++++++
 .../alsijil/class_register/week_view.html     |  38 ++++--
 .../templates/alsijil/partials/legend.html    |  55 +++++++++
 .../alsijil/partials/persons_with_stats.html  | 114 ++++++++++++++++++
 aleksis/apps/alsijil/urls.py                  |   1 +
 aleksis/apps/alsijil/views.py                 | 108 ++++++++---------
 11 files changed, 531 insertions(+), 92 deletions(-)
 create mode 100644 aleksis/apps/alsijil/templates/alsijil/class_register/students_list.html
 create mode 100644 aleksis/apps/alsijil/templates/alsijil/partials/legend.html
 create mode 100644 aleksis/apps/alsijil/templates/alsijil/partials/persons_with_stats.html

diff --git a/aleksis/apps/alsijil/model_extensions.py b/aleksis/apps/alsijil/model_extensions.py
index 8b4d4ff42..a4ab382d6 100644
--- a/aleksis/apps/alsijil/model_extensions.py
+++ b/aleksis/apps/alsijil/model_extensions.py
@@ -1,8 +1,8 @@
 from datetime import date
-from typing import Dict, Iterator, Optional, Union
+from typing import Dict, Iterable, Iterator, Optional, Union
 
 from django.db.models import Exists, OuterRef, Q, QuerySet
-from django.db.models.aggregates import Count
+from django.db.models.aggregates import Count, Sum
 from django.utils.translation import gettext as _
 
 import reversion
@@ -282,3 +282,77 @@ def get_owner_groups_with_lessons(self: Person):
     Groups which have child groups with related lessons are also included.
     """
     return Group.get_groups_with_lessons().filter(owners=self)
+
+
+@Group.method
+def generate_person_list_with_class_register_statistics(
+    self: Group, persons: Optional[Iterable] = None
+) -> QuerySet:
+    """Get with class register statistics annotated list of all members."""
+    persons = persons or self.members.all()
+    persons = persons.filter(
+        personal_notes__groups_of_person=self,
+        personal_notes__lesson_period__lesson__validity__school_term=self.school_term,
+    ).annotate(
+        absences_count=Count(
+            "personal_notes__absent",
+            filter=Q(
+                personal_notes__absent=True,
+                personal_notes__lesson_period__lesson__validity__school_term=self.school_term,
+            ),
+        ),
+        excused=Count(
+            "personal_notes__absent",
+            filter=Q(
+                personal_notes__absent=True,
+                personal_notes__excused=True,
+                personal_notes__excuse_type__isnull=True,
+                personal_notes__lesson_period__lesson__validity__school_term=self.school_term,
+            ),
+        ),
+        unexcused=Count(
+            "personal_notes__absent",
+            filter=Q(
+                personal_notes__absent=True,
+                personal_notes__excused=False,
+                personal_notes__lesson_period__lesson__validity__school_term=self.school_term,
+            ),
+        ),
+        tardiness=Sum("personal_notes__late"),
+        tardiness_count=Count(
+            "personal_notes",
+            filter=~Q(personal_notes__late=0)
+            & Q(
+                personal_notes__lesson_period__lesson__validity__school_term=self.school_term,
+            ),
+        ),
+    )
+
+    for extra_mark in ExtraMark.objects.all():
+        persons = persons.annotate(
+            **{
+                extra_mark.count_label: Count(
+                    "personal_notes",
+                    filter=Q(
+                        personal_notes__extra_marks=extra_mark,
+                        personal_notes__lesson_period__lesson__validity__school_term=self.school_term,
+                    ),
+                )
+            }
+        )
+
+    for excuse_type in ExcuseType.objects.all():
+        persons = persons.annotate(
+            **{
+                excuse_type.count_label: Count(
+                    "personal_notes__absent",
+                    filter=Q(
+                        personal_notes__absent=True,
+                        personal_notes__excuse_type=excuse_type,
+                        personal_notes__lesson_period__lesson__validity__school_term=self.school_term,
+                    ),
+                )
+            }
+        )
+
+    return persons
diff --git a/aleksis/apps/alsijil/rules.py b/aleksis/apps/alsijil/rules.py
index cb2bda333..a295a1dbd 100644
--- a/aleksis/apps/alsijil/rules.py
+++ b/aleksis/apps/alsijil/rules.py
@@ -146,6 +146,14 @@ add_perm("alsijil.view_my_students", view_my_students_predicate)
 view_my_groups_predicate = has_person & is_teacher
 add_perm("alsijil.view_my_groups", view_my_groups_predicate)
 
+# View students list
+view_students_list_predicate = view_my_groups_predicate & (
+    is_group_owner
+    | has_global_perm("alsijil.view_personalnote")
+    | has_object_perm("core.view_personalnote_group")
+)
+add_perm("alsijil.view_students_list", view_students_list_predicate)
+
 # View person overview
 view_person_overview_predicate = has_person & (
     (is_current_person & is_site_preference_set("alsijil", "view_own_personal_notes"))
diff --git a/aleksis/apps/alsijil/static/css/alsijil/alsijil.css b/aleksis/apps/alsijil/static/css/alsijil/alsijil.css
index 892df4810..3e14ca014 100644
--- a/aleksis/apps/alsijil/static/css/alsijil/alsijil.css
+++ b/aleksis/apps/alsijil/static/css/alsijil/alsijil.css
@@ -35,3 +35,26 @@ table a.tr-link {
         margin-bottom: 10px;
     }
 }
+
+.collapsible li .show-on-active {
+    display: none;
+}
+
+.collapsible li.active .show-on-active {
+    display: block;
+}
+
+th.chip-height {
+    height: 67px;
+    line-height: 2.2;
+}
+
+.collection-item.chip-height {
+    height: 52px;
+    line-height: 2.2;
+}
+
+li.collection-item.button-height {
+    height: 58px;
+    line-height: 2.5;
+}
diff --git a/aleksis/apps/alsijil/templates/alsijil/class_register/groups.html b/aleksis/apps/alsijil/templates/alsijil/class_register/groups.html
index 87385476b..a2bf6799d 100644
--- a/aleksis/apps/alsijil/templates/alsijil/class_register/groups.html
+++ b/aleksis/apps/alsijil/templates/alsijil/class_register/groups.html
@@ -1,24 +1,93 @@
 {# -*- engine:django -*- #}
 {% extends "core/base.html" %}
-{% load i18n %}
+{% load i18n static %}
 
 {% block browser_title %}{% blocktrans %}My groups{% endblocktrans %}{% endblock %}
 
-
 {% block page_title %}
   {% blocktrans %}My groups{% endblocktrans %}
 {% endblock %}
 
+{% block extra_head %}
+  {{ block.super }}
+  <link rel="stylesheet" href="{% static 'css/alsijil/alsijil.css' %}"/>
+{% endblock %}
+
 {% block content %}
-  <div class="collection">
+  <table class="highlight responsive-table hide-on-med-and-down">
+    <thead>
+    <tr>
+      <th>{% trans "Name" %}</th>
+      <th>{% trans "Students" %}</th>
+      <th></th>
+    </tr>
+    </thead>
     {% for group in groups %}
-      <a class="collection-item" href="{% url "week_view" "group" group.pk %}">
-        {{ group }}
-      </a>
+      <tr>
+        <td>
+          {{ group }}
+        </td>
+        <td>{{ group.students_count }}</td>
+        <td>
+          <div class="right">
+            <a class="btn primary-color waves-effect waves-light" href="{% url "students_list" group.pk %}">
+              <i class="material-icons left">people</i>
+              {% trans "Students list" %}
+            </a>
+            <a class="btn secondary-color waves-effect waves-light" href="{% url "week_view" "group" group.pk %}">
+              <i class="material-icons left">view_week</i>
+              {% trans "Week view" %}
+            </a>
+            <a class="btn primary waves-effect waves-light" href="{% url "full_register_group" group.pk %}"
+               target="_blank">
+              <i class="material-icons left">print</i>
+              {% trans "Generate printout" %}
+            </a>
+          </div>
+        </td>
+      </tr>
     {% empty %}
-      <li class="collection-item flow-text">
-        {% blocktrans %}No groups available.{% endblocktrans %}
-      </li>
+      <tr>
+        <td class="flow-text" colspan="3">
+          {% blocktrans %}No groups available.{% endblocktrans %}
+        </td>
+      </tr>
     {% endfor %}
+  </table>
+
+  <div class="hide-on-large-only">
+    <ul class="collection">
+      {% for group in groups %}
+        <li class="collection-item">
+          <span class="title">{{ group }}</span>
+          <p>
+            {{ group.students_count }} {% trans "students" %}
+          </p>
+          <p>
+            <a class="btn primary-color waves-effect waves-light" href="{% url "week_view" "group" group.pk %}">
+              <i class="material-icons left">people</i>
+              {% trans "Students list" %}
+            </a>
+          </p>
+          <p>
+            <a class="btn secondary-color waves-effect waves-light" href="{% url "week_view" "group" group.pk %}">
+              <i class="material-icons left">view_week</i>
+              {% trans "Week view" %}
+            </a>
+          </p>
+          <p>
+            <a class="btn primary waves-effect waves-light" href="{% url "full_register_group" group.pk %}"
+               target="_blank">
+              <i class="material-icons left">print</i>
+              {% trans "Generate printout" %}
+            </a>
+          </p>
+        </li>
+      {% empty %}
+          <li class="collection-item flow-text">
+            {% blocktrans %}No groups available.{% endblocktrans %}
+          </li>
+      {% endfor %}
+    </ul>
   </div>
 {% endblock %}
diff --git a/aleksis/apps/alsijil/templates/alsijil/class_register/persons.html b/aleksis/apps/alsijil/templates/alsijil/class_register/persons.html
index c3f5e35d4..84461c183 100644
--- a/aleksis/apps/alsijil/templates/alsijil/class_register/persons.html
+++ b/aleksis/apps/alsijil/templates/alsijil/class_register/persons.html
@@ -1,8 +1,6 @@
 {# -*- engine:django -*- #}
 {% extends "core/base.html" %}
-{% load data_helpers %}
-{% load week_helpers %}
-{% load i18n %}
+{% load i18n week_helpers data_helpers static time_helpers %}
 
 {% block browser_title %}{% blocktrans %}My students{% endblocktrans %}{% endblock %}
 
@@ -11,16 +9,56 @@
   {% blocktrans %}My students{% endblocktrans %}
 {% endblock %}
 
+{% block extra_head %}
+  {{ block.super }}
+  <link rel="stylesheet" href="{% static 'css/alsijil/alsijil.css' %}"/>
+{% endblock %}
+
+
 {% block content %}
-  <div class="collection">
-    {% for person in persons %}
-      <a class="collection-item" href="{% url "overview_person" person.pk %}">
-      {{ person }}
-      </a>
-    {% empty %}
-      <li class="collection-item flow-text">
-        {% blocktrans %}No students available.{% endblocktrans %}
+  <ul class="collapsible">
+    {% for group, persons in groups %}
+      <li {% if forloop.first %}class="active"{% endif %}>
+        <div class="collapsible-header">
+          <div class="hundred-percent">
+            <span class="right show-on-active hide-on-small-and-down">
+              <a class="btn primary-color waves-effect waves-light" href="{% url "week_view" "group" group.pk %}">
+                <i class="material-icons left">view_week</i>
+                {% trans "Week view" %}
+              </a>
+              <a class="btn waves-effect waves-light" href="{% url "full_register_group" group.pk %}" target="_blank">
+                <i class="material-icons left">print</i>
+                {% trans "Generate printout" %}
+              </a>
+            </span>
+
+            <h6>{{ group.name }}
+              <span class="chip">{{ group.school_term }}</span>
+            </h6>
+
+            <p class="show-on-active hide-on-med-and-up">
+              <a class="btn primary-color waves-effect waves-light hundred-percent"
+                 href="{% url "week_view" "group" group.pk %}">
+                <i class="material-icons left">view_week</i>
+                {% trans "Week view" %}
+              </a>
+            </p>
+            <p class="show-on-active hide-on-med-and-up">
+              <a class="btn waves-effect waves-light hundred-percent" href="{% url "full_register_group" group.pk %}"
+                 target="_blank">
+                <i class="material-icons left">print</i>
+                {% trans "Generate printout" %}
+              </a>
+            </p>
+          </div>
+        </div>
+
+        <div class="collapsible-body">
+          {% include "alsijil/partials/persons_with_stats.html" with persons=persons %}
+        </div>
       </li>
     {% endfor %}
-  </div>
+  </ul>
+
+  {% include "alsijil/partials/legend.html" %}
 {% endblock %}
diff --git a/aleksis/apps/alsijil/templates/alsijil/class_register/students_list.html b/aleksis/apps/alsijil/templates/alsijil/class_register/students_list.html
new file mode 100644
index 000000000..245addc83
--- /dev/null
+++ b/aleksis/apps/alsijil/templates/alsijil/class_register/students_list.html
@@ -0,0 +1,49 @@
+{# -*- engine:django -*- #}
+{% extends "core/base.html" %}
+{% load static time_helpers data_helpers week_helpers i18n %}
+
+{% block browser_title %}{% blocktrans with group=group %}Students list: {{ group }}{% endblocktrans %}{% endblock %}
+
+{% block page_title %}
+  <a href="{% url "my_groups" %}"
+     class="btn-flat primary-color-text waves-light waves-effect">
+    <i class="material-icons left">chevron_left</i> {% trans "Back" %}
+  </a>
+  {% blocktrans with group=group %}Students list: {{ group }}{% endblocktrans %}
+  <span class="right show-on-active hide-on-small-and-down">
+    <a class="btn primary-color waves-effect waves-light" href="{% url "week_view" "group" group.pk %}">
+      <i class="material-icons left">view_week</i>
+      {% trans "Week view" %}
+    </a>
+    <a class="btn waves-effect waves-light" href="{% url "full_register_group" group.pk %}" target="_blank">
+      <i class="material-icons left">print</i>
+      {% trans "Generate printout" %}
+    </a>
+  </span>
+{% endblock %}
+
+{% block extra_head %}
+  {{ block.super }}
+  <link rel="stylesheet" href="{% static 'css/alsijil/alsijil.css' %}"/>
+{% endblock %}
+
+{% block content %}
+  <p class="show-on-active hide-on-med-and-up">
+    <a class="btn primary-color waves-effect waves-light hundred-percent"
+       href="{% url "week_view" "group" group.pk %}">
+      <i class="material-icons left">view_week</i>
+      {% trans "Week view" %}
+    </a>
+  </p>
+  <p class="show-on-active hide-on-med-and-up">
+    <a class="btn waves-effect waves-light hundred-percent" href="{% url "full_register_group" group.pk %}"
+       target="_blank">
+      <i class="material-icons left">print</i>
+      {% trans "Generate printout" %}
+    </a>
+  </p>
+
+  {% include "alsijil/partials/persons_with_stats.html" with persons=persons %}
+
+  {% include "alsijil/partials/legend.html" %}
+{% endblock %}
diff --git a/aleksis/apps/alsijil/templates/alsijil/class_register/week_view.html b/aleksis/apps/alsijil/templates/alsijil/class_register/week_view.html
index 8d51886fa..c9278671b 100644
--- a/aleksis/apps/alsijil/templates/alsijil/class_register/week_view.html
+++ b/aleksis/apps/alsijil/templates/alsijil/class_register/week_view.html
@@ -15,14 +15,7 @@
   {{ week_select|json_script:"week_select" }}
   <script type="text/javascript" src="{% static "js/chronos/week_select.js" %}"></script>
   <div class="row">
-    {% if group %}
-      <div class="col s12 m2 push-m10 l1 push-l11">
-        <a class="col s12 btn waves-effect waves-light right" href="{% url 'full_register_group' group.id %}">
-          <i class="material-icons center">print</i>
-        </a>
-      </div>
-    {% endif %}
-    <div class="col s12 {% if group %}m10 pull-m2 l11 pull-l1 {% endif %}">
+    <div class="col s12">
       <form method="post" action="">
         {% csrf_token %}
         {% form form=select_form %}{% endform %}
@@ -34,12 +27,39 @@
   </div>
 
 
-  <div class="row">
+  <div class="row no-margin">
     <h4 class="col s12 m6">{% blocktrans with el=el week=week.week %}CW {{ week }}:
       {{ instance }}{% endblocktrans %} </h4>
     {% include "chronos/partials/week_select.html" with wanted_week=week %}
   </div>
 
+  {% if group %}
+    <p class="hide-on-med-and-down">
+      <a class="btn primary-color waves-effect waves-light" href="{% url "students_list" group.pk %}">
+        <i class="material-icons left">people</i>
+        {% trans "Students list" %}
+      </a>
+      <a class="btn waves-effect waves-light" href="{% url "full_register_group" group.pk %}" target="_blank">
+        <i class="material-icons left">print</i>
+        {% trans "Generate printout" %}
+      </a>
+    </p>
+
+    <p class="hide-on-med-and-up">
+      <a class="btn primary-color waves-effect waves-light hundred-percent" href="{% url "students_list" group.pk %}">
+        <i class="material-icons left">people</i>
+        {% trans "Students list" %}
+      </a>
+    </p>
+    <p class="hide-on-med-and-up">
+      <a class="btn waves-effect waves-light hundred-percent" href="{% url "full_register_group" group.pk %}"
+         target="_blank">
+        <i class="material-icons left">print</i>
+        {% trans "Generate printout" %}
+      </a>
+    </p>
+  {% endif %}
+
   {% if lesson_periods %}
     <div class="row">
       <div class="col s12">
diff --git a/aleksis/apps/alsijil/templates/alsijil/partials/legend.html b/aleksis/apps/alsijil/templates/alsijil/partials/legend.html
new file mode 100644
index 000000000..a2c6ba1aa
--- /dev/null
+++ b/aleksis/apps/alsijil/templates/alsijil/partials/legend.html
@@ -0,0 +1,55 @@
+{% load i18n %}
+<div class="card">
+  <div class="card-content">
+    <div class="card-title">{% trans "Legend" %}</div>
+    <div class="row">
+      <div class="col s12 m12 l4">
+        <h6>{% trans "General" %}</h6>
+        <ul class="collection">
+          <li class="collection-item chip-height">
+            <strong>(a)</strong> {% trans "Absences" %}
+            <span class="chip secondary-color white-text right">0</span>
+          </li>
+          <li class="collection-item chip-height">
+            <strong>(u)</strong> {% trans "Unexcused absences" %}
+            <span class="chip red white-text right">0</span>
+          </li>
+          <li class="collection-item chip-height">
+            <strong>(e)</strong> {% trans "Excused absences" %}
+            <span class="chip green white-text right">0</span>
+          </li>
+        </ul>
+      </div>
+
+      {% if excuse_types %}
+        <div class="col s12 m12 l4">
+          <h6>{% trans "Excuse types" %}</h6>
+
+          <ul class="collection">
+            {% for excuse_type in excuse_types %}
+              <li class="collection-item chip-height">
+                <strong>({{ excuse_type.short_name }})</strong> {{ excuse_type.name }}
+                <span class="chip grey white-text right">0</span>
+              </li>
+            {% endfor %}
+          </ul>
+        </div>
+      {% endif %}
+
+      {% if extra_marks %}
+        <div class="col s12 m12 l4">
+          <h6>{% trans "Extra marks" %}</h6>
+
+          <ul class="collection">
+            {% for extra_mark in extra_marks %}
+              <li class="collection-item chip-height">
+                <strong>{{ extra_mark.short_name }}</strong> {{ extra_mark.name }}
+                <span class="chip grey white-text right">0</span>
+              </li>
+            {% endfor %}
+          </ul>
+        </div>
+      {% endif %}
+    </div>
+  </div>
+</div>
diff --git a/aleksis/apps/alsijil/templates/alsijil/partials/persons_with_stats.html b/aleksis/apps/alsijil/templates/alsijil/partials/persons_with_stats.html
new file mode 100644
index 000000000..7da999cff
--- /dev/null
+++ b/aleksis/apps/alsijil/templates/alsijil/partials/persons_with_stats.html
@@ -0,0 +1,114 @@
+{% load data_helpers time_helpers i18n %}
+
+{% if not persons %}
+  <div class="alert primary">
+    <div>
+      <i class="material-icons left">warning</i>
+      {% blocktrans %}No students available.{% endblocktrans %}
+    </div>
+  </div>
+{% else %}
+  <table class="highlight responsive-table">
+  <thead>
+  <tr class="hide-on-med-and-down">
+    <th rowspan="2">{% trans "Name" %}</th>
+    <th rowspan="2">{% trans "Primary group" %}</th>
+    <th colspan="{{ excuse_types.count|add:3 }}">{% trans "Absences" %}</th>
+    <th rowspan="2">{% trans "Tardiness" %}</th>
+    {% if extra_marks %}
+      <th colspan="{{ extra_marks.count }}">{% trans "Extra marks" %}</th>
+    {% endif %}
+    <th rowspan="2"></th>
+  </tr>
+  <tr class="hide-on-large-only">
+    <th class="truncate">{% trans "Name" %}</th>
+    <th class="truncate">{% trans "Primary group" %}</th>
+    <th class="truncate chip-height">{% trans "Absences" %}</th>
+    <th class="chip-height">{% trans "(e)" %}</th>
+    {% for excuse_type in excuse_types %}
+      <th class="chip-height">
+        ({{ excuse_type.short_name }})
+      </th>
+    {% endfor %}
+    <th class="chip-height">{% trans "(u)" %}</th>
+    <th class="truncate chip-height">{% trans "Tardiness" %}</th>
+    {% for extra_mark in extra_marks %}
+      <th class="chip-height">
+        {{ extra_mark.short_name }}
+      </th>
+    {% endfor %}
+    <th rowspan="2"></th>
+  </tr>
+  <tr class="hide-on-med-and-down">
+    <th>{% trans "Sum" %}</th>
+    <th>{% trans "(e)" %}</th>
+    {% for excuse_type in excuse_types %}
+      <th>
+        ({{ excuse_type.short_name }})
+      </th>
+    {% endfor %}
+    <th>{% trans "(u)" %}</th>
+    {% for extra_mark in extra_marks %}
+      <th>
+        {{ extra_mark.short_name }}
+      </th>
+    {% endfor %}
+  </tr>
+  </thead>
+  {% for person in persons %}
+    <tr>
+      <td>
+        <a href="{% url "overview_person" person.pk %}">
+          {{ person }}
+        </a>
+      </td>
+      <td>
+        {% firstof person.primary_group  "–" %}
+      </td>
+      <td>
+        <span class="chip secondary-color white-text" title="{% trans "Absences" %}">
+          {{ person.absences_count }}
+        </span>
+      </td>
+      <td class="green-text">
+        <span class="chip green white-text" title="{% trans "Excused" %}">
+        {{ person.excused }}
+        </span>
+      </td>
+      {% for excuse_type in excuse_types %}
+        <td>
+          <span class="chip grey white-text" title="{{ excuse_type.name }}">
+            {{ person|get_dict:excuse_type.count_label }}
+          </span>
+        </td>
+      {% endfor %}
+      <td class="red-text">
+        <span class="chip red white-text" title="{% trans "Unexcused" %}">
+        {{ person.unexcused }}
+        </span>
+      </td>
+      <td>
+        <span class="chip orange white-text" title="{% trans "Tardiness" %}">
+          {% firstof person.tardiness|to_time|time:"H\h i\m"  "–" %}
+        </span>
+        <span class="chip orange white-text" title="{% trans "Count of tardiness" %}">{{ person.tardiness_count }} &times;</span>
+      </td>
+      {% for extra_mark in extra_marks %}
+        <td>
+          <span class="chip grey white-text" title="{{ extra_mark.name }}">
+            {{ person|get_dict:extra_mark.count_label }}
+          </span>
+        </td>
+      {% endfor %}
+
+      <td>
+        <a class="btn primary waves-effect waves-light" href="{% url "overview_person" person.pk %}">
+          <i class="material-icons left">insert_chart</i>
+          <span class="hide-on-med-and-down"> {% trans "Show more details" %}</span>
+          <span class="hide-on-large-only">{% trans "Details" %}</span>
+        </a>
+      </td>
+    </tr>
+  {% endfor %}
+{% endif %}
+</table>
diff --git a/aleksis/apps/alsijil/urls.py b/aleksis/apps/alsijil/urls.py
index af972edd5..4d4c490a2 100644
--- a/aleksis/apps/alsijil/urls.py
+++ b/aleksis/apps/alsijil/urls.py
@@ -27,6 +27,7 @@ urlpatterns = [
         "print/group/<int:id_>", views.full_register_group, name="full_register_group"
     ),
     path("groups/", views.my_groups, name="my_groups"),
+    path("groups/<int:pk>/", views.StudentsList.as_view(), name="students_list"),
     path("persons/", views.my_students, name="my_students"),
     path("persons/<int:id_>/", views.overview_person, name="overview_person"),
     path("me/", views.overview_person, name="overview_me"),
diff --git a/aleksis/apps/alsijil/views.py b/aleksis/apps/alsijil/views.py
index d72c58563..e8e135a04 100644
--- a/aleksis/apps/alsijil/views.py
+++ b/aleksis/apps/alsijil/views.py
@@ -436,63 +436,18 @@ def full_register_group(request: HttpRequest, id_: int) -> HttpResponse:
                     (lesson_period, documentations, notes, substitution)
                 )
 
-    persons = (
-        Person.objects.prefetch_related(
-            "personal_notes",
-            "personal_notes__excuse_type",
-            "personal_notes__extra_marks",
-            "personal_notes__lesson_period__lesson__subject",
-            "personal_notes__lesson_period__substitutions",
-            "personal_notes__lesson_period__substitutions__subject",
-            "personal_notes__lesson_period__substitutions__teachers",
-            "personal_notes__lesson_period__lesson__teachers",
-            "personal_notes__lesson_period__period",
-        )
-        .filter(
-            personal_notes__groups_of_person=group,
-            personal_notes__lesson_period__lesson__validity__school_term=current_school_term,
-        )
-        .annotate(
-            absences_count=Count(
-                "personal_notes__absent",
-                filter=Q(
-                    personal_notes__absent=True,
-                    personal_notes__lesson_period__lesson__validity__school_term=current_school_term,
-                ),
-            ),
-            excused=Count(
-                "personal_notes__absent",
-                filter=Q(
-                    personal_notes__absent=True,
-                    personal_notes__excused=True,
-                    personal_notes__excuse_type__isnull=True,
-                    personal_notes__lesson_period__lesson__validity__school_term=current_school_term,
-                ),
-            ),
-            unexcused=Count(
-                "personal_notes__absent",
-                filter=Q(
-                    personal_notes__absent=True,
-                    personal_notes__excused=False,
-                    personal_notes__lesson_period__lesson__validity__school_term=current_school_term,
-                ),
-            ),
-            tardiness=Sum("personal_notes__late"),
-        )
+    persons = Person.objects.prefetch_related(
+        "personal_notes",
+        "personal_notes__excuse_type",
+        "personal_notes__extra_marks",
+        "personal_notes__lesson_period__lesson__subject",
+        "personal_notes__lesson_period__substitutions",
+        "personal_notes__lesson_period__substitutions__subject",
+        "personal_notes__lesson_period__substitutions__teachers",
+        "personal_notes__lesson_period__lesson__teachers",
+        "personal_notes__lesson_period__period",
     )
-
-    for extra_mark in ExtraMark.objects.all():
-        persons = persons.annotate(
-            **{
-                extra_mark.count_label: Count(
-                    "personal_notes",
-                    filter=Q(
-                        personal_notes__extra_marks=extra_mark,
-                        personal_notes__lesson_period__lesson__validity__school_term=current_school_term,
-                    ),
-                )
-            }
-        )
+    persons = group.generate_person_list_with_class_register_statistics(persons)
 
     for excuse_type in ExcuseType.objects.all():
         persons = persons.annotate(
@@ -535,19 +490,52 @@ def full_register_group(request: HttpRequest, id_: int) -> HttpResponse:
 @permission_required("alsijil.view_my_students")
 def my_students(request: HttpRequest) -> HttpResponse:
     context = {}
-    relevant_groups = request.user.person.get_owner_groups_with_lessons()
-    persons = Person.objects.filter(member_of__in=relevant_groups)
-    context["persons"] = persons
+    relevant_groups = (
+        request.user.person.get_owner_groups_with_lessons()
+        .filter(members__isnull=False)
+        .prefetch_related("members")
+        .distinct()
+    )
+    relevant_groups = sorted(
+        relevant_groups, key=lambda g: g.name if g.parent_groups else "0"
+    )
+
+    new_groups = []
+    for group in relevant_groups:
+        persons = group.generate_person_list_with_class_register_statistics()
+        new_groups.append((group, persons))
+
+    context["groups"] = new_groups
+    context["excuse_types"] = ExcuseType.objects.all()
+    context["extra_marks"] = ExtraMark.objects.all()
     return render(request, "alsijil/class_register/persons.html", context)
 
 
 @permission_required("alsijil.view_my_groups",)
 def my_groups(request: HttpRequest) -> HttpResponse:
     context = {}
-    context["groups"] = request.user.person.get_owner_groups_with_lessons()
+    context["groups"] = request.user.person.get_owner_groups_with_lessons().annotate(
+        students_count=Count("members")
+    )
     return render(request, "alsijil/class_register/groups.html", context)
 
 
+class StudentsList(PermissionRequiredMixin, DetailView):
+    model = Group
+    template_name = "alsijil/class_register/students_list.html"
+    permission_required = "alsijil.view_students_list"
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["group"] = self.object
+        context[
+            "persons"
+        ] = self.object.generate_person_list_with_class_register_statistics()
+        context["extra_marks"] = ExtraMark.objects.all()
+        context["excuse_types"] = ExcuseType.objects.all()
+        return context
+
+
 @permission_required(
     "alsijil.view_person_overview",
     fn=objectgetter_optional(Person, "request.user.person", True),
-- 
GitLab