Skip to content
Snippets Groups Projects
models.py 31.4 KiB
Newer Older
Tom Teichler's avatar
Tom Teichler committed
# flake8: noqa: DJ01

Tom Teichler's avatar
Tom Teichler committed
from datetime import date, datetime, time, timedelta
from typing import Dict, List, Optional, Tuple, Union
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Max, Min, Q
from django.db.models.functions import Coalesce
from django.forms import Media
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format
Jonathan Weth's avatar
Jonathan Weth committed
from django.utils.functional import classproperty
from django.utils.translation import gettext_lazy as _
Jonathan Weth's avatar
Jonathan Weth committed
from cache_memoize import cache_memoize
from calendarweek.django import CalendarWeek, i18n_day_abbr_choices_lazy, i18n_day_name_choices_lazy
Tom Teichler's avatar
Tom Teichler committed
from colorfield.fields import ColorField
Tom Teichler's avatar
Tom Teichler committed
from aleksis.apps.chronos.managers import (
    AbsenceQuerySet,
    BreakManager,
Hangzhi Yu's avatar
Hangzhi Yu committed
    CurrentSiteManager,
Jonathan Weth's avatar
Jonathan Weth committed
    EventManager,
Tom Teichler's avatar
Tom Teichler committed
    EventQuerySet,
Jonathan Weth's avatar
Jonathan Weth committed
    ExtraLessonManager,
Tom Teichler's avatar
Tom Teichler committed
    ExtraLessonQuerySet,
    GroupPropertiesMixin,
    HolidayQuerySet,
    LessonPeriodManager,
    LessonPeriodQuerySet,
    LessonSubstitutionManager,
    LessonSubstitutionQuerySet,
Jonathan Weth's avatar
Jonathan Weth committed
    SupervisionManager,
Tom Teichler's avatar
Tom Teichler committed
    SupervisionQuerySet,
Jonathan Weth's avatar
Jonathan Weth committed
    SupervisionSubstitutionManager,
Tom Teichler's avatar
Tom Teichler committed
    TeacherPropertiesMixin,
Jonathan Weth's avatar
Jonathan Weth committed
    ValidityRangeQuerySet,
from aleksis.apps.chronos.mixins import (
    ValidityRangeRelatedExtensibleModel,
    WeekAnnotationMixin,
    WeekRelatedMixin,
)
from aleksis.apps.chronos.util.date import get_current_year
from aleksis.apps.chronos.util.format import format_m2m
from aleksis.core.managers import CurrentSiteManagerWithoutMigrations
from aleksis.core.mixins import ExtensibleModel, SchoolTermRelatedExtensibleModel
from aleksis.core.models import DashboardWidget, SchoolTerm
Tom Teichler's avatar
Tom Teichler committed
from aleksis.core.util.core_helpers import has_person
class ValidityRange(ExtensibleModel):
    """Validity range model.

    This is used to link data to a validity range.
    """

    objects = CurrentSiteManagerWithoutMigrations.from_queryset(ValidityRangeQuerySet)()

    school_term = models.ForeignKey(
        SchoolTerm,
        on_delete=models.CASCADE,
        verbose_name=_("School term"),
        related_name="validity_ranges",
    )
    name = models.CharField(verbose_name=_("Name"), max_length=255, blank=True)

    date_start = models.DateField(verbose_name=_("Start date"))
    date_end = models.DateField(verbose_name=_("End date"))

    @classmethod
    def get_current(cls, day: Optional[date] = None):
        if not day:
            day = timezone.now().date()
        try:
            return cls.objects.on_day(day).first()
        except ValidityRange.DoesNotExist:
            return None

    @classproperty
    def current(cls):
        return cls.get_current()

    def clean(self):
        """Ensure there is only one validity range at each point of time."""
        if self.date_end < self.date_start:
Tom Teichler's avatar
Tom Teichler committed
            raise ValidationError(_("The start date must be earlier than the end date."))

        if self.school_term:
            if (
                self.date_end > self.school_term.date_end
                or self.date_start < self.school_term.date_start
            ):
Tom Teichler's avatar
Tom Teichler committed
                raise ValidationError(_("The validity range must be within the school term."))

        qs = ValidityRange.objects.within_dates(self.date_start, self.date_end)
        if self.pk:
Jonathan Weth's avatar
Jonathan Weth committed
            qs = qs.exclude(pk=self.pk)
        if qs.exists():
            raise ValidationError(
Tom Teichler's avatar
Tom Teichler committed
                _("There is already a validity range for this time or a part of this time.")
Tom Teichler's avatar
Tom Teichler committed
        return self.name or f"{date_format(self.date_start)}{date_format(self.date_end)}"

    class Meta:
        verbose_name = _("Validity range")
        verbose_name_plural = _("Validity ranges")
        unique_together = ["date_start", "date_end"]


class TimePeriod(ValidityRangeRelatedExtensibleModel):
    WEEKDAY_CHOICES = i18n_day_name_choices_lazy()
    WEEKDAY_CHOICES_SHORT = i18n_day_abbr_choices_lazy()
Jonathan Weth's avatar
Jonathan Weth committed
    weekday = models.PositiveSmallIntegerField(
        verbose_name=_("Week day"), choices=i18n_day_name_choices_lazy()
Jonathan Weth's avatar
Jonathan Weth committed
    )
    period = models.PositiveSmallIntegerField(verbose_name=_("Number of period"))
Jonathan Weth's avatar
Jonathan Weth committed
    time_start = models.TimeField(verbose_name=_("Start time"))
    time_end = models.TimeField(verbose_name=_("End time"))
Nik | Klampfradler's avatar
Nik | Klampfradler committed
    def __str__(self) -> str:
        return f"{self.get_weekday_display()}, {self.period}."
    def get_times_dict(cls) -> Dict[int, Tuple[datetime, datetime]]:
        for period in cls.objects.for_current_or_all().all():
            periods[period.period] = (period.time_start, period.time_end)

        return periods

    def get_date(self, week: Optional[CalendarWeek] = None) -> date:
        if isinstance(week, CalendarWeek):
            wanted_week = week
        else:
            year = getattr(self, "_year", None) or date.today().year
            week_number = getattr(self, "_week", None) or CalendarWeek().week

            wanted_week = CalendarWeek(year=year, week=week_number)

        return wanted_week[self.weekday]
Tom Teichler's avatar
Tom Teichler committed
    def get_datetime_start(self, week: Optional[Union[CalendarWeek, int]] = None) -> datetime:
        """Get datetime of lesson start in a specific week."""
        day = self.get_date(week)
        return datetime.combine(day, self.time_start)

Tom Teichler's avatar
Tom Teichler committed
    def get_datetime_end(self, week: Optional[Union[CalendarWeek, int]] = None) -> datetime:
        """Get datetime of lesson end in a specific week."""
        day = self.get_date(week)
        return datetime.combine(day, self.time_end)

Tom Teichler's avatar
Tom Teichler committed
    def get_next_relevant_day(
        cls, day: Optional[date] = None, time: Optional[time] = None, prev: bool = False
    ) -> date:
Tom Teichler's avatar
Tom Teichler committed
        """Return next (previous) day with lessons depending on date and time."""
        if day is None:
            day = timezone.now().date()

        if time is not None and cls.time_max and not prev:
            if time > cls.time_max:
                day += timedelta(days=1)

        cw = CalendarWeek.from_date(day)

        if day.weekday() > cls.weekday_max:
            if prev:
                day = cw[cls.weekday_max]
            else:
                cw += 1
                day = cw[cls.weekday_min]
        elif day.weekday() < TimePeriod.weekday_min:
            if prev:
                cw -= 1
                day = cw[cls.weekday_max]
            else:
                day = cw[cls.weekday_min]

        return day

Tom Teichler's avatar
Tom Teichler committed
    def get_relevant_week_from_datetime(cls, when: Optional[datetime] = None) -> CalendarWeek:
        """Return currently relevant week depending on current date and time."""
        if not when:
            when = timezone.now()

        day = when.date()
        time = when.time()

        week = CalendarWeek.from_date(day)

        if cls.weekday_max and day.weekday() > cls.weekday_max:
            week += 1
        elif cls.time_max and time > cls.time_max and day.weekday() == cls.weekday_max:
            week += 1

        return week

    @classmethod
    def get_prev_next_by_day(cls, day: date, url: str) -> Tuple[str, str]:
Tom Teichler's avatar
Tom Teichler committed
        """Build URLs for previous/next day."""
        day_prev = cls.get_next_relevant_day(day - timedelta(days=1), prev=True)
        day_next = cls.get_next_relevant_day(day + timedelta(days=1))

        url_prev = reverse(url, args=[day_prev.year, day_prev.month, day_prev.day])
        url_next = reverse(url, args=[day_next.year, day_next.month, day_next.day])

        return url_prev, url_next

Jonathan Weth's avatar
Jonathan Weth committed
    def period_min(cls) -> int:
        return (
            cls.objects.for_current_or_all()
            .aggregate(period__min=Coalesce(Min("period"), 1))
            .get("period__min")
Jonathan Weth's avatar
Jonathan Weth committed
        )
Jonathan Weth's avatar
Jonathan Weth committed
    def period_max(cls) -> int:
        return (
            cls.objects.for_current_or_all()
            .aggregate(period__max=Coalesce(Max("period"), 7))
            .get("period__max")
Jonathan Weth's avatar
Jonathan Weth committed
        )
Jonathan Weth's avatar
Jonathan Weth committed
    def time_min(cls) -> Optional[time]:
Tom Teichler's avatar
Tom Teichler committed
        return cls.objects.for_current_or_all().aggregate(Min("time_start")).get("time_start__min")
Jonathan Weth's avatar
Jonathan Weth committed
    def time_max(cls) -> Optional[time]:
Tom Teichler's avatar
Tom Teichler committed
        return cls.objects.for_current_or_all().aggregate(Max("time_end")).get("time_end__max")
Jonathan Weth's avatar
Jonathan Weth committed
    def weekday_min(cls) -> int:
        return (
            cls.objects.for_current_or_all()
            .aggregate(weekday__min=Coalesce(Min("weekday"), 0))
            .get("weekday__min")
Jonathan Weth's avatar
Jonathan Weth committed
        )
Jonathan Weth's avatar
Jonathan Weth committed
    def weekday_max(cls) -> int:
        return (
            cls.objects.for_current_or_all()
            .aggregate(weekday__max=Coalesce(Max("weekday"), 6))
            .get("weekday__max")
Jonathan Weth's avatar
Jonathan Weth committed
        )
    def period_choices(cls) -> List[Tuple[Union[str, int], str]]:
        """Build choice list of periods for usage within Django."""
        time_periods = (
            cls.objects.filter(weekday=cls.weekday_min)
            .for_current_or_all()
            .values("period", "time_start", "time_end")
            .distinct()
        )

        period_choices = [("", "")] + [
Tom Teichler's avatar
Tom Teichler committed
            (period, f"{period}.") for period in time_periods.values_list("period", flat=True)
    class Meta:
        unique_together = [["weekday", "period", "validity"]]
        ordering = ["weekday", "period"]
        indexes = [models.Index(fields=["time_start", "time_end"])]
        verbose_name = _("Time period")
        verbose_name_plural = _("Time periods")
class Subject(ExtensibleModel):
Tom Teichler's avatar
Tom Teichler committed
    short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True)
    name = models.CharField(verbose_name=_("Long name"), max_length=255, unique=True)
    colour_fg = ColorField(verbose_name=_("Foreground colour"), blank=True)
    colour_bg = ColorField(verbose_name=_("Background colour"), blank=True)
    def __str__(self) -> str:
        return f"{self.short_name} ({self.name})"
        ordering = ["name", "short_name"]
Jonathan Weth's avatar
Jonathan Weth committed
        verbose_name = _("Subject")
        verbose_name_plural = _("Subjects")
class Room(ExtensibleModel):
Tom Teichler's avatar
Tom Teichler committed
    short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True)
    name = models.CharField(verbose_name=_("Long name"), max_length=255)
    def __str__(self) -> str:
        return f"{self.name} ({self.short_name})"
Tom Teichler's avatar
Tom Teichler committed
    def get_absolute_url(self) -> str:
        return reverse("timetable", args=["room", self.id])

        ordering = ["name", "short_name"]
Jonathan Weth's avatar
Jonathan Weth committed
        verbose_name = _("Room")
        verbose_name_plural = _("Rooms")
Tom Teichler's avatar
Tom Teichler committed
class Lesson(ValidityRangeRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin):
Tom Teichler's avatar
Tom Teichler committed
    subject = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        "Subject", on_delete=models.CASCADE, related_name="lessons", verbose_name=_("Subject"),
Tom Teichler's avatar
Tom Teichler committed
    )
    teachers = models.ManyToManyField(
        "core.Person", related_name="lessons_as_teacher", verbose_name=_("Teachers")
    )
    periods = models.ManyToManyField(
Tom Teichler's avatar
Tom Teichler committed
        "TimePeriod", related_name="lessons", through="LessonPeriod", verbose_name=_("Periods"),
Tom Teichler's avatar
Tom Teichler committed
    )
Tom Teichler's avatar
Tom Teichler committed
    groups = models.ManyToManyField("core.Group", related_name="lessons", verbose_name=_("Groups"))
    def get_year(self, week: int) -> int:
        year = self.validity.date_start.year
        if week < int(self.validity.date_start.strftime("%V")):
            year += 1
        return year
    def get_calendar_week(self, week: int):
        year = self.get_year(week)

        return CalendarWeek(year=year, week=week)

    def __str__(self):
        return f"{format_m2m(self.groups)}, {self.subject.short_name}, {format_m2m(self.teachers)}"
        ordering = ["validity__date_start", "subject"]
        verbose_name = _("Lesson")
        verbose_name_plural = _("Lessons")
class LessonSubstitution(ExtensibleModel, WeekRelatedMixin):
    objects = LessonSubstitutionManager.from_queryset(LessonSubstitutionQuerySet)()
Tom Teichler's avatar
Tom Teichler committed
    week = models.IntegerField(verbose_name=_("Week"), default=CalendarWeek.current_week)
    year = models.IntegerField(verbose_name=_("Year"), default=get_current_year)
Tom Teichler's avatar
Tom Teichler committed
    lesson_period = models.ForeignKey(
        "LessonPeriod", models.CASCADE, "substitutions", verbose_name=_("Lesson period")
    )
Nik | Klampfradler's avatar
Nik | Klampfradler committed

    subject = models.ForeignKey(
        "Subject",
        on_delete=models.CASCADE,
        related_name="lesson_substitutions",
        null=True,
        blank=True,
        verbose_name=_("Subject"),
    )
    teachers = models.ManyToManyField(
Tom Teichler's avatar
Tom Teichler committed
        "core.Person", related_name="lesson_substitutions", blank=True, verbose_name=_("Teachers"),
Tom Teichler's avatar
Tom Teichler committed
    room = models.ForeignKey("Room", models.CASCADE, null=True, blank=True, verbose_name=_("Room"))
    cancelled = models.BooleanField(default=False, verbose_name=_("Cancelled?"))
Tom Teichler's avatar
Tom Teichler committed
    cancelled_for_teachers = models.BooleanField(
        default=False, verbose_name=_("Cancelled for teachers?")
    )
    comment = models.TextField(verbose_name=_("Comment"), blank=True, null=True)
    def clean(self) -> None:
        if self.subject and self.cancelled:
Tom Teichler's avatar
Tom Teichler committed
            raise ValidationError(_("Lessons can only be either substituted or cancelled."))
Tom Teichler's avatar
Tom Teichler committed
    @property
    def date(self):
        week = CalendarWeek(week=self.week, year=self.year)
        return week[self.lesson_period.period.weekday]

    def __str__(self):
        return f"{self.lesson_period}, {date_format(self.date)}"
        unique_together = [["lesson_period", "week"]]
        ordering = [
            "week",
            "lesson_period__period__weekday",
            "lesson_period__period__period",
        ]
        constraints = [
            models.CheckConstraint(
                check=~Q(cancelled=True, subject__isnull=False),
                name="either_substituted_or_cancelled",
        verbose_name = _("Lesson substitution")
        verbose_name_plural = _("Lesson substitutions")
class LessonPeriod(ExtensibleModel, WeekAnnotationMixin):
    objects = LessonPeriodManager.from_queryset(LessonPeriodQuerySet)()
Tom Teichler's avatar
Tom Teichler committed
    lesson = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        "Lesson", models.CASCADE, related_name="lesson_periods", verbose_name=_("Lesson"),
Tom Teichler's avatar
Tom Teichler committed
    )
    period = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        "TimePeriod", models.CASCADE, related_name="lesson_periods", verbose_name=_("Time period"),
Tom Teichler's avatar
Tom Teichler committed
    )
Tom Teichler's avatar
Tom Teichler committed
    room = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        "Room", models.CASCADE, null=True, related_name="lesson_periods", verbose_name=_("Room"),
Tom Teichler's avatar
Tom Teichler committed
    )
Tom Teichler's avatar
Tom Teichler committed
    def get_substitution(self, week: Optional[CalendarWeek] = None) -> LessonSubstitution:
        wanted_week = week or self.week or CalendarWeek()

        # We iterate over all substitutions because this can make use of
        # prefetching when this model is loaded from outside, in contrast
        # to .filter()
        for substitution in self.substitutions.all():
Tom Teichler's avatar
Tom Teichler committed
            if substitution.week == wanted_week.week and substitution.year == wanted_week.year:
    def get_subject(self) -> Optional[Subject]:
        if self.get_substitution() and self.get_substitution().subject:
            return self.get_substitution().subject
    def get_teachers(self) -> models.query.QuerySet:
        if self.get_substitution():
            return self.get_substitution().teachers
    def get_room(self) -> Optional[Room]:
        if self.get_substitution() and self.get_substitution().room:
            return self.get_substitution().room
    def get_teacher_names(self, sep: Optional[str] = ", ") -> str:
        return sep.join([teacher.full_name for teacher in self.get_teachers().all()])

    def get_groups(self) -> models.query.QuerySet:
    def __str__(self) -> str:
        return f"{self.period}, {self.lesson}"
    @property
    def _equal_lessons(self):
        """Get all lesson periods with equal lessons in the whole school term."""

        qs = LessonPeriod.objects.filter(
            lesson__subject=self.lesson.subject,
            lesson__validity__school_term=self.lesson.validity.school_term,
        )
        for group in self.lesson.groups.all():
            qs = qs.filter(lesson__groups=group)
        for teacher in self.lesson.teachers.all():
            qs = qs.filter(lesson__teachers=teacher)
        return qs

    @property
    def next(self) -> "LessonPeriod":
        """Get next lesson period of this lesson.

        .. warning::
            To use this property,  the provided lesson period must be annotated with a week.
        return self._equal_lessons.next_lesson(self)

    @property
    def prev(self) -> "LessonPeriod":
        """Get previous lesson period of this lesson.

        .. warning::
            To use this property,  the provided lesson period must be annotated with a week.
        return self._equal_lessons.next_lesson(self, -1)
Tom Teichler's avatar
Tom Teichler committed
        ordering = [
            "lesson__validity__date_start",
Tom Teichler's avatar
Tom Teichler committed
            "period__weekday",
            "period__period",
            "lesson__subject",
        ]
        indexes = [models.Index(fields=["lesson", "period"])]
        verbose_name = _("Lesson period")
        verbose_name_plural = _("Lesson periods")


class TimetableWidget(DashboardWidget):
    template = "chronos/widget.html"

Julian's avatar
Julian committed
    def get_context(self, request):
Tom Teichler's avatar
Tom Teichler committed
        from aleksis.apps.chronos.util.build import build_timetable  # noqa
        context = {"has_plan": True}
Tom Teichler's avatar
Tom Teichler committed
        wanted_day = TimePeriod.get_next_relevant_day(timezone.now().date(), datetime.now().time())

        if has_person(request.user):
            person = request.user.person
            # Build timetable
            timetable = build_timetable("person", person, wanted_day)

                # If no student or teacher, redirect to all timetables
                context["has_plan"] = False
            else:
Jonathan Weth's avatar
Jonathan Weth committed
                context["holiday"] = Holiday.on_day(wanted_day)
                context["type"] = type_
                context["day"] = wanted_day
                context["periods"] = TimePeriod.get_times_dict()
                context["smart"] = True
        else:
            context["has_plan"] = False
Tom Teichler's avatar
Tom Teichler committed
    media = Media(css={"all": ("css/chronos/timetable.css",)})

    class Meta:
        proxy = True
        verbose_name = _("Timetable widget")
Jonathan Weth's avatar
Jonathan Weth committed
        verbose_name_plural = _("Timetable widgets")
    short_name = models.CharField(verbose_name=_("Short name"), max_length=255)
Tom Teichler's avatar
Tom Teichler committed
    name = models.CharField(verbose_name=_("Name"), blank=True, null=True, max_length=255)
    def __str__(self):
        if self.name:
            return f"{self.short_name} ({self.name})"
        else:
            return self.short_name

    class Meta:
        verbose_name = _("Absence reason")
Jonathan Weth's avatar
Jonathan Weth committed
        verbose_name_plural = _("Absence reasons")
class Absence(SchoolTermRelatedExtensibleModel):
    objects = CurrentSiteManager.from_queryset(AbsenceQuerySet)()
Tom Teichler's avatar
Tom Teichler committed
    reason = models.ForeignKey(
        "AbsenceReason",
        on_delete=models.SET_NULL,
        related_name="absences",
        blank=True,
        null=True,
        verbose_name=_("Absence reason"),
    )
Tom Teichler's avatar
Tom Teichler committed
    teacher = models.ForeignKey(
        "core.Person",
        on_delete=models.CASCADE,
        related_name="absences",
        null=True,
        blank=True,
        verbose_name=_("Teacher"),
    )
    group = models.ForeignKey(
        "core.Group",
        on_delete=models.CASCADE,
        related_name="absences",
        null=True,
        blank=True,
        verbose_name=_("Group"),
    )
    room = models.ForeignKey(
        "Room",
        on_delete=models.CASCADE,
        related_name="absences",
        null=True,
        blank=True,
        verbose_name=_("Room"),
    )
Jonathan Weth's avatar
Jonathan Weth committed
    date_start = models.DateField(verbose_name=_("Start date"), null=True)
    date_end = models.DateField(verbose_name=_("End date"), null=True)
Tom Teichler's avatar
Tom Teichler committed
    period_from = models.ForeignKey(
        "TimePeriod",
        on_delete=models.CASCADE,
        verbose_name=_("Start period"),
        null=True,
        related_name="+",
    )
    period_to = models.ForeignKey(
        "TimePeriod",
        on_delete=models.CASCADE,
        verbose_name=_("End period"),
        null=True,
        related_name="+",
    )
    comment = models.TextField(verbose_name=_("Comment"), blank=True, null=True)
    def __str__(self):
        if self.teacher:
            return str(self.teacher)
        elif self.group:
            return str(self.group)
        elif self.room:
            return str(self.room)
        else:
            return _("Unknown absence")

Tom Teichler's avatar
Tom Teichler committed
        ordering = ["date_start"]
        indexes = [models.Index(fields=["date_start", "date_end"])]
Jonathan Weth's avatar
Jonathan Weth committed
        verbose_name_plural = _("Absences")
class Exam(SchoolTermRelatedExtensibleModel):
Tom Teichler's avatar
Tom Teichler committed
    lesson = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        "Lesson", on_delete=models.CASCADE, related_name="exams", verbose_name=_("Lesson"),
Tom Teichler's avatar
Tom Teichler committed
    )

    date = models.DateField(verbose_name=_("Date of exam"), null=True)
Tom Teichler's avatar
Tom Teichler committed
    period_from = models.ForeignKey(
        "TimePeriod",
        on_delete=models.CASCADE,
        verbose_name=_("Start period"),
        null=True,
        related_name="+",
    )
    period_to = models.ForeignKey(
        "TimePeriod",
        on_delete=models.CASCADE,
        verbose_name=_("End period"),
        null=True,
        related_name="+",
    )
    title = models.CharField(verbose_name=_("Title"), max_length=255)
    comment = models.TextField(verbose_name=_("Comment"), blank=True, null=True)
Tom Teichler's avatar
Tom Teichler committed
        indexes = [models.Index(fields=["date"])]
Jonathan Weth's avatar
Jonathan Weth committed
        verbose_name_plural = _("Exams")
    objects = CurrentSiteManager.from_queryset(HolidayQuerySet)()
Jonathan Weth's avatar
Jonathan Weth committed

    title = models.CharField(verbose_name=_("Title"), max_length=255)
Jonathan Weth's avatar
Jonathan Weth committed
    date_start = models.DateField(verbose_name=_("Start date"), null=True)
    date_end = models.DateField(verbose_name=_("End date"), null=True)
    comments = models.TextField(verbose_name=_("Comments"), blank=True, null=True)
Jonathan Weth's avatar
Jonathan Weth committed
    @classmethod
    def on_day(cls, day: date) -> Optional["Holiday"]:
        holidays = cls.objects.on_day(day)
        if holidays.exists():
            return holidays[0]
        else:
            return None

    @classmethod
    def in_week(cls, week: CalendarWeek) -> Dict[int, Optional["Holiday"]]:
        per_weekday = {}
        holidays = Holiday.objects.in_week(week)
Jonathan Weth's avatar
Jonathan Weth committed

        for weekday in range(TimePeriod.weekday_min, TimePeriod.weekday_max + 1):
            holiday_date = week[weekday]
            holidays = list(
                filter(
Tom Teichler's avatar
Tom Teichler committed
                    lambda h: holiday_date >= h.date_start and holiday_date <= h.date_end, holidays,
                )
            )
            if holidays:
Jonathan Weth's avatar
Jonathan Weth committed
                per_weekday[weekday] = holidays[0]

        return per_weekday

    def __str__(self):
        return self.title

Tom Teichler's avatar
Tom Teichler committed
        ordering = ["date_start"]
        indexes = [models.Index(fields=["date_start", "date_end"])]
Jonathan Weth's avatar
Jonathan Weth committed
        verbose_name_plural = _("Holidays")
Tom Teichler's avatar
Tom Teichler committed


class SupervisionArea(ExtensibleModel):
    short_name = models.CharField(verbose_name=_("Short name"), max_length=255)
    name = models.CharField(verbose_name=_("Long name"), max_length=255)
    colour_fg = ColorField(default="#000000")
Tom Teichler's avatar
Tom Teichler committed
    colour_bg = ColorField()

    def __str__(self):
        return f"{self.name} ({self.short_name})"
Tom Teichler's avatar
Tom Teichler committed
    class Meta:
        ordering = ["name"]
        verbose_name = _("Supervision area")
        verbose_name_plural = _("Supervision areas")


class Break(ValidityRangeRelatedExtensibleModel):
    objects = BreakManager()

    short_name = models.CharField(verbose_name=_("Short name"), max_length=255)
    name = models.CharField(verbose_name=_("Long name"), max_length=255)
Tom Teichler's avatar
Tom Teichler committed
    after_period = models.ForeignKey(
        "TimePeriod",
        on_delete=models.CASCADE,
        verbose_name=_("Time period after break starts"),
        related_name="break_after",
        blank=True,
        null=True,
    )
    before_period = models.ForeignKey(
        "TimePeriod",
        on_delete=models.CASCADE,
        verbose_name=_("Time period before break ends"),
        related_name="break_before",
        blank=True,
        null=True,
    )
Tom Teichler's avatar
Tom Teichler committed
    @property
    def weekday(self):
Tom Teichler's avatar
Tom Teichler committed
        return self.after_period.weekday if self.after_period else self.before_period.weekday
Tom Teichler's avatar
Tom Teichler committed
    @property
Tom Teichler's avatar
Tom Teichler committed
        return self.after_period.period if self.after_period else self.before_period.period - 1
Tom Teichler's avatar
Tom Teichler committed
    @property
    def before_period_number(self):
Tom Teichler's avatar
Tom Teichler committed
        return self.before_period.period if self.before_period else self.after_period.period + 1
Tom Teichler's avatar
Tom Teichler committed
    @property
    def time_start(self):
        return self.after_period.time_end if self.after_period else None

Tom Teichler's avatar
Tom Teichler committed
    @property
    def time_end(self):
        return self.before_period.time_start if self.before_period else None

    @classmethod
    def get_breaks_dict(cls) -> Dict[int, Tuple[datetime, datetime]]:
        breaks = {}
        for break_ in cls.objects.all():
            breaks[break_.before_period_number] = break_

        return breaks

    def __str__(self):
        return f"{self.name} ({self.short_name})"
    class Meta:
        ordering = ["after_period"]
        indexes = [models.Index(fields=["after_period", "before_period"])]
        verbose_name = _("Break")
        verbose_name_plural = _("Breaks")


class Supervision(ValidityRangeRelatedExtensibleModel, WeekAnnotationMixin):
    objects = SupervisionManager.from_queryset(SupervisionQuerySet)()
Tom Teichler's avatar
Tom Teichler committed
    area = models.ForeignKey(
        SupervisionArea,
        models.CASCADE,
        verbose_name=_("Supervision area"),
        related_name="supervisions",
    )
    break_item = models.ForeignKey(
        Break, models.CASCADE, verbose_name=_("Break"), related_name="supervisions"
    )
    teacher = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        "core.Person", models.CASCADE, related_name="supervisions", verbose_name=_("Teacher"),
Tom Teichler's avatar
Tom Teichler committed
    )
    def get_year(self, week: int) -> int:
        year = self.validity.date_start.year
        if week < int(self.validity.date_start.strftime("%V")):
            year += 1
        return year

Jonathan Weth's avatar
Jonathan Weth committed
    def get_substitution(
        self, week: Optional[CalendarWeek] = None
Jonathan Weth's avatar
Jonathan Weth committed
    ) -> Optional[SupervisionSubstitution]:
        wanted_week = week or self.week or CalendarWeek()
        # We iterate over all substitutions because this can make use of
        # prefetching when this model is loaded from outside, in contrast
        # to .filter()
        for substitution in self.substitutions.all():
            for weekday in range(0, 7):
                if substitution.date == wanted_week[weekday]:
                    return substitution
        return None

Tom Teichler's avatar
Tom Teichler committed
    @property
    def teachers(self):
        return [self.teacher]

    def __str__(self):
        return f"{self.break_item}, {self.area}, {self.teacher}"
    class Meta:
        ordering = ["area", "break_item"]
Tom Teichler's avatar
Tom Teichler committed
        verbose_name = _("Supervision")
        verbose_name_plural = _("Supervisions")


class SupervisionSubstitution(ExtensibleModel):
    date = models.DateField(verbose_name=_("Date"))
Tom Teichler's avatar
Tom Teichler committed
    supervision = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        Supervision, models.CASCADE, verbose_name=_("Supervision"), related_name="substitutions",
Tom Teichler's avatar
Tom Teichler committed
    )
    teacher = models.ForeignKey(
        "core.Person",
        models.CASCADE,
        related_name="substituted_supervisions",
        verbose_name=_("Teacher"),
    )
Tom Teichler's avatar
Tom Teichler committed
    @property
    def teachers(self):
        return [self.teacher]

    def __str__(self):
        return f"{self.supervision}, {date_format(self.date)}"
    class Meta:
        ordering = ["date", "supervision"]
        verbose_name = _("Supervision substitution")
        verbose_name_plural = _("Supervision substitutions")
Tom Teichler's avatar
Tom Teichler committed
class Event(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin):
Tom Teichler's avatar
Tom Teichler committed
    title = models.CharField(verbose_name=_("Title"), max_length=255, blank=True, null=True)
Jonathan Weth's avatar
Jonathan Weth committed
    date_start = models.DateField(verbose_name=_("Start date"), null=True)
    date_end = models.DateField(verbose_name=_("End date"), null=True)
Tom Teichler's avatar
Tom Teichler committed
    period_from = models.ForeignKey(
        "TimePeriod",
        on_delete=models.CASCADE,
        verbose_name=_("Start time period"),
        related_name="+",
    )
    period_to = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        "TimePeriod", on_delete=models.CASCADE, verbose_name=_("End time period"), related_name="+",
Tom Teichler's avatar
Tom Teichler committed
    )
Tom Teichler's avatar
Tom Teichler committed
    groups = models.ManyToManyField("core.Group", related_name="events", verbose_name=_("Groups"))
    rooms = models.ManyToManyField("Room", related_name="events", verbose_name=_("Rooms"))
Tom Teichler's avatar
Tom Teichler committed
    teachers = models.ManyToManyField(
        "core.Person", related_name="events", verbose_name=_("Teachers")
    )
Tom Teichler's avatar
Tom Teichler committed

    def __str__(self):
        if self.title:
            return self.title
        else:
            return _(f"Event {self.pk}")
Tom Teichler's avatar
Tom Teichler committed
    @property
    def period_from_on_day(self) -> int:
        day = getattr(self, "_date", timezone.now().date())
        if day != self.date_start:
            return TimePeriod.period_min
        else:
            return self.period_from.period

Tom Teichler's avatar
Tom Teichler committed
    @property
    def period_to_on_day(self) -> int:
        day = getattr(self, "_date", timezone.now().date())
        if day != self.date_end:
            return TimePeriod.period_max
        else:
            return self.period_to.period

Tom Teichler's avatar
Tom Teichler committed
    class Meta:
Tom Teichler's avatar
Tom Teichler committed
        ordering = ["date_start"]
Tom Teichler's avatar
Tom Teichler committed
        indexes = [models.Index(fields=["period_from", "period_to", "date_start", "date_end"])]
Tom Teichler's avatar
Tom Teichler committed
        verbose_name = _("Event")
Jonathan Weth's avatar
Jonathan Weth committed
        verbose_name_plural = _("Events")
Tom Teichler's avatar
Tom Teichler committed
class ExtraLesson(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, WeekRelatedMixin):
    label_ = "extra_lesson"

    objects = ExtraLessonManager.from_queryset(ExtraLessonQuerySet)()
Tom Teichler's avatar
Tom Teichler committed
    week = models.IntegerField(verbose_name=_("Week"), default=CalendarWeek.current_week)
    year = models.IntegerField(verbose_name=_("Year"), default=get_current_year)
Tom Teichler's avatar
Tom Teichler committed
    period = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        "TimePeriod", models.CASCADE, related_name="extra_lessons", verbose_name=_("Time period"),
Tom Teichler's avatar
Tom Teichler committed
    )
Tom Teichler's avatar
Tom Teichler committed
    subject = models.ForeignKey(
        "Subject",
        on_delete=models.CASCADE,
        related_name="extra_lessons",
        verbose_name=_("Subject"),
    )
    groups = models.ManyToManyField(
        "core.Group", related_name="extra_lessons", verbose_name=_("Groups")
    )
    teachers = models.ManyToManyField(
Tom Teichler's avatar
Tom Teichler committed
        "core.Person", related_name="extra_lessons_as_teacher", verbose_name=_("Teachers"),
Tom Teichler's avatar
Tom Teichler committed
    )
    room = models.ForeignKey(
Tom Teichler's avatar
Tom Teichler committed
        "Room", models.CASCADE, null=True, related_name="extra_lessons", verbose_name=_("Room"),
Tom Teichler's avatar
Tom Teichler committed
    )
Tom Teichler's avatar
Tom Teichler committed
    comment = models.CharField(verbose_name=_("Comment"), blank=True, null=True, max_length=255)

    def __str__(self):
        return f"{self.week}, {self.period}, {self.subject}"

    class Meta:
        verbose_name = _("Extra lesson")
        verbose_name_plural = _("Extra lessons")
class ChronosGlobalPermissions(ExtensibleModel):
    class Meta:
        managed = False
        permissions = (
            ("view_all_timetables", _("Can view all timetables")),
            ("view_timetable_overview", _("Can view timetable overview")),
            ("view_lessons_day", _("Can view all lessons per day")),
        )