Skip to content
Snippets Groups Projects
Commit 161d38c4 authored by Nik | Klampfradler's avatar Nik | Klampfradler
Browse files

Merge branch '66-add-support-for-multiple-excuse-types' into 'master'

Resolve "Add support for multiple excuse types"

Closes #66

See merge request !64
parents fcd426e2 66a9e57a
No related branches found
No related tags found
1 merge request!64Resolve "Add support for multiple excuse types"
Pipeline #3060 passed
Showing
with 406 additions and 35 deletions
......@@ -11,7 +11,7 @@ from material import Layout, Row
from aleksis.apps.chronos.managers import TimetableType
from aleksis.core.models import Group, Person
from .models import LessonDocumentation, PersonalNote, PersonalNoteFilter
from .models import ExcuseType, LessonDocumentation, PersonalNote, PersonalNoteFilter
class LessonDocumentationForm(forms.ModelForm):
......@@ -28,7 +28,7 @@ class LessonDocumentationForm(forms.ModelForm):
class PersonalNoteForm(forms.ModelForm):
class Meta:
model = PersonalNote
fields = ["absent", "late", "excused", "remarks"]
fields = ["absent", "late", "excused", "excuse_type", "remarks"]
person_name = forms.CharField(disabled=True)
......@@ -47,10 +47,7 @@ class SelectForm(forms.Form):
layout = Layout(Row("group", "teacher"))
group = forms.ModelChoiceField(
queryset=None,
label=_("Group"),
required=False,
widget=Select2Widget,
queryset=None, label=_("Group"), required=False, widget=Select2Widget,
)
teacher = forms.ModelChoiceField(
queryset=Person.objects.annotate(
......@@ -81,8 +78,10 @@ class SelectForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["group"].queryset = Group.objects.for_current_school_term_or_all().annotate(lessons_count=Count("lessons")).filter(
lessons_count__gt=0
self.fields["group"].queryset = (
Group.objects.for_current_school_term_or_all()
.annotate(lessons_count=Count("lessons"))
.filter(lessons_count__gt=0)
)
......@@ -116,3 +115,11 @@ class PersonalNoteFilterForm(forms.ModelForm):
class Meta:
model = PersonalNoteFilter
fields = ["identifier", "description", "regex"]
class ExcuseTypeForm(forms.ModelForm):
layout = Layout("short_name", "name")
class Meta:
model = ExcuseType
fields = ["short_name", "name"]
......@@ -36,6 +36,12 @@ MENUS = {
"icon": "filter_list",
"validators": ["menu_generator.validators.is_superuser"],
},
{
"name": _("Excuse types"),
"url": "excuse_types",
"icon": "label",
"validators": ["menu_generator.validators.is_superuser"],
},
],
}
]
......
# Generated by Django 3.0.8 on 2020-07-10 10:46
import django.contrib.postgres.fields.jsonb
import django.contrib.sites.managers
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sites", "0002_alter_domain_unique"),
("alsijil", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="ExcuseType",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"extended_data",
django.contrib.postgres.fields.jsonb.JSONField(
default=dict, editable=False
),
),
(
"short_name",
models.CharField(
max_length=255, unique=True, verbose_name="Short name"
),
),
(
"name",
models.CharField(max_length=255, unique=True, verbose_name="Name"),
),
(
"site",
models.ForeignKey(
default=1,
editable=False,
on_delete=django.db.models.deletion.CASCADE,
to="sites.Site",
),
),
],
options={
"verbose_name": "Excuse type",
"verbose_name_plural": "Excuse types",
"ordering": ["name"],
},
managers=[("objects", django.contrib.sites.managers.CurrentSiteManager()),],
),
migrations.AddField(
model_name="personalnote",
name="excuse_type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="alsijil.ExcuseType",
verbose_name="Excuse type",
),
),
]
......@@ -8,7 +8,7 @@ from calendarweek import CalendarWeek
from aleksis.apps.chronos.models import LessonPeriod
from aleksis.core.models import Group, Person
from .models import LessonDocumentation, PersonalNote
from .models import ExcuseType, LessonDocumentation, PersonalNote
@Person.method
......@@ -18,6 +18,7 @@ def mark_absent(
from_period: int = 0,
absent: bool = True,
excused: bool = False,
excuse_type: Optional[ExcuseType] = None,
remarks: str = "",
):
"""Mark a person absent for all lessons in a day, optionally starting with a selected period number.
......@@ -44,7 +45,7 @@ def mark_absent(
person=self,
lesson_period=lesson_period,
week=wanted_week.week,
defaults={"absent": absent, "excused": excused},
defaults={"absent": absent, "excused": excused, "excuse_type": excuse_type},
)
if remarks:
......
......@@ -8,6 +8,30 @@ def isidentifier(value: str) -> bool:
return value.isidentifier()
class ExcuseType(ExtensibleModel):
"""An type of excuse.
Can be used to count different types of absences separately.
"""
short_name = models.CharField(
max_length=255, unique=True, verbose_name=_("Short name")
)
name = models.CharField(max_length=255, unique=True, verbose_name=_("Name"))
def __str__(self):
return f"{self.name} ({self.short_name})"
@property
def count_label(self):
return f"{self.short_name}_count"
class Meta:
ordering = ["name"]
verbose_name = _("Excuse type")
verbose_name_plural = _("Excuse types")
class PersonalNote(ExtensibleModel):
"""A personal note about a single person.
......@@ -27,9 +51,21 @@ class PersonalNote(ExtensibleModel):
absent = models.BooleanField(default=False)
late = models.IntegerField(default=0)
excused = models.BooleanField(default=False)
excuse_type = models.ForeignKey(
ExcuseType,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Excuse type"),
)
remarks = models.CharField(max_length=200, blank=True)
def save(self, *args, **kwargs):
if self.excuse_type:
self.excused = True
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Personal note")
verbose_name_plural = _("Personal notes")
......
table.small-print {
table.small-print, td.small-print, th.small-print {
font-size: 10pt;
}
......@@ -25,7 +25,7 @@ tr.lessons-day-first {
border-top: 3px solid rgba(0, 0, 0, 0.3);
}
th.lessons-day-head {
th.lessons-day-head, td.rotate, th.rotate {
text-align: center;
transform: rotate(-90deg);
}
......
......@@ -17,3 +17,23 @@ class PersonalNoteFilterTable(tables.Table):
text=_("Edit"),
attrs={"a": {"class": "btn-flat waves-effect waves-orange"}},
)
class ExcuseTypeTable(tables.Table):
class Meta:
attrs = {"class": "highlight"}
name = tables.LinkColumn("edit_excuse_type", args=[A("id")])
short_name = tables.Column()
edit = tables.LinkColumn(
"edit_excuse_type",
args=[A("id")],
text=_("Edit"),
attrs={"a": {"class": "btn-flat waves-effect waves-orange orange-text"}},
)
delete = tables.LinkColumn(
"delete_excuse_type",
args=[A("id")],
text=_("Delete"),
attrs={"a": {"class": "btn-flat waves-effect waves-red red-text"}},
)
......@@ -144,6 +144,7 @@
<th>{% blocktrans %}Absent{% endblocktrans %}</th>
<th>{% blocktrans %}Tardiness{% endblocktrans %}</th>
<th>{% blocktrans %}Excused{% endblocktrans %}</th>
<th>{% blocktrans %}Excuse type{% endblocktrans %}</th>
<th>{% blocktrans %}Remarks{% endblocktrans %}</th>
</tr>
</thead>
......@@ -172,6 +173,14 @@
<span></span>
</label>
</td>
<td>
<div class="input-field">
{{ form.excuse_type }}
<label for="{{ form.excuse_type.id_for_label }}">
{% trans "Excuse type" %}
</label>
</div>
</td>
<td>
<div class="input-field">
{{ form.remarks }}
......
{# -*- engine:django -*- #}
{% extends "core/base.html" %}
{% load material_form i18n %}
{% block browser_title %}{% blocktrans %}Create excuse type{% endblocktrans %}{% endblock %}
{% block page_title %}{% blocktrans %}Create excuse type{% endblocktrans %}{% endblock %}
{% block content %}
{% include "alsijil/excuse_type/warning.html" %}
<form method="post">
{% csrf_token %}
{% form form=form %}{% endform %}
{% include "core/partials/save_button.html" %}
</form>
{% endblock %}
{# -*- engine:django -*- #}
{% extends "core/base.html" %}
{% load material_form i18n %}
{% block browser_title %}{% blocktrans %}Edit excuse type{% endblocktrans %}{% endblock %}
{% block page_title %}{% blocktrans %}Edit excuse type{% endblocktrans %}{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{% form form=form %}{% endform %}
{% include "core/partials/save_button.html" %}
</form>
{% endblock %}
{# -*- engine:django -*- #}
{% extends "core/base.html" %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block browser_title %}{% blocktrans %}Excuse types{% endblocktrans %}{% endblock %}
{% block page_title %}{% blocktrans %}Excuse types{% endblocktrans %}{% endblock %}
{% block content %}
{% include "alsijil/excuse_type/warning.html" %}
<a class="btn green waves-effect waves-light" href="{% url 'create_excuse_type' %}">
<i class="material-icons left">add</i>
{% trans "Create excuse type" %}
</a>
{% render_table table %}
{% endblock %}
{% 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>
{% load i18n %}
{% for note in notes %}
<span class="{% if note.excused %}green-text{% else %}red-text{% endif %}">{{ note.person }}
{% if note.excused %}{% trans "(e)" %}{% else %}{% trans "(u)" %}{% endif %}{% if not forloop.last %},{% endif %}
{% if note.excused %}{% if note.excuse_type %}({{ note.excuse_type.short_name }}){% else %}{% trans "(e)" %}{% endif %}{% else %}{% trans "(u)" %}{% endif %}{% if not forloop.last %},{% endif %}
</span>
{% endfor %}
......@@ -66,6 +66,40 @@
<div class="page-break">&nbsp;</div>
<h4>{% trans "Abbreviations" %}</h4>
<h5>{% trans "General" %}</h5>
<ul class="collection">
<li class="collection-item">
<strong>(a)</strong> {% trans "Absent" %}
</li>
<li class="collection-item">
<strong>(b)</strong> {% trans "Late" %}
</li>
<li class="collection-item">
<strong>(u)</strong> {% trans "Unexcused" %}
</li>
<li class="collection-item">
<strong>(e)</strong> {% trans "Excused" %}
</li>
</ul>
{% if excuse_types %}
<h5>{% trans "Custom excuse types" %}</h5>
<ul class="collection">
{% for excuse_type in excuse_types %}
<li class="collection-item">
<strong>({{ excuse_type.short_name }})</strong> {{ excuse_type.name }}
</li>
{% endfor %}
</ul>
{% endif %}
<div class="page-break">&nbsp;</div>
<h4>{% trans 'Persons in group' %} {{ group.name }}</h4>
<table id="persons">
......@@ -76,9 +110,13 @@
<th>{% trans 'First name' %}</th>
<th>{% trans 'Sex' %}</th>
<th>{% trans 'Date of birth' %}</th>
<th>{% trans 'Absences' %}</th>
<th>{% trans 'Unexcused' %}</th>
<th>{% trans 'Tard.' %}</th>
<th>{% trans '(a)' %}</th>
<th>{% trans "(e)" %}</th>
{% for excuse_type in excuse_types %}
<th>({{ excuse_type.short_name }})</th>
{% endfor %}
<th>{% trans '(u)' %}</th>
<th>{% trans '(b)' %}</th>
</tr>
</thead>
......@@ -91,6 +129,10 @@
<td>{{ person.get_sex_display }}</td>
<td>{{ person.date_of_birth }}</td>
<td>{{ person.absences_count }}</td>
<td>{{ person.excused }}</td>
{% for excuse_type in excuse_types %}
<td>{{ person|get_dict:excuse_type.count_label }}</td>
{% endfor %}
<td>{{ person.unexcused }}</td>
<td>{{ person.tardiness }}'</td>
</tr>
......@@ -228,21 +270,28 @@
<h5>{% trans 'Absences and tardiness' %}</h5>
<table>
<thead>
<tr>
<th>{% trans 'Absences' %}</th>
<th>{% trans 'Unexcused' %}</th>
<th>{% trans 'Tardiness' %}</th>
<th colspan="2">{% trans 'Absences' %}</th>
<td>{{ person.absences_count }}</td>
</tr>
</thead>
<tbody>
<tr>
<td>{{ person.absences_count }}</td>
<td rowspan="{{ excuse_types.count|add:2 }}" style="width: 16mm;"
class="rotate small-print">{% trans "thereof" %}</td>
<th>{% trans 'Excused' %}</th>
<td>{{ person.excused }}</td>
</tr>
{% for excuse_type in excuse_types %}
<th>{{ excuse_type.name }}</th>
<td>{{ person|get_dict:excuse_type.count_label }}</td>
{% endfor %}
<tr>
<th>{% trans 'Unexcused' %}</th>
<td>{{ person.unexcused }}</td>
</tr>
<tr>
<th colspan="2">{% trans 'Tardiness' %}</th>
<td>{{ person.tardiness }}'</td>
</tr>
</tbody>
</table>
<h5>{% trans 'Relevant personal notes' %}</h5>
......@@ -271,8 +320,12 @@
<td>
{% if note.absent %}
{% trans 'Yes' %}
{% if note.escused %}
({% trans 'e' %})
{% if note.excused %}
{% if note.excuse_type %}
({{ note.excuse_type.short_name }})
{% else %}
({% trans 'e' %})
{% endif %}
{% endif %}
{% endif %}
</td>
......@@ -349,8 +402,12 @@
{{ note.person.last_name }}, {{ note.person.first_name|slice:"0:1" }}.
{% if note.excused %}
<span class="lesson-note-excused">
({% trans 'e' %})
</span>
{% if note.excuse_type %}
({{ note.excuse_type.short_name }})
{% else %}
({% trans 'e' %})
{% endif %}
</span>
{% endif %}
</span>
{% endif %}
......@@ -360,8 +417,12 @@
({{ note.late }}′)
{% if note.excused %}
<span class="lesson-note-excused">
({% trans 'e' %})
</span>
{% if note.excuse_type %}
({{ note.excuse_type.short_name }})
{% else %}
({% trans 'e' %})
{% endif %}
</span>
{% endif %}
</span>
{% endif %}
......
......@@ -41,4 +41,20 @@ urlpatterns = [
views.delete_personal_note_filter,
name="delete_personal_note_filter",
),
path("excuse_types/", views.ExcuseTypeListView.as_view(), name="excuse_types"),
path(
"excuse_types/create/",
views.ExcuseTypeCreateView.as_view(),
name="create_excuse_type",
),
path(
"excuse_types/<int:pk>/edit/",
views.ExcuseTypeEditView.as_view(),
name="edit_excuse_type",
),
path(
"excuse_types/<int:pk>/delete/",
views.ExcuseTypeDeleteView.as_view(),
name="delete_excuse_type",
),
]
......@@ -5,27 +5,31 @@ from django.core.exceptions import PermissionDenied
from django.db.models import Count, Exists, F, OuterRef, 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
from django.urls import reverse, reverse_lazy
from django.utils.translation import ugettext as _
from calendarweek import CalendarWeek
from django_tables2 import RequestConfig
from django_tables2 import RequestConfig, SingleTableView
from reversion.views import RevisionMixin
from rules.contrib.views import PermissionRequiredMixin
from aleksis.apps.chronos.managers import TimetableType
from aleksis.apps.chronos.models import LessonPeriod
from aleksis.apps.chronos.util.chronos_helpers import get_el_by_pk
from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
from aleksis.core.models import Group, Person, SchoolTerm
from aleksis.core.util import messages
from .forms import (
ExcuseTypeForm,
LessonDocumentationForm,
PersonalNoteFilterForm,
PersonalNoteFormSet,
RegisterAbsenceForm,
SelectForm,
)
from .models import LessonDocumentation, PersonalNoteFilter
from .tables import PersonalNoteFilterTable
from .models import ExcuseType, LessonDocumentation, PersonalNoteFilter
from .tables import ExcuseTypeTable, PersonalNoteFilterTable
def lesson(
......@@ -99,6 +103,8 @@ def lesson(
if lesson_documentation_form.is_valid():
lesson_documentation_form.save()
messages.success(request, _("The lesson documentation has been saved."))
if personal_note_formset.is_valid():
instances = personal_note_formset.save()
......@@ -109,8 +115,16 @@ def lesson(
lesson_period.period.period + 1,
instance.absent,
instance.excused,
instance.excuse_type,
)
messages.success(request, _("The personal notes have been saved."))
# Regenerate form here to ensure that programmatically changed data will be shown correctly
personal_note_formset = PersonalNoteFormSet(
None, queryset=persons_qs, prefix="personal_notes"
)
context["lesson_documentation"] = lesson_documentation
context["lesson_documentation_form"] = lesson_documentation_form
context["personal_note_formset"] = personal_note_formset
......@@ -324,6 +338,14 @@ def full_register_group(request: HttpRequest, id_: int) -> HttpResponse:
absences_count=Count(
"personal_notes__absent", filter=Q(personal_notes__absent=True)
),
excused=Count(
"personal_notes__absent",
filter=Q(
personal_notes__absent=True,
personal_notes__excused=True,
personal_notes__excuse_type__isnull=True,
),
),
unexcused=Count(
"personal_notes__absent",
filter=Q(personal_notes__absent=True, personal_notes__excused=False),
......@@ -331,6 +353,19 @@ def full_register_group(request: HttpRequest, id_: int) -> HttpResponse:
tardiness=Sum("personal_notes__late"),
)
for excuse_type in ExcuseType.objects.all():
persons = persons.annotate(
**{
excuse_type.count_label: Count(
"personal_notes__absent",
filter=Q(
personal_notes__absent=True,
personal_notes__excuse_type=excuse_type,
),
)
}
)
# FIXME Move to manager
personal_note_filters = PersonalNoteFilter.objects.all()
for personal_note_filter in personal_note_filters:
......@@ -349,6 +384,7 @@ def full_register_group(request: HttpRequest, id_: int) -> HttpResponse:
context["school_term"] = current_school_term
context["persons"] = persons
context["personal_note_filters"] = personal_note_filters
context["excuse_types"] = ExcuseType.objects.all()
context["group"] = group
context["weeks"] = weeks
context["periods_by_day"] = periods_by_day
......@@ -439,3 +475,44 @@ def delete_personal_note_filter(request: HttpRequest, id_: int) -> HttpResponse:
context["personal_note_filter"] = personal_note_filter
return redirect("list_personal_note_filters")
class ExcuseTypeListView(SingleTableView, PermissionRequiredMixin):
"""Table of all excuse types."""
model = ExcuseType
table_class = ExcuseTypeTable
permission_required = "core.view_excusetype"
template_name = "alsijil/excuse_type/list.html"
class ExcuseTypeCreateView(AdvancedCreateView, PermissionRequiredMixin):
"""Create view for excuse types."""
model = ExcuseType
form_class = ExcuseTypeForm
permission_required = "core.create_excusetype"
template_name = "alsijil/excuse_type/create.html"
success_url = reverse_lazy("excuse_types")
success_message = _("The excuse type has been created.")
class ExcuseTypeEditView(AdvancedEditView, PermissionRequiredMixin):
"""Edit view for excuse types."""
model = ExcuseType
form_class = ExcuseTypeForm
permission_required = "core.edit_excusetype"
template_name = "alsijil/excuse_type/edit.html"
success_url = reverse_lazy("excuse_types")
success_message = _("The excuse type has been saved.")
class ExcuseTypeDeleteView(AdvancedDeleteView, PermissionRequiredMixin, RevisionMixin):
"""Delete view for excuse types"""
model = ExcuseType
permission_required = "core.delete_excusetype"
template_name = "core/pages/delete.html"
success_url = reverse_lazy("excuse_types")
success_message = _("The excuse type has been deleted.")
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment