Newer
Older
from typing import Optional, Union
from urllib.parse import urlparse
from django.core.exceptions import PermissionDenied
from django.db.models import QuerySet

Jonathan Weth
committed
from django.db.models.constraints import CheckConstraint
from django.db.models.query_utils import Q
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _

Jonathan Weth
committed
from calendarweek import CalendarWeek
from colorfield.fields import ColorField

Jonathan Weth
committed
from aleksis.apps.alsijil.data_checks import (
ExcusesWithoutAbsences,
LessonDocumentationOnHolidaysDataCheck,
NoGroupsOfPersonsSetInPersonalNotesDataCheck,
NoPersonalNotesInCancelledLessonsDataCheck,
PersonalNoteOnHolidaysDataCheck,
)

Jonathan Weth
committed
from aleksis.apps.alsijil.managers import (
GroupRoleAssignmentManager,
GroupRoleAssignmentQuerySet,
GroupRoleManager,
GroupRoleQuerySet,

Jonathan Weth
committed
LessonDocumentationManager,
LessonDocumentationQuerySet,
ParticipationStatusManager,

Jonathan Weth
committed
PersonalNoteManager,
PersonalNoteQuerySet,
)
from aleksis.apps.chronos.managers import GroupPropertiesMixin
from aleksis.apps.chronos.mixins import WeekRelatedMixin
from aleksis.apps.chronos.models import Event, ExtraLesson, LessonEvent, LessonPeriod, TimePeriod
from aleksis.apps.chronos.util.format import format_m2m
from aleksis.apps.cursus.models import Course, Subject
from aleksis.apps.kolego.models import Absence as KolegoAbsence
from aleksis.apps.kolego.models import AbsenceReason
from aleksis.core.data_checks import field_validation_data_check_factory
from aleksis.core.mixins import ExtensibleModel, GlobalPermissionModel
from aleksis.core.models import CalendarEvent, Group, Person, SchoolTerm

Jonathan Weth
committed
from aleksis.core.util.core_helpers import get_site_preferences
from aleksis.core.util.model_helpers import ICONS
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"))
count_as_absent = models.BooleanField(
help_text=_(
"If checked, this excuse type will be counted as a missed lesson. If not checked,"
"it won't show up in the absence report."
),
def __str__(self):
return f"{self.name} ({self.short_name})"
@property
def count_label(self):

Hangzhi Yu
committed
return f"excuse_type_{self.id}_count"
class Meta:
ordering = ["name"]
verbose_name = _("Excuse type")
verbose_name_plural = _("Excuse types")

Jonathan Weth
committed
lesson_related_constraint_q = (
Q(
lesson_period__isnull=False,
event__isnull=True,
extra_lesson__isnull=True,
week__isnull=False,
year__isnull=False,
)
| Q(
lesson_period__isnull=True,
event__isnull=False,
extra_lesson__isnull=True,
week__isnull=True,
year__isnull=True,
)
| Q(
lesson_period__isnull=True,
event__isnull=True,
extra_lesson__isnull=False,
week__isnull=True,
year__isnull=True,
)
)
class RegisterObjectRelatedMixin(WeekRelatedMixin):
"""Mixin with common API for lesson documentations and personal notes."""
self: Union["LessonDocumentation", "PersonalNote"],
) -> Union[LessonPeriod, Event, ExtraLesson]:
"""Get the object related to this lesson documentation or personal note."""
if self.lesson_period:
return self.lesson_period
elif self.event:
return self.event
else:
return self.extra_lesson

Jonathan Weth
committed
@property
def register_object_key(self: Union["LessonDocumentation", "PersonalNote"]) -> str:
"""Get a unique reference to the related object related."""
if self.week and self.year:
return f"{self.register_object.pk}_{self.week}_{self.year}"
else:
return self.register_object.pk
def calendar_week(self: Union["LessonDocumentation", "PersonalNote"]) -> CalendarWeek:
"""Get the calendar week of this lesson documentation or personal note.
.. note::
As events can be longer than one week,
this will return the week of the start date for events.
"""
if self.lesson_period:
return CalendarWeek(week=self.week, year=self.year)
elif self.extra_lesson:
return self.extra_lesson.calendar_week
else:
return CalendarWeek.from_date(self.register_object.date_start)
def school_term(self: Union["LessonDocumentation", "PersonalNote"]) -> SchoolTerm:
"""Get the school term of the related register object."""
if self.lesson_period:
return self.lesson_period.lesson.validity.school_term
else:
return self.register_object.school_term
def date(self: Union["LessonDocumentation", "PersonalNote"]) -> Optional[date]:
"""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.lesson_period:
return super().date
elif self.extra_lesson:
return self.extra_lesson.date
return None
@property
def date_formatted(self: Union["LessonDocumentation", "PersonalNote"]) -> str:
"""Get a formatted version of the date of this object.
Lesson periods, extra lessons: formatted date
Events: formatted date range
"""
return (
date_format(self.date)
if self.date
else f"{date_format(self.event.date_start)}–{date_format(self.event.date_end)}"
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
@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)
class PersonalNote(RegisterObjectRelatedMixin, ExtensibleModel):
"""A personal note about a single person.
Used in the class register to note absences, excuses
and remarks about a student in a single lesson period.
data_checks = [
NoPersonalNotesInCancelledLessonsDataCheck,
NoGroupsOfPersonsSetInPersonalNotesDataCheck,
PersonalNoteOnHolidaysDataCheck,
ExcusesWithoutAbsences,
]

Jonathan Weth
committed
objects = PersonalNoteManager.from_queryset(PersonalNoteQuerySet)()
person = models.ForeignKey("core.Person", models.CASCADE, related_name="personal_notes")
groups_of_person = models.ManyToManyField("core.Group", related_name="+")

Jonathan Weth
committed
week = models.IntegerField(blank=True, null=True)
year = models.IntegerField(verbose_name=_("Year"), blank=True, null=True)

Jonathan Weth
committed
"chronos.LessonPeriod", models.CASCADE, related_name="personal_notes", blank=True, null=True
)
event = models.ForeignKey(
"chronos.Event", models.CASCADE, related_name="personal_notes", blank=True, null=True
)
extra_lesson = models.ForeignKey(
"chronos.ExtraLesson", models.CASCADE, related_name="personal_notes", blank=True, null=True
absent = models.BooleanField(default=False)
tardiness = models.PositiveSmallIntegerField(default=0)
excused = models.BooleanField(default=False)
ExcuseType,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Excuse type"),
remarks = models.CharField(max_length=200, blank=True)
extra_marks = models.ManyToManyField("ExtraMark", blank=True, verbose_name=_("Extra marks"))
def save(self, *args, **kwargs):
if self.excuse_type:
self.excused = True
if not self.absent:
self.excused = False
self.excuse_type = None
def reset_values(self):
"""Reset all saved data to default values.
.. warning ::
This won't save the data, please execute ``save`` extra.
defaults = PersonalNote()
self.absent = defaults.absent
self.tardiness = defaults.tardiness
self.excused = defaults.excused
self.excuse_type = defaults.excuse_type
self.remarks = defaults.remarks
self.extra_marks.clear()
def __str__(self) -> str:
return f"{self.date_formatted}, {self.lesson_period}, {self.person}"
def get_absolute_url(self) -> str:
"""Get the absolute url of the detail view for the related register object."""
return urlparse(super().get_absolute_url())._replace(fragment="personal-notes").geturl()
verbose_name = _("Personal note")
verbose_name_plural = _("Personal notes")
"week",
"lesson_period__period__weekday",
"lesson_period__period__period",
"person__last_name",
"person__first_name",
]

Jonathan Weth
committed
constraints = [
CheckConstraint(
check=lesson_related_constraint_q, name="one_relation_only_personal_note"
),
models.UniqueConstraint(
fields=("week", "year", "lesson_period", "person"),
name="unique_note_per_lp",
fields=("week", "year", "event", "person"),
name="unique_note_per_ev",
fields=("week", "year", "extra_lesson", "person"),
name="unique_note_per_el",

Jonathan Weth
committed
]
class LessonDocumentation(RegisterObjectRelatedMixin, ExtensibleModel):
"""A documentation on a single lesson period.
Non-personal, includes the topic and homework of the lesson.

Jonathan Weth
committed
objects = LessonDocumentationManager.from_queryset(LessonDocumentationQuerySet)()
data_checks = [LessonDocumentationOnHolidaysDataCheck]

Jonathan Weth
committed
week = models.IntegerField(blank=True, null=True)
year = models.IntegerField(verbose_name=_("Year"), blank=True, null=True)

Jonathan Weth
committed
"chronos.LessonPeriod", models.CASCADE, related_name="documentations", blank=True, null=True
)
event = models.ForeignKey(
"chronos.Event", models.CASCADE, related_name="documentations", blank=True, null=True
)
extra_lesson = models.ForeignKey(
"chronos.ExtraLesson", models.CASCADE, related_name="documentations", blank=True, null=True
topic = models.CharField(verbose_name=_("Lesson topic"), max_length=200, blank=True)
homework = models.CharField(verbose_name=_("Homework"), max_length=200, blank=True)
group_note = models.CharField(verbose_name=_("Group note"), max_length=200, blank=True)
def carry_over_data(self, all_periods_of_lesson: LessonPeriod):
"""Carry over data to given periods in this lesson if data is not already set.

Jonathan Weth
committed
Both forms of carrying over data can be deactivated using site preferences
``alsijil__carry_over_next_periods`` and ``alsijil__allow_carry_over_same_week``
respectively.

Jonathan Weth
committed
"""
for period in all_periods_of_lesson:
lesson_documentation = period.get_or_create_lesson_documentation(
CalendarWeek(week=self.week, year=self.year)

Jonathan Weth
committed

Jonathan Weth
committed
if not lesson_documentation.topic:
lesson_documentation.topic = self.topic
changed = True

Jonathan Weth
committed
if not lesson_documentation.homework:
lesson_documentation.homework = self.homework
changed = True

Jonathan Weth
committed
if not lesson_documentation.group_note:
lesson_documentation.group_note = self.group_note
changed = True

Jonathan Weth
committed
lesson_documentation.save(carry_over_next_periods=False)

Jonathan Weth
committed
return f"{self.lesson_period}, {self.date_formatted}"
def save(self, carry_over_next_periods=True, *args, **kwargs):
get_site_preferences()["alsijil__carry_over_next_periods"]
and (self.topic or self.homework or self.group_note)
and self.lesson_period
and carry_over_next_periods
self.carry_over_data(
LessonPeriod.objects.filter(
lesson=self.lesson_period.lesson,
period__weekday=self.lesson_period.period.weekday,
)
)

Jonathan Weth
committed
super().save(*args, **kwargs)
verbose_name = _("Lesson documentation")
verbose_name_plural = _("Lesson documentations")
"week",
"lesson_period__period__weekday",
"lesson_period__period__period",
]

Jonathan Weth
committed
constraints = [
CheckConstraint(
check=lesson_related_constraint_q,
name="one_relation_only_lesson_documentation",
),
models.UniqueConstraint(
fields=("week", "year", "lesson_period"),
name="unique_documentation_per_lp",
),
models.UniqueConstraint(
fields=("week", "year", "event"),
name="unique_documentation_per_ev",
),
models.UniqueConstraint(
fields=("week", "year", "extra_lesson"),
name="unique_documentation_per_el",

Jonathan Weth
committed
]
class ExtraMark(ExtensibleModel):
"""A model for extra marks.
Can be used for lesson-based counting of things (like forgotten homework).
"""
short_name = models.CharField(max_length=255, unique=True, verbose_name=_("Short name"))
name = models.CharField(max_length=255, unique=True, verbose_name=_("Name"))
colour_fg = ColorField(verbose_name=_("Foreground colour"), blank=True)
colour_bg = ColorField(verbose_name=_("Background colour"), blank=True)
show_in_coursebook = models.BooleanField(default=True, verbose_name=_("Show in coursebook"))
return f"{self.name}"

Hangzhi Yu
committed
return f"extra_mark_{self.id}_count"
class Meta:
ordering = ["short_name"]
verbose_name = _("Extra mark")
verbose_name_plural = _("Extra marks")
class Documentation(CalendarEvent):
"""A documentation on teaching content in a freely choosable time frame.
Non-personal, includes the topic and homework of the lesson.
"""
# FIXME: DataCheck
objects = DocumentationManager()
related_name="documentations",
blank=True,
null=True,
verbose_name=_("Course"),
)
subject = models.ForeignKey(
Subject, models.PROTECT, related_name="+", blank=True, null=True, verbose_name=_("Subject")
"core.Person",
related_name="documentations_as_teacher",
blank=True,
null=True,
verbose_name=_("Teachers"),
topic = models.CharField(verbose_name=_("Lesson Topic"), max_length=255, blank=True)
homework = models.CharField(verbose_name=_("Homework"), max_length=255, blank=True)
group_note = models.CharField(verbose_name=_("Group Note"), max_length=255, blank=True)
def get_subject(self) -> str:
if self.subject:
return self.subject
if self.amends:
if self.amends.subject:
return self.amends.subject
if self.amends.course:
return self.amends.course.subject
if self.course:
return self.course.subject
def get_groups(self) -> QuerySet[Group]:
if self.amends:
return self.amends.actual_groups
if self.course:
return self.course.groups.all()
def __str__(self) -> str:
start_datetime = CalendarEvent.value_start_datetime(self)
end_datetime = CalendarEvent.value_end_datetime(self)
return (
f"{format_m2m(self.get_groups())} {self.get_subject()}"
+ f" {start_datetime} - {end_datetime}"
)
verbose_name = _("Documentation")
verbose_name_plural = _("Documentations")
# should check if object has either course or amends,
# which is not possible via constraint, because amends is not local to Documentation
) -> tuple:
"""Get all the documentations for the events.
Create dummy documentations if none exist.
Returns a tuple with a list of existing documentations and a list dummy documentations.
for event in events:
if incomplete and event["STATUS"] == "CANCELLED":
continue
event_reference_obj = event["REFERENCE_OBJECT"]
existing_documentations = event_reference_obj.amended_by.filter(
datetime_start=event["DTSTART"].dt,
datetime_end=event["DTEND"].dt,
)
if existing_documentations.exists():
doc = existing_documentations.first()
if incomplete and doc.topic:
continue
docs.append(doc)
else:
if event_reference_obj.amends:
if event_reference_obj.course:
course = event_reference_obj.course
else:
course = event_reference_obj.amends.course
if event_reference_obj.subject:
subject = event_reference_obj.subject
else:
subject = event_reference_obj.amends.subject
else:
course, subject = event_reference_obj.course, event_reference_obj.subject
cls(
pk=f"DUMMY;{event_reference_obj.id};{event['DTSTART'].dt.isoformat()};{event['DTEND'].dt.isoformat()}",
course=course,
subject=subject,
datetime_start=event["DTSTART"].dt,
datetime_end=event["DTEND"].dt,
@classmethod
def get_documentations_for_person(
cls,
person: int,
start: datetime,
end: datetime,
incomplete: Optional[bool] = False,
) -> tuple:
"""Get all the documentations for the person from start to end datetime.
Create dummy documentations if none exist.
Returns a tuple with a list of existing documentations and a list dummy documentations.
"""
event_params = {
"type": "PARTICIPANT",
}
events = LessonEvent.get_single_events(
start,
end,
None,
event_params,
with_reference_object=True,
)
return Documentation.get_documentations_for_events(events, incomplete)
@classmethod
def parse_dummy(
"""Parse dummy id string into lesson_event, datetime_start, datetime_end."""
dummy, lesson_event_id, datetime_start_iso, datetime_end_iso = _id.split(";")
lesson_event = LessonEvent.objects.get(id=lesson_event_id)
datetime_start = datetime.fromisoformat(datetime_start_iso).astimezone(
lesson_event.timezone
)
datetime_end = datetime.fromisoformat(datetime_end_iso).astimezone(lesson_event.timezone)
return (lesson_event, datetime_start, datetime_end)
@classmethod
def create_from_lesson_event(
cls,
user: User,
lesson_event: LessonEvent,
datetime_start: datetime,
datetime_end: datetime,
"""Create a documentation from a lesson_event with start and end datetime.
User is needed for permission checking.
"""
"alsijil.add_documentation_for_lesson_event_rule", lesson_event
) or not (
get_site_preferences()["alsijil__allow_edit_future_documentations"] == "all"
or (
get_site_preferences()["alsijil__allow_edit_future_documentations"] == "current_day"
and datetime_start.date() <= localdate()
)
or (
get_site_preferences()["alsijil__allow_edit_future_documentations"]
== "current_time"
and datetime_start <= localtime()
)
):
course = lesson_event.course if lesson_event.course else lesson_event.amends.course
subject = lesson_event.subject if lesson_event.subject else lesson_event.amends.subject
teachers = (
lesson_event.teachers if lesson_event.teachers else lesson_event.amends.teachers
)
else:
course, subject, teachers = (
lesson_event.course,
lesson_event.subject,
lesson_event.teachers,
)
obj = cls.objects.create(
datetime_start=datetime_start,
datetime_end=datetime_end,
amends=lesson_event,
course=course,
subject=subject,
)
obj.teachers.set(teachers.all())
obj.save()
# Create Participation Statuses
if _id.startswith("DUMMY"):
return cls.create_from_lesson_event(
user,
*cls.parse_dummy(_id),
), True
return cls.objects.get(id=_id), False
def build_participation_status(self, person: Person) -> "ParticipationStatus":
"""Build participation status object for this documentation and a person."""
return ParticipationStatus(
person=person,
related_documentation=self,
datetime_start=self.datetime_start,
datetime_end=self.datetime_end,
# Set timezone directly as save of ParticipationStatus won't be called in bulk_create
timezone=self.timezone,
)
"""Ensure that participation statuses are created for this documentation."""
# TODO: Check for preexisting absences in kolego
if not self.amends or self.datetime_start <= localtime():
# There is no source to update from or it's to early
return
lesson_event: LessonEvent = self.amends
all_members = lesson_event.all_members
existing_participations = ParticipationStatus.objects.filter(related_documentation=self)
if existing_participations.exists():
return
new_persons = Person.objects.filter(Q(pk__in=[p.pk for p in all_members])).prefetch_related(
"member_of"
)
new_participations = []
new_groups_of_person = []
participation_status = ParticipationStatus.objects.create(
person=person,
related_documentation=self,
datetime_start=self.datetime_start,
datetime_end=self.datetime_end,
timezone=self.timezone,
)
new_groups_of_person += [
ParticipationStatus.groups_of_person.through(
group=group, participationstatus=participation_status
)
for group in person.member_of.all()
]
new_participations.append(participation_status)
ParticipationStatus.groups_of_person.through.objects.bulk_create(new_groups_of_person)
class ParticipationStatus(CalendarEvent):
"""A participation or absence record about a single person.
Used in the class register to note participation or absence of a student
in a documented unit (e.g. a single lesson event or a custom time frame; see Documentation).
"""
# FIXME: DataChecks
objects = ParticipationStatusManager()
person = models.ForeignKey(
"core.Person", models.CASCADE, related_name="participations", verbose_name=_("Person")
)
groups_of_person = models.ManyToManyField(
"core.Group", related_name="+", verbose_name=_("Groups of Person")
)
related_documentation = models.ForeignKey(
Documentation,
models.CASCADE,
related_name="participations",
verbose_name=_("Documentation"),
# Absence part
absence_reason = models.ForeignKey(
AbsenceReason,
verbose_name=_("Absence Reason"),
on_delete=models.PROTECT,
blank=True,
null=True,
KolegoAbsence,
models.SET_NULL,
blank=True,
null=True,
related_name="absences",
verbose_name=_("Base Absence"),
return f"{self.related_documentation.id}, {self.person}"
verbose_name = _("Participation Status")
verbose_name_plural = _("Participation Status")
"related_documentation",
"person__last_name",
"person__first_name",
]
constraints = [
models.UniqueConstraint(
fields=("related_documentation", "person"),
name="unique_participation_status_per_documentation",
),
]
class NewPersonalNote(ExtensibleModel):
person = models.ForeignKey(
"core.Person", models.CASCADE, related_name="new_personal_notes", verbose_name=_("Person")
)
documentation = models.ForeignKey(
Documentation,
models.CASCADE,
related_name="personal_notes",
verbose_name=_("Documentation"),
blank=True,
null=True,
)
note = models.TextField(blank=True, verbose_name=_("Note"))
extra_mark = models.ForeignKey(
ExtraMark, on_delete=models.PROTECT, blank=True, null=True, verbose_name=_("Extra Mark")
)
def __str__(self) -> str:
return f"{self.person}, {self.note}, {self.extra_mark}"
class Meta:
verbose_name = _("Personal Note")
verbose_name_plural = _("Personal Notes")
constraints = [
models.CheckConstraint(
check=~Q(note="") | Q(extra_mark__isnull=False),
name="unique_absence_per_documentation",
),
]
data_checks = [field_validation_data_check_factory("alsijil", "GroupRole", "icon")]
objects = GroupRoleManager.from_queryset(GroupRoleQuerySet)()
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
icon = models.CharField(max_length=50, blank=True, choices=ICONS, verbose_name=_("Icon"))
colour = ColorField(blank=True, verbose_name=_("Colour"))
def __str__(self):
return self.name
class Meta:
verbose_name = _("Group role")
verbose_name_plural = _("Group roles")
permissions = (("assign_group_role", _("Can assign group role")),)
def get_absolute_url(self) -> str:
return reverse("edit_group_role", args=[self.id])
class GroupRoleAssignment(GroupPropertiesMixin, ExtensibleModel):
objects = GroupRoleAssignmentManager.from_queryset(GroupRoleAssignmentQuerySet)()
on_delete=models.CASCADE,
related_name="assignments",
)
person = models.ForeignKey(
"core.Person",
on_delete=models.CASCADE,
"core.Group",
related_name="group_roles",
verbose_name=_("Groups"),
)
date_start = models.DateField(verbose_name=_("Start date"))
date_end = models.DateField(
blank=True,
null=True,
verbose_name=_("End date"),
help_text=_("Can be left empty if end date is not clear yet"),
)
def __str__(self):
date_end = date_format(self.date_end) if self.date_end else "?"
return f"{self.role}: {self.person}, {date_format(self.date_start)}–{date_end}"
@property
def date_range(self) -> str:
if not self.date_end:
return f"{date_format(self.date_start)}–?"
else:
return f"{date_format(self.date_start)}–{date_format(self.date_end)}"
verbose_name = _("Group role assignment")
verbose_name_plural = _("Group role assignments")
class AlsijilGlobalPermissions(GlobalPermissionModel):
class Meta:
managed = False
permissions = (
("view_lesson", _("Can view lesson overview")),
("view_week", _("Can view week overview")),
("view_full_register", _("Can view full register")),
("register_absence", _("Can register absence")),
("list_personal_note_filters", _("Can list all personal note filters")),
)