Skip to content
Commits on Source (10)
......@@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file.
The format is based on `Keep a Changelog`_,
and this project adheres to `Semantic Versioning`_.
`2.5`_ - 2022-11-12
-------------------
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.
`2.4.2`_ - 2022-11-02
---------------------
......@@ -332,3 +343,4 @@ Fixed
.. _2.4: https://edugit.org/AlekSIS/Official/AlekSIS-App-Chronos/-/tags/2.4
.. _2.4.1: https://edugit.org/AlekSIS/Official/AlekSIS-App-Chronos/-/tags/2.4.1
.. _2.4.2: https://edugit.org/AlekSIS/Official/AlekSIS-App-Chronos/-/tags/2.4.2
.. _2.5: https://edugit.org/AlekSIS/Official/AlekSIS-App-Chronos/-/tags/2.5
from typing import Sequence
from django.db.models import Count, Q
from django.forms import RadioSelect
from django.utils.translation import gettext as _
from django_filters import BooleanFilter, FilterSet, ModelMultipleChoiceFilter
from django_select2.forms import ModelSelect2MultipleWidget
from material import Layout, Row
from aleksis.core.models import Group, Person, SchoolTerm
from .models import Break, Room, Subject, SupervisionArea, TimePeriod
class MultipleModelMultipleChoiceFilter(ModelMultipleChoiceFilter):
"""Filter for filtering multiple fields with one input.
>>> multiple_filter = MultipleModelMultipleChoiceFilter(["room", "substitution_room"])
"""
def filter(self, qs, value): # noqa
if not value:
return qs
if self.is_noop(qs, value):
return qs
q = Q()
for v in set(value):
if v == self.null_value:
v = None
for field in self.lookup_fields:
q = q | Q(**{field: v})
qs = self.get_method(qs)(q)
return qs.distinct() if self.distinct else qs
def __init__(self, lookup_fields: Sequence[str], *args, **kwargs):
self.lookup_fields = lookup_fields
super().__init__(self, *args, **kwargs)
class LessonPeriodFilter(FilterSet):
period = ModelMultipleChoiceFilter(queryset=TimePeriod.objects.all())
lesson__groups = ModelMultipleChoiceFilter(
queryset=Group.objects.all(),
widget=ModelSelect2MultipleWidget(
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
search_fields=[
"name__icontains",
"short_name__icontains",
],
),
)
room = MultipleModelMultipleChoiceFilter(
["room", "current_substitution__room"],
queryset=Room.objects.all(),
label=_("Room"),
widget=ModelSelect2MultipleWidget(
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
search_fields=[
"name__icontains",
"short_name__icontains",
],
),
)
lesson__teachers = MultipleModelMultipleChoiceFilter(
["lesson__teachers", "current_substitution__teachers"],
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=_("Teachers"),
widget=ModelSelect2MultipleWidget(
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
search_fields=[
"first_name__icontains",
"last_name__icontains",
"short_name__icontains",
],
),
)
lesson__subject = MultipleModelMultipleChoiceFilter(
["lesson__subject", "current_substitution__subject"],
queryset=Subject.objects.all(),
label=_("Subject"),
widget=ModelSelect2MultipleWidget(
attrs={"data-minimum-input-length": 0, "class": "browser-default"},
search_fields=[
"name__icontains",
"short_name__icontains",
],
),
)
substituted = BooleanFilter(
field_name="current_substitution",
label=_("Substitution status"),
lookup_expr="isnull",
exclude=True,
widget=RadioSelect(
choices=[
("", _("All lessons")),
(True, _("Substituted")),
(False, _("Not substituted")),
]
),
)
def __init__(self, *args, **kwargs):
weekday = kwargs.pop("weekday")
super().__init__(*args, **kwargs)
self.filters["period"].queryset = TimePeriod.objects.filter(weekday=weekday)
self.form.layout = Layout(
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"),
)
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")
......
......@@ -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",
......
# 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'))},
),
]
......@@ -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")),
)
......@@ -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)
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 _
import django_tables2 as tables
from django_tables2.utils import A
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"
......@@ -23,18 +25,39 @@ def _css_class_from_lesson_state(
return ""
class SubstitutionColumn(tables.Column):
def render(self, value, record: Optional[Union[LessonPeriod, Supervision]] = None):
if record.get_substitution():
return format_html(
"<s>{}</s> → {}",
value,
self.substitution_accessor.resolve(record.get_substitution()),
)
return value
def __init__(self, *args, **kwargs):
self.substitution_accessor = Accessor(kwargs.pop("substitution_accessor"))
super().__init__(*args, **kwargs)
class LessonsTable(tables.Table):
"""Table for daily lessons and management of substitutions."""
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"))
lesson__teachers = tables.Column(accessor="lesson__teacher_names", verbose_name=_("Teachers"))
lesson__subject = tables.Column(accessor="lesson__subject")
room = tables.Column(accessor="room")
lesson__teachers = SubstitutionColumn(
accessor="lesson__teacher_names",
substitution_accessor="teacher_names",
verbose_name=_("Teachers"),
)
lesson__subject = SubstitutionColumn(
accessor="lesson__subject", substitution_accessor="subject"
)
room = SubstitutionColumn(accessor="room", substitution_accessor="room")
edit_substitution = tables.LinkColumn(
"edit_substitution",
args=[A("id"), A("_week")],
......@@ -42,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"),
)
{# -*- 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 %}
{# -*- engine:django -*- #}
{% extends "core/base.html" %}
{% load i18n %}
{% load i18n material_form any_js %}
{% load render_table from django_tables2 %}
{% block extra_head %}
{{ lesson_periods_filter.form.media.css }}
{% include_css "select2-materialize" %}
{% endblock %}
{% block browser_title %}{% blocktrans %}Lessons{% endblocktrans %}{% endblock %}
{% block no_page_title %}{% endblock %}
......@@ -14,6 +19,13 @@
var dest = Urls.lessonsDay();
</script>
<h2>{% trans "Filter lessons" %}</h2>
<form method="get">
{% form form=lesson_periods_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 %}Lessons{% endblocktrans %} {{ day|date:"l" }}, {{ day }}</h1>
......@@ -24,4 +36,7 @@
</div>
{% render_table lessons_table %}
{% include_js "select2-materialize" %}
{{ lesson_periods_filter.form.media.js }}
{% endblock %}
{# -*- 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 %}
......@@ -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",
),
]
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)
......
......@@ -2,6 +2,7 @@ from datetime import datetime
from typing import Optional
from django.apps import apps
from django.db.models import FilteredRelation, Q
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
......@@ -18,10 +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 .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 (
......@@ -30,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
......@@ -220,10 +223,25 @@ 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(
request.GET,
queryset=lesson_periods.annotate(
current_substitution=FilteredRelation(
"substitutions",
condition=(
Q(substitutions__week=wanted_day.isocalendar()[1], substitutions__year=year)
),
)
),
weekday=wanted_day.weekday(),
)
context["lesson_periods_filter"] = lesson_periods_filter
# Build table
lessons_table = LessonsTable(lesson_periods.all())
lessons_table = LessonsTable(lesson_periods_filter.qs)
RequestConfig(request).configure(lessons_table)
context["lessons_table"] = lessons_table
......@@ -320,3 +338,122 @@ 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.on_day(wanted_day).filter_by_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)
......@@ -29,9 +29,9 @@ copyright = "2018-2022 The AlekSIS team"
author = "The AlekSIS Team"
# The short X.Y version
version = "2.4"
version = "2.5"
# The full version, including alpha/beta/rc tags
release = "2.4.2"
release = "2.5"
# -- General configuration ---------------------------------------------------
......
[tool.poetry]
name = "AlekSIS-App-Chronos"
version = "2.4.2"
version = "2.5"
packages = [
{ include = "aleksis" }
]
......