diff --git a/aleksis/apps/alsijil/forms.py b/aleksis/apps/alsijil/forms.py index d518f49c61be2f840547813b6ba57b0a2a603cc2..228e825bae4e1170a3327629b6c8a973c192f7c0 100644 --- a/aleksis/apps/alsijil/forms.py +++ b/aleksis/apps/alsijil/forms.py @@ -5,16 +5,24 @@ from django.core.exceptions import ValidationError from django.db.models import Count, Q from django.utils.translation import gettext_lazy as _ -from django_select2.forms import Select2Widget +from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget, Select2Widget from guardian.shortcuts import get_objects_for_user from material import Fieldset, Layout, Row from aleksis.apps.chronos.managers import TimetableType from aleksis.apps.chronos.models import TimePeriod from aleksis.core.models import Group, Person +from aleksis.core.util.core_helpers import get_site_preferences from aleksis.core.util.predicates import check_global_permission -from .models import ExcuseType, ExtraMark, LessonDocumentation, PersonalNote +from .models import ( + ExcuseType, + ExtraMark, + GroupRole, + GroupRoleAssignment, + LessonDocumentation, + PersonalNote, +) class LessonDocumentationForm(forms.ModelForm): @@ -160,3 +168,89 @@ class ExcuseTypeForm(forms.ModelForm): class Meta: model = ExcuseType fields = ["short_name", "name"] + + +class GroupRoleForm(forms.ModelForm): + layout = Layout("name", "icon", "colour") + + class Meta: + model = GroupRole + fields = ["name", "icon", "colour"] + + +class AssignGroupRoleForm(forms.ModelForm): + layout_base = ["groups", "person", "role", Row("date_start", "date_end")] + + groups = forms.ModelMultipleChoiceField( + label=_("Group"), + required=True, + queryset=Group.objects.all(), + widget=ModelSelect2MultipleWidget( + model=Group, + search_fields=["name__icontains", "short_name__icontains"], + attrs={"data-minimum-input-length": 0, "class": "browser-default",}, + ), + ) + person = forms.ModelChoiceField( + label=_("Person"), + required=True, + queryset=Person.objects.all(), + widget=ModelSelect2Widget( + model=Person, + search_fields=[ + "first_name__icontains", + "last_name__icontains", + "short_name__icontains", + ], + attrs={"data-minimum-input-length": 0, "class": "browser-default"}, + ), + ) + + def __init__(self, request, *args, **kwargs): + self.request = request + initial = kwargs.get("initial", {}) + + # Build layout with or without groups field + base_layout = self.layout_base[:] + if "groups" in initial: + base_layout.remove("groups") + self.layout = Layout(*base_layout) + + super().__init__(*args, **kwargs) + + if "groups" in initial: + self.fields["groups"].required = False + + # Filter persons and groups by permissions + if not self.request.user.has_perm("alsijil.assign_grouprole"): # Global permission + persons = Person.objects + if initial.get("groups"): + persons = persons.filter(member_of__in=initial["groups"]) + if get_site_preferences()["alsijil__group_owners_can_assign_roles_to_parents"]: + persons = persons.filter( + Q(member_of__owners=self.request.user.person) + | Q(children__member_of__owners=self.request.user.person) + ) + else: + persons = persons.filter(member_of__owners=self.request.user.person) + self.fields["person"].queryset = persons + + if "groups" not in initial: + groups = Group.objects.for_current_school_term_or_all().filter( + owners=self.request.user.person + ) + self.fields["groups"].queryset = groups + + def clean_groups(self): + """Ensure that only permitted groups are used.""" + return self.initial["groups"] if "groups" in self.initial else self.cleaned_data["groups"] + + class Meta: + model = GroupRoleAssignment + fields = ["groups", "person", "role", "date_start", "date_end"] + + +class GroupRoleAssignmentEditForm(forms.ModelForm): + class Meta: + model = GroupRoleAssignment + fields = ["date_start", "date_end"] diff --git a/aleksis/apps/alsijil/managers.py b/aleksis/apps/alsijil/managers.py index 7350cf375a12205b7ebe56b9d5fd57bd045a2477..862e33a6d6a1cf0c8893359a65cb9a8ea6440da5 100644 --- a/aleksis/apps/alsijil/managers.py +++ b/aleksis/apps/alsijil/managers.py @@ -1,6 +1,13 @@ +from datetime import date, datetime +from typing import Optional, Sequence, Union + from django.db.models import QuerySet +from django.db.models.query import Prefetch from django.db.models.query_utils import Q +from calendarweek import CalendarWeek + +from aleksis.apps.chronos.managers import DateRangeQuerySetMixin from aleksis.core.managers import CurrentSiteManagerWithoutMigrations @@ -42,3 +49,51 @@ class LessonDocumentationQuerySet(QuerySet): def not_empty(self): """Get all not empty lesson documentations.""" return self.filter(~Q(topic="") | ~Q(group_note="") | ~Q(homework="")) + + +class GroupRoleManager(CurrentSiteManagerWithoutMigrations): + pass + + +class GroupRoleQuerySet(QuerySet): + def with_assignments( + self, time_ref: Union[date, CalendarWeek], groups: Sequence["Group"] + ) -> QuerySet: + from aleksis.apps.alsijil.models import GroupRoleAssignment + + if isinstance(time_ref, CalendarWeek): + qs = GroupRoleAssignment.objects.in_week(time_ref) + else: + qs = GroupRoleAssignment.objects.on_day(time_ref) + + qs = qs.for_groups(groups).distinct() + return self.prefetch_related(Prefetch("assignments", queryset=qs,)) + + +class GroupRoleAssignmentManager(CurrentSiteManagerWithoutMigrations): + pass + + +class GroupRoleAssignmentQuerySet(DateRangeQuerySetMixin, QuerySet): + def within_dates(self, start: date, end: date): + """Filter for all role assignments within a date range.""" + return self.filter( + Q(date_start__lte=end) & (Q(date_end__gte=start) | Q(date_end__isnull=True)) + ) + + def at_time(self, when: Optional[datetime] = None): + """Filter for role assignments assigned at a certain point in time.""" + now = when or datetime.now() + + return self.on_day(now.date()) + + def for_groups(self, groups: Sequence["Group"]): + """Filter all role assignments for a sequence of groups.""" + qs = self + for group in groups: + qs = qs.for_group(group) + return qs + + def for_group(self, group: "Group"): + """Filter all role assignments for a group.""" + return self.filter(Q(groups=group) | Q(groups__child_groups=group)) diff --git a/aleksis/apps/alsijil/menus.py b/aleksis/apps/alsijil/menus.py index f90052a765147b8555840d84f24203b12f09b248..951ee5b8e7d1757bc899542977507026e65a6fa8 100644 --- a/aleksis/apps/alsijil/menus.py +++ b/aleksis/apps/alsijil/menus.py @@ -67,6 +67,17 @@ MENUS = { ), ], }, + { + "name": _("Assign group role"), + "url": "assign_group_role_multiple", + "icon": "assignment_ind", + "validators": [ + ( + "aleksis.core.util.predicates.permission_validator", + "alsijil.assign_grouprole_for_multiple", + ), + ], + }, { "name": _("Excuse types"), "url": "excuse_types", @@ -89,6 +100,17 @@ MENUS = { ), ], }, + { + "name": _("Manage group roles"), + "url": "group_roles", + "icon": "assignment_ind", + "validators": [ + ( + "aleksis.core.util.predicates.permission_validator", + "alsijil.view_grouproles", + ), + ], + }, ], } ] diff --git a/aleksis/apps/alsijil/migrations/0009_group_roles.py b/aleksis/apps/alsijil/migrations/0009_group_roles.py new file mode 100644 index 0000000000000000000000000000000000000000..ec8a061f05353e080a6b343eb7fbaa78f73b8146 --- /dev/null +++ b/aleksis/apps/alsijil/migrations/0009_group_roles.py @@ -0,0 +1,52 @@ +# Generated by Django 3.1.5 on 2021-02-07 15:21 + +import aleksis.apps.chronos.managers +from aleksis.core.util.model_helpers import ICONS +import colorfield.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_default_dashboard'), + ('sites', '0002_alter_domain_unique'), + ('alsijil', '0008_global_permissions'), + ] + + operations = [ + migrations.CreateModel( + name='GroupRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('icon', models.CharField(blank=True, choices=(lambda: ICONS)(), max_length=50, verbose_name='Icon')), + ('colour', colorfield.fields.ColorField(blank=True, default='', max_length=18, verbose_name='Colour')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'verbose_name': 'Group role', + 'verbose_name_plural': 'Group roles', + }, + ), + migrations.CreateModel( + name='GroupRoleAssignment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('date_start', models.DateField(verbose_name='Start date')), + ('date_end', models.DateField(blank=True, help_text='Can be left empty if end date is not clear yet', null=True, verbose_name='End date')), + ('groups', models.ManyToManyField(related_name='group_roles', to='core.Group', verbose_name='Groups')), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_roles', to='core.person', verbose_name='Assigned person')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='alsijil.grouprole', verbose_name='Group role')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'verbose_name': 'Group role assignment', + 'verbose_name_plural': 'Group role assignments', + }, + bases=(aleksis.apps.chronos.managers.GroupPropertiesMixin, models.Model), + ), + ] diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index 5a41074b78c25e98ea2daecb94bd769ed647d694..a0447a17423e487a68dbf57a80cc9ca15b0a54cc 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -4,6 +4,7 @@ from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _ from calendarweek import CalendarWeek +from colorfield.fields import ColorField from aleksis.apps.alsijil.data_checks import ( ExcusesWithoutAbsences, @@ -13,16 +14,22 @@ from aleksis.apps.alsijil.data_checks import ( PersonalNoteOnHolidaysDataCheck, ) from aleksis.apps.alsijil.managers import ( + GroupRoleAssignmentManager, + GroupRoleAssignmentQuerySet, + GroupRoleManager, + GroupRoleQuerySet, LessonDocumentationManager, LessonDocumentationQuerySet, PersonalNoteManager, PersonalNoteQuerySet, ) +from aleksis.apps.chronos.managers import GroupPropertiesMixin from aleksis.apps.chronos.mixins import WeekRelatedMixin from aleksis.apps.chronos.models import LessonPeriod from aleksis.apps.chronos.util.date import get_current_year from aleksis.core.mixins import ExtensibleModel from aleksis.core.util.core_helpers import get_site_preferences +from aleksis.core.util.model_helpers import ICONS def isidentifier(value: str) -> bool: @@ -239,6 +246,63 @@ class ExtraMark(ExtensibleModel): verbose_name_plural = _("Extra marks") +class GroupRole(ExtensibleModel): + objects = GroupRoleManager.from_queryset(GroupRoleQuerySet)() + + name = models.CharField(max_length=255, verbose_name=_("Name")) + icon = models.CharField(max_length=50, blank=True, choices=ICONS, verbose_name=_("Icon")) + colour = ColorField(blank=True, verbose_name=_("Colour")) + + def __str__(self): + return self.name + + class Meta: + verbose_name = _("Group role") + verbose_name_plural = _("Group roles") + + +class GroupRoleAssignment(GroupPropertiesMixin, ExtensibleModel): + objects = GroupRoleAssignmentManager.from_queryset(GroupRoleAssignmentQuerySet)() + + role = models.ForeignKey( + GroupRole, + on_delete=models.CASCADE, + related_name="assignments", + verbose_name=_("Group role"), + ) + person = models.ForeignKey( + "core.Person", + on_delete=models.CASCADE, + related_name="group_roles", + verbose_name=_("Assigned person"), + ) + groups = models.ManyToManyField( + "core.Group", related_name="group_roles", verbose_name=_("Groups"), + ) + date_start = models.DateField(verbose_name=_("Start date")) + date_end = models.DateField( + blank=True, + null=True, + verbose_name=_("End date"), + help_text=_("Can be left empty if end date is not clear yet"), + ) + + def __str__(self): + date_end = date_format(self.date_end) if self.date_end else "?" + return f"{self.role}: {self.person}, {date_format(self.date_start)}–{date_end}" + + @property + def date_range(self) -> str: + if not self.date_end: + return f"{date_format(self.date_start)}–?" + else: + return f"{date_format(self.date_start)}–{date_format(self.date_end)}" + + class Meta: + verbose_name = _("Group role assignment") + verbose_name_plural = _("Group role assignments") + + class AlsijilGlobalPermissions(ExtensibleModel): class Meta: managed = False diff --git a/aleksis/apps/alsijil/preferences.py b/aleksis/apps/alsijil/preferences.py index bd51c03e87ed612fb6ae4d865f5b911448241bb8..98cdcc7104271ed1ee9d5e8a8313cd87cee57bc8 100644 --- a/aleksis/apps/alsijil/preferences.py +++ b/aleksis/apps/alsijil/preferences.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext as _ from dynamic_preferences.preferences import Section from dynamic_preferences.types import BooleanPreference -from aleksis.core.registries import site_preferences_registry +from aleksis.core.registries import person_preferences_registry, site_preferences_registry alsijil = Section("alsijil", verbose_name=_("Class register")) @@ -74,3 +74,30 @@ class AllowEntriesInHolidays(BooleanPreference): name = "allow_entries_in_holidays" default = False verbose_name = _("Allow teachers to add data for lessons in holidays") + + +@site_preferences_registry.register +class GroupOwnersCanAssignRolesToParents(BooleanPreference): + section = alsijil + name = "group_owners_can_assign_roles_to_parents" + default = False + verbose_name = _( + "Allow group owners to assign group roles to the parents of the group's members" + ) + + +@person_preferences_registry.register +class ShowGroupRolesInWeekView(BooleanPreference): + section = alsijil + name = "group_roles_in_week_view" + default = True + verbose_name = _("Show assigned group roles in week view") + help_text = _("Only week view of groups") + + +@person_preferences_registry.register +class ShowGroupRolesInLessonView(BooleanPreference): + section = alsijil + name = "group_roles_in_lesson_view" + default = True + verbose_name = _("Show assigned group roles in lesson view") diff --git a/aleksis/apps/alsijil/rules.py b/aleksis/apps/alsijil/rules.py index f553c2773ef6e96aab87b585d01c389399cecd50..6f2a8d614aab4ba0f8a5bb1d83fcfd7b6c0fd4f5 100644 --- a/aleksis/apps/alsijil/rules.py +++ b/aleksis/apps/alsijil/rules.py @@ -14,11 +14,13 @@ from .util.predicates import ( has_personal_note_group_perm, is_group_member, is_group_owner, + is_group_role_assignment_group_owner, is_lesson_parent_group_owner, is_lesson_participant, is_lesson_teacher, is_none, is_own_personal_note, + is_owner_of_any_group, is_person_group_owner, is_person_primary_group_owner, is_personal_note_lesson_parent_group_owner, @@ -218,3 +220,56 @@ add_perm("alsijil.edit_extramark", edit_extramark_predicate) # Delete extra mark delete_extramark_predicate = view_extramarks_predicate & has_global_perm("alsijil.delete_extramark") add_perm("alsijil.delete_extramark", delete_extramark_predicate) + +# View group role list +view_group_roles_predicate = has_global_perm("alsijil.view_grouprole") +add_perm("alsijil.view_grouproles", view_group_roles_predicate) + +# Add group role +add_group_role_predicate = view_group_roles_predicate & has_global_perm("alsijil.add_grouprole") +add_perm("alsijil.add_grouprole", add_group_role_predicate) + +# Edit group role +edit_group_role_predicate = view_group_roles_predicate & has_global_perm("alsijil.change_grouprole") +add_perm("alsijil.edit_grouprole", edit_group_role_predicate) + +# Delete group role +delete_group_role_predicate = view_group_roles_predicate & has_global_perm( + "alsijil.delete_grouprole" +) +add_perm("alsijil.delete_grouprole", delete_group_role_predicate) + +view_assigned_group_roles_predicate = ( + is_group_owner + | is_lesson_teacher + | is_lesson_parent_group_owner + | has_global_perm("alsjil.assign_grouprole") + | has_object_perm("alsijil.assign_grouprole") +) +add_perm("alsijil.view_assigned_grouproles", view_assigned_group_roles_predicate) + +assign_group_role_person_predicate = is_person_group_owner | has_global_perm( + "alsjil.assign_grouprole" +) +add_perm("alsijil.assign_grouprole_to_person", assign_group_role_person_predicate) + +assign_group_role_for_multiple_predicate = is_owner_of_any_group | has_global_perm( + "alsjil.assign_grouprole" +) +add_perm("alsijil.assign_grouprole_for_multiple", assign_group_role_for_multiple_predicate) + +assign_group_role_group_predicate = view_assigned_group_roles_predicate +add_perm("alsijil.assign_grouprole_for_group", assign_group_role_group_predicate) + +edit_group_role_assignment_predicate = ( + has_global_perm("alsjil.assign_grouprole") | is_group_role_assignment_group_owner +) +add_perm("alsijil.edit_grouproleassignment", edit_group_role_assignment_predicate) + +stop_group_role_assignment_predicate = edit_group_role_assignment_predicate +add_perm("alsijil.stop_grouproleassignment", stop_group_role_assignment_predicate) + +delete_group_role_assignment_predicate = ( + has_global_perm("alsjil.assign_grouprole") | is_group_role_assignment_group_owner +) +add_perm("alsijil.delete_grouproleassignment", delete_group_role_assignment_predicate) diff --git a/aleksis/apps/alsijil/tables.py b/aleksis/apps/alsijil/tables.py index b9a8e68404d6b2672dfbb37d2433e55cad08cf08..c3835e973164f5a6cd3b50540fa7b9f5a3d4ea43 100644 --- a/aleksis/apps/alsijil/tables.py +++ b/aleksis/apps/alsijil/tables.py @@ -1,3 +1,4 @@ +from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ import django_tables2 as tables @@ -48,3 +49,32 @@ class ExcuseTypeTable(tables.Table): self.columns.hide("edit") if not request.user.has_perm("alsijil.delete_excusetype"): self.columns.hide("delete") + + +class GroupRoleTable(tables.Table): + class Meta: + attrs = {"class": "highlight"} + + name = tables.LinkColumn("edit_excuse_type", args=[A("id")]) + edit = tables.LinkColumn( + "edit_group_role", + args=[A("id")], + text=_("Edit"), + attrs={"a": {"class": "btn-flat waves-effect waves-orange orange-text"}}, + ) + delete = tables.LinkColumn( + "delete_group_role", + args=[A("id")], + text=_("Delete"), + attrs={"a": {"class": "btn-flat waves-effect waves-red red-text"}}, + ) + + def render_name(self, value, record): + context = dict(role=record) + return render_to_string("alsijil/group_role/chip.html", context) + + def before_render(self, request): + if not request.user.has_perm("alsijil.edit_grouprole"): + self.columns.hide("edit") + if not request.user.has_perm("alsijil.delete_grouprole"): + self.columns.hide("delete") diff --git a/aleksis/apps/alsijil/templates/alsijil/class_register/groups.html b/aleksis/apps/alsijil/templates/alsijil/class_register/groups.html index a2bf6799df9a4f9390fd1a912d5eb9dab51c3cef..f300f3b8457eed468cb3bad31821eb8d94033cf8 100644 --- a/aleksis/apps/alsijil/templates/alsijil/class_register/groups.html +++ b/aleksis/apps/alsijil/templates/alsijil/class_register/groups.html @@ -1,6 +1,6 @@ {# -*- engine:django -*- #} {% extends "core/base.html" %} -{% load i18n static %} +{% load i18n static rules %} {% block browser_title %}{% blocktrans %}My groups{% endblocktrans %}{% endblock %} @@ -38,6 +38,13 @@ <i class="material-icons left">view_week</i> {% trans "Week view" %} </a> + {% has_perm "alsijil.view_assigned_grouproles" user group as can_view_assigned_group_roles %} + {% if can_view_assigned_group_roles %} + <a class="btn primary waves-effect waves-light" href="{% url 'assigned_group_roles' group.pk %}"> + <i class="material-icons left">assignment_ind</i> + {% trans "Roles" %} + </a> + {% endif %} <a class="btn primary waves-effect waves-light" href="{% url "full_register_group" group.pk %}" target="_blank"> <i class="material-icons left">print</i> @@ -75,6 +82,15 @@ {% trans "Week view" %} </a> </p> + {% has_perm "alsijil.view_assigned_grouproles" user group as can_view_assigned_group_roles %} + {% if can_view_assigned_group_roles %} + <p> + <a class="btn primary waves-effect waves-light" href="{% url 'assigned_group_roles' group.pk %}"> + <i class="material-icons left">assignment_ind</i> + {% trans "Roles" %} + </a> + </p> + {% endif %} <p> <a class="btn primary waves-effect waves-light" href="{% url "full_register_group" group.pk %}" target="_blank"> diff --git a/aleksis/apps/alsijil/templates/alsijil/class_register/lesson.html b/aleksis/apps/alsijil/templates/alsijil/class_register/lesson.html index 23c96614dba54513c8c8dc4d033fd10cdc2665d6..f04e06fc317d58d202501ff09ff38a76f1c85e1a 100644 --- a/aleksis/apps/alsijil/templates/alsijil/class_register/lesson.html +++ b/aleksis/apps/alsijil/templates/alsijil/class_register/lesson.html @@ -111,6 +111,11 @@ <a href="#previous-lesson">{% trans "Previous lesson" %}</a> </li> {% endif %} + {% if group_roles %} + <li class="tab"> + <a href="#group-roles">{% trans "Group roles" %}</a> + </li> + {% endif %} <li class="tab"> <a href="#version-history">{% trans "Change history" %}</a> </li> @@ -257,7 +262,13 @@ {% if can_edit_lesson_personalnote %} <tr> {{ form.id }} - <td>{{ form.person_name }}{{ form.person_name.value }}</td> + <td>{{ form.person_name }}{{ form.person_name.value }} + <p> + {% for assignment in form.instance.person.group_roles.all %} + {% include "alsijil/group_role/chip.html" with role=assignment.role %} + {% endfor %} + </p> + </td> <td class="center-align"> <label> {{ form.absent }} @@ -310,7 +321,13 @@ </tr> {% else %} <tr> - <td>{{ form.person_name.value }}</td> + <td>{{ form.person_name.value }} + <p> + {% for assignment in form.instance.person.group_roles.all %} + {% include "alsijil/group_role/chip.html" with role=assignment.role %} + {% endfor %} + </p> + </td> <td><i class="material-icons center">{{ form.absent.value|yesno:"check,clear" }}</i></td> <td> <i class="material-icons center">{{ form.late.value|yesno:"check,clear" }}</i> @@ -338,6 +355,12 @@ </div> {% endif %} + {% if group_roles %} + <div class="col s12" id="group-roles"> + {% include "alsijil/group_role/partials/assigned_roles.html" with roles=group_roles group=lesson_period.lesson.groups.first back_url=back_url %} + </div> + {% endif %} + {% if can_view_lesson_documentation %} <div class="col s12" id="version-history"> <div class="card"> 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 021f227eb261ed757200b485660ee0cd9f23b5c5..24780eba16318c05d925d57aed87637caa9c2a85 100644 --- a/aleksis/apps/alsijil/templates/alsijil/class_register/week_view.html +++ b/aleksis/apps/alsijil/templates/alsijil/class_register/week_view.html @@ -64,12 +64,17 @@ <div class="row"> <div class="col s12"> <ul class="tabs"> - <li class="tab col s6"> + <li class="tab col"> <a class="active" href="#week-overview">{% trans "Lesson documentations" %}</a> </li> - <li class="tab col s6"> - <a class="active" href="#personal-notes">{% trans "Personal notes" %}</a> + <li class="tab col"> + <a href="#personal-notes">{% trans "Personal notes" %}</a> </li> + {% if group_roles %} + <li class="tab col"> + <a href="#group-roles">{% trans "Group roles" %}</a> + </li> + {% endif %} </ul> </div> <div class="col s12" id="week-overview"> @@ -283,6 +288,11 @@ </a> {% endif %} </h5> + <p> + {% for assignment in person.person.group_roles.all %} + {% include "alsijil/group_role/chip.html" with role=assignment.role small=assignment.date_range %} + {% endfor %} + </p> <p class="card-text"> {% trans "Absent" %}: {{ person.person.absences_count }} ({{ person.person.unexcused_count }} {% trans "unexcused" %}) @@ -315,6 +325,11 @@ </div> </div> </div> + {% if group_roles %} + <div class="col s12" id="group-roles"> + {% include "alsijil/group_role/partials/assigned_roles.html" with roles=group_roles group=group back_url=back_url %} + </div> + {% endif %} </div> {% else %} <div class="card red darken-1"> diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/assign.html b/aleksis/apps/alsijil/templates/alsijil/group_role/assign.html new file mode 100644 index 0000000000000000000000000000000000000000..a4a5ac137179ea12278d16029c06428221c0ef25 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/assign.html @@ -0,0 +1,41 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n rules any_js material_form %} + +{% block browser_title %} + {% if group %} + {% blocktrans with group=group.name %}Assign group role for {{ group }}{% endblocktrans %} + {% else %} + {% trans "Assign group role" %} + {% endif %} +{% endblock %} +{% block page_title %} + {% if group %} + {% blocktrans with group=group.name %}Assign group role for {{ group }}{% endblocktrans %} + {% else %} + {% trans "Assign group role" %} + {% endif %} +{% endblock %} + +{% block extra_head %} + {{ form.media.css }} + {% include_css "select2-materialize" %} +{% endblock %} + +{% block content %} + <form action="" method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + + <button type="submit" class="btn green waves-effect waves-light"> + <i class="material-icons left">assignment_ind</i> + {% trans "Assign" %} + </button> + </form> + + {% include_js "select2-materialize" %} + {{ form.media.js }} +{% endblock %} + diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/assigned_list.html b/aleksis/apps/alsijil/templates/alsijil/group_role/assigned_list.html new file mode 100644 index 0000000000000000000000000000000000000000..087dffaaf7151272b97c71e60ca178bc994547a3 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/assigned_list.html @@ -0,0 +1,93 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n rules any_js material_form static %} +{% load render_table from django_tables2 %} + +{% block browser_title %} + {% blocktrans with group=object.name %}Group roles for {{ group }}{% endblocktrans %} +{% endblock %} +{% block page_title %} + {% blocktrans with group=object.name %}Group roles for {{ group }}{% endblocktrans %} +{% endblock %} + +{% block extra_head %} + {{ block.super }} + <link rel="stylesheet" href="{% static "css/alsijil/alsijil.css" %}"/> +{% endblock %} + +{% block content %} + {% url "assigned_group_roles" object.pk as back_url %} + + <p> + {% has_perm "alsijil.view_my_groups" user as can_view_group_overview %} + {% if can_view_group_overview %} + <a class="btn waves-effect waves-light" href="{% url "my_groups" %}"> + <i class="material-icons left">arrow_back</i> + {% trans "Back to my groups" %} + </a> + {% endif %} + + {% has_perm "alsijil.assign_grouprole_for_group" user object as can_assign_group_role %} + {% if can_assign_group_role %} + <a class="btn green waves-effect waves-light" href="{% url "assign_group_role" object.pk %}"> + <i class="material-icons left">assignment_ind</i> + {% trans "Assign a role to a person" %} + </a> + {% endif %} + </p> + + <div class="row"> + <div class="col s12"> + <ul class="tabs"> + <li class="tab"> + <a class="active" href="#current">{% trans "Current roles" %} ({{ today|date:"SHORT_DATE_FORMAT" }})</a> + </li> + <li class="tab"> + <a href="#all">{% trans "All assignments" %}</a> + </li> + </ul> + </div> + + <div id="current" class="col s12"> + {% include "alsijil/group_role/partials/assigned_roles.html" with roles=roles group=object back_url=back_url %} + </div> + + + <div class="col s12 " id="all"> + <table class="responsive-table"> + <thead> + <tr> + <th class="chip-height">{% trans "Group role" %}</th> + <th>{% trans "Person" %}</th> + <th>{% trans "Start date" %}</th> + <th>{% trans "End date" %}</th> + <th>{% trans "Actions" %}</th> + </tr> + </thead> + {% for assignment in assignments %} + <tr> + <td> + {% include "alsijil/group_role/chip.html" with role=assignment.role %} + </td> + <td> + {{ assignment.person }} + </td> + <td>{{ assignment.date_start }}</td> + <td>{{ assignment.date_end|default:"–" }}</td> + <td> + <a class="btn waves-effect waves-light dropdown-trigger" href="#" + data-target="dropdown-{{ assignment.pk }}-d2"> + <i class="material-icons left">list</i> + {% trans "Actions" %} + </a> + {% include "alsijil/group_role/partials/assignment_options.html" with assignment=assignment back_url=back_url suffix="-d2" %} + </td> + </tr> + {% endfor %} + </table> + </div> + </div> +{% endblock %} + diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/chip.html b/aleksis/apps/alsijil/templates/alsijil/group_role/chip.html new file mode 100644 index 0000000000000000000000000000000000000000..530ffa05b531acd34bfa7726b374e56ee703298b --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/chip.html @@ -0,0 +1,9 @@ +{# -*- engine:django -*- #} + +<div class="chip white-text" style="background-color: {{ role.colour|default:"black" }};"> + <i class="material-icons left">{{ role.icon|default:"assignment_ind" }}</i> + {{ role.name }} + {% if small %} + <small>({{ small }})</small> + {% endif %} +</div> \ No newline at end of file diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/create.html b/aleksis/apps/alsijil/templates/alsijil/group_role/create.html new file mode 100644 index 0000000000000000000000000000000000000000..5f83f575f37c43431cbaac9f75ee1b511755ad04 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/create.html @@ -0,0 +1,15 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} +{% load material_form i18n %} + +{% block browser_title %}{% blocktrans %}Create group role{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Create group role{% endblocktrans %}{% endblock %} + +{% block content %} + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% include "core/partials/save_button.html" %} + </form> +{% endblock %} diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/edit.html b/aleksis/apps/alsijil/templates/alsijil/group_role/edit.html new file mode 100644 index 0000000000000000000000000000000000000000..2e19c9cafe6ca7e31785d0345204dfe0785d90ec --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/edit.html @@ -0,0 +1,18 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} +{% load material_form i18n %} + +{% block browser_title %}{% blocktrans %}Edit group role{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Edit group role{% endblocktrans %}{% endblock %} + +{% block content %} + + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% include "core/partials/save_button.html" %} + </form> + + {{ form.media.js }} +{% endblock %} diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/edit_assignment.html b/aleksis/apps/alsijil/templates/alsijil/group_role/edit_assignment.html new file mode 100644 index 0000000000000000000000000000000000000000..bc5038654980374fcd0c65639b7c4d3488871625 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/edit_assignment.html @@ -0,0 +1,18 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n rules material_form %} + +{% block browser_title %}{% blocktrans %}Edit group role assignment{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Edit group role assignment{% endblocktrans %}{% endblock %} + +{% block content %} + <form action="" method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + + {% include "core/partials/save_button.html" %} + </form> +{% endblock %} + diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/list.html b/aleksis/apps/alsijil/templates/alsijil/group_role/list.html new file mode 100644 index 0000000000000000000000000000000000000000..f3e4a487edfcec8761f978f89b25640b7580da40 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/list.html @@ -0,0 +1,22 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n rules %} +{% load render_table from django_tables2 %} + +{% block browser_title %}{% blocktrans %}Group roles{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Group roles{% endblocktrans %}{% endblock %} + +{% block content %} + {% has_perm "alsijil.add_grouprole" user as add_group_role %} + {% if add_group_role %} + <a class="btn green waves-effect waves-light" href="{% url 'create_group_role' %}"> + <i class="material-icons left">add</i> + {% trans "Create group role" %} + </a> + {% endif %} + + {% render_table table %} +{% endblock %} + diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/partials/assigned_roles.html b/aleksis/apps/alsijil/templates/alsijil/group_role/partials/assigned_roles.html new file mode 100644 index 0000000000000000000000000000000000000000..bc8c16f022b528cd666b3056d61317ad7fd297d8 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/partials/assigned_roles.html @@ -0,0 +1,47 @@ +{% load i18n rules %} +{% has_perm "alsijil.assign_grouprole_for_group" user group as can_assign_group_role %} +<div class="collection"> + {% for role in roles %} + <div class="collection-item"> + <div class="row no-margin"> + <div class="col s12 m5 l4 xl3 no-padding"> + {% if can_assign_group_role %} + <a class="btn waves-effect waves-light right hide-on-med-and-up" + href="{% url "assign_group_role" group.pk role.pk %}?next={{ back_url }}"> + <i class="material-icons center">add</i> + </a> + {% endif %} + + <div class="btn-margin"> + {% include "alsijil/group_role/chip.html" with role=role %} + </div> + </div> + + <div class="col s12 m7 l8 xl9 no-padding"> + {% if can_assign_group_role %} + <a class="btn waves-effect waves-light right hide-on-small-only" + href="{% url "assign_group_role" group.pk role.pk %}?next={{ back_url }}"> + <i class="material-icons center">add</i> + </a> + {% endif %} + + {% for assignment in role.assignments.all %} + {% include "alsijil/group_role/partials/assignment.html" with assignment=assignment group=group back_url=back_url %} + {% empty %} + <div class="grey-text darken-3">{% trans "No one assigned." %}</div> + {% endfor %} + </div> + </div> + </div> + {% endfor %} +</div> + +<div class="alert primary"> + <div> + <i class="material-icons left">info</i> + {% blocktrans %} + You can get some additional actions for each group role assignment if you click on the name of the + corresponding person. + {% endblocktrans %} + </div> +</div> \ No newline at end of file diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/partials/assignment.html b/aleksis/apps/alsijil/templates/alsijil/group_role/partials/assignment.html new file mode 100644 index 0000000000000000000000000000000000000000..673e05f2ccca7f23b2a890ce9f403b569eccfbe4 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/partials/assignment.html @@ -0,0 +1,8 @@ +<a class="chip dropdown-trigger" href="#" + data-target="dropdown-{{ assignment.pk }}" title="{{ assignment }}">{{ assignment.person }} + {% if group not in assignment.groups.all %} + <small>({{ assignment.group_names }})</small> + {% endif %} +</a> + +{% include "alsijil/group_role/partials/assignment_options.html" with assignment=assignment back_url=back_url %} \ No newline at end of file diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/partials/assignment_options.html b/aleksis/apps/alsijil/templates/alsijil/group_role/partials/assignment_options.html new file mode 100644 index 0000000000000000000000000000000000000000..ff50720db9dacd5437fb4b43ecf19e9719138de3 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/partials/assignment_options.html @@ -0,0 +1,33 @@ +{# -*- engine:django -*- #} + +{% load i18n rules %} + +{% has_perm "alsijil.edit_grouproleassignment" user assignment as can_edit %} +{% has_perm "alsijil.stop_grouproleassignment" user assignment as can_stop %} +{% has_perm "alsijil.delete_grouproleassignment" user assignment as can_delete %} + +<ul id="dropdown-{{ assignment.pk }}{{ suffix }}" class="dropdown-content"> + {% if can_edit %} + <li> + <a href="{% url "edit_group_role_assignment" assignment.pk %}?next={{ back_url }}"> + <i class="material-icons left">edit</i> {% trans "Edit" %} + </a> + </li> + {% endif %} + + {% if not assignment.date_end and can_stop %} + <li> + <a href="#"> + <i class="material-icons left">stop</i> {% trans "Stop" %} + </a> + </li> + {% endif %} + + {% if can_delete %} + <li> + <a href="{% url "delete_group_role_assignment" assignment.pk %}?next={{ back_url }}" class="red-text"> + <i class="material-icons left">delete</i> {% trans "Delete" %} + </a> + </li> + {% endif %} +</ul> \ No newline at end of file diff --git a/aleksis/apps/alsijil/templates/alsijil/group_role/warning.html b/aleksis/apps/alsijil/templates/alsijil/group_role/warning.html new file mode 100644 index 0000000000000000000000000000000000000000..d90d2e8205b1c91c18e74e02654fde3daebc4971 --- /dev/null +++ b/aleksis/apps/alsijil/templates/alsijil/group_role/warning.html @@ -0,0 +1,10 @@ +{% load i18n %} +<div class="alert warning"> + <p> + <i class="material-icons left">warning</i> + {% blocktrans %} + This function should only be used to define alternatives to the default excuse which also will be counted extra. + Don't use this to create a default excuse or if you don't divide between different types of excuse. + {% endblocktrans %} + </p> +</div> diff --git a/aleksis/apps/alsijil/urls.py b/aleksis/apps/alsijil/urls.py index 673154f496b081b194b95cdde9aeef8464f05519..864c862ae36490c5a7817c7e7ddfce17042930c6 100644 --- a/aleksis/apps/alsijil/urls.py +++ b/aleksis/apps/alsijil/urls.py @@ -49,4 +49,47 @@ urlpatterns = [ views.ExcuseTypeDeleteView.as_view(), name="delete_excuse_type", ), + path("group_roles/", views.GroupRoleListView.as_view(), name="group_roles"), + path("group_roles/create/", views.GroupRoleCreateView.as_view(), name="create_group_role"), + path("group_roles/<int:pk>/edit/", views.GroupRoleEditView.as_view(), name="edit_group_role",), + path( + "group_roles/<int:pk>/delete/", + views.GroupRoleDeleteView.as_view(), + name="delete_group_role", + ), + path( + "groups/<int:pk>/group_roles/", + views.AssignedGroupRolesView.as_view(), + name="assigned_group_roles", + ), + path( + "groups/<int:pk>/group_roles/assign/", + views.AssignGroupRoleView.as_view(), + name="assign_group_role", + ), + path( + "groups/<int:pk>/group_roles/<int:role_pk>/assign/", + views.AssignGroupRoleView.as_view(), + name="assign_group_role", + ), + path( + "group_roles/assignments/<int:pk>/edit/", + views.GroupRoleAssignmentEditView.as_view(), + name="edit_group_role_assignment", + ), + path( + "group_roles/assignments/<int:pk>/stop/", + views.GroupRoleAssignmentStopView.as_view(), + name="stop_group_role_assignment", + ), + path( + "group_roles/assignments/<int:pk>/delete/", + views.GroupRoleAssignmentDeleteView.as_view(), + name="delete_group_role_assignment", + ), + path( + "group_roles/assignments/assign/", + views.AssignGroupRoleMultipleView.as_view(), + name="assign_group_role_multiple", + ), ] diff --git a/aleksis/apps/alsijil/util/predicates.py b/aleksis/apps/alsijil/util/predicates.py index 95c7825c2a1eceeb2aed222260816ec9575ad0a8..e039a3b4232a67db3069cbbe05c79538fd22c027 100644 --- a/aleksis/apps/alsijil/util/predicates.py +++ b/aleksis/apps/alsijil/util/predicates.py @@ -246,3 +246,24 @@ def is_personal_note_lesson_parent_group_owner(user: User, obj: PersonalNote) -> def is_teacher(user: User, obj: Person) -> bool: """Predicate which checks if the provided object is a teacher.""" return user.person.is_teacher + + +@predicate +def is_group_role_assignment_group_owner(user: User, obj: Union[Group, Person]) -> bool: + """Predicate for group owners of a group role assignment. + + Checks whether the person linked to the user is the owner of the groups + linked to the given group role assignment. + If there isn't provided a group role assignment, it will return `False`. + """ + if obj: + for group in obj.groups.all(): + if user.person in list(group.owners.all()): + return True + return False + + +@predicate +def is_owner_of_any_group(user: User, obj): + """Predicate which checks if the person is group owner of any group.""" + return Group.objects.filter(owners=user.person).exists() diff --git a/aleksis/apps/alsijil/views.py b/aleksis/apps/alsijil/views.py index 397019f31564c68a4b24a912d7487ba03d51ad96..91199a3d6eb9a49d8cfd4cf477103f83ecfc2f7a 100644 --- a/aleksis/apps/alsijil/views.py +++ b/aleksis/apps/alsijil/views.py @@ -1,12 +1,13 @@ from contextlib import nullcontext from datetime import date, datetime, timedelta -from typing import Optional +from typing import Any, Dict, Optional from django.core.exceptions import PermissionDenied from django.db.models import Count, Exists, OuterRef, Prefetch, Q, Subquery, Sum from django.http import Http404, HttpRequest, HttpResponse, HttpResponseNotFound from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.translation import ugettext as _ from django.views.decorators.cache import never_cache @@ -22,21 +23,36 @@ from aleksis.apps.chronos.managers import TimetableType from aleksis.apps.chronos.models import Holiday, LessonPeriod, TimePeriod from aleksis.apps.chronos.util.build import build_weekdays from aleksis.apps.chronos.util.date import get_weeks_for_year, week_weekday_to_date -from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView +from aleksis.core.mixins import ( + AdvancedCreateView, + AdvancedDeleteView, + AdvancedEditView, + SuccessNextMixin, +) from aleksis.core.models import Group, Person, SchoolTerm from aleksis.core.util import messages from aleksis.core.util.core_helpers import get_site_preferences, objectgetter_optional from .forms import ( + AssignGroupRoleForm, ExcuseTypeForm, ExtraMarkForm, + GroupRoleAssignmentEditForm, + GroupRoleForm, LessonDocumentationForm, PersonalNoteFormSet, RegisterAbsenceForm, SelectForm, ) -from .models import ExcuseType, ExtraMark, LessonDocumentation, PersonalNote -from .tables import ExcuseTypeTable, ExtraMarkTable +from .models import ( + ExcuseType, + ExtraMark, + GroupRole, + GroupRoleAssignment, + LessonDocumentation, + PersonalNote, +) +from .tables import ExcuseTypeTable, ExtraMarkTable, GroupRoleTable from .util.alsijil_helpers import get_lesson_period_by_pk, get_timetable_instance_by_pk @@ -101,6 +117,11 @@ def lesson( context["blocked_because_holidays"] = blocked_because_holidays context["holiday"] = holiday + back_url = reverse( + "lesson_by_week_and_period", args=[wanted_week.year, wanted_week.week, lesson_period.pk] + ) + context["back_url"] = back_url + next_lesson = request.user.person.next_lesson(lesson_period, date_of_lesson) prev_lesson = request.user.person.previous_lesson(lesson_period, date_of_lesson) @@ -113,6 +134,14 @@ def lesson( context["next_lesson"] = lesson_period.next if not blocked_because_holidays: + # Group roles + show_group_roles = request.user.person.preferences[ + "alsijil__group_roles_in_lesson_view" + ] and request.user.has_perm("alsijil.view_assigned_grouproles", lesson_period) + if show_group_roles: + groups = lesson_period.lesson.groups.all() + group_roles = GroupRole.objects.with_assignments(date_of_lesson, groups) + context["group_roles"] = group_roles # Create or get lesson documentation object; can be empty when first opening lesson lesson_documentation = lesson_period.get_or_create_lesson_documentation(wanted_week) @@ -127,6 +156,16 @@ def lesson( persons = Person.objects.all() persons_qs = lesson_period.get_personal_notes(persons, wanted_week) + + # Annotate group roles + if show_group_roles: + persons_qs = persons_qs.prefetch_related( + Prefetch( + "person__group_roles", + queryset=GroupRoleAssignment.objects.on_day(date_of_lesson).for_groups(groups), + ), + ) + personal_note_formset = PersonalNoteFormSet( request.POST or None, queryset=persons_qs, prefix="personal_notes" ) @@ -226,8 +265,13 @@ def week_view( # Add a form to filter the view if type_: initial = {type_.value: instance} + back_url = reverse( + "week_view_by_week", args=[wanted_week.year, wanted_week.week, type_.value, instance.pk] + ) else: initial = {} + back_url = reverse("week_view_by_week", args=[wanted_week.year, wanted_week.week]) + context["back_url"] = back_url select_form = SelectForm(request, request.POST or None, initial=initial) if request.method == "POST": @@ -248,6 +292,16 @@ def week_view( else: group = None + # Group roles + show_group_roles = ( + group + and request.user.person.preferences["alsijil__group_roles_in_week_view"] + and request.user.has_perm("alsijil.view_assigned_grouproles", group) + ) + if show_group_roles: + group_roles = GroupRole.objects.with_assignments(wanted_week, [group]) + context["group_roles"] = group_roles + extra_marks = ExtraMark.objects.all() if lesson_periods_query_exists: @@ -289,63 +343,70 @@ def week_view( else: persons_qs = persons_qs.filter(member_of__lessons__lesson_periods__in=lesson_periods_pk) - persons_qs = ( - persons_qs.distinct() - .prefetch_related( + persons_qs = persons_qs.distinct().prefetch_related( + Prefetch( + "personal_notes", + queryset=PersonalNote.objects.filter( + week=wanted_week.week, + year=wanted_week.year, + lesson_period__in=lesson_periods_pk, + ), + ), + "member_of__owners", + ) + + # Annotate group roles + if show_group_roles: + persons_qs = persons_qs.prefetch_related( Prefetch( - "personal_notes", - queryset=PersonalNote.objects.filter( - week=wanted_week.week, - year=wanted_week.year, - lesson_period__in=lesson_periods_pk, - ), + "group_roles", + queryset=GroupRoleAssignment.objects.in_week(wanted_week).for_group(group), ), - "member_of__owners", ) - .annotate( - absences_count=Count( - "personal_notes", - filter=Q( - personal_notes__lesson_period__in=lesson_periods_pk, - personal_notes__week=wanted_week.week, - personal_notes__year=wanted_week.year, - personal_notes__absent=True, - ), - distinct=True, - ), - unexcused_count=Count( - "personal_notes", - filter=Q( - personal_notes__lesson_period__in=lesson_periods_pk, - personal_notes__week=wanted_week.week, - personal_notes__year=wanted_week.year, - personal_notes__absent=True, - personal_notes__excused=False, - ), - distinct=True, - ), - tardiness_sum=Subquery( - Person.objects.filter( - pk=OuterRef("pk"), - personal_notes__lesson_period__in=lesson_periods_pk, - personal_notes__week=wanted_week.week, - personal_notes__year=wanted_week.year, - ) - .distinct() - .annotate(tardiness_sum=Sum("personal_notes__late")) - .values("tardiness_sum") + + persons_qs = persons_qs.annotate( + absences_count=Count( + "personal_notes", + filter=Q( + personal_notes__lesson_period__in=lesson_periods_pk, + personal_notes__week=wanted_week.week, + personal_notes__year=wanted_week.year, + personal_notes__absent=True, ), - tardiness_count=Count( - "personal_notes", - filter=Q( - personal_notes__lesson_period__in=lesson_periods_pk, - personal_notes__week=wanted_week.week, - personal_notes__year=wanted_week.year, - ) - & ~Q(personal_notes__late=0), - distinct=True, + distinct=True, + ), + unexcused_count=Count( + "personal_notes", + filter=Q( + personal_notes__lesson_period__in=lesson_periods_pk, + personal_notes__week=wanted_week.week, + personal_notes__year=wanted_week.year, + personal_notes__absent=True, + personal_notes__excused=False, ), - ) + distinct=True, + ), + tardiness_sum=Subquery( + Person.objects.filter( + pk=OuterRef("pk"), + personal_notes__lesson_period__in=lesson_periods_pk, + personal_notes__week=wanted_week.week, + personal_notes__year=wanted_week.year, + ) + .distinct() + .annotate(tardiness_sum=Sum("personal_notes__late")) + .values("tardiness_sum") + ), + tardiness_count=Count( + "personal_notes", + filter=Q( + personal_notes__lesson_period__in=lesson_periods_pk, + personal_notes__week=wanted_week.week, + personal_notes__year=wanted_week.year, + ) + & ~Q(personal_notes__late=0), + distinct=True, + ), ) for extra_mark in extra_marks: @@ -861,3 +922,169 @@ class ExcuseTypeDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDelet template_name = "core/pages/delete.html" success_url = reverse_lazy("excuse_types") success_message = _("The excuse type has been deleted.") + + +class GroupRoleListView(PermissionRequiredMixin, SingleTableView): + """Table of all group roles.""" + + model = GroupRole + table_class = GroupRoleTable + permission_required = "alsijil.view_grouproles" + template_name = "alsijil/group_role/list.html" + + +@method_decorator(never_cache, name="dispatch") +class GroupRoleCreateView(PermissionRequiredMixin, AdvancedCreateView): + """Create view for group roles.""" + + model = GroupRole + form_class = GroupRoleForm + permission_required = "alsijil.add_grouprole" + template_name = "alsijil/group_role/create.html" + success_url = reverse_lazy("group_roles") + success_message = _("The group role has been created.") + + +@method_decorator(never_cache, name="dispatch") +class GroupRoleEditView(PermissionRequiredMixin, AdvancedEditView): + """Edit view for group roles.""" + + model = GroupRole + form_class = GroupRoleForm + permission_required = "alsijil.edit_grouprole" + template_name = "alsijil/group_role/edit.html" + success_url = reverse_lazy("group_roles") + success_message = _("The group role has been saved.") + + +@method_decorator(never_cache, "dispatch") +class GroupRoleDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView): + """Delete view for group roles.""" + + model = GroupRole + permission_required = "alsijil.delete_grouprole" + template_name = "core/pages/delete.html" + success_url = reverse_lazy("group_roles") + success_message = _("The group role has been deleted.") + + +class AssignedGroupRolesView(PermissionRequiredMixin, DetailView): + permission_required = "alsijil.view_assigned_grouproles" + model = Group + template_name = "alsijil/group_role/assigned_list.html" + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data() + + today = timezone.now().date() + context["today"] = today + + self.roles = GroupRole.objects.with_assignments(today, [self.object]) + context["roles"] = self.roles + assignments = ( + GroupRoleAssignment.objects.filter( + Q(groups=self.object) | Q(groups__child_groups=self.object) + ) + .distinct() + .order_by("-date_start") + ) + context["assignments"] = assignments + return context + + +@method_decorator(never_cache, name="dispatch") +class AssignGroupRoleView(PermissionRequiredMixin, SuccessNextMixin, AdvancedCreateView): + model = GroupRoleAssignment + form_class = AssignGroupRoleForm + permission_required = "alsijil.assign_grouprole_for_group" + template_name = "alsijil/group_role/assign.html" + success_message = _("The group role has been assigned.") + + def get_success_url(self) -> str: + return reverse("assigned_group_roles", args=[self.group.pk]) + + def get_permission_object(self): + self.group = get_object_or_404(Group, pk=self.kwargs.get("pk")) + try: + self.role = GroupRole.objects.get(pk=self.kwargs.get("role_pk")) + except GroupRole.DoesNotExist: + self.role = None + return self.group + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["request"] = self.request + kwargs["initial"] = {"role": self.role, "groups": [self.group]} + return kwargs + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["role"] = self.role + context["group"] = self.group + return context + + +@method_decorator(never_cache, name="dispatch") +class AssignGroupRoleMultipleView(PermissionRequiredMixin, SuccessNextMixin, AdvancedCreateView): + model = GroupRoleAssignment + form_class = AssignGroupRoleForm + permission_required = "alsijil.assign_grouprole_for_multiple" + template_name = "alsijil/group_role/assign.html" + success_message = _("The group role has been assigned.") + + def get_success_url(self) -> str: + return reverse("assign_group_role_multiple") + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["request"] = self.request + return kwargs + + +@method_decorator(never_cache, name="dispatch") +class GroupRoleAssignmentEditView(PermissionRequiredMixin, SuccessNextMixin, AdvancedEditView): + """Edit view for group role assignments.""" + + model = GroupRoleAssignment + form_class = GroupRoleAssignmentEditForm + permission_required = "alsijil.edit_grouproleassignment" + template_name = "alsijil/group_role/edit_assignment.html" + success_message = _("The group role assignment has been saved.") + + def get_success_url(self) -> str: + pk = self.object.groups.first().pk + return reverse("assigned_group_roles", args=[pk]) + + +@method_decorator(never_cache, "dispatch") +class GroupRoleAssignmentStopView(PermissionRequiredMixin, SuccessNextMixin, DetailView): + model = GroupRoleAssignment + permission_required = "alsijil.stop_grouproleassignment" + + def get_success_url(self) -> str: + pk = self.object.groups.first().pk + return reverse("assigned_group_roles", args=[pk]) + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + if not self.object.date_end: + self.object.date_end = timezone.now().date() + self.object.save() + messages.success(request, _("The group role assignment has been stopped.")) + return redirect(self.get_success_url()) + + +@method_decorator(never_cache, "dispatch") +class GroupRoleAssignmentDeleteView( + PermissionRequiredMixin, RevisionMixin, SuccessNextMixin, AdvancedDeleteView +): + """Delete view for group role assignments.""" + + model = GroupRoleAssignment + permission_required = "alsijil.delete_grouproleassignment" + template_name = "core/pages/delete.html" + success_message = _("The group role assignment has been deleted.") + + def get_success_url(self) -> str: + pk = self.object.groups.first().pk + return reverse("assigned_group_roles", args=[pk])