diff --git a/aleksis/apps/alsijil/actions.py b/aleksis/apps/alsijil/actions.py index ec80a8cf510c579ab09171e29f0d3163618f9522..5e7da3a3207965fc8ddb176fef5581784674885e 100644 --- a/aleksis/apps/alsijil/actions.py +++ b/aleksis/apps/alsijil/actions.py @@ -1,4 +1,4 @@ -from typing import Sequence +from typing import Callable, Sequence from django.contrib import messages from django.contrib.humanize.templatetags.humanize import apnumber @@ -10,6 +10,37 @@ from django.utils.translation import gettext_lazy as _ from aleksis.core.models import Notification +def mark_as_excused(modeladmin, request, queryset): + queryset.update(excused=True, excuse_type=None) + + +mark_as_excused.short_description = _("Mark as excused") + + +def mark_as_unexcused(modeladmin, request, queryset): + queryset.update(excused=False, excuse_type=None) + + +mark_as_unexcused.short_description = _("Mark as unexcused") + + +def mark_as_excuse_type_generator(excuse_type) -> Callable: + def mark_as_excuse_type(modeladmin, request, queryset): + queryset.update(excused=True, excuse_type=excuse_type) + + mark_as_excuse_type.short_description = _(f"Mark as {excuse_type.name}") + mark_as_excuse_type.__name__ = f"mark_as_excuse_type_{excuse_type.short_name}" + + return mark_as_excuse_type + + +def delete_personal_note(modeladmin, request, queryset): + queryset.delete() + + +delete_personal_note.short_description = _("Delete") + + def send_request_to_check_entry(modeladmin, request: HttpRequest, selected_items: Sequence[dict]): """Send notifications to the teachers of the selected register objects. diff --git a/aleksis/apps/alsijil/filters.py b/aleksis/apps/alsijil/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..4033e36e5500e3c440b73950fb96741761c46acf --- /dev/null +++ b/aleksis/apps/alsijil/filters.py @@ -0,0 +1,33 @@ +from django.utils.translation import gettext as _ + +from django_filters import CharFilter, DateFilter, FilterSet +from material import Layout, Row + +from .models import PersonalNote + + +class PersonalNoteFilter(FilterSet): + day_start = DateFilter(lookup_expr="gte", label=_("After")) + day_end = DateFilter(lookup_expr="lte", label=_("Before")) + subject = CharFilter(lookup_expr="icontains", label=_("Subject")) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields["late__lt"].label = _("Tardiness is lower than") + self.form.fields["late__gt"].label = _("Tardiness is bigger than") + self.form.layout = Layout( + Row("subject"), + Row("day_start", "day_end"), + Row("absent", "excused", "excuse_type"), + Row("late__gt", "late__lt", "extra_marks"), + ) + + class Meta: + model = PersonalNote + fields = { + "excused": ["exact"], + "late": ["lt", "gt"], + "absent": ["exact"], + "excuse_type": ["exact"], + "extra_marks": ["exact"], + } diff --git a/aleksis/apps/alsijil/forms.py b/aleksis/apps/alsijil/forms.py index 322de9d2fa763a3be9cec5fab8fae30c8550a1ec..53b5ffee1c68c2b0645d86753e0eb8eb934d2c0c 100644 --- a/aleksis/apps/alsijil/forms.py +++ b/aleksis/apps/alsijil/forms.py @@ -14,12 +14,18 @@ from material import Fieldset, Layout, Row from aleksis.apps.chronos.managers import TimetableType from aleksis.apps.chronos.models import Subject, TimePeriod -from aleksis.core.forms import ListActionForm +from aleksis.core.forms import ActionForm, ListActionForm from aleksis.core.models import Group, Person, SchoolTerm from aleksis.core.util.core_helpers import get_site_preferences from aleksis.core.util.predicates import check_global_permission -from .actions import send_request_to_check_entry +from .actions import ( + delete_personal_note, + mark_as_excuse_type_generator, + mark_as_excused, + mark_as_unexcused, + send_request_to_check_entry, +) from .models import ( ExcuseType, ExtraMark, @@ -175,6 +181,18 @@ class ExcuseTypeForm(forms.ModelForm): fields = ["short_name", "name"] +class PersonOverviewForm(ActionForm): + def get_actions(self): + return ( + [mark_as_excused, mark_as_unexcused] + + [ + mark_as_excuse_type_generator(excuse_type) + for excuse_type in ExcuseType.objects.all() + ] + + [delete_personal_note] + ) + + class GroupRoleForm(forms.ModelForm): layout = Layout("name", "icon", "colour") diff --git a/aleksis/apps/alsijil/managers.py b/aleksis/apps/alsijil/managers.py index ab9daaa7ebb171b9979fa8893fc7eaa0c598a31d..69cc3fd7d62e3669e770ea12468490bf08ea1009 100644 --- a/aleksis/apps/alsijil/managers.py +++ b/aleksis/apps/alsijil/managers.py @@ -6,6 +6,7 @@ from django.db.models.fields import DateField from django.db.models.functions import Concat from django.db.models.query import Prefetch from django.db.models.query_utils import Q +from django.utils.translation import gettext as _ from calendarweek import CalendarWeek @@ -71,6 +72,16 @@ class RegisterObjectRelatedQuerySet(QuerySet): ), ) + def annotate_subject(self) -> QuerySet: + """Annotate lesson documentations with the subjects.""" + return self.annotate( + subject=Case( + When(lesson_period__isnull=False, then="lesson_period__lesson__subject__name",), + When(extra_lesson__isnull=False, then="extra_lesson__subject__name",), + default=Value(_("Event")), + ) + ) + class PersonalNoteManager(CurrentSiteManagerWithoutMigrations): """Manager adding specific methods to personal notes.""" diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index 4eecb39f4ca4290c3d650d333a78f22d2dc76279..94044b74a3f3c859cd95ae19290e375baf9ee595 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -30,7 +30,7 @@ from aleksis.apps.alsijil.managers import ( ) from aleksis.apps.chronos.managers import GroupPropertiesMixin from aleksis.apps.chronos.mixins import WeekRelatedMixin -from aleksis.apps.chronos.models import Event, ExtraLesson, LessonPeriod +from aleksis.apps.chronos.models import Event, ExtraLesson, LessonPeriod, TimePeriod from aleksis.core.mixins import ExtensibleModel, GlobalPermissionModel from aleksis.core.models import SchoolTerm from aleksis.core.util.core_helpers import get_site_preferences @@ -155,6 +155,33 @@ class RegisterObjectRelatedMixin(WeekRelatedMixin): else f"{date_format(self.event.date_start)}–{date_format(self.event.date_end)}" ) + @property + def period(self: Union["LessonDocumentation", "PersonalNote"]) -> TimePeriod: + """Get the date of this lesson documentation or personal note. + + :: warning:: + + As events can be longer than one day, + this will return `None` for events. + """ + if self.event: + return self.event.period_from + else: + return self.register_object.period + + @property + def period_formatted(self: Union["LessonDocumentation", "PersonalNote"]) -> str: + """Get a formatted version of the period of this object. + + Lesson periods, extra lessons: formatted period + Events: formatted period range + """ + return ( + f"{self.period.period}." + if not self.event + else f"{self.event.period_from.period}.–{self.event.period_to.period}." + ) + def get_absolute_url(self: Union["LessonDocumentation", "PersonalNote"]) -> str: """Get the absolute url of the detail view for the related register object.""" return self.register_object.get_alsijil_url(self.calendar_week) diff --git a/aleksis/apps/alsijil/static/css/alsijil/person.css b/aleksis/apps/alsijil/static/css/alsijil/person.css new file mode 100644 index 0000000000000000000000000000000000000000..fdba89c2808ccb46cc1535b3f27545c366af6c68 --- /dev/null +++ b/aleksis/apps/alsijil/static/css/alsijil/person.css @@ -0,0 +1,95 @@ +span.input-field.inline > .select-wrapper > input { + color: red; + padding: 14px 0 0 0; + line-height: 2px; + height: 36px; + vertical-align: middle; +} + +span.input-field.inline > .select-wrapper .caret { + top: 12px !important; +} + +@media screen and (min-width: 1400px) { + li.collection-item form { + margin: -30px 0 -30px 0; + } + + li.collection-item#title #select_all_span { + margin-top: 5px; + } +} + +.collection { + overflow: visible; + overflow-x: hidden; +} + +#select_all_container { + display: none; +} + +#select_all_box:indeterminate + span:not(.lever):before { + top: -4px; + left: -6px; + width: 10px; + height: 12px; + border-top: none; + border-left: none; + border-right: white 2px solid; + border-bottom: none; + transform: rotate(90deg); + backface-visibility: hidden; + transform-origin: 100% 100%; + +} + +#select_all_box:indeterminate + span:not(.lever):after { + top: 0; + width: 20px; + height: 20px; + border: 2px solid currentColor; + background-color: currentColor; + z-index: 0; +} + +#select_all_box_text { + color: #9e9e9e !important; +} + +td.material-icons { + display: table-cell; +} + +.medium-high { + position: relative; + top: 50%; + left: 50%; + transform: translate(-50%, 50%); +} + +@media screen and (min-width: 600px) { + /* On medium and up devices */ + .medium-high-right { + float: right; + transform: translate(0%, 50%); + } +} + +@media screen and (max-width: 600px) { + /* Only on small devices */ + .full-width-s { + width: 100%; + } + + #heading { + display: block; + } + #heading + a { + float: none!important; + } +} + +.overflow-x-scroll { + overflow-x: scroll; +} diff --git a/aleksis/apps/alsijil/tables.py b/aleksis/apps/alsijil/tables.py index 336ca5827932b81fab534ab277ee5e2dab0aad6d..b0337d749297affafcf487b3936aec0efa4c0789 100644 --- a/aleksis/apps/alsijil/tables.py +++ b/aleksis/apps/alsijil/tables.py @@ -1,11 +1,15 @@ from django.template.loader import render_to_string +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ import django_tables2 as tables from django_tables2.utils import A +from aleksis.apps.chronos.models import Event, LessonPeriod from aleksis.core.util.tables import SelectColumn +from .models import PersonalNote + class ExtraMarkTable(tables.Table): class Meta: @@ -82,6 +86,87 @@ class GroupRoleTable(tables.Table): self.columns.hide("delete") +class PersonalNoteTable(tables.Table): + selected = SelectColumn(attrs={"input": {"name": "selected_objects"}}, accessor=A("pk")) + date = tables.Column( + verbose_name=_("Date"), accessor=A("date_formatted"), order_by=A("day_start") + ) + period = tables.Column( + verbose_name=_("Period"), accessor=A("period_formatted"), order_by=A("order_period") + ) + groups = tables.Column( + verbose_name=_("Groups"), + accessor=A("register_object__group_names"), + order_by=A("order_groups"), + ) + teachers = tables.Column( + verbose_name=_("Teachers"), + accessor=A("register_object__teacher_names"), + order_by=A("order_teachers"), + ) + subject = tables.Column(verbose_name=_("Subject"), accessor=A("subject")) + absent = tables.Column() + late = tables.Column() + excused = tables.Column(verbose_name=_("Excuse")) + extra_marks = tables.Column(verbose_name="Extra marks", accessor=A("extra_marks__all")) + + def render_groups(self, value, record): + if isinstance(record.register_object, LessonPeriod): + return record.register_object.lesson.group_names + else: + return value + + def render_subject(self, value, record): + if isinstance(record.register_object, Event): + return _("Event") + else: + return value + + def render_absent(self, value): + return ( + render_to_string( + "components/materialize-chips.html", + dict(content="Absent", classes="red white-text"), + ) + if value + else "–" + ) + + def render_excused(self, value, record): + if record.absent and value: + context = dict(content=_("Excused"), classes="green white-text") + badge = render_to_string("components/materialize-chips.html", context) + if record.excuse_type: + context = dict(content=record.excuse_type.name, classes="green white-text") + badge = render_to_string("components/materialize-chips.html", context) + return badge + return "–" + + def render_late(self, value): + if value: + content = _(f"{value}' late") + context = dict(content=content, classes="orange white-text") + return render_to_string("components/materialize-chips.html", context) + else: + return "–" + + def render_extra_marks(self, value): + if value: + badges = "" + for extra_mark in value: + content = extra_mark.name + badges += render_to_string( + "components/materialize-chips.html", context=dict(content=content) + ) + return mark_safe(badges) # noqa + else: + return "–" + + class Meta: + model = PersonalNote + fields = () + + def _get_link(value, record): return record["register_object"].get_alsijil_url(record.get("week")) diff --git a/aleksis/apps/alsijil/templates/alsijil/class_register/person.html b/aleksis/apps/alsijil/templates/alsijil/class_register/person.html index 65c38961a07a425c5b611e0b94683bf73b608cd6..4d063d3ddaf1dc67da0ae29a5d0ac2e0b4c67911 100644 --- a/aleksis/apps/alsijil/templates/alsijil/class_register/person.html +++ b/aleksis/apps/alsijil/templates/alsijil/class_register/person.html @@ -1,6 +1,11 @@ {# -*- engine:django -*- #} {% extends "core/base.html" %} -{% load rules data_helpers week_helpers i18n material_form django_tables2 %} +{% load rules data_helpers week_helpers i18n material_form static django_tables2 %} + +{% block extra_head %} + <link rel="stylesheet" href="{% static "css/alsijil/person.css" %}"> + <script src="{% static "js/multi_select.js" %}" type="text/javascript"></script> +{% endblock %} {% block browser_title %}{% blocktrans %}Class register: person{% endblocktrans %}{% endblock %} @@ -13,13 +18,24 @@ <i class="material-icons left">chevron_left</i> {% trans "Back" %} </a> {% endif %} - {% blocktrans with person=person %} - Class register overview for {{ person }} - {% endblocktrans %} + <span id="heading"> + {% blocktrans with person=person %} + Class register overview for {{ person }} + {% endblocktrans %} + </span> + {% has_perm "alsijil.register_absence" user person as can_register_absence %} + {% if can_register_absence %} + <a class="btn primary-color waves-effect waves-light right" href="{% url "register_absence" person.pk %}"> + <i class="material-icons left">rate_review</i> + {% trans "Register absence" %} + </a> + {% endif %} {% endblock %} {% block content %} <div class="row"> + + <!-- Tab Buttons --> <div class="col s12"> <ul class="tabs"> {% if register_object_table %} @@ -30,259 +46,125 @@ <li class="tab"> <a href="#personal-notes">{% trans "Personal notes" %}</a> </li> + {% if stats %} + <li class="tab"><a href="#statistics">{% trans "Statistics" %}</a></li> + {% endif %} </ul> </div> + + <!-- Lesson Documentation Tab --> {% if register_object_table %} <div class="col s12" id="lesson-documentations"> {% include "alsijil/partials/objects_table.html" with table=register_object_table filter_form=filter_form %} </div> {% endif %} - <div class="col s12" id="personal-notes"> - {% has_perm "alsijil.edit_person_overview_personalnote" user person as can_mark_all_as_excused %} - {% has_perm "alsijil.register_absence" user person as can_register_absence %} - {% if can_register_absence %} - <a class="btn primary-color waves-effect waves-light" href="{% url "register_absence" person.pk %}"> - <i class="material-icons left">rate_review</i> - {% trans "Register absence" %} - </a> - {% endif %} - - <div class="row"> - <div class="col s12 m12 l6"> - <h5>{% trans "Unexcused absences" %}</h5> - <ul class="collection"> - {% for note in unexcused_absences %} - <li class="collection-item"> - {% has_perm "alsijil.edit_personalnote" user note as can_edit_personal_note %} - {% if can_edit_personal_note %} - <form action="" method="post" class="right hide-on-small-only" style="margin-top: -7px;"> - {% csrf_token %} - {% trans "Mark as" %} - <input type="hidden" value="{{ note.pk }}" name="personal_note"> - {% include "alsijil/partials/mark_as_buttons.html" %} - <a class="btn-flat red-text" title="{% trans "Delete note" %}" - href="{% url "delete_personal_note" note.pk %}"> - <i class="material-icons center">cancel</i> - </a> - </form> - {% endif %} - <i class="material-icons left red-text">warning</i> - <p class="no-margin"> - <a href="{{ note.get_absolute_url }}">{{ note.date }}, {{ note.lesson_period }}</a> - </p> - {% if note.remarks %} - <p class="no-margin"><em>{{ note.remarks }}</em></p> - {% endif %} - {% if can_edit_personal_note %} - <form action="" method="post" class="hide-on-med-and-up"> - {% csrf_token %} - {% trans "Mark as" %} - <input type="hidden" value="{{ note.pk }}" name="personal_note"> - {% include "alsijil/partials/mark_as_buttons.html" %} - <a class="btn-flat red-text" title="{% trans "Delete note" %}" - href="{% url "delete_personal_note" note.pk %}"> - <i class="material-icons center">cancel</i> - </a> - </form> - {% endif %} - </li> - {% empty %} - <li class="collection-item avatar valign-wrapper"> - <i class="material-icons left materialize-circle green white-text">check</i> - <span class="title">{% trans "There are no unexcused lessons." %}</span> - </li> - {% endfor %} - </ul> - {% if stats %} - <h5>{% trans "Statistics on absences, tardiness and remarks" %}</h5> - <ul class="collapsible"> - {% for school_term, stat in stats %} - <li {% if forloop.first %}class="active"{% endif %}> - <div class="collapsible-header"> - <i class="material-icons">date_range</i>{{ school_term }}</div> - <div class="collapsible-body"> - <table> - <tr> - <th colspan="2">{% trans 'Absences' %}</th> - <td>{{ stat.absences_count }}</td> - </tr> - <tr> - <td rowspan="{{ excuse_types.count|add:2 }}" class="hide-on-small-only">{% trans "thereof" %}</td> - <td rowspan="{{ excuse_types.count|add:2 }}" class="hide-on-med-and-up"></td> - <th class="truncate">{% trans 'Excused' %}</th> - <td>{{ stat.excused }}</td> - </tr> - {% for excuse_type in excuse_types %} - <th>{{ excuse_type.name }}</th> - <td>{{ stat|get_dict:excuse_type.count_label }}</td> - {% endfor %} - <tr> - <th>{% trans 'Unexcused' %}</th> - <td>{{ stat.unexcused }}</td> - </tr> - <tr> - <th colspan="2">{% trans 'Tardiness' %}</th> - <td>{{ stat.tardiness }}'/{{ stat.tardiness_count }} ×</td> - </tr> - {% for extra_mark in extra_marks %} - <tr> - <th colspan="2">{{ extra_mark.name }}</th> - <td>{{ stat|get_dict:extra_mark.count_label }}</td> - </tr> - {% endfor %} - </table> - </div> - </li> - {% endfor %} - </ul> - {% endif %} + <!-- Personal Note Tab --> + <div class="col s12" id="personal-notes"> + <div class="col s12" id="overview"> + <h5>{% trans "Relevant personal notes" %}</h5> + <form class="modal" id="filter-modal"> + <div class="modal-content"> + <h4>{% trans "Filter personal notes" %}</h4> + {% form form=personal_note_filter_form %}{% endform %} + </div> + <div class="modal-footer"> + <button type="button" class="btn-flat secondary-color-text waves-effect waves-ripple" id="remove-filters"> + <i class="material-icons left">clear</i>{% trans "Clear all filters" %} + </button> + <button type="button" class="modal-close btn-flat red-text waves-effect waves-ripple waves-red"> + <i class="material-icons left">cancel</i>{% trans "Close" %} + </button> + <button type="submit" class="modal-close btn-flat primary-color-text waves-effect waves-ripple waves-light"> + <i class="material-icons left">filter_alt</i>{% trans "Filter" %} + </button> + </div> + </form> + {% has_perm "alsijil.edit_person_overview_personalnote" user person as can_mark_all_as_excused %} + <div class="row"> + <div class="col s12 m3 l5 push-m9 push-l7"> + <button + class="modal-trigger btn primary-color waves-effect waves-light + {% if can_mark_all_as_excused %} medium-high-right {% endif %}" + data-target="filter-modal" + type="button"> + Filter results ({{ num_filters }})<i class="material-icons right">filter_alt</i> + </button> + </div> + <form action="" method="post" class=""> + {% csrf_token %} + <div class="col s12 m9 l7 pull-m3 pull-l5 row"> + {% if can_mark_all_as_excused %} + <div class="col s12 m9"> + {% form form=action_form %}{% endform %} + </div> + <div class="col s12 m3"> + <button type="submit" class="btn waves-effect waves-light medium-high full-width-s"> + Run <i class="material-icons right">send</i> + </button> + </div> + {% endif %} + </div> + <div class="col s12 overflow-x-scroll"> + {% render_table personal_notes_table %} + </div> + </form> </div> - <div class="col s12 m12 l6"> - <h5>{% trans "Relevant personal notes" %}</h5> - <ul class="collapsible"> - <li> - <div> - <ul> - {% for note in personal_notes %} - {% ifchanged note.school_term %}</ul></div></li> - <li {% if forloop.first %}class="active"{% endif %}> - <div class="collapsible-header"><i - class="material-icons">date_range</i>{{ note.school_term }}</div> - <div class="collapsible-body"> - <ul class="collection"> - {% endifchanged %} - - {% ifchanged note.week %} - <li class="collection-item"> - <strong>{% blocktrans with week=note.calendar_week.week %}Week {{ week }}{% endblocktrans %}</strong> - </li> - {% endifchanged %} - {% ifchanged note.date %} - <li class="collection-item"> - {% if can_mark_all_as_excused and note.date %} - <form action="" method="post" class="right hide-on-small-only" style="margin-top: -7px;"> - {% csrf_token %} - {% trans "Mark all as" %} - <input type="hidden" value="{{ note.date|date:"Y-m-d" }}" name="date"> - {% include "alsijil/partials/mark_as_buttons.html" %} - </form> - {% endif %} - <i class="material-icons left">schedule</i> - - {% if note.date %} - {{ note.date }} - {% else %} - {{ note.register_object.date_start }} - {{ note.register_object.period_from.period }}.–{{ note.register_object.date_end }} - {{ note.register_object.period_to.period }}. - {% endif %} - - {% if can_mark_all_as_excused and note.date %} - <form action="" method="post" class="hide-on-med-and-up"> - {% csrf_token %} - {% trans "Mark all as" %} - <input type="hidden" value="{{ note.date|date:"Y-m-d" }}" name="date"> - {% include "alsijil/partials/mark_as_buttons.html" %} - </form> - {% endif %} - </li> - {% endifchanged %} - - <li class="collection-item"> - <div class="row no-margin"> - <div class="col s2 m1"> - {% if note.register_object.period %} - {{ note.register_object.period.period }}. - {% endif %} - </div> - - <div class="col s10 m4"> - <i class="material-icons left">event_note</i> - <a href="{{ note.get_absolute_url }}"> - {% if note.register_object.get_subject %} - {{ note.register_object.get_subject.name }} - {% else %} - {% trans "Event" %} ({{ note.register_object.title }}) - {% endif %}<br/> - {{ note.register_object.teacher_names }} - </a> - </div> - - <div class="col s12 m7 no-padding"> - {% has_perm "alsijil.edit_personalnote" user note as can_edit_personal_note %} - {% if note.absent and not note.excused and can_edit_personal_note %} - <form action="" method="post" class="right hide-on-small-only" style="margin-top: -7px;"> - {% csrf_token %} - {% trans "Mark as" %} - <input type="hidden" value="{{ note.pk }}" name="personal_note"> - {% include "alsijil/partials/mark_as_buttons.html" %} - <a class="btn-flat red-text" title="{% trans "Delete note" %}" - href="{% url "delete_personal_note" note.pk %}"> - <i class="material-icons center">cancel</i> - </a> - </form> - {% elif can_edit_personal_note %} - <a class="btn-flat red-text right hide-on-small-only" title="{% trans "Delete note" %}" - href="{% url "delete_personal_note" note.pk %}"> - <i class="material-icons center">cancel</i> - </a> - {% endif %} - - {% if note.absent %} - <div class="chip red white-text"> - {% trans 'Absent' %} - </div> - {% endif %} - {% if note.excused %} - <div class="chip green white-text"> - {% if note.excuse_type %} - {{ note.excuse_type.name }} - {% else %} - {% trans 'Excused' %} - {% endif %} - </div> - {% endif %} - - {% if note.late %} - <div class="chip orange white-text"> - {% blocktrans with late=note.late %}{{ late }}' late{% endblocktrans %} - </div> - {% endif %} - - {% for extra_mark in note.extra_marks.all %} - <div class="chip">{{ extra_mark.name }}</div> - {% endfor %} - - <em>{{ note.remarks }}</em> + </div> + </div> - </div> - <div class="col s12 hide-on-med-and-up"> - {% if note.absent and not note.excused and can_edit_personal_note %} - <form action="" method="post"> - {% csrf_token %} - {% trans "Mark as" %} - <input type="hidden" value="{{ note.pk }}" name="personal_note"> - {% include "alsijil/partials/mark_as_buttons.html" %} - <a class="btn-flat red-text" title="{% trans "Delete note" %}" - href="{% url "delete_personal_note" note.pk %}"> - <i class="material-icons center">cancel</i> - </a> - </form> - {% elif can_edit_personal_note %} - <a class="btn-flat red-text" title="{% trans "Delete note" %}" - href="{% url "delete_personal_note" note.pk %}"> - <i class="material-icons left">cancel</i> - {% trans "Delete" %} - </a> - {% endif %} - </div> - </li> + <!-- Statistics Tab --> + {% if stats %} + <div class="col s12" id="statistics"> + <h5>{% trans "Statistics on absences, tardiness and remarks" %}</h5> + <ul class="collapsible"> + {% for school_term, stat in stats %} + <li {% if forloop.first %}class="active"{% endif %}> + <div class="collapsible-header"> + <i class="material-icons">date_range</i>{{ school_term }}</div> + <div class="collapsible-body"> + <table> + <tr> + <th colspan="2">{% trans 'Absences' %}</th> + <td>{{ stat.absences_count }}</td> + </tr> + <tr> + <td rowspan="{{ excuse_types.count|add:2 }}" class="hide-on-small-only">{% trans "thereof" %}</td> + <td rowspan="{{ excuse_types.count|add:2 }}" class="hide-on-med-and-up"></td> + <th class="truncate">{% trans 'Excused' %}</th> + <td>{{ stat.excused }}</td> + </tr> + {% for excuse_type in excuse_types %} + <th>{{ excuse_type.name }}</th> + <td>{{ stat|get_dict:excuse_type.count_label }}</td> {% endfor %} - </li> - </ul> - </div> - </div> + <tr> + <th>{% trans 'Unexcused' %}</th> + <td>{{ stat.unexcused }}</td> + </tr> + <tr> + <th colspan="2">{% trans 'Tardiness' %}</th> + <td>{{ stat.tardiness }}'/{{ stat.tardiness_count }} ×</td> + </tr> + {% for extra_mark in extra_marks %} + <tr> + <th colspan="2">{{ extra_mark.name }}</th> + <td>{{ stat|get_dict:extra_mark.count_label }}</td> + </tr> + {% endfor %} + </table> + </div> + </li> + {% endfor %} + </ul> </div> - </div> + {% endif %} + <script type="text/javascript"> + $("#remove-filters").click(function () { + $("#filter-modal").trigger("reset"); + $("#filter-modal input, #filter-modal select").each(function () { + $(this).val(""); + }) + }) + </script> {% endblock %} diff --git a/aleksis/apps/alsijil/templates/alsijil/partials/mark_as_buttons.html b/aleksis/apps/alsijil/templates/alsijil/partials/mark_as_buttons.html deleted file mode 100644 index 5b198afa4bceea55c2b749fa8b3f3b8d88b335e8..0000000000000000000000000000000000000000 --- a/aleksis/apps/alsijil/templates/alsijil/partials/mark_as_buttons.html +++ /dev/null @@ -1,12 +0,0 @@ -{% load i18n %} -<button type="submit" class="btn-flat tooltipped" name="excuse_type" value="e" title="{% trans "Excused" %}" - data-position="bottom" data-tooltip="{% trans "Excused" %}" style="width: 50px;"> - {% trans "e" %} -</button> -{% for excuse_type in excuse_types %} - <button type="submit" class="btn-flat tooltipped" value="{{ excuse_type.pk }}" name="excuse_type" - title="{{ excuse_type.name }}" data-position="bottom" data-tooltip="{{ excuse_type.name }}" - style="width: 50px;"> - {{ excuse_type.short_name }} - </button> -{% endfor %} diff --git a/aleksis/apps/alsijil/views.py b/aleksis/apps/alsijil/views.py index c4c4385f6e00bafe70e7c0147e0e974c9132d5d9..42237abc840ebd246a6a249168fc20f2d9667aa9 100644 --- a/aleksis/apps/alsijil/views.py +++ b/aleksis/apps/alsijil/views.py @@ -42,6 +42,7 @@ from aleksis.core.util.core_helpers import get_site_preferences, objectgetter_op from aleksis.core.util.pdf import render_pdf from aleksis.core.util.predicates import check_global_permission +from .filters import PersonalNoteFilter from .forms import ( AssignGroupRoleForm, ExcuseTypeForm, @@ -51,6 +52,7 @@ from .forms import ( GroupRoleForm, LessonDocumentationForm, PersonalNoteFormSet, + PersonOverviewForm, RegisterAbsenceForm, RegisterObjectActionForm, SelectForm, @@ -67,6 +69,7 @@ from .tables import ( ExcuseTypeTable, ExtraMarkTable, GroupRoleTable, + PersonalNoteTable, RegisterObjectSelectTable, RegisterObjectTable, ) @@ -772,76 +775,14 @@ def overview_person(request: HttpRequest, id_: Optional[int] = None) -> HttpResp )(request, id_) context["person"] = person - if request.method == "POST": - if request.POST.get("excuse_type"): - # Get excuse type - excuse_type = request.POST["excuse_type"] - found = False - if excuse_type == "e": - excuse_type = None - found = True - else: - try: - excuse_type = ExcuseType.objects.get(pk=int(excuse_type)) - found = True - except (ExcuseType.DoesNotExist, ValueError): - pass - - if found: - if request.POST.get("date"): - # Mark absences on date as excused - try: - date = datetime.strptime(request.POST["date"], "%Y-%m-%d").date() - - if not request.user.has_perm( - "alsijil.edit_person_overview_personalnote", person - ): - raise PermissionDenied() - - notes = person.personal_notes.filter(absent=True, excused=False,).filter( - Q( - week=date.isocalendar()[1], - lesson_period__period__weekday=date.weekday(), - lesson_period__lesson__validity__date_start__lte=date, - lesson_period__lesson__validity__date_end__gte=date, - ) - | Q( - extra_lesson__week=date.isocalendar()[1], - extra_lesson__period__weekday=date.weekday(), - ) - ) - for note in notes: - note.excused = True - note.excuse_type = excuse_type - with reversion.create_revision(): - reversion.set_user(request.user) - note.save() - - messages.success(request, _("The absences have been marked as excused.")) - except ValueError: - pass - elif request.POST.get("personal_note"): - # Mark specific absence as excused - try: - note = PersonalNote.objects.get(pk=int(request.POST["personal_note"])) - if not request.user.has_perm("alsijil.edit_personalnote", note): - raise PermissionDenied() - if note.absent: - note.excused = True - note.excuse_type = excuse_type - with reversion.create_revision(): - reversion.set_user(request.user) - note.save() - messages.success(request, _("The absence has been marked as excused.")) - except (PersonalNote.DoesNotExist, ValueError): - pass - - person.refresh_from_db() - - person_personal_notes = person.personal_notes.all().prefetch_related( - "lesson_period__lesson__groups", - "lesson_period__lesson__teachers", - "lesson_period__substitutions", + person_personal_notes = ( + person.personal_notes.all() + .prefetch_related( + "lesson_period__lesson__groups", + "lesson_period__lesson__teachers", + "lesson_period__substitutions", + ) + .annotate_date_range() ) # Prefetch object permissions for groups the person is a member of @@ -896,11 +837,33 @@ def overview_person(request: HttpRequest, id_: Optional[int] = None) -> HttpResp When(extra_lesson__isnull=False, then="extra_lesson__period__period"), When(lesson_period__isnull=False, then="lesson_period__period__period"), ), + order_groups=Case( + When(event__isnull=False, then="event__groups"), + When(extra_lesson__isnull=False, then="extra_lesson__groups"), + When(lesson_period__isnull=False, then="lesson_period__lesson__groups"), + ), + order_teachers=Case( + When(event__isnull=False, then="event__teachers"), + When(extra_lesson__isnull=False, then="extra_lesson__teachers"), + When(lesson_period__isnull=False, then="lesson_period__lesson__teachers"), + ), ) .order_by( "-school_term_start", "-order_year", "-order_week", "-order_weekday", "order_period", ) + .annotate_date_range() + .annotate_subject() ) + + personal_note_filter_object = PersonalNoteFilter(request.GET, queryset=personal_notes) + filtered_personal_notes = personal_note_filter_object.qs + context["personal_note_filter_form"] = personal_note_filter_object.form + + used_filters = list(personal_note_filter_object.data.values()) + context["num_filters"] = ( + len(used_filters) - used_filters.count("") - used_filters.count("unknown") + ) + personal_notes_list = [] for note in personal_notes: note.set_object_permission_checker(checker) @@ -908,6 +871,19 @@ def overview_person(request: HttpRequest, id_: Optional[int] = None) -> HttpResp context["personal_notes"] = personal_notes_list context["excuse_types"] = ExcuseType.objects.all() + form = PersonOverviewForm(request, request.POST or None, queryset=allowed_personal_notes) + if request.method == "POST": + if form.is_valid(): + with reversion.create_revision(): + reversion.set_user(request.user) + form.execute() + person.refresh_from_db() + context["action_form"] = form + + table = PersonalNoteTable(filtered_personal_notes) + RequestConfig(request, paginate={"per_page": 20}).configure(table) + context["personal_notes_table"] = table + extra_marks = ExtraMark.objects.all() excuse_types = ExcuseType.objects.all() if request.user.has_perm("alsijil.view_person_statistics_personalnote", person):