diff --git a/aleksis/apps/alsijil/managers.py b/aleksis/apps/alsijil/managers.py index 681e3e562be5f8b26c4fd31fb543dcadbbe5fb29..6f097bc626f2de506423af20ba943445acb10c9d 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 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 f337be4f836c8f73383e952c88f79e229613e02a..975b8930e3e184aba8523795a2d86d3b77cd468e 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 71de0d315e6a2d7fbb66b99d5298d21b52502429..355396dcb91fdb1436df8ec0b62d78dadde87066 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 6c91c34730b10116e190b4892bb26415a4ceed20..f8cd4eaddfe981f022e39dd466899b5f7fe733de 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 ab626b7dd389ae6fa77732f5dd34328b31f3946a..95fb8e8f3977623346aa637d6f4986a1e85029bc 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 0000000000000000000000000000000000000000..ee7cb3057dcf6cb564e8449dfb70adf50dcf1cd2 --- /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