From bb519b696356e4daa485bd5393866704fea0ea68 Mon Sep 17 00:00:00 2001 From: Hangzhi Yu <hangzhi@protonmail.com> Date: Thu, 10 Nov 2022 00:16:44 +0100 Subject: [PATCH] Add daily supervisions page and form for entering of supervision substitutions --- CHANGELOG.rst | 2 + aleksis/apps/chronos/filters.py | 50 ++++++- aleksis/apps/chronos/forms.py | 22 ++- aleksis/apps/chronos/menus.py | 11 ++ .../0012_add_supervision_global_permission.py | 17 +++ aleksis/apps/chronos/models.py | 6 + aleksis/apps/chronos/rules.py | 16 +++ aleksis/apps/chronos/tables.py | 40 ++++-- .../edit_supervision_substitution.html | 31 ++++ .../templates/chronos/supervisions_day.html | 38 +++++ aleksis/apps/chronos/urls.py | 16 +++ aleksis/apps/chronos/util/chronos_helpers.py | 18 ++- aleksis/apps/chronos/views.py | 133 +++++++++++++++++- 13 files changed, 382 insertions(+), 18 deletions(-) create mode 100644 aleksis/apps/chronos/migrations/0012_add_supervision_global_permission.py create mode 100644 aleksis/apps/chronos/templates/chronos/edit_supervision_substitution.html create mode 100644 aleksis/apps/chronos/templates/chronos/supervisions_day.html diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f888e60b..58ff8cb5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,8 @@ Unreleased Added ~~~~~ +* Add overview page of all daily supervisions. +* Add form to add substitutions to supervisions. * Add filter to daily lessons page. * Display initial lesson data with substituted lessons in daily lessons table. diff --git a/aleksis/apps/chronos/filters.py b/aleksis/apps/chronos/filters.py index 83d38698..76048fc9 100644 --- a/aleksis/apps/chronos/filters.py +++ b/aleksis/apps/chronos/filters.py @@ -10,7 +10,7 @@ from material import Layout, Row from aleksis.core.models import Group, Person, SchoolTerm -from .models import Room, Subject, TimePeriod +from .models import Break, Room, Subject, SupervisionArea, TimePeriod class MultipleModelMultipleChoiceFilter(ModelMultipleChoiceFilter): @@ -122,3 +122,51 @@ class LessonPeriodFilter(FilterSet): Row("period", "lesson__groups", "room"), Row("lesson__teachers", "lesson__subject", "substituted"), ) + + +class SupervisionFilter(FilterSet): + break_item = ModelMultipleChoiceFilter(queryset=Break.objects.all()) + area = ModelMultipleChoiceFilter(queryset=SupervisionArea.objects.all()) + teacher = MultipleModelMultipleChoiceFilter( + ["teacher", "current_substitution__teacher"], + queryset=Person.objects.annotate( + lessons_count=Count( + "lessons_as_teacher", + filter=Q(lessons_as_teacher__validity__school_term=SchoolTerm.current) + if SchoolTerm.current + else Q(), + ) + ) + .filter(lessons_count__gt=0) + .order_by("short_name", "last_name"), + label=_("Teacher"), + widget=ModelSelect2MultipleWidget( + attrs={"data-minimum-input-length": 0, "class": "browser-default"}, + search_fields=[ + "first_name__icontains", + "last_name__icontains", + "short_name__icontains", + ], + ), + ) + substituted = BooleanFilter( + field_name="current_substitution", + label=_("Substitution status"), + lookup_expr="isnull", + exclude=True, + widget=RadioSelect( + choices=[ + ("", _("All supervisions")), + (True, _("Substituted")), + (False, _("Not substituted")), + ] + ), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.filters["break_item"].queryset = Break.objects.filter(supervisions__in=self.queryset) + self.form.layout = Layout( + Row("break_item", "area"), + Row("teacher", "substituted"), + ) diff --git a/aleksis/apps/chronos/forms.py b/aleksis/apps/chronos/forms.py index b1cfb87e..df2952e7 100644 --- a/aleksis/apps/chronos/forms.py +++ b/aleksis/apps/chronos/forms.py @@ -1,9 +1,9 @@ from django import forms -from django_select2.forms import ModelSelect2MultipleWidget +from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget from material import Layout -from .models import AutomaticPlan, LessonSubstitution +from .models import AutomaticPlan, LessonSubstitution, SupervisionSubstitution class LessonSubstitutionForm(forms.ModelForm): @@ -24,6 +24,24 @@ class LessonSubstitutionForm(forms.ModelForm): } +class SupervisionSubstitutionForm(forms.ModelForm): + """Form to manage supervisions substitutions.""" + + class Meta: + model = SupervisionSubstitution + fields = ["teacher"] + widgets = { + "teacher": ModelSelect2Widget( + search_fields=[ + "first_name__icontains", + "last_name__icontains", + "short_name__icontains", + ], + attrs={"data-minimum-input-length": 0, "class": "browser-default"}, + ), + } + + class AutomaticPlanForm(forms.ModelForm): layout = Layout("slug", "name", "number_of_days", "show_header_box") diff --git a/aleksis/apps/chronos/menus.py b/aleksis/apps/chronos/menus.py index dc07d04b..810e88c6 100644 --- a/aleksis/apps/chronos/menus.py +++ b/aleksis/apps/chronos/menus.py @@ -45,6 +45,17 @@ MENUS = { ), ], }, + { + "name": _("Daily supervisions"), + "url": "supervisions_day", + "svg_icon": "mdi:calendar-outline", + "validators": [ + ( + "aleksis.core.util.predicates.permission_validator", + "chronos.view_supervisions_day_rule", + ), + ], + }, { "name": _("Substitutions"), "url": "substitutions", diff --git a/aleksis/apps/chronos/migrations/0012_add_supervision_global_permission.py b/aleksis/apps/chronos/migrations/0012_add_supervision_global_permission.py new file mode 100644 index 00000000..e41820df --- /dev/null +++ b/aleksis/apps/chronos/migrations/0012_add_supervision_global_permission.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.13 on 2022-11-09 23:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chronos', '0011_exam'), + ] + + operations = [ + migrations.AlterModelOptions( + name='chronosglobalpermissions', + options={'managed': False, 'permissions': (('view_all_room_timetables', 'Can view all room timetables'), ('view_all_group_timetables', 'Can view all group timetables'), ('view_all_person_timetables', 'Can view all person timetables'), ('view_timetable_overview', 'Can view timetable overview'), ('view_lessons_day', 'Can view all lessons per day'), ('view_supervisions_day', 'Can view all supervisions per day'))}, + ), + ] diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py index 22268363..e6553ab3 100644 --- a/aleksis/apps/chronos/models.py +++ b/aleksis/apps/chronos/models.py @@ -988,6 +988,11 @@ class Supervision(ValidityRangeRelatedExtensibleModel, WeekAnnotationMixin): year += 1 return year + def get_calendar_week(self, week: int): + year = self.get_year(week) + + return CalendarWeek(year=year, week=week) + def get_substitution( self, week: Optional[CalendarWeek] = None ) -> Optional[SupervisionSubstitution]: @@ -1362,4 +1367,5 @@ class ChronosGlobalPermissions(GlobalPermissionModel): ("view_all_person_timetables", _("Can view all person timetables")), ("view_timetable_overview", _("Can view timetable overview")), ("view_lessons_day", _("Can view all lessons per day")), + ("view_supervisions_day", _("Can view all supervisions per day")), ) diff --git a/aleksis/apps/chronos/rules.py b/aleksis/apps/chronos/rules.py index 5dcfd20a..85a0d1c4 100644 --- a/aleksis/apps/chronos/rules.py +++ b/aleksis/apps/chronos/rules.py @@ -48,6 +48,22 @@ view_substitutions_predicate = has_person & ( ) add_perm("chronos.view_substitutions_rule", view_substitutions_predicate) +# View all supervisions per day +view_supervisions_day_predicate = has_person & has_global_perm("chronos.view_supervisions_day") +add_perm("chronos.view_supervisions_day_rule", view_supervisions_day_predicate) + +# Edit supervision substitution +edit_supervision_substitution_predicate = has_person & ( + has_global_perm("chronos.change_supervisionsubstitution") +) +add_perm("chronos.edit_supervision_substitution_rule", edit_supervision_substitution_predicate) + +# Delete supervision substitution +delete_supervision_substitution_predicate = has_person & ( + has_global_perm("chronos.delete_supervisionsubstitution") +) +add_perm("chronos.delete_supervision_substitution_rule", delete_supervision_substitution_predicate) + # View room (timetable) view_room_predicate = has_person & has_room_timetable_perm add_perm("chronos.view_room_rule", view_room_predicate) diff --git a/aleksis/apps/chronos/tables.py b/aleksis/apps/chronos/tables.py index 887291f7..89eb15f8 100644 --- a/aleksis/apps/chronos/tables.py +++ b/aleksis/apps/chronos/tables.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Union from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ @@ -8,15 +8,16 @@ from django.utils.translation import gettext_lazy as _ import django_tables2 as tables from django_tables2.utils import A, Accessor -from .models import LessonPeriod +from .models import LessonPeriod, Supervision -def _css_class_from_lesson_state( - record: Optional[LessonPeriod] = None, table: Optional[LessonsTable] = None +def _css_class_from_lesson_or_supervision_state( + record: Optional[Union[LessonPeriod, Supervision]] = None, + table: Optional[Union[LessonsTable, SupervisionsTable]] = None, ) -> str: - """Return CSS class depending on lesson state.""" + """Return CSS class depending on lesson or supervision state.""" if record.get_substitution(): - if record.get_substitution().cancelled: + if hasattr(record.get_substitution(), "cancelled") and record.get_substitution().cancelled: return "success" else: return "warning" @@ -25,7 +26,7 @@ def _css_class_from_lesson_state( class SubstitutionColumn(tables.Column): - def render(self, value, record: Optional[LessonPeriod] = None): + def render(self, value, record: Optional[Union[LessonPeriod, Supervision]] = None): if record.get_substitution(): return format_html( "<s>{}</s> → {}", @@ -44,7 +45,7 @@ class LessonsTable(tables.Table): class Meta: attrs = {"class": "highlight"} - row_attrs = {"class": _css_class_from_lesson_state} + row_attrs = {"class": _css_class_from_lesson_or_supervision_state} period__period = tables.Column(accessor="period__period") lesson__groups = tables.Column(accessor="lesson__group_names", verbose_name=_("Groups")) @@ -64,3 +65,26 @@ class LessonsTable(tables.Table): attrs={"a": {"class": "btn-flat waves-effect waves-orange"}}, verbose_name=_("Manage substitution"), ) + + +class SupervisionsTable(tables.Table): + """Table for daily supervisions and management of substitutions.""" + + class Meta: + attrs = {"class": "highlight"} + row_attrs = {"class": _css_class_from_lesson_or_supervision_state} + + break_item = tables.Column(accessor="break_item") + area = tables.Column(accessor="area") + teacher = SubstitutionColumn( + accessor="teacher", + substitution_accessor="teacher", + verbose_name=_("Teachers"), + ) + edit_substitution = tables.LinkColumn( + "edit_supervision_substitution", + args=[A("id"), A("_week")], + text=_("Substitution"), + attrs={"a": {"class": "btn-flat waves-effect waves-orange"}}, + verbose_name=_("Manage substitution"), + ) diff --git a/aleksis/apps/chronos/templates/chronos/edit_supervision_substitution.html b/aleksis/apps/chronos/templates/chronos/edit_supervision_substitution.html new file mode 100644 index 00000000..2ee637e8 --- /dev/null +++ b/aleksis/apps/chronos/templates/chronos/edit_supervision_substitution.html @@ -0,0 +1,31 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} +{% load material_form i18n any_js %} + +{% block extra_head %} + {{ edit_supervision_substitution_form.media.css }} + {% include_css "select2-materialize" %} +{% endblock %} + +{% block browser_title %}{% blocktrans %}Edit substitution.{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Edit substitution{% endblocktrans %}{% endblock %} + +{% block content %} + <p class="flow-text">{{ date }}: {{ supervision }}</p> + <form method="post"> + {% csrf_token %} + + {% form form=edit_supervision_substitution_form %}{% endform %} + + {% include "core/partials/save_button.html" %} + {% if substitution %} + <a href="{% url 'delete_supervision_substitution' substitution.supervision.id week %}" + class="btn red waves-effect waves-light"> + <i class="material-icons iconify left" data-icon="mdi:delete-outline"></i> {% trans "Delete" %} + </a> + {% endif %} + </form> + {% include_js "select2-materialize" %} + {{ edit_supervision_substitution_form.media.js }} +{% endblock %} diff --git a/aleksis/apps/chronos/templates/chronos/supervisions_day.html b/aleksis/apps/chronos/templates/chronos/supervisions_day.html new file mode 100644 index 00000000..8c28324e --- /dev/null +++ b/aleksis/apps/chronos/templates/chronos/supervisions_day.html @@ -0,0 +1,38 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} +{% load i18n material_form any_js %} + + +{% load render_table from django_tables2 %} + +{% block extra_head %} + {{ supervisions_filter.form.media.css }} + {% include_css "select2-materialize" %} +{% endblock %} + +{% block browser_title %}{% blocktrans %}Lessons{% endblocktrans %}{% endblock %} +{% block no_page_title %}{% endblock %} + +{% block content %} + <h2>{% trans "Filter supervisions" %}</h2> + <form method="get"> + {% form form=supervisions_filter.form %}{% endform %} + {% trans "Search" as caption %} + {% include "core/partials/save_button.html" with caption=caption icon="mdi:search" %} + </form> + + <div class="row no-margin"> + <div class="col s12 m6 l8 no-padding"> + <h1>{% blocktrans %}Supervisions{% endblocktrans %} {{ day|date:"l" }}, {{ day }}</h1> + </div> + <div class="col s12 m6 l4 no-padding"> + {% include "chronos/partials/datepicker.html" %} + </div> + </div> + + {% render_table supervisions_table %} + + {% include_js "select2-materialize" %} + {{ supervisions_filter.form.media.js }} +{% endblock %} diff --git a/aleksis/apps/chronos/urls.py b/aleksis/apps/chronos/urls.py index 411e5cd8..e4cf2619 100644 --- a/aleksis/apps/chronos/urls.py +++ b/aleksis/apps/chronos/urls.py @@ -61,4 +61,20 @@ urlpatterns = [ {"is_print": True}, name="substitutions_print_by_date", ), + path("supervisions/", views.supervisions_day, name="supervisions_day"), + path( + "supervisions/<int:year>/<int:month>/<int:day>/", + views.supervisions_day, + name="supervisions_day_by_date", + ), + path( + "supervisions/<int:id_>/<int:week>/substitution/", + views.edit_supervision_substitution, + name="edit_supervision_substitution", + ), + path( + "supervisions/<int:id_>/<int:week>/substitution/delete/", + views.delete_supervision_substitution, + name="delete_supervision_substitution", + ), ] diff --git a/aleksis/apps/chronos/util/chronos_helpers.py b/aleksis/apps/chronos/util/chronos_helpers.py index e93637d1..4f427b63 100644 --- a/aleksis/apps/chronos/util/chronos_helpers.py +++ b/aleksis/apps/chronos/util/chronos_helpers.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Optional from django.db.models import Count, Q @@ -14,7 +14,15 @@ from aleksis.core.util.core_helpers import get_site_preferences from aleksis.core.util.predicates import check_global_permission from ..managers import TimetableType -from ..models import Absence, LessonPeriod, LessonSubstitution, Room, TimePeriod +from ..models import ( + Absence, + LessonPeriod, + LessonSubstitution, + Room, + Supervision, + SupervisionSubstitution, + TimePeriod, +) from .build import build_substitutions_list from .js import date_unix @@ -57,6 +65,12 @@ def get_substitution_by_id(request: HttpRequest, id_: int, week: int): ).first() +def get_supervision_substitution_by_id(request: HttpRequest, id_: int, date: datetime.date): + supervision = get_object_or_404(Supervision, pk=id_) + + return SupervisionSubstitution.objects.filter(date=date, supervision=supervision).first() + + def get_teachers(user: "User"): """Get the teachers whose timetables are allowed to be seen by current user.""" checker = ObjectPermissionChecker(user) diff --git a/aleksis/apps/chronos/views.py b/aleksis/apps/chronos/views.py index a5742ee1..59f8444c 100644 --- a/aleksis/apps/chronos/views.py +++ b/aleksis/apps/chronos/views.py @@ -19,11 +19,11 @@ from aleksis.core.util import messages from aleksis.core.util.core_helpers import has_person from aleksis.core.util.pdf import render_pdf -from .filters import LessonPeriodFilter -from .forms import LessonSubstitutionForm +from .filters import LessonPeriodFilter, SupervisionFilter +from .forms import LessonSubstitutionForm, SupervisionSubstitutionForm from .managers import TimetableType -from .models import Holiday, LessonPeriod, TimePeriod -from .tables import LessonsTable +from .models import Holiday, LessonPeriod, Supervision, TimePeriod +from .tables import LessonsTable, SupervisionsTable from .util.build import build_timetable, build_weekdays from .util.change_tracker import TimetableDataChangeTracker from .util.chronos_helpers import ( @@ -32,6 +32,7 @@ from .util.chronos_helpers import ( get_rooms, get_substitution_by_id, get_substitutions_context_data, + get_supervision_substitution_by_id, get_teachers, ) from .util.date import CalendarWeek, get_weeks_for_year, week_weekday_to_date @@ -222,7 +223,7 @@ def lessons_day( wanted_day = TimePeriod.get_next_relevant_day(timezone.now().date(), datetime.now().time()) # Get lessons - lesson_periods = LessonPeriod.objects.on_day(wanted_day) + lesson_periods = LessonPeriod.objects.all() # Get filter lesson_periods_filter = LessonPeriodFilter( @@ -337,3 +338,125 @@ def substitutions( return render(request, "chronos/substitutions.html", context) else: return render_pdf(request, "chronos/substitutions_print.html", context) + + +@permission_required("chronos.view_supervisions_day_rule") +def supervisions_day( + request: HttpRequest, + year: Optional[int] = None, + month: Optional[int] = None, + day: Optional[int] = None, +) -> HttpResponse: + """View all supervisions taking place on a specified day.""" + context = {} + + if day: + wanted_day = timezone.datetime(year=year, month=month, day=day).date() + wanted_day = TimePeriod.get_next_relevant_day(wanted_day) + else: + wanted_day = TimePeriod.get_next_relevant_day(timezone.now().date(), datetime.now().time()) + + # Get supervisions + supervisions = Supervision.objects.filter( + Q(break_item__after_period__weekday=wanted_day.weekday()) + | Q(break_item__before_period__weekday=wanted_day.weekday()) + ) + + # Get filter + supervisions_filter = SupervisionFilter( + request.GET, + queryset=supervisions.annotate( + current_substitution=FilteredRelation( + "substitutions", + condition=(Q(substitutions__date=wanted_day)), + ) + ), + ) + context["supervisions_filter"] = supervisions_filter + + # Build table + supervisions_table = SupervisionsTable( + supervisions_filter.qs.annotate_week(week=CalendarWeek.from_date(wanted_day)) + ) + RequestConfig(request).configure(supervisions_table) + + context["supervisions_table"] = supervisions_table + context["day"] = wanted_day + context["supervisions"] = supervisions + + context["datepicker"] = { + "date": date_unix(wanted_day), + "dest": reverse("supervisions_day"), + } + + context["url_prev"], context["url_next"] = TimePeriod.get_prev_next_by_day( + wanted_day, "supervisions_day_by_date" + ) + + return render(request, "chronos/supervisions_day.html", context) + + +@never_cache +@permission_required("chronos.edit_supervision_substitution_rule") +def edit_supervision_substitution(request: HttpRequest, id_: int, week: int) -> HttpResponse: + """View a form to edit a supervision substitution.""" + context = {} + + supervision = get_object_or_404(Supervision, pk=id_) + wanted_week = supervision.get_calendar_week(week) + context["week"] = week + context["supervision"] = supervision + date = week_weekday_to_date(wanted_week, supervision.break_item.weekday) + context["date"] = date + + supervision_substitution = get_supervision_substitution_by_id(request, id_, date) + + if supervision_substitution: + edit_supervision_substitution_form = SupervisionSubstitutionForm( + request.POST or None, instance=supervision_substitution + ) + else: + edit_supervision_substitution_form = SupervisionSubstitutionForm( + request.POST or None, + ) + + context["substitution"] = supervision_substitution + + if request.method == "POST": + if edit_supervision_substitution_form.is_valid(): + with reversion.create_revision(atomic=True): + tracker = TimetableDataChangeTracker() + + supervision_substitution = edit_supervision_substitution_form.save(commit=False) + if not supervision_substitution.pk: + supervision_substitution.supervision = supervision + supervision_substitution.date = date + supervision_substitution.save() + edit_supervision_substitution_form.save_m2m() + + messages.success(request, _("The substitution has been saved.")) + + return redirect( + "supervisions_day_by_date", year=date.year, month=date.month, day=date.day + ) + + context["edit_supervision_substitution_form"] = edit_supervision_substitution_form + + return render(request, "chronos/edit_supervision_substitution.html", context) + + +@permission_required("chronos.delete_supervision_substitution_rule") +def delete_supervision_substitution(request: HttpRequest, id_: int, week: int) -> HttpResponse: + """Delete a supervision substitution. + + Redirects back to supervision list on success. + """ + supervision = get_object_or_404(Supervision, pk=id_) + wanted_week = supervision.get_calendar_week(week) + date = week_weekday_to_date(wanted_week, supervision.break_item.weekday) + + get_supervision_substitution_by_id(request, id_, date).delete() + + messages.success(request, _("The substitution has been deleted.")) + + return redirect("supervisions_day_by_date", year=date.year, month=date.month, day=date.day) -- GitLab