Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • AlekSIS/official/AlekSIS-App-Alsijil
  • sunweaver/AlekSIS-App-Alsijil
  • 8tincsoVluke/AlekSIS-App-Alsijil
  • perfreicpo/AlekSIS-App-Alsijil
  • noifobarep/AlekSIS-App-Alsijil
  • 7ingannisdo/AlekSIS-App-Alsijil
  • unmruntartpa/AlekSIS-App-Alsijil
  • balrorebta/AlekSIS-App-Alsijil
  • comliFdifwa/AlekSIS-App-Alsijil
  • 3ranaadza/AlekSIS-App-Alsijil
10 results
Show changes
Commits on Source (1015)
Showing
with 2036 additions and 470 deletions
......@@ -6,7 +6,7 @@
"eslint": "^8.26.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-vue": "^9.7.0",
"prettier": "^3.0.0",
"prettier": "^3.4.0",
"stylelint": "^15.0.0",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-standard": "^34.0.0"
......
......@@ -92,3 +92,5 @@ aleksis/core/util/licenses.json
.pnp.cjs
.pnp.loader.mjs
.git/
......@@ -9,10 +9,86 @@ and this project adheres to `Semantic Versioning`_.
Unreleased
----------
Upgrade notice
~~~~~~~~~~~~~~
If you're upgrading from 3.x, there is now a migration path to use.
Therefore, please install ``AlekSIS-App-Lesrooster`` which now
includes parts of the legacy Chronos and the migration path.
`4.0.0.dev9`_ - 2024-12-07
--------------------------
Added
~~~~~
* Configurable PDF export of the coursebook for one or more groups.
`4.0.0.dev8`_ - 2024-11-15
--------------------------
Added
~~~~~
* Widgets on person and group pages with detailed coursebook statistics
and including all participations/personal notes.
`4.0.0.dev3`_ - 2024-07-10
--------------------------
Added
~~~~~
* Support for entering personal notes for students in the new coursebook interface.
* Support for entering tardiness for students in the new coursebook interface.
`4.0.0.dev2`_ - 2024-07-13
--------------------------
Fixed version of 4.0.0.dev1
`4.0.0.dev1`_ - 2024-06-13
--------------------------
Added
~~~~~
* Support for entering absences for students in the new coursebook interface.
`4.0.0.dev0`_ - 2024-04-23
--------------------------
Notable, breaking changes
~~~~~~~~~~~~~~~~~~~~~~~~~
Starting from the class register core functionality, Alsijil is getting a entire rewrite
of both its frontend and backend. The models formerly used for lesson documentation, notably
`LessonDocumentation` and `PersonalNote` are replaced by new ones based on the calendar framework
provided by `AlekSIS-Core` and the absense framework provided by `AlekSIS-App-Kolego`. The legacy
views providing management functionality for those legacy models are not available anymore.
Changed
~~~~~~~
* Modern rewrite of class register/coursebook, both in the frontend and the backend
* Several legacy class register views were consolidated in a modern frontend (coursebook).
* [Dev] The `LessonDocumentation` model is replaced with the `Documentation` model, based on the calendar framework.
* [Dev] The `PersonalNote` model is replaced with the `NewPersonalNote` model.
* [Dev] Participation status documentation is taken over by the new `Participation` model.
Fixed
~~~~~
* Migrating failed due to an incorrect field reference.
`3.0.1`_ - 2023-09-02
-------------------
Fixed
~~~~~
* Migrations failed on empty database
`3.0`_ - 2023-05-15
-------------------
......@@ -323,3 +399,10 @@ Fixed
.. _2.1.1: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/2.1.1
.. _3.0b0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/3.0b0
.. _3.0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/3.0
.. _3.0.1: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/3.0.1
.. _4.0.0.dev0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/4.0.0.dev0
.. _4.0.0.dev1: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/4.0.0.dev1
.. _4.0.0.dev2: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/4.0.0.dev2
.. _4.0.0.dev3: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/4.0.0.dev3
.. _4.0.0.dev8: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/4.0.0.dev8
.. _4.0.0.dev9: https://edugit.org/AlekSIS/Official/AlekSIS-App-Alsijil/-/tags/4.0.0.dev9
......@@ -34,10 +34,11 @@ Licence
Copyright © 2019, 2021 Dominik George <dominik.george@teckids.org>
Copyright © 2019, 2020 Tom Teichler <tom.teichler@teckids.org>
Copyright © 2019 mirabilos <thorsten.glaser@teckids.org>
Copyright © 2020, 2021, 2022 Jonathan Weth <dev@jonathanweth.de>
Copyright © 2020, 2021 Julian Leucker <leuckeju@katharineum.de>
Copyright © 2020, 2022 Hangzhi Yu <yuha@katharineum.de>
Copyright © 2020, 2021, 2022, 2024 Jonathan Weth <dev@jonathanweth.de>
Copyright © 2020, 2021, 2024 Julian Leucker <leuckeju@katharineum.de>
Copyright © 2020, 2022, 2023, 2024 Hangzhi Yu <yuha@katharineum.de>
Copyright © 2021 Lloyd Meins <meinsll@katharineum.de>
Copyright © 2024 Michael Bauer <michael-bauer@posteo.de>
Licenced under the EUPL, version 1.2 or later, by Teckids e.V. (Bonn, Germany).
......
from typing import Callable, Sequence
from django.contrib import messages
from django.contrib.humanize.templatetags.humanize import apnumber
from django.http import HttpRequest
from django.template.loader import get_template
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from aleksis.apps.alsijil.models import PersonalNote
from aleksis.core.models import Notification
def mark_as_excused(modeladmin, request, queryset):
queryset.filter(absent=True).update(excused=True, excuse_type=None)
mark_as_excused.short_description = _("Mark as excused")
def mark_as_unexcused(modeladmin, request, queryset):
queryset.filter(absent=True).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.filter(absent=True).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):
notes = []
for personal_note in queryset:
personal_note.reset_values()
notes.append(personal_note)
PersonalNote.objects.bulk_update(
notes, fields=["absent", "excused", "tardiness", "excuse_type", "remarks"]
)
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.
Action for use with ``RegisterObjectTable`` and ``RegisterObjectActionForm``.
"""
# Group class register entries by teachers so each teacher gets just one notification
grouped_by_teachers = {}
for entry in selected_items:
teachers = entry["register_object"].get_teachers().all()
for teacher in teachers:
grouped_by_teachers.setdefault(teacher, [])
grouped_by_teachers[teacher].append(entry)
template = get_template("alsijil/notifications/check.html")
for teacher, items in grouped_by_teachers.items():
msg = template.render({"items": items})
title = _("{} asks you to check some class register entries.").format(
request.user.person.addressing_name
)
n = Notification(
title=title,
description=msg,
sender=request.user.person.addressing_name,
recipient=teacher,
link=request.build_absolute_uri(reverse("overview_me")),
)
n.save()
count_teachers = len(grouped_by_teachers.keys())
count_items = len(selected_items)
messages.success(
request,
_(
"We have successfully sent notifications to "
"{count_teachers} persons for {count_items} lessons."
).format(count_teachers=apnumber(count_teachers), count_items=apnumber(count_items)),
)
send_request_to_check_entry.short_description = _("Ask teacher to check data")
......@@ -13,8 +13,22 @@ class AlsijilConfig(AppConfig):
([2019, 2021], "Dominik George", "dominik.george@teckids.org"),
([2019, 2020], "Tom Teichler", "tom.teichler@teckids.org"),
([2019], "mirabilos", "thorsten.glaser@teckids.org"),
([2020, 2021, 2022], "Jonathan Weth", "dev@jonathanweth.de"),
([2020, 2021], "Julian Leucker", "leuckeju@katharineum.de"),
([2020, 2022], "Hangzhi Yu", "yuha@katharineum.de"),
([2020, 2021, 2022, 2024], "Jonathan Weth", "dev@jonathanweth.de"),
([2020, 2021, 2024], "Julian Leucker", "leuckeju@katharineum.de"),
([2020, 2022, 2023, 2024], "Hangzhi Yu", "yuha@katharineum.de"),
([2021], "Lloyd Meins", "meinsll@katharineum.de"),
([2024], "Michael Bauer", "michael-bauer@posteo.de"),
)
def post_migrate(
self,
app_config: AppConfig,
verbosity: int,
interactive: bool,
using: str,
**kwargs,
) -> None:
super().post_migrate(app_config, verbosity, interactive, using, **kwargs)
from .util.alsijil_helpers import get_absence_reason_tag
get_absence_reason_tag()
import logging
from datetime import datetime, time
from typing import TYPE_CHECKING
from django.db.models import F
from django.db.models.query_utils import Q
from django.utils.translation import gettext as _
from aleksis.apps.chronos.models import LessonEvent
from aleksis.core.data_checks import DataCheck, IgnoreSolveOption, SolveOption
if TYPE_CHECKING:
......@@ -32,22 +33,12 @@ class SetGroupsWithCurrentGroupsSolveOption(SolveOption):
check_result.delete()
class ResetPersonalNoteSolveOption(SolveOption):
name = "reset_personal_note"
verbose_name = _("Reset personal note to defaults")
@classmethod
def solve(cls, check_result: "DataCheckResult"):
note = check_result.related_object
note.reset_values()
note.save()
check_result.delete()
class NoPersonalNotesInCancelledLessonsDataCheck(DataCheck):
name = "no_personal_notes_in_cancelled_lessons"
verbose_name = _("Ensure that there are no personal notes in cancelled lessons")
problem_name = _("The personal note is related to a cancelled lesson.")
class NoParticipationStatusesPersonalNotesInCancelledLessonsDataCheck(DataCheck):
name = "no_personal_notes_participation_statuses_in_cancelled_lessons"
verbose_name = _(
"Ensure that there are no participation statuses and personal notes in cancelled lessons"
)
problem_name = _("The participation status or personal note is related to a cancelled lesson.")
solve_options = {
DeleteRelatedObjectSolveOption.name: DeleteRelatedObjectSolveOption,
IgnoreSolveOption.name: IgnoreSolveOption,
......@@ -55,27 +46,28 @@ class NoPersonalNotesInCancelledLessonsDataCheck(DataCheck):
@classmethod
def check_data(cls):
from .models import PersonalNote
personal_notes = (
PersonalNote.objects.not_empty()
.filter(
lesson_period__substitutions__cancelled=True,
lesson_period__substitutions__week=F("week"),
lesson_period__substitutions__year=F("year"),
)
.prefetch_related("lesson_period", "lesson_period__substitutions")
from .models import NewPersonalNote, ParticipationStatus
participation_statuses = ParticipationStatus.objects.filter(
related_documentation__amends__in=LessonEvent.objects.filter(cancelled=True)
)
personal_notes = NewPersonalNote.objects.filter(
documentation__amends__in=LessonEvent.objects.filter(cancelled=True)
)
for status in participation_statuses:
logging.info(f"Check participation status {status}")
cls.register_result(status)
for note in personal_notes:
logging.info(f"Check personal note {note}")
cls.register_result(note)
class NoGroupsOfPersonsSetInPersonalNotesDataCheck(DataCheck):
name = "no_groups_of_persons_set_in_personal_notes"
verbose_name = _("Ensure that 'groups_of_person' is set for every personal note")
problem_name = _("The personal note has no group in 'groups_of_person'.")
class NoGroupsOfPersonsSetInParticipationStatusesDataCheck(DataCheck):
name = "no_groups_of_persons_set_in_participation_statuses"
verbose_name = _("Ensure that 'groups_of_person' is set for every participation status")
problem_name = _("The participation status has no group in 'groups_of_person'.")
solve_options = {
SetGroupsWithCurrentGroupsSolveOption.name: SetGroupsWithCurrentGroupsSolveOption,
DeleteRelatedObjectSolveOption.name: DeleteRelatedObjectSolveOption,
......@@ -84,24 +76,21 @@ class NoGroupsOfPersonsSetInPersonalNotesDataCheck(DataCheck):
@classmethod
def check_data(cls):
from .models import PersonalNote
personal_notes = PersonalNote.objects.filter(groups_of_person__isnull=True)
from .models import ParticipationStatus
for note in personal_notes:
logging.info(f"Check personal note {note}")
cls.register_result(note)
participation_statuses = ParticipationStatus.objects.filter(groups_of_person__isnull=True)
for status in participation_statuses:
logging.info(f"Check participation status {status}")
cls.register_result(status)
class LessonDocumentationOnHolidaysDataCheck(DataCheck):
"""Checks for lesson documentation objects on holidays.
This ignores empty lesson documentation as they are created by default.
"""
class DocumentationOnHolidaysDataCheck(DataCheck):
"""Checks for documentation objects on holidays."""
name = "lesson_documentation_on_holidays"
verbose_name = _("Ensure that there are no filled out lesson documentations on holidays")
problem_name = _("The lesson documentation is on holidays.")
name = "documentation_on_holidays"
verbose_name = _("Ensure that there are no documentations on holidays")
problem_name = _("The documentation is on holidays.")
solve_options = {
DeleteRelatedObjectSolveOption.name: DeleteRelatedObjectSolveOption,
IgnoreSolveOption.name: IgnoreSolveOption,
......@@ -109,33 +98,33 @@ class LessonDocumentationOnHolidaysDataCheck(DataCheck):
@classmethod
def check_data(cls):
from aleksis.apps.chronos.models import Holiday
from aleksis.core.models import Holiday
from .models import LessonDocumentation
from .models import Documentation
holidays = Holiday.objects.all()
documentations = LessonDocumentation.objects.not_empty().annotate_date_range()
q = Q(pk__in=[])
for holiday in holidays:
q = q | Q(day_end__gte=holiday.date_start, day_start__lte=holiday.date_end)
documentations = documentations.filter(q)
q = q | Q(
datetime_start__gte=datetime.combine(holiday.date_start, time.min),
datetime_end__lte=datetime.combine(holiday.date_end, time.max),
)
documentations = Documentation.objects.filter(q)
for doc in documentations:
logging.info(f"Lesson documentation {doc} is on holidays")
logging.info(f"Documentation {doc} is on holidays")
cls.register_result(doc)
class PersonalNoteOnHolidaysDataCheck(DataCheck):
"""Checks for personal note objects on holidays.
This ignores empty personal notes as they are created by default.
"""
class ParticipationStatusPersonalNoteOnHolidaysDataCheck(DataCheck):
"""Checks for participation status and personal note objects on holidays."""
name = "personal_note_on_holidays"
verbose_name = _("Ensure that there are no filled out personal notes on holidays")
problem_name = _("The personal note is on holidays.")
name = "participation_status_personal_note_on_holidays"
verbose_name = _(
"Ensure that there are no participation statuses or personal notes on holidays"
)
problem_name = _("The participation status or personal note is on holidays.")
solve_options = {
DeleteRelatedObjectSolveOption.name: DeleteRelatedObjectSolveOption,
IgnoreSolveOption.name: IgnoreSolveOption,
......@@ -143,39 +132,21 @@ class PersonalNoteOnHolidaysDataCheck(DataCheck):
@classmethod
def check_data(cls):
from aleksis.apps.chronos.models import Holiday
from aleksis.core.models import Holiday
from .models import PersonalNote
from .models import ParticipationStatus
holidays = Holiday.objects.all()
personal_notes = PersonalNote.objects.not_empty().annotate_date_range()
q = Q(pk__in=[])
for holiday in holidays:
q = q | Q(day_end__gte=holiday.date_start, day_start__lte=holiday.date_end)
personal_notes = personal_notes.filter(q)
for note in personal_notes:
logging.info(f"Personal note {note} is on holidays")
cls.register_result(note)
class ExcusesWithoutAbsences(DataCheck):
name = "excuses_without_absences"
verbose_name = _("Ensure that there are no excused personal notes without an absence")
problem_name = _("The personal note is marked as excused, but not as absent.")
solve_options = {
ResetPersonalNoteSolveOption.name: ResetPersonalNoteSolveOption,
IgnoreSolveOption.name: IgnoreSolveOption,
}
@classmethod
def check_data(cls):
from .models import PersonalNote
q = q | Q(
datetime_start__gte=datetime.combine(holiday.date_start, time.min),
datetime_end__lte=datetime.combine(holiday.date_end, time.max),
)
personal_notes = PersonalNote.objects.filter(excused=True, absent=False)
participation_statuses = ParticipationStatus.objects.filter(q)
for note in personal_notes:
logging.info(f"Check personal note {note}")
cls.register_result(note)
for status in participation_statuses:
logging.info(f"Participation status {status} is on holidays")
cls.register_result(status)
from django.utils.translation import gettext as _
from django_filters import CharFilter, DateFilter, FilterSet
from material import Layout, Row
from aleksis.core.models import SchoolTerm
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, data=None, *args, **kwargs):
if data is not None:
data = data.copy()
current_school_term = SchoolTerm.current
if not data.get("day_start") and current_school_term:
data["day_start"] = current_school_term.date_start
for name, f in self.base_filters.items():
initial = f.extra.get("initial")
if not data.get(name) and initial:
data[name] = initial
super().__init__(data, *args, **kwargs)
self.form.fields["tardiness__lt"].label = _("Tardiness is lower than")
self.form.fields["tardiness__gt"].label = _("Tardiness is bigger than")
self.form.layout = Layout(
Row("subject"),
Row("day_start", "day_end"),
Row("absent", "excused", "excuse_type"),
Row("tardiness__gt", "tardiness__lt", "extra_marks"),
)
class Meta:
model = PersonalNote
fields = {
"excused": ["exact"],
"tardiness": ["lt", "gt"],
"absent": ["exact"],
"excuse_type": ["exact"],
"extra_marks": ["exact"],
}
from datetime import datetime, timedelta
from typing import Optional, Sequence
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count, Q
from django.http import HttpRequest
from django.utils import timezone
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget, Select2Widget
from guardian.shortcuts import get_objects_for_user
from material import Fieldset, Layout, Row
from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget
from material import Layout, Row
from aleksis.apps.chronos.managers import TimetableType
from aleksis.apps.chronos.models import LessonPeriod, Subject, TimePeriod
from aleksis.core.forms import ActionForm, ListActionForm
from aleksis.core.models import Group, Person, SchoolTerm
from aleksis.core.models import Group, Person
from aleksis.core.util.core_helpers import get_site_preferences
from aleksis.core.util.predicates import check_global_permission
from .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,
GroupRole,
GroupRoleAssignment,
LessonDocumentation,
PersonalNote,
)
class LessonDocumentationForm(forms.ModelForm):
class Meta:
model = LessonDocumentation
fields = ["topic", "homework", "group_note"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["homework"].label = _("Homework for the next lesson")
if (
self.instance.lesson_period
and get_site_preferences()["alsijil__allow_carry_over_same_week"]
):
self.fields["carry_over_week"] = forms.BooleanField(
label=_("Carry over data to all other lessons with the same subject in this week"),
initial=True,
required=False,
)
def save(self, **kwargs):
lesson_documentation = super().save(commit=True)
if (
get_site_preferences()["alsijil__allow_carry_over_same_week"]
and self.cleaned_data["carry_over_week"]
and (
lesson_documentation.topic
or lesson_documentation.homework
or lesson_documentation.group_note
)
and lesson_documentation.lesson_period
):
lesson_documentation.carry_over_data(
LessonPeriod.objects.filter(lesson=lesson_documentation.lesson_period.lesson)
)
class PersonalNoteForm(forms.ModelForm):
class Meta:
model = PersonalNote
fields = ["absent", "tardiness", "excused", "excuse_type", "extra_marks", "remarks"]
person_name = forms.CharField(disabled=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["person_name"].widget.attrs.update(
{"class": "alsijil-lesson-personal-note-name"}
)
self.fields["person_name"].widget = forms.HiddenInput()
if self.instance and getattr(self.instance, "person", None):
self.fields["person_name"].initial = str(self.instance.person)
class SelectForm(forms.Form):
layout = Layout(Row("group", "teacher"))
group = forms.ModelChoiceField(
queryset=None,
label=_("Group"),
required=False,
widget=Select2Widget,
)
teacher = forms.ModelChoiceField(
queryset=None,
label=_("Teacher"),
required=False,
widget=Select2Widget,
)
def clean(self) -> dict:
data = super().clean()
if data.get("group") and not data.get("teacher"):
type_ = TimetableType.GROUP
instance = data["group"]
elif data.get("teacher") and not data.get("group"):
type_ = TimetableType.TEACHER
instance = data["teacher"]
elif not data.get("teacher") and not data.get("group"):
return data
else:
raise ValidationError(_("You can't select a group and a teacher both."))
data["type_"] = type_
data["instance"] = instance
return data
def __init__(self, request, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
person = self.request.user.person
group_qs = Group.get_groups_with_lessons()
# Filter selectable groups by permissions
if not check_global_permission(self.request.user, "alsijil.view_week"):
# 1) All groups the user is allowed to see the week view by object permissions
# 2) All groups the user is a member of an owner of
# 3) If the corresponding preference is turned on:
# All groups that have a parent group the user is an owner of
group_qs = (
group_qs.filter(
pk__in=get_objects_for_user(
self.request.user, "core.view_week_class_register_group", Group
).values_list("pk", flat=True)
)
).union(
group_qs.filter(
Q(members=person) | Q(owners=person) | Q(parent_groups__owners=person)
if get_site_preferences()["alsijil__inherit_privileges_from_parent_group"]
else Q(members=person) | Q(owners=person)
)
)
# Flatten query by filtering groups by pk
self.fields["group"].queryset = Group.objects.filter(
pk__in=list(group_qs.values_list("pk", flat=True))
)
teacher_qs = Person.objects.annotate(lessons_count=Count("lessons_as_teacher")).filter(
lessons_count__gt=0
)
# Filter selectable teachers by permissions
if not check_global_permission(self.request.user, "alsijil.view_week"):
# If the user hasn't got the global permission and the inherit privileges preference is
# turned off, the user is only allowed to see their own person. Otherwise, the user
# is allowed to see all persons that teach lessons that the given groups attend.
if get_site_preferences()["alsijil__inherit_privileges_from_parent_group"]:
teacher_pks = []
for group in group_qs:
for lesson in group.lessons.all():
for teacher in lesson.teachers.all():
teacher_pks.append(teacher.pk)
teacher_qs = teacher_qs.filter(pk__in=teacher_pks)
else:
teacher_qs = teacher_qs.filter(pk=person.pk)
self.fields["teacher"].queryset = teacher_qs
PersonalNoteFormSet = forms.modelformset_factory(
PersonalNote, form=PersonalNoteForm, max_num=0, extra=0
)
class RegisterAbsenceForm(forms.Form):
layout = Layout(
Fieldset("", "person"),
Fieldset("", Row("date_start", "date_end"), Row("from_period", "to_period")),
Fieldset("", Row("absent", "excused"), Row("excuse_type"), Row("remarks")),
)
person = forms.ModelChoiceField(label=_("Person"), queryset=None, widget=Select2Widget)
date_start = forms.DateField(label=_("Start date"), initial=datetime.today)
date_end = forms.DateField(label=_("End date"), initial=datetime.today)
from_period = forms.ChoiceField(label=_("Start period"))
to_period = forms.ChoiceField(label=_("End period"))
absent = forms.BooleanField(label=_("Absent"), initial=True, required=False)
excused = forms.BooleanField(label=_("Excused"), initial=True, required=False)
excuse_type = forms.ModelChoiceField(
label=_("Excuse type"),
queryset=ExcuseType.objects.all(),
widget=Select2Widget,
required=False,
)
remarks = forms.CharField(label=_("Remarks"), max_length=30, required=False)
def __init__(self, request, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
period_choices = TimePeriod.period_choices
if self.request.user.has_perm("alsijil.register_absence"):
self.fields["person"].queryset = Person.objects.all()
else:
persons_qs = Person.objects.filter(
Q(
pk__in=get_objects_for_user(
self.request.user, "core.register_absence_person", Person
)
)
| Q(primary_group__owners=self.request.user.person)
| Q(
member_of__in=get_objects_for_user(
self.request.user, "core.register_absence_group", Group
)
)
).distinct()
self.fields["person"].queryset = persons_qs
self.fields["from_period"].choices = period_choices
self.fields["to_period"].choices = period_choices
self.fields["from_period"].initial = TimePeriod.period_min
self.fields["to_period"].initial = TimePeriod.period_max
class ExtraMarkForm(forms.ModelForm):
layout = Layout("short_name", "name")
class Meta:
model = ExtraMark
fields = ["short_name", "name"]
class ExcuseTypeForm(forms.ModelForm):
layout = Layout("short_name", "name", "count_as_absent")
class Meta:
model = ExcuseType
fields = ["short_name", "name", "count_as_absent"]
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")
......@@ -357,74 +108,3 @@ class GroupRoleAssignmentEditForm(forms.ModelForm):
class Meta:
model = GroupRoleAssignment
fields = ["date_start", "date_end"]
class FilterRegisterObjectForm(forms.Form):
"""Form for filtering register objects in ``RegisterObjectTable``."""
layout = Layout(
Row("school_term", "date_start", "date_end"), Row("has_documentation", "group", "subject")
)
school_term = forms.ModelChoiceField(queryset=None, label=_("School term"))
has_documentation = forms.NullBooleanField(label=_("Has lesson documentation"))
group = forms.ModelChoiceField(queryset=None, label=_("Group"), required=False)
subject = forms.ModelChoiceField(queryset=None, label=_("Subject"), required=False)
date_start = forms.DateField(label=_("Start date"))
date_end = forms.DateField(label=_("End date"))
@classmethod
def get_initial(cls, has_documentation: Optional[bool] = None):
date_end = timezone.now().date()
date_start = date_end - timedelta(days=30)
school_term = SchoolTerm.current
# If there is no current school year, use last known school year.
if not school_term:
school_term = SchoolTerm.objects.all().last()
return {
"school_term": school_term,
"date_start": date_start,
"date_end": date_end,
"has_documentation": has_documentation,
}
def __init__(
self,
request: HttpRequest,
*args,
for_person: bool = True,
default_documentation: Optional[bool] = None,
groups: Optional[Sequence[Group]] = None,
**kwargs,
):
self.request = request
person = self.request.user.person
kwargs["initial"] = self.get_initial(has_documentation=default_documentation)
super().__init__(*args, **kwargs)
self.fields["school_term"].queryset = SchoolTerm.objects.all()
if not groups and for_person:
groups = Group.objects.filter(
Q(lessons__teachers=person)
| Q(lessons__lesson_periods__substitutions__teachers=person)
| Q(events__teachers=person)
| Q(extra_lessons__teachers=person)
).distinct()
elif not for_person:
groups = Group.objects.all()
self.fields["group"].queryset = groups
# Filter subjects by selectable groups
subject_qs = Subject.objects.filter(
Q(lessons__groups__in=groups) | Q(extra_lessons__groups__in=groups)
).distinct()
self.fields["subject"].queryset = subject_qs
class RegisterObjectActionForm(ListActionForm):
"""Action form for managing register objects for use with ``RegisterObjectTable``."""
actions = [send_request_to_check_entry]
<template>
<div>
<infinite-scrolling-date-sorted-c-r-u-d-iterator
i18n-key="alsijil.coursebook"
:gql-query="gqlQuery"
:gql-additional-query-args="gqlQueryArgs"
:enable-create="false"
:enable-edit="false"
:elevated="false"
@lastQuery="lastQuery = $event"
ref="iterator"
fixed-header
disable-pagination
hide-default-footer
use-deep-search
>
<template #additionalActions="{ attrs, on }">
<coursebook-controls :page-type="pageType" v-model="filters" />
<v-expand-transition>
<v-card
outlined
class="full-width"
v-show="
pageType === 'absences' && combinedSelectedParticipations.length
"
>
<v-card-text>
<v-row align="center">
<v-col cols="6">
{{
$tc(
"alsijil.coursebook.absences.action_for_selected",
combinedSelectedParticipations.length,
)
}}
</v-col>
<v-col cols="6">
<absence-reason-buttons
allow-empty
empty-value="present"
:custom-absence-reasons="absenceReasons"
@input="handleMultipleAction"
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-expand-transition>
</template>
<template #item="{ item, lastQuery }">
<component
:is="itemComponent"
:extra-marks="extraMarks"
:absence-reasons="absenceReasons"
:subjects="subjects"
:documentation="item"
:affected-query="lastQuery"
:value="selectedParticipations[item.id] ??= []"
@input="selectParticipation(item.id, $event)"
/>
</template>
<template #loading>
<coursebook-loader :number-of-days="10" :number-of-docs="5" />
</template>
<template #itemLoader>
<DocumentationLoader />
</template>
</infinite-scrolling-date-sorted-c-r-u-d-iterator>
<absence-creation-dialog :absence-reasons="absenceReasons" />
</div>
</template>
<script>
import InfiniteScrollingDateSortedCRUDIterator from "aleksis.core/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue";
import { documentationsForCoursebook } from "./coursebook.graphql";
import AbsenceReasonButtons from "aleksis.apps.kolego/components/AbsenceReasonButtons.vue";
import CoursebookControls from "./CoursebookControls.vue";
import CoursebookLoader from "./CoursebookLoader.vue";
import DocumentationModal from "./documentation/DocumentationModal.vue";
import DocumentationAbsencesModal from "./absences/DocumentationAbsencesModal.vue";
import AbsenceCreationDialog from "./absences/AbsenceCreationDialog.vue";
import { extraMarks } from "./queries/extraMarks.graphql";
import DocumentationLoader from "./documentation/DocumentationLoader.vue";
import sendToServerMixin from "./absences/sendToServerMixin";
import { absenceReasons } from "./queries/absenceReasons.graphql";
import { subjects } from "./queries/subjects.graphql";
export default {
name: "Coursebook",
components: {
DocumentationLoader,
AbsenceReasonButtons,
CoursebookControls,
CoursebookLoader,
DocumentationModal,
DocumentationAbsencesModal,
InfiniteScrollingDateSortedCRUDIterator,
AbsenceCreationDialog,
},
mixins: [sendToServerMixin],
props: {
filterType: {
type: String,
required: true,
},
objId: {
type: [Number, String],
required: false,
default: null,
},
objType: {
type: String,
required: false,
default: null,
},
pageType: {
type: String,
required: false,
default: "documentations",
},
/**
* Number of consecutive to load at once
* This number of days is initially loaded and loaded
* incrementally while scrolling.
*/
dayIncrement: {
type: Number,
required: false,
default: 7,
},
/**
* Margin from coursebook list to top of viewport in pixels
*/
topMargin: {
type: Number,
required: false,
default: 165,
},
},
data() {
return {
gqlQuery: documentationsForCoursebook,
lastQuery: null,
dateStart: "",
dateEnd: "",
// Placeholder values while query isn't completed yet
groups: [],
courses: [],
incomplete: false,
absencesExist: true,
ready: false,
initDate: false,
currentDate: "",
hashUpdater: false,
extraMarks: [],
absenceReasons: [],
subjects: [],
selectedParticipations: {},
};
},
apollo: {
extraMarks: {
query: extraMarks,
update: (data) => data.items,
},
absenceReasons: {
query: absenceReasons,
update: (data) => data.items,
},
subjects: {
query: subjects,
update: (data) => data.items,
},
},
computed: {
// Assertion: Should only fire on page load or selection change.
// Resets date range.
gqlQueryArgs() {
return {
own: this.filterType === "all" ? false : true,
objId: this.objId ? Number(this.objId) : undefined,
objType: this.objType?.toUpperCase(),
dateStart: this.dateStart,
dateEnd: this.dateEnd,
incomplete: !!this.incomplete,
absencesExist: !!this.absencesExist && this.pageType === "absences",
};
},
filters: {
get() {
return {
objType: this.objType,
objId: this.objId,
filterType: this.filterType,
incomplete: this.incomplete,
pageType: this.pageType,
absencesExist: this.absencesExist,
};
},
set(selectedFilters) {
if (Object.hasOwn(selectedFilters, "incomplete")) {
this.incomplete = selectedFilters.incomplete;
} else if (Object.hasOwn(selectedFilters, "absencesExist")) {
this.absencesExist = selectedFilters.absencesExist;
} else if (
Object.hasOwn(selectedFilters, "filterType") ||
Object.hasOwn(selectedFilters, "objId") ||
Object.hasOwn(selectedFilters, "objType") ||
Object.hasOwn(selectedFilters, "pageType")
) {
this.$router.push({
name: "alsijil.coursebook",
params: {
filterType: selectedFilters.filterType
? selectedFilters.filterType
: this.filterType,
objType: selectedFilters.objType,
objId: selectedFilters.objId,
pageType: selectedFilters.pageType
? selectedFilters.pageType
: this.pageType,
},
hash: this.$route.hash,
});
// computed should not have side effects
// but this was actually done before filters was refactored into
// its own component
this.$refs.iterator.resetDate();
// might skip query until both set = atomic
if (Object.hasOwn(selectedFilters, "pageType")) {
this.absencesExist = true;
this.$setToolBarTitle(
this.$t(`alsijil.coursebook.title_${selectedFilters.pageType}`),
null,
);
}
}
},
},
itemComponent() {
if (this.pageType === "documentations") {
return "DocumentationModal";
} else {
return "DocumentationAbsencesModal";
}
},
combinedSelectedParticipations() {
return Object.values(this.selectedParticipations).flat();
},
},
methods: {
selectParticipation(id, value) {
this.selectedParticipations = Object.assign(
{},
this.selectedParticipations,
{ [id]: value },
);
},
handleMultipleAction(absenceReasonId) {
this.loadSelectedParticiptions = true;
this.sendToServer(
this.combinedSelectedParticipations,
"absenceReason",
absenceReasonId,
);
this.$once("save", this.resetMultipleAction);
},
resetMultipleAction() {
this.loadSelectedParticiptions = false;
this.selectedParticipations = {};
},
},
mounted() {
this.$setToolBarTitle(
this.$t(`alsijil.coursebook.title_${this.pageType}`),
null,
);
},
};
</script>
<style>
.max-width {
max-width: 25rem;
}
</style>
<script setup>
import CoursebookPrintDialog from "./CoursebookPrintDialog.vue";
</script>
<template>
<div
class="d-flex flex-column flex-sm-row flex-nowrap flex-grow-1 justify-end gap align-stretch"
>
<v-autocomplete
:items="selectable"
item-text="name"
:item-value="(item) => `${item.__typename}-${item.id}`"
clearable
return-object
filled
dense
hide-details
:placeholder="$t('alsijil.coursebook.filter.filter_for_obj')"
:loading="selectLoading"
:value="currentObj"
@input="selectObject"
@click:clear="selectObject"
class="max-width"
/>
<div class="mx-6">
<v-switch
:loading="selectLoading"
:label="$t('alsijil.coursebook.filter.own')"
:input-value="value.filterType === 'my'"
@change="selectFilterType($event)"
dense
inset
hide-details
/>
<v-switch
:loading="selectLoading"
:label="$t('alsijil.coursebook.filter.missing')"
:input-value="value.incomplete"
@change="
$emit('input', {
incomplete: $event,
})
"
dense
inset
hide-details
/>
<v-switch
v-if="pageType === 'absences'"
:loading="selectLoading"
:label="$t('alsijil.coursebook.filter.absences_exist')"
:input-value="value.absencesExist"
@change="
$emit('input', {
absencesExist: $event,
})
"
dense
inset
hide-details
/>
</div>
<div class="d-flex flex-column gap">
<v-btn
outlined
color="primary"
:loading="selectLoading"
@click="togglePageType()"
>
{{ pageTypeButtonText }}
</v-btn>
<coursebook-print-dialog
v-if="pageType === 'documentations'"
:loading="selectLoading"
:available-groups="groups"
:value="currentGroups"
/>
</div>
</div>
</template>
<script>
import { coursesOfPerson, groupsByPerson } from "./coursebook.graphql";
const TYPENAMES_TO_TYPES = {
CourseType: "course",
GroupType: "group",
};
export default {
name: "CoursebookFilters",
data() {
return {
// Placeholder values while query isn't completed yet
groups: [],
courses: [],
};
},
props: {
value: {
type: Object,
required: true,
},
pageType: {
type: String,
required: false,
default: "documentations",
},
},
emits: ["input"],
apollo: {
groups: {
query: groupsByPerson,
},
courses: {
query: coursesOfPerson,
},
},
computed: {
selectable() {
return [
{ header: this.$t("alsijil.coursebook.filter.groups") },
...this.groups,
{ header: this.$t("alsijil.coursebook.filter.courses") },
...this.courses,
];
},
selectLoading() {
return (
this.$apollo.queries.groups.loading ||
this.$apollo.queries.courses.loading
);
},
currentObj() {
return this.selectable.find(
(o) =>
TYPENAMES_TO_TYPES[o.__typename] === this.value.objType &&
o.id === this.value.objId,
);
},
currentGroups() {
return this.groups.filter(
(o) =>
TYPENAMES_TO_TYPES[o.__typename] === this.value.objType &&
o.id === this.value.objId,
);
},
pageTypeButtonText() {
if (this.value.pageType === "documentations") {
return this.$t("alsijil.coursebook.filter.page_type.absences");
} else {
return this.$t("alsijil.coursebook.filter.page_type.documentations");
}
},
},
methods: {
selectObject(selection) {
this.$emit("input", {
objType: selection ? TYPENAMES_TO_TYPES[selection.__typename] : null,
objId: selection ? selection.id : null,
});
},
selectFilterType(switchValue) {
this.$emit("input", {
filterType: switchValue ? "my" : "all",
objType: this.value.objType,
objId: this.value.objId,
});
},
togglePageType() {
this.$emit("input", {
pageType:
this.value.pageType === "documentations"
? "absences"
: "documentations",
objType: this.value.objType,
objId: this.value.objId,
});
},
},
};
</script>
<template>
<div>
<v-list-item v-for="i in numberOfDays" :key="'i-' + i" class="px-0">
<v-list-item-content>
<v-list-item-title>
<v-skeleton-loader type="heading" />
</v-list-item-title>
<v-list max-width="100%">
<v-list-item v-for="j in numberOfDocs" :key="'j-' + j" class="px-1">
<DocumentationLoader />
</v-list-item>
</v-list>
</v-list-item-content>
</v-list-item>
</div>
</template>
<script>
import DocumentationLoader from "./documentation/DocumentationLoader.vue";
export default {
name: "CoursebookLoader",
components: { DocumentationLoader },
props: {
numberOfDays: {
type: Number,
required: false,
default: 1,
},
numberOfDocs: {
type: Number,
required: false,
default: 1,
},
},
};
</script>
<script setup>
import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue";
import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue";
import PrimaryActionButton from "aleksis.core/components/generic/buttons/PrimaryActionButton.vue";
import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue";
</script>
<template>
<mobile-fullscreen-dialog v-model="dialog">
<template #activator>
<secondary-action-button
i18n-key="alsijil.coursebook.print.button"
icon-text="$print"
:loading="loading"
@click="dialog = true"
:disabled="dialog"
/>
</template>
<template #title>
{{ $t("alsijil.coursebook.print.title") }}
</template>
<template #content>
{{ $t("alsijil.coursebook.print.groups") }}
<v-autocomplete
:items="availableGroups"
item-text="name"
item-value="id"
:value="value"
@input="setGroupSelection"
@click:clear="setGroupSelection"
multiple
chips
deletable-chips
/>
<div class="d-flex flex-column">
{{ $t("alsijil.coursebook.print.include") }}
<v-checkbox
v-model="includeCover"
:label="$t('alsijil.coursebook.print.include_cover')"
/>
<v-checkbox
v-model="includeAbbreviations"
:label="$t('alsijil.coursebook.print.include_abbreviations')"
/>
<v-checkbox
v-model="includeMembersTable"
:label="$t('alsijil.coursebook.print.include_members_table')"
/>
<v-checkbox
v-model="includeTeachersAndSubjectsTable"
:label="
$t('alsijil.coursebook.print.include_teachers_and_subjects_table')
"
/>
<v-checkbox
v-model="includePersonOverviews"
:label="$t('alsijil.coursebook.print.include_person_overviews')"
/>
<v-checkbox
v-model="includeCoursebook"
:label="$t('alsijil.coursebook.print.include_coursebook')"
/>
</div>
</template>
<template #actions>
<!-- TODO: Should cancel reset state? -->
<cancel-button @click="dialog = false" />
<primary-action-button
i18n-key="alsijil.coursebook.print.button"
icon-text="$print"
:disabled="!valid"
@click="print"
/>
</template>
</mobile-fullscreen-dialog>
</template>
<script>
/**
* This component provides a dialog for configuring the coursebook-printout
*/
export default {
name: "CoursebookPrintDialog",
props: {
/**
* Groups available for selection
*/
availableGroups: {
type: Array,
required: true,
},
/**
* Initially selected groups
*/
value: {
type: Array,
required: false,
default: () => [],
},
/**
* Loading state
*/
loading: {
type: Boolean,
required: false,
default: false,
},
},
emits: ["input"],
data() {
return {
dialog: false,
currentGroupSelection: [],
includeCover: true,
includeAbbreviations: true,
includeMembersTable: true,
includeTeachersAndSubjectsTable: true,
includePersonOverviews: true,
includeCoursebook: true,
};
},
computed: {
selectedGroups() {
if (this.currentGroupSelection.length == 0) {
return this.value.map((group) => group.id);
} else {
return this.currentGroupSelection;
}
},
valid() {
return (
this.selectedGroups.length > 0 &&
(this.includeMembersTable ||
this.includeTeachersAndSubjectsTable ||
this.includePersonOverviews ||
this.includeCoursebook)
);
},
},
methods: {
setGroupSelection(groups) {
this.$emit("input", groups);
this.currentGroupSelection = groups;
},
print() {
this.$router.push({
name: "alsijil.coursebook_print",
params: {
groupIds: this.selectedGroups,
},
query: {
cover: this.includeCover,
abbreviations: this.includeAbbreviations,
members_table: this.includeMembersTable,
teachers_and_subjects_table: this.includeTeachersAndSubjectsTable,
person_overviews: this.includePersonOverviews,
coursebook: this.includeCoursebook,
},
});
},
},
};
</script>
<template>
<mobile-fullscreen-dialog v-model="popup" persistent :close-button="false">
<template #activator="activator">
<fab-button
color="secondary"
@click="popup = true"
:disabled="popup"
:class="{
'd-none': !checkPermission('alsijil.view_register_absence_rule'),
}"
icon-text="$plus"
i18n-key="alsijil.coursebook.absences.button"
>
<v-icon>$plus</v-icon>
</fab-button>
</template>
<template #title>
<div>
{{ $t("alsijil.coursebook.absences.title") }}
</div>
<span v-if="!form" class="px-2">·</span>
<div v-if="!form">
{{ $t("alsijil.coursebook.absences.summary") }}
</div>
</template>
<template #content>
<absence-creation-form
:persons="persons"
:start-date="startDate"
:end-date="endDate"
:comment="comment"
:absence-reason="absenceReason"
:absence-reasons="absenceReasons"
@valid="formValid = $event"
@persons="persons = $event"
@start-date="startDate = $event"
@end-date="endDate = $event"
@comment="comment = $event"
@absence-reason="absenceReason = $event"
:class="{
'd-none': !form,
}"
/>
<absence-creation-summary
v-if="!form"
:persons="persons"
:start-date="startDate"
:end-date="endDate"
@loading="handleLoading"
/>
</template>
<template #actionsLeft>
<cancel-button @click="cancel" />
</template>
<template #actions>
<!-- secondary -->
<secondary-action-button
@click="form = true"
v-if="!form"
:disabled="loading"
i18n-key="actions.back"
>
<v-icon left>$prev</v-icon>
{{ $t("actions.back") }}
</secondary-action-button>
<!-- primary -->
<save-button
v-if="form"
@click="form = false"
:loading="loading"
:disabled="!formValid || !absenceReason"
>
{{ $t("actions.continue") }}
<v-icon right>$next</v-icon>
</save-button>
<save-button
v-else
i18n-key="actions.confirm"
@click="confirm"
:loading="loading"
/>
</template>
</mobile-fullscreen-dialog>
</template>
<script>
import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue";
import AbsenceCreationForm from "./AbsenceCreationForm.vue";
import AbsenceCreationSummary from "./AbsenceCreationSummary.vue";
import FabButton from "aleksis.core/components/generic/buttons/FabButton.vue";
import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue";
import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue";
import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue";
import loadingMixin from "aleksis.core/mixins/loadingMixin.js";
import permissionsMixin from "aleksis.core/mixins/permissions.js";
import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
import { DateTime } from "luxon";
import { createAbsencesForPersons } from "./absenceCreation.graphql";
export default {
name: "AbsenceCreationDialog",
components: {
MobileFullscreenDialog,
AbsenceCreationForm,
AbsenceCreationSummary,
CancelButton,
SaveButton,
SecondaryActionButton,
FabButton,
},
mixins: [loadingMixin, mutateMixin, permissionsMixin],
data() {
return {
popup: false,
form: true,
formValid: false,
persons: [],
startDate: "",
endDate: "",
comment: "",
absenceReason: "",
};
},
props: {
absenceReasons: {
type: Array,
required: true,
},
},
mounted() {
this.addPermissions(["alsijil.view_register_absence_rule"]);
this.clearForm();
},
methods: {
cancel() {
this.popup = false;
this.form = true;
this.clearForm();
},
clearForm() {
this.persons = [];
this.startDate = DateTime.now()
.startOf("day")
.toISO({ suppressSeconds: true });
this.endDate = DateTime.now()
.endOf("day")
.toISO({ suppressSeconds: true });
this.comment = "";
this.absenceReason = "";
},
confirm() {
this.handleLoading(true);
this.mutate(
createAbsencesForPersons,
{
persons: this.persons.map((p) => p.id),
start: this.$toUTCISO(this.$parseISODate(this.startDate)),
end: this.$toUTCISO(this.$parseISODate(this.endDate)),
comment: this.comment,
reason: this.absenceReason,
},
(storedDocumentations, incomingStatuses) => {
incomingStatuses.forEach((newStatus) => {
const documentation = storedDocumentations.find(
(doc) => doc.id === newStatus.relatedDocumentation.id,
);
if (!documentation) {
return;
}
const participationStatus = documentation.participations.find(
(part) => part.id === newStatus.id,
);
participationStatus.absenceReason = newStatus.absenceReason;
participationStatus.isOptimistic = newStatus.isOptimistic;
});
return storedDocumentations;
},
);
this.$once("save", this.handleSave);
},
handleSave() {
this.cancel();
this.$toastSuccess(this.$t("alsijil.coursebook.absences.success"));
},
},
};
</script>
<template>
<v-form @input="$emit('valid', $event)">
<v-container>
<v-row>
<div aria-required="true" class="full-width">
<!-- FIXME Vue 3: clear-on-select -->
<v-autocomplete
:label="$t('forms.labels.persons')"
:items="allPersons"
item-text="fullName"
return-object
multiple
chips
deletable-chips
:rules="
$rules().build([
(value) => value.length > 0 || $t('forms.errors.required'),
])
"
:value="persons"
:loading="$apollo.queries.allPersons.loading"
@input="$emit('persons', $event)"
/>
</div>
</v-row>
<v-row>
<v-col cols="12" :sm="6" class="pl-0">
<div aria-required="true">
<date-time-field
:label="$t('forms.labels.start')"
:max-date="endDate"
:max-time="maxStartTime"
:rules="$rules().required.build()"
:value="startDate"
@input="$emit('start-date', $event)"
/>
</div>
</v-col>
<v-col cols="12" :sm="6" class="pr-0">
<div aria-required="true">
<date-time-field
:label="$t('forms.labels.end')"
:min-date="startDate"
:min-time="minEndTime"
:rules="$rules().required.build()"
:value="endDate"
@input="$emit('end-date', $event)"
/>
</div>
</v-col>
</v-row>
<v-row>
<v-text-field
:label="$t('forms.labels.comment')"
:value="comment"
@input="$emit('comment', $event)"
/>
</v-row>
<v-row>
<div aria-required="true">
<absence-reason-group-select
:rules="$rules().required.build()"
:value="absenceReason"
:custom-absence-reasons="absenceReasons"
@input="$emit('absence-reason', $event)"
/>
</div>
</v-row>
</v-container>
</v-form>
</template>
<script>
import AbsenceReasonGroupSelect from "aleksis.apps.kolego/components/AbsenceReasonGroupSelect.vue";
import DateTimeField from "aleksis.core/components/generic/forms/DateTimeField.vue";
import { persons } from "./absenceCreation.graphql";
import formRulesMixin from "aleksis.core/mixins/formRulesMixin.js";
import { DateTime } from "luxon";
export default {
name: "AbsenceCreationForm",
components: {
AbsenceReasonGroupSelect,
DateTimeField,
},
mixins: [formRulesMixin],
emits: [
"valid",
"persons",
"start-date",
"end-date",
"comment",
"absence-reason",
],
apollo: {
allPersons: persons,
},
props: {
persons: {
type: Array,
required: true,
},
startDate: {
type: String,
required: true,
},
endDate: {
type: String,
required: true,
},
comment: {
type: String,
required: true,
},
absenceReason: {
type: String,
required: true,
},
absenceReasons: {
type: Array,
required: true,
},
},
computed: {
maxStartTime() {
// Only if on the same day
const start = DateTime.fromISO(this.startDate);
const end = DateTime.fromISO(this.endDate);
if (start.day !== end.day) return;
return end.minus({ minutes: 5 }).toFormat("HH:mm");
},
minEndTime() {
// Only if on the same day
const start = DateTime.fromISO(this.startDate);
const end = DateTime.fromISO(this.endDate);
if (start.day !== end.day) return;
return start.plus({ minutes: 5 }).toFormat("HH:mm");
},
},
};
</script>
<template>
<div>
<message-box dense type="warning" class="mt-5">
{{ $t("alsijil.coursebook.absences.warning") }}
</message-box>
<!-- MAYBE introduce a minimal variant of CRUDIterator -->
<!-- with most features disabled for this list usecase -->
<c-r-u-d-iterator
i18n-key=""
:gql-query="gqlQuery"
:gql-additional-query-args="gqlArgs"
:enable-search="false"
:enable-create="false"
:enable-edit="false"
:elevated="false"
disable-pagination
hide-default-footer
@loading="handleLoading"
>
<template #default="{ items }">
<v-expansion-panels>
<v-expansion-panel v-for="person in items" :key="person.id">
<v-expansion-panel-header>
<div>
{{ persons.find((p) => p.id === person.id).fullName }}
</div>
<v-spacer />
<div>
{{
$tc(
"alsijil.coursebook.absences.lessons",
person.lessons.length,
{ count: person.lessons.length },
)
}}
</div>
</v-expansion-panel-header>
<v-expansion-panel-content>
<v-list-item
v-for="lesson in person.lessons"
class="px-0"
:key="lesson.id"
>
<v-row>
<!-- TODO: We should extract this display & share it -->
<v-col cols="3">
<time :datetime="lesson.datetimeStart" class="text-no-wrap">
{{
$d(
$parseISODate(lesson.datetimeStart),
"shortWithWeekday",
)
}}&nbsp;
</time>
</v-col>
<v-col cols="3">
<time :datetime="lesson.datetimeStart" class="text-no-wrap">
{{ $d($parseISODate(lesson.datetimeStart), "shortTime") }}
</time>
<span> - </span>
<time :datetime="lesson.datetimeEnd" class="text-no-wrap">
{{ $d($parseISODate(lesson.datetimeEnd), "shortTime") }}
</time>
</v-col>
<v-col cols="3">
{{ lesson.course?.name }}
</v-col>
<v-col cols="3">
<subject-chip :subject="lesson.subject" />
</v-col>
</v-row>
</v-list-item>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
</template>
</c-r-u-d-iterator>
</div>
</template>
<script>
import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue";
import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
import { lessonsForPersons } from "./absenceCreation.graphql";
import loadingMixin from "aleksis.core/mixins/loadingMixin.js";
export default {
name: "AbsenceCreationSummary",
components: {
CRUDIterator,
SubjectChip,
},
mixins: [loadingMixin],
props: {
persons: {
type: Array,
required: true,
},
startDate: {
type: String,
required: true,
},
endDate: {
type: String,
required: true,
},
},
data() {
return {
gqlQuery: lessonsForPersons,
};
},
computed: {
gqlArgs() {
return {
persons: this.persons.map((person) => person.id),
start: this.startDate,
end: this.endDate,
};
},
},
};
</script>
<template>
<v-card :class="{ 'my-1 full-width': true, 'd-flex flex-column': !compact }">
<v-card-title v-if="!compact">
<lesson-information v-bind="documentationPartProps" />
</v-card-title>
<v-card-text
class="full-width main-body"
:class="{
vertical: !compact || $vuetify.breakpoint.mobile,
'pa-2': compact,
}"
>
<lesson-information v-if="compact" v-bind="documentationPartProps" />
<lesson-notes class="span-2" v-bind="documentationPartProps" />
<participation-list
v-if="documentation.canEditParticipationStatus"
:include-present="false"
class="participation-list"
v-bind="documentationPartProps"
:value="value"
@input="$emit('input', $event)"
/>
</v-card-text>
<v-spacer />
<v-divider />
<v-card-actions v-if="!compact">
<v-spacer />
<cancel-button
v-if="documentation.canEdit"
@click="$emit('close')"
:disabled="loading"
/>
<save-button
v-if="documentation.canEdit"
@click="save"
:loading="loading"
/>
<cancel-button
v-if="!documentation.canEdit"
i18n-key="actions.close"
@click="$emit('close')"
/>
</v-card-actions>
</v-card>
</template>
<script>
import ParticipationList from "./ParticipationList.vue";
import LessonInformation from "../documentation/LessonInformation.vue";
import LessonNotes from "../documentation/LessonNotes.vue";
import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue";
import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue";
import { createOrUpdateDocumentations } from "../coursebook.graphql";
import documentationPartMixin from "../documentation/documentationPartMixin";
export default {
name: "DocumentationAbsences",
components: {
ParticipationList,
LessonInformation,
LessonNotes,
SaveButton,
CancelButton,
},
emits: ["open", "close"],
mixins: [documentationPartMixin],
data() {
return {
loading: false,
documentationsMutation: createOrUpdateDocumentations,
selectedParticipations: [],
};
},
props: {
value: {
type: Array,
required: true,
},
},
methods: {
save() {
this.$refs.summary.save();
this.$emit("close");
},
},
};
</script>
<style scoped>
.main-body {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: min-content min-content;
column-gap: 1em;
}
.participation-list {
grid-column-start: 1;
grid-column-end: span 3;
}
.span-2 {
grid-column-end: span 2;
}
.vertical > * {
grid-column-end: span 3;
}
</style>
<!-- Wrapper around DocumentationAbsences.vue -->
<!-- That uses it either as list item or as editable modal dialog. -->
<template>
<mobile-fullscreen-dialog v-model="popup" max-width="500px">
<template #activator="activator">
<!-- list view -> activate dialog -->
<documentation-absences
compact
v-bind="$attrs"
:extra-marks="extraMarks"
:absence-reasons="absenceReasons"
:dialog-activator="activator"
:value="value"
@input="$emit('input', $event)"
/>
</template>
<!-- dialog view -> deactivate dialog -->
<!-- cancel | save (through lesson-summary) -->
<documentation
v-bind="$attrs"
:extra-marks="extraMarks"
:absence-reasons="absenceReasons"
@close="popup = false"
/>
</mobile-fullscreen-dialog>
</template>
<script>
import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue";
import DocumentationAbsences from "./DocumentationAbsences.vue";
import Documentation from "../documentation/Documentation.vue";
export default {
name: "DocumentationAbsencesModal",
components: {
MobileFullscreenDialog,
Documentation,
DocumentationAbsences,
},
data() {
return {
popup: false,
};
},
props: {
value: {
type: Array,
required: true,
},
extraMarks: {
type: Array,
required: true,
},
absenceReasons: {
type: Array,
required: true,
},
},
};
</script>
<script>
import AbsenceReasonButtons from "aleksis.apps.kolego/components/AbsenceReasonButtons.vue";
import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue";
import AbsenceReasonGroupSelect from "aleksis.apps.kolego/components/AbsenceReasonGroupSelect.vue";
import DialogCloseButton from "aleksis.core/components/generic/buttons/DialogCloseButton.vue";
import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue";
import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue";
import updateParticipationMixin from "./updateParticipationMixin.js";
import deepSearchMixin from "aleksis.core/mixins/deepSearchMixin.js";
import LessonInformation from "../documentation/LessonInformation.vue";
import {
extendParticipationStatuses,
updateParticipationStatuses,
} from "./participationStatus.graphql";
import SlideIterator from "aleksis.core/components/generic/SlideIterator.vue";
import PersonalNotes from "../personal_notes/PersonalNotes.vue";
import PersonalNoteChip from "../personal_notes/PersonalNoteChip.vue";
import ExtraMarkChip from "../../extra_marks/ExtraMarkChip.vue";
import TardinessChip from "./TardinessChip.vue";
import TardinessField from "./TardinessField.vue";
import ExtraMarkButtons from "../../extra_marks/ExtraMarkButtons.vue";
import MessageBox from "aleksis.core/components/generic/MessageBox.vue";
export default {
name: "ManageStudentsDialog",
extends: MobileFullscreenDialog,
components: {
ExtraMarkButtons,
TardinessChip,
ExtraMarkChip,
AbsenceReasonChip,
AbsenceReasonGroupSelect,
AbsenceReasonButtons,
PersonalNotes,
PersonalNoteChip,
LessonInformation,
MessageBox,
MobileFullscreenDialog,
SecondaryActionButton,
SlideIterator,
TardinessField,
DialogCloseButton,
},
mixins: [updateParticipationMixin, deepSearchMixin],
data() {
return {
dialog: false,
search: "",
loadSelected: false,
selected: [],
isExpanded: false,
markAsAbsentDay: {
showAlert: false,
num: 0,
reason: "no reason",
name: "nobody",
participationIDs: [],
loading: false,
},
};
},
props: {
loadingIndicator: {
type: Boolean,
default: false,
required: false,
},
useDeepSearch: {
type: Boolean,
default: true,
required: false,
},
},
computed: {
items() {
return this.documentation.participations;
},
},
methods: {
handleMultipleAction(field, id) {
this.loadSelected = true;
this.sendToServer(this.selected, field, id);
this.$once("save", this.resetMultipleAction);
},
resetMultipleAction() {
this.loadSelected = false;
this.$set(this.selected, []);
this.$refs.iterator.selected = [];
},
activateFullDayDialog(items) {
const itemIds = items.map((item) => item.id);
const participations = this.documentation.participations.filter((part) =>
itemIds.includes(part.id),
);
if (this.markAsAbsentDay.num === 1) {
this.markAsAbsentDay.name = participations[0].person.firstName;
}
this.$set(this.markAsAbsentDay, "participationIDs", itemIds);
this.markAsAbsentDay.loading = false;
this.markAsAbsentDay.showAlert = true;
},
beforeSendToServer() {
this.markAsAbsentDay.showAlert = false;
this.markAsAbsentDay.participationIDs = [];
},
duringUpdateSendToServer(
_participations,
_field,
_value,
incomingStatuses,
) {
this.markAsAbsentDay.reason = incomingStatuses[0].absenceReason?.name;
this.markAsAbsentDay.num = incomingStatuses.length;
},
afterSendToServer(_participations, field, value) {
if (field === "absenceReason" && value !== "present") {
this.$once("save", this.activateFullDayDialog);
}
},
markAsAbsentDayClick() {
this.markAsAbsentDay.loading = true;
this.mutate(
extendParticipationStatuses,
{
input: this.markAsAbsentDay.participationIDs,
},
(storedDocumentations, incomingStatuses) => {
incomingStatuses.forEach((newStatus) => {
const documentation = storedDocumentations.find(
(doc) => doc.id === newStatus.relatedDocumentation.id,
);
if (!documentation) {
return;
}
const participationStatus = documentation.participations.find(
(part) => part.id === newStatus.id,
);
participationStatus.absenceReason = newStatus.absenceReason;
participationStatus.isOptimistic = newStatus.isOptimistic;
});
this.markAsAbsentDay.reason = "no reason";
this.markAsAbsentDay.num = 0;
this.markAsAbsentDay.participationIDs = [];
this.markAsAbsentDay.loading = false;
this.markAsAbsentDay.showAlert = false;
return storedDocumentations;
},
);
},
},
};
</script>
<template>
<mobile-fullscreen-dialog
scrollable
v-bind="$attrs"
v-on="$listeners"
v-model="dialog"
:close-button="false"
>
<template #activator="activator">
<slot name="activator" v-bind="activator" />
</template>
<template #title>
<div class="d-flex full-width">
<lesson-information v-bind="documentationPartProps" :compact="false" />
<dialog-close-button @click="dialog = false" class="ml-4" />
</div>
<v-scroll-x-transition leave-absolute>
<v-text-field
v-show="!isExpanded"
type="search"
v-model="search"
clearable
rounded
hide-details
single-line
prepend-inner-icon="$search"
dense
outlined
:placeholder="$t('actions.search')"
class="pt-4 full-width"
/>
</v-scroll-x-transition>
<message-box
v-model="markAsAbsentDay.showAlert"
color="success"
icon="$success"
transition="slide-y-transition"
dismissible
class="mt-4 mb-0 full-width"
>
<div class="text-subtitle-2">
{{
$tc(
"alsijil.coursebook.mark_as_absent_day.title",
markAsAbsentDay.num,
markAsAbsentDay,
)
}}
</div>
<p class="text-body-2 pa-0 ma-0" style="word-break: break-word">
{{
$t(
"alsijil.coursebook.mark_as_absent_day.description",
markAsAbsentDay,
)
}}
</p>
<secondary-action-button
color="success"
i18n-key="alsijil.coursebook.mark_as_absent_day.action_button"
class="mt-2"
:loading="markAsAbsentDay.loading"
@click="markAsAbsentDayClick"
/>
</message-box>
</template>
<template #content>
<slide-iterator
ref="iterator"
v-model="selected"
:items="items"
:search="search"
:item-key-getter="
(item) => 'documentation-' + documentation.id + '-student-' + item.id
"
:is-expanded.sync="isExpanded"
:loading="loadingIndicator || loadSelected"
:load-only-selected="loadSelected"
:disabled="loading"
:custom-filter="deepSearch"
>
<template #listItemContent="{ item }">
<v-list-item-title>
{{ item.person.fullName }}
</v-list-item-title>
<v-list-item-subtitle
v-if="
item.absenceReason ||
item.notesWithNote?.length > 0 ||
item.notesWithExtraMark?.length > 0 ||
item.tardiness
"
class="d-flex flex-wrap gap"
>
<absence-reason-chip
v-if="item.absenceReason"
small
:absence-reason="item.absenceReason"
/>
<personal-note-chip
v-for="note in item.notesWithNote"
:key="'text-note-note-overview-' + note.id"
:note="note"
small
/>
<extra-mark-chip
v-for="note in item.notesWithExtraMark"
:key="'extra-mark-note-overview-' + note.id"
:extra-mark="extraMarks.find((e) => e.id === note.extraMark.id)"
small
/>
<tardiness-chip
v-if="item.tardiness"
:tardiness="item.tardiness"
small
/>
</v-list-item-subtitle>
</template>
<template #expandedItem="{ item, close }">
<v-card-title>
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" icon @click="close">
<v-icon>$prev</v-icon>
</v-btn>
</template>
<span v-t="'actions.back_to_overview'" />
</v-tooltip>
{{ item.person.fullName }}
<v-spacer />
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<v-btn
v-bind="attrs"
v-on="on"
icon
:to="{
name: 'core.personById',
params: {
id: item.person.id,
},
}"
>
<v-icon>mdi-open-in-new</v-icon>
</v-btn>
</template>
{{ $t("actions.open_person_page", item.person) }}
</v-tooltip>
</v-card-title>
<v-card-text>
<absence-reason-group-select
allow-empty
:load-selected-chip="loading"
:value="item.absenceReason?.id || 'present'"
:custom-absence-reasons="absenceReasons"
@input="sendToServer([item], 'absenceReason', $event)"
/>
<tardiness-field
v-bind="documentationPartProps"
:loading="loading"
:disabled="loading"
:participations="[item]"
:value="item.tardiness"
@input="sendToServer([item], 'tardiness', $event)"
/>
</v-card-text>
<v-divider />
<v-card-text>
<personal-notes
v-bind="documentationPartProps"
:participation="
documentation.participations.find((p) => p.id === item.id)
"
/>
</v-card-text>
</template>
</slide-iterator>
</template>
<template #actions>
<v-scroll-y-reverse-transition>
<div v-show="selected.length > 0" class="full-width">
<h4>{{ $t("alsijil.coursebook.participation_status") }}</h4>
<absence-reason-buttons
class="mb-1"
allow-empty
empty-value="present"
:custom-absence-reasons="absenceReasons"
@input="handleMultipleAction('absenceReason', $event)"
/>
<h4>{{ $t("alsijil.extra_marks.title_plural") }}</h4>
<extra-mark-buttons
:custom-extra-marks="extraMarks"
@input="handleMultipleAction('extraMark', $event)"
/>
<h4>{{ $t("alsijil.personal_notes.tardiness") }}</h4>
<tardiness-field
v-bind="documentationPartProps"
:loading="loading"
:disabled="loading"
:value="0"
:participations="selected"
@input="handleMultipleAction('tardiness', $event)"
/>
</div>
</v-scroll-y-reverse-transition>
</template>
</mobile-fullscreen-dialog>
</template>
<style scoped></style>
<script>
import { DateTime } from "luxon";
import ManageStudentsDialog from "./ManageStudentsDialog.vue";
import documentationPartMixin from "../documentation/documentationPartMixin";
import { touchDocumentation } from "./participationStatus.graphql";
import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
export default {
name: "ManageStudentsTrigger",
components: { ManageStudentsDialog },
mixins: [documentationPartMixin, mutateMixin],
data() {
return {
canOpenParticipation: false,
timeout: null,
};
},
props: {
labelKey: {
type: String,
required: false,
default: undefined,
},
},
mounted() {
const lessonStart = DateTime.fromISO(this.documentation.datetimeStart);
const now = DateTime.now();
this.canOpenParticipation = now >= lessonStart;
if (!this.canOpenParticipation) {
this.timeout = setTimeout(
() => (this.canOpenParticipation = true),
lessonStart.diff(now).toObject().milliseconds,
);
}
},
beforeDestroy() {
if (this.timeout) {
clearTimeout(this.timeout);
}
},
methods: {
touchDocumentation() {
this.mutate(
touchDocumentation,
{
documentationId: this.documentation.id,
},
(storedDocumentations, incoming) => {
// ID may be different now
return storedDocumentations.map((doc) =>
doc.id === this.documentation.id
? Object.assign(doc, incoming, { oldId: doc.id })
: doc,
);
},
);
},
},
computed: {
showLabel() {
return !!this.labelKey || !this.canOpenParticipation;
},
innerLabelKey() {
if (this.documentation.futureNoticeParticipationStatus) {
return "alsijil.coursebook.notes.future";
}
return this.labelKey;
},
},
};
</script>
<template>
<manage-students-dialog
v-bind="documentationPartProps"
@update="() => null"
:loading-indicator="loading"
v-if="!documentation.amends?.cancelled"
>
<template #activator="{ attrs, on }">
<v-chip
dense
color="primary"
outlined
:disabled="!canOpenParticipation || loading"
v-bind="attrs"
v-on="on"
@click="touchDocumentation"
>
<v-icon :left="showLabel">mdi-account-edit-outline</v-icon>
<template v-if="showLabel">
{{ $t(innerLabelKey) }}
</template>
</v-chip>
</template>
</manage-students-dialog>
</template>
<style scoped></style>