Skip to content
Snippets Groups Projects
models.py 16.4 KiB
Newer Older
from datetime import date
from typing import Optional, Union
from urllib.parse import urlparse
Nik | Klampfradler's avatar
Nik | Klampfradler committed
from django.db import models
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 _
from colorfield.fields import ColorField
from aleksis.apps.alsijil.data_checks import (
    ExcusesWithoutAbsences,
    LessonDocumentationOnHolidaysDataCheck,
    NoGroupsOfPersonsSetInPersonalNotesDataCheck,
    NoPersonalNotesInCancelledLessonsDataCheck,
    PersonalNoteOnHolidaysDataCheck,
)
    GroupRoleAssignmentManager,
    GroupRoleAssignmentQuerySet,
    GroupRoleManager,
    GroupRoleQuerySet,
    LessonDocumentationManager,
    LessonDocumentationQuerySet,
    PersonalNoteManager,
    PersonalNoteQuerySet,
)
from aleksis.apps.chronos.managers import GroupPropertiesMixin
from aleksis.apps.chronos.mixins import WeekRelatedMixin
from aleksis.apps.chronos.models import Event, ExtraLesson, LessonPeriod, TimePeriod
Tom Teichler's avatar
Tom Teichler committed
from aleksis.core.mixins import ExtensibleModel, GlobalPermissionModel
from aleksis.core.models import SchoolTerm
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"))

    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")
        constraints = [
            models.UniqueConstraint(
                fields=("site_id", "short_name"), name="unique_excuse_short_name"
            ),
            models.UniqueConstraint(fields=("site_id", "name"), name="unique_excuse_name"),
        ]
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."""

    def register_object(
        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

    @property
    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)}"
    @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,
    ]

    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="+")
    week = models.IntegerField(blank=True, null=True)
    year = models.IntegerField(verbose_name=_("Year"), blank=True, null=True)
    lesson_period = models.ForeignKey(
        "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)
    late = models.PositiveSmallIntegerField(default=0)
    excused = models.BooleanField(default=False)
Jonathan Weth's avatar
Jonathan Weth committed
    excuse_type = models.ForeignKey(
        ExcuseType, on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Excuse type"),
Jonathan Weth's avatar
Jonathan Weth committed
    )

    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
Jonathan Weth's avatar
Jonathan Weth committed
        if not self.absent:
            self.excused = False
            self.excuse_type = None
        super().save(*args, **kwargs)

        """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.late = defaults.late
        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")
        ordering = [
            "week",
            "lesson_period__period__weekday",
            "lesson_period__period__period",
            "person__last_name",
            "person__first_name",
        ]
        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",
            ),
            models.UniqueConstraint(
                fields=("week", "year", "event", "person"), name="unique_note_per_ev",
            ),
            models.UniqueConstraint(
                fields=("week", "year", "extra_lesson", "person"), name="unique_note_per_el",
class LessonDocumentation(RegisterObjectRelatedMixin, ExtensibleModel):
    """A documentation on a single lesson period.

    Non-personal, includes the topic and homework of the lesson.
    objects = LessonDocumentationManager.from_queryset(LessonDocumentationQuerySet)()

    data_checks = [LessonDocumentationOnHolidaysDataCheck]

    week = models.IntegerField(blank=True, null=True)
    year = models.IntegerField(verbose_name=_("Year"), blank=True, null=True)
    lesson_period = models.ForeignKey(
        "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)
Jonathan Weth's avatar
Jonathan Weth committed
    def _carry_over_data(self):
        """Carry over data to directly adjacent periods in this lesson if data is not already set.
Jonathan Weth's avatar
Jonathan Weth committed
        Can be deactivated using site preference ``alsijil__carry_over``.
        all_periods_of_lesson = LessonPeriod.objects.filter(
Lloyd Meins's avatar
Lloyd Meins committed
            lesson=self.lesson_period.lesson, period__weekday=self.lesson_period.period.weekday,
        for period in all_periods_of_lesson:
            lesson_documentation = period.get_or_create_lesson_documentation(
                CalendarWeek(week=self.week, year=self.year)
            if not lesson_documentation.topic:
                lesson_documentation.topic = self.topic
                changed = True
            if not lesson_documentation.homework:
                lesson_documentation.homework = self.homework
                changed = True
            if not lesson_documentation.group_note:
                lesson_documentation.group_note = self.group_note
                changed = True
                lesson_documentation.save(carry_over=False)
    def __str__(self) -> str:
        return f"{self.lesson_period}, {self.date_formatted}"
    def save(self, carry_over=True, *args, **kwargs):
        if (
            get_site_preferences()["alsijil__carry_over"]
            and (self.topic or self.homework or self.group_note)
            and self.lesson_period
        verbose_name = _("Lesson documentation")
        verbose_name_plural = _("Lesson documentations")
        ordering = [
            "week",
            "lesson_period__period__weekday",
            "lesson_period__period__period",
        ]
        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's avatar
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"))
Jonathan Weth's avatar
Jonathan Weth committed
    name = models.CharField(max_length=255, unique=True, verbose_name=_("Name"))

    def __str__(self):
Jonathan Weth's avatar
Jonathan Weth committed

    @property
    def count_label(self):
        return f"{self.short_name}_count"

    class Meta:
        ordering = ["short_name"]
        verbose_name = _("Extra mark")
        verbose_name_plural = _("Extra marks")
        constraints = [
            models.UniqueConstraint(
                fields=("site_id", "short_name"), name="unique_mark_short_name"
            ),
            models.UniqueConstraint(fields=("site_id", "name"), name="unique_mark_name"),
        ]
class GroupRole(ExtensibleModel):
    objects = GroupRoleManager.from_queryset(GroupRoleQuerySet)()

    name = models.CharField(max_length=255, verbose_name=_("Name"))
    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")
        constraints = [
            models.UniqueConstraint(fields=("site_id", "name"), name="unique_role_per_site"),
        ]
class GroupRoleAssignment(GroupPropertiesMixin, ExtensibleModel):
    objects = GroupRoleAssignmentManager.from_queryset(GroupRoleAssignmentQuerySet)()

    role = models.ForeignKey(
        GroupRole,
        on_delete=models.CASCADE,
        related_name="assignments",
        verbose_name=_("Group role"),
    )
    person = models.ForeignKey(
        "core.Person",
        on_delete=models.CASCADE,
        related_name="group_roles",
        verbose_name=_("Assigned person"),
    )
    groups = models.ManyToManyField(
        "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)}"

    class Meta:
        verbose_name = _("Group role assignment")
        verbose_name_plural = _("Group role assignments")
Tom Teichler's avatar
Tom Teichler committed
class AlsijilGlobalPermissions(GlobalPermissionModel):
    class Meta:
        managed = False
        permissions = (
            ("view_week", _("Can view week overview")),
            ("register_absence", _("Can register absence")),
            ("list_personal_note_filters", _("Can list all personal note filters")),
        )