# flake8: noqa: DJ01 from __future__ import annotations from datetime import date, datetime, time, timedelta from typing import Dict, 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.decorators import classproperty from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _ from calendarweek.django import CalendarWeek, i18n_day_abbrs_lazy, i18n_day_names_lazy from colorfield.fields import ColorField from django_global_request.middleware import get_request from aleksis.apps.chronos.managers import ( AbsenceQuerySet, CurrentSiteManager, EventQuerySet, ExtraLessonQuerySet, GroupPropertiesMixin, HolidayQuerySet, LessonPeriodManager, LessonPeriodQuerySet, LessonSubstitutionManager, LessonSubstitutionQuerySet, SupervisionQuerySet, TeacherPropertiesMixin, 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 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: 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 ): 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: qs.exclude(pk=self.pk) if qs.exists(): raise ValidationError( _( "There is already a validity range for this time or a part of this time." ) ) def __str__(self): 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 = list(enumerate(i18n_day_names_lazy())) WEEKDAY_CHOICES_SHORT = list(enumerate(i18n_day_abbrs_lazy())) weekday = models.PositiveSmallIntegerField( verbose_name=_("Week day"), choices=WEEKDAY_CHOICES ) period = models.PositiveSmallIntegerField(verbose_name=_("Number of period")) time_start = models.TimeField(verbose_name=_("Start time")) time_end = models.TimeField(verbose_name=_("End time")) def __str__(self) -> str: return f"{self.get_weekday_display()}, {self.period}." @classmethod def get_times_dict(cls) -> Dict[int, Tuple[datetime, datetime]]: periods = {} 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] 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) 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) @classmethod def get_next_relevant_day( cls, day: Optional[date] = None, time: Optional[time] = None, prev: bool = False ) -> date: """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 @classmethod def get_prev_next_by_day(cls, day: date, url: str) -> Tuple[str, str]: """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 @classproperty def period_min(cls) -> int: return ( cls.objects.for_current_or_all() .aggregate(period__min=Coalesce(Min("period"), 1)) .get("period__min") ) @classproperty def period_max(cls) -> int: return ( cls.objects.for_current_or_all() .aggregate(period__max=Coalesce(Max("period"), 7)) .get("period__max") ) @classproperty def time_min(cls) -> Optional[time]: return ( cls.objects.for_current_or_all() .aggregate(Min("time_start")) .get("time_start__min") ) @classproperty def time_max(cls) -> Optional[time]: return ( cls.objects.for_current_or_all() .aggregate(Max("time_end")) .get("time_end__max") ) @classproperty def weekday_min(cls) -> int: return ( cls.objects.for_current_or_all() .aggregate(weekday__min=Coalesce(Min("weekday"), 0)) .get("weekday__min") ) @classproperty def weekday_max(cls) -> int: return ( cls.objects.for_current_or_all() .aggregate(weekday__max=Coalesce(Max("weekday"), 6)) .get("weekday__max") ) 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): 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})" class Meta: ordering = ["name", "short_name"] verbose_name = _("Subject") verbose_name_plural = _("Subjects") class Room(ExtensibleModel): 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})" class Meta: ordering = ["name", "short_name"] verbose_name = _("Room") verbose_name_plural = _("Rooms") class Lesson( ValidityRangeRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin ): subject = models.ForeignKey( "Subject", on_delete=models.CASCADE, related_name="lessons", verbose_name=_("Subject"), ) teachers = models.ManyToManyField( "core.Person", related_name="lessons_as_teacher", verbose_name=_("Teachers") ) periods = models.ManyToManyField( "TimePeriod", related_name="lessons", through="LessonPeriod", verbose_name=_("Periods"), ) 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)}" class Meta: ordering = ["validity__date_start", "subject"] verbose_name = _("Lesson") verbose_name_plural = _("Lessons") class LessonSubstitution(ExtensibleModel, WeekRelatedMixin): objects = LessonSubstitutionManager.from_queryset(LessonSubstitutionQuerySet)() week = models.IntegerField( verbose_name=_("Week"), default=CalendarWeek.current_week ) year = models.IntegerField(verbose_name=_("Year"), default=get_current_year) lesson_period = models.ForeignKey( "LessonPeriod", models.CASCADE, "substitutions", verbose_name=_("Lesson period") ) subject = models.ForeignKey( "Subject", on_delete=models.CASCADE, related_name="lesson_substitutions", null=True, blank=True, verbose_name=_("Subject"), ) teachers = models.ManyToManyField( "core.Person", related_name="lesson_substitutions", blank=True, verbose_name=_("Teachers"), ) room = models.ForeignKey( "Room", models.CASCADE, null=True, blank=True, verbose_name=_("Room") ) cancelled = models.BooleanField(default=False, verbose_name=_("Cancelled?")) 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: raise ValidationError( _("Lessons can only be either substituted or cancelled.") ) @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)}" class Meta: unique_together = [["lesson_period", "week"]] ordering = [ "year", "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): label_ = "lesson_period" objects = LessonPeriodManager.from_queryset(LessonPeriodQuerySet)() lesson = models.ForeignKey( "Lesson", models.CASCADE, related_name="lesson_periods", verbose_name=_("Lesson"), ) period = models.ForeignKey( "TimePeriod", models.CASCADE, related_name="lesson_periods", verbose_name=_("Time period"), ) room = models.ForeignKey( "Room", models.CASCADE, null=True, related_name="lesson_periods", verbose_name=_("Room"), ) 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(): if ( substitution.week == wanted_week.week and substitution.year == wanted_week.year ): return substitution return None def get_subject(self) -> Optional[Subject]: if self.get_substitution() and self.get_substitution().subject: return self.get_substitution().subject else: return self.lesson.subject def get_teachers(self) -> models.query.QuerySet: if self.get_substitution(): return self.get_substitution().teachers else: return self.lesson.teachers def get_room(self) -> Optional[Room]: if self.get_substitution() and self.get_substitution().room: return self.get_substitution().room else: return self.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: return self.lesson.groups def __str__(self) -> str: return f"{self.period}, {self.lesson}" @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 LessonPeriod.objects.filter(lesson=self.lesson).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 LessonPeriod.objects.filter(lesson=self.lesson).next_lesson(self, -1) class Meta: ordering = [ "lesson__validity__date_start", "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" def get_context(self): from aleksis.apps.chronos.util.build import build_timetable # noqa request = get_request() context = {"has_plan": True} wanted_day = TimePeriod.get_next_relevant_day( timezone.now().date(), datetime.now().time() ) if has_person(request.user): person = request.user.person type_ = person.timetable_type # Build timetable timetable = build_timetable("person", person, wanted_day) if type_ is None: # If no student or teacher, redirect to all timetables context["has_plan"] = False else: context["timetable"] = timetable 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 return context media = Media(css={"all": ("css/chronos/timetable.css",)}) class Meta: proxy = True verbose_name = _("Timetable widget") verbose_name_plural = _("Timetable widgets") class AbsenceReason(ExtensibleModel): short_name = models.CharField(verbose_name=_("Short name"), max_length=255) 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") verbose_name_plural = _("Absence reasons") class Absence(SchoolTermRelatedExtensibleModel): objects = CurrentSiteManager.from_queryset(AbsenceQuerySet)() reason = models.ForeignKey( "AbsenceReason", on_delete=models.SET_NULL, related_name="absences", blank=True, null=True, verbose_name=_("Absence reason"), ) 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"), ) date_start = models.DateField(verbose_name=_("Start date"), null=True) date_end = models.DateField(verbose_name=_("End date"), null=True) 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") class Meta: ordering = ["date_start"] indexes = [models.Index(fields=["date_start", "date_end"])] verbose_name = _("Absence") verbose_name_plural = _("Absences") class Exam(SchoolTermRelatedExtensibleModel): lesson = models.ForeignKey( "Lesson", on_delete=models.CASCADE, related_name="exams", verbose_name=_("Lesson"), ) date = models.DateField(verbose_name=_("Date of exam"), null=True) 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) class Meta: ordering = ["date"] indexes = [models.Index(fields=["date"])] verbose_name = _("Exam") verbose_name_plural = _("Exams") class Holiday(ExtensibleModel): objects = CurrentSiteManager.from_queryset(HolidayQuerySet)() title = models.CharField(verbose_name=_("Title"), max_length=255) 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) @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 = {} for weekday in range(TimePeriod.weekday_min, TimePeriod.weekday_max + 1): holiday_date = week[weekday] holidays = Holiday.objects.on_day(holiday_date) if holidays.exists(): per_weekday[weekday] = holidays[0] return per_weekday def __str__(self): return self.title class Meta: ordering = ["date_start"] indexes = [models.Index(fields=["date_start", "date_end"])] verbose_name = _("Holiday") verbose_name_plural = _("Holidays") 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") colour_bg = ColorField() def __str__(self): return f"{self.name} ({self.short_name})" class Meta: ordering = ["name"] verbose_name = _("Supervision area") verbose_name_plural = _("Supervision areas") class Break(ValidityRangeRelatedExtensibleModel): short_name = models.CharField(verbose_name=_("Short name"), max_length=255) name = models.CharField(verbose_name=_("Long name"), max_length=255) 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, ) @property def weekday(self): return ( self.after_period.weekday if self.after_period else self.before_period.weekday ) @property def after_period_number(self): return ( self.after_period.period if self.after_period else self.before_period.period - 1 ) @property def before_period_number(self): return ( self.before_period.period if self.before_period else self.after_period.period + 1 ) @property def time_start(self): return self.after_period.time_end if self.after_period else None @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 = CurrentSiteManager.from_queryset(SupervisionQuerySet)() 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( "core.Person", models.CASCADE, related_name="supervisions", verbose_name=_("Teacher"), ) 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_substitution( self, week: Optional[CalendarWeek] = None ) -> 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 @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"] verbose_name = _("Supervision") verbose_name_plural = _("Supervisions") class SupervisionSubstitution(ExtensibleModel): date = models.DateField(verbose_name=_("Date")) supervision = models.ForeignKey( Supervision, models.CASCADE, verbose_name=_("Supervision"), related_name="substitutions", ) teacher = models.ForeignKey( "core.Person", models.CASCADE, related_name="substituted_supervisions", verbose_name=_("Teacher"), ) @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") class Event( SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin ): label_ = "event" objects = CurrentSiteManager.from_queryset(EventQuerySet)() title = models.CharField( verbose_name=_("Title"), max_length=255, blank=True, null=True ) date_start = models.DateField(verbose_name=_("Start date"), null=True) date_end = models.DateField(verbose_name=_("End date"), null=True) period_from = models.ForeignKey( "TimePeriod", on_delete=models.CASCADE, verbose_name=_("Start time period"), related_name="+", ) period_to = models.ForeignKey( "TimePeriod", on_delete=models.CASCADE, verbose_name=_("End time period"), related_name="+", ) groups = models.ManyToManyField( "core.Group", related_name="events", verbose_name=_("Groups") ) rooms = models.ManyToManyField( "Room", related_name="events", verbose_name=_("Rooms") ) teachers = models.ManyToManyField( "core.Person", related_name="events", verbose_name=_("Teachers") ) def __str__(self): if self.title: return self.title else: return _(f"Event {self.pk}") @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 @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 class Meta: ordering = ["date_start"] indexes = [ models.Index(fields=["period_from", "period_to", "date_start", "date_end"]) ] verbose_name = _("Event") verbose_name_plural = _("Events") class ExtraLesson( SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, WeekRelatedMixin ): label_ = "extra_lesson" objects = CurrentSiteManager.from_queryset(ExtraLessonQuerySet)() week = models.IntegerField( verbose_name=_("Week"), default=CalendarWeek.current_week ) year = models.IntegerField(verbose_name=_("Year"), default=get_current_year) period = models.ForeignKey( "TimePeriod", models.CASCADE, related_name="extra_lessons", verbose_name=_("Time period"), ) 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( "core.Person", related_name="extra_lessons_as_teacher", verbose_name=_("Teachers"), ) room = models.ForeignKey( "Room", models.CASCADE, null=True, related_name="extra_lessons", verbose_name=_("Room"), ) 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")), )