diff --git a/aleksis/apps/chronos/admin.py b/aleksis/apps/chronos/admin.py index f2fefcd458f610f8cfd265e5b1ed6af8ffae1d6d..5145472fdbf9eff3349c81d0cbf10a59c24442f2 100644 --- a/aleksis/apps/chronos/admin.py +++ b/aleksis/apps/chronos/admin.py @@ -20,6 +20,7 @@ from .models import ( SupervisionSubstitution, TimePeriod, TimetableWidget, + ValidityRange, ) from .util.format import format_date_period, format_m2m @@ -201,3 +202,11 @@ class TimetableWidgetAdmin(admin.ModelAdmin): admin.site.register(TimetableWidget, TimetableWidgetAdmin) + + +class ValidityRangeAdmin(admin.ModelAdmin): + list_display = ("__str__", "date_start", "date_end") + list_display_links = ("__str__", "date_start", "date_end") + + +admin.site.register(ValidityRange, ValidityRangeAdmin) diff --git a/aleksis/apps/chronos/managers.py b/aleksis/apps/chronos/managers.py index 361f8da29cdeecf23ff80fc14ea722f9623d52c1..212a70f0ffaa00812d46f055857f0bddaaea3c32 100644 --- a/aleksis/apps/chronos/managers.py +++ b/aleksis/apps/chronos/managers.py @@ -4,15 +4,67 @@ from typing import Optional, Union from django.contrib.sites.managers import CurrentSiteManager as _CurrentSiteManager from django.db import models -from django.db.models import Count, F, Q +from django.db.models import Count, F, Q, QuerySet from calendarweek import CalendarWeek from aleksis.apps.chronos.util.date import week_weekday_from_date +from aleksis.core.managers import DateRangeQuerySetMixin, SchoolTermRelatedQuerySet from aleksis.core.models import Group, Person from aleksis.core.util.core_helpers import get_site_preferences +class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin): + """Custom query set for validity ranges.""" + + +class ValidityRangeRelatedQuerySet(QuerySet): + """Custom query set for all models related to validity ranges.""" + + def within_dates(self, start: date, end: date) -> "ValidityRangeRelatedQuerySet": + """Filter for all objects within a date range.""" + return self.filter(validity__date_start__lte=end, validity__date_end__gte=start) + + def in_week(self, wanted_week: CalendarWeek) -> "ValidityRangeRelatedQuerySet": + """Filter for all objects within a calendar week.""" + return self.within_dates(wanted_week[0], wanted_week[6]) + + def on_day(self, day: date) -> "ValidityRangeRelatedQuerySet": + """Filter for all objects on a certain day.""" + return self.within_dates(day, day) + + def for_validity_range( + self, validity_range: "ValidityRange" + ) -> "ValidityRangeRelatedQuerySet": + return self.filter(validity_range=validity_range) + + def for_current_or_all(self) -> "ValidityRangeRelatedQuerySet": + """Get all objects related to current validity range. + + If there is no current validity range, it will return all objects. + """ + from aleksis.apps.chronos.models import ValidityRange + + current_validity_range = ValidityRange.current + if current_validity_range: + return self.for_validity_range(current_validity_range) + else: + return self + + def for_current_or_none(self) -> Union["ValidityRangeRelatedQuerySet", None]: + """Get all objects related to current validity range. + + If there is no current validity range, it will return `None`. + """ + from aleksis.apps.chronos.models import ValidityRange + + current_validity_range = ValidityRange.current + if current_validity_range: + return self.for_validity_range(current_validity_range) + else: + return None + + class CurrentSiteManager(_CurrentSiteManager): use_in_migrations = False @@ -108,8 +160,8 @@ class LessonDataQuerySet(models.QuerySet, WeekQuerySetMixin): """Filter for all lessons within a date range.""" return self.filter( **{ - self._period_path + "lesson__date_start__lte": start, - self._period_path + "lesson__date_end__gte": end, + self._period_path + "lesson__validity__date_start__lte": start, + self._period_path + "lesson__validity__date_end__gte": end, } ) @@ -137,8 +189,8 @@ class LessonDataQuerySet(models.QuerySet, WeekQuerySetMixin): return self.filter( **{ - self._period_path + "lesson__date_start__lte": now.date(), - self._period_path + "lesson__date_end__gte": now.date(), + self._period_path + "lesson__validity__date_start__lte": now.date(), + self._period_path + "lesson__validity__date_end__gte": now.date(), self._period_path + "period__weekday": now.weekday(), self._period_path + "period__time_start__lte": now.time(), self._period_path + "period__time_end__gte": now.time(), @@ -282,7 +334,7 @@ class LessonSubstitutionQuerySet(LessonDataQuerySet): ) -class DateRangeQuerySet(models.QuerySet): +class DateRangeQuerySetMixin: """QuerySet with custom query methods for models with date and period ranges. Filterable fields: date_start, date_end, period_from, period_to @@ -309,7 +361,7 @@ class DateRangeQuerySet(models.QuerySet): ) -class AbsenceQuerySet(DateRangeQuerySet): +class AbsenceQuerySet(SchoolTermRelatedQuerySet, DateRangeQuerySetMixin): """QuerySet with custom query methods for absences.""" def absent_teachers(self): @@ -322,13 +374,13 @@ class AbsenceQuerySet(DateRangeQuerySet): return Person.objects.filter(absences__in=self).annotate(absences_count=Count("absences")) -class HolidayQuerySet(DateRangeQuerySet): +class HolidayQuerySet(QuerySet, DateRangeQuerySetMixin): """QuerySet with custom query methods for holidays.""" pass -class SupervisionQuerySet(models.QuerySet, WeekQuerySetMixin): +class SupervisionQuerySet(ValidityRangeRelatedQuerySet, WeekQuerySetMixin): """QuerySet with custom query methods for supervisions.""" def filter_by_weekday(self, weekday: int): @@ -423,7 +475,9 @@ class TimetableQuerySet(models.QuerySet): return None -class EventQuerySet(DateRangeQuerySet, TimetableQuerySet): +class EventQuerySet( + SchoolTermRelatedQuerySet, DateRangeQuerySetMixin, TimetableQuerySet +): """QuerySet with custom query methods for events.""" def annotate_day(self, day: date): @@ -431,7 +485,9 @@ class EventQuerySet(DateRangeQuerySet, TimetableQuerySet): return self.annotate(_date=models.Value(day, models.DateField())) -class ExtraLessonQuerySet(TimetableQuerySet, GroupByPeriodsMixin): +class ExtraLessonQuerySet( + SchoolTermRelatedQuerySet, TimetableQuerySet, GroupByPeriodsMixin +): """QuerySet with custom query methods for extra lessons.""" _multiple_rooms = False diff --git a/aleksis/apps/chronos/mixins.py b/aleksis/apps/chronos/mixins.py new file mode 100644 index 0000000000000000000000000000000000000000..dabaabf6550210088f2d4481b62612f6130ef814 --- /dev/null +++ b/aleksis/apps/chronos/mixins.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.translation import gettext as _ + +from aleksis.core.managers import CurrentSiteManagerWithoutMigrations +from aleksis.core.mixins import ExtensibleModel + +from .managers import ValidityRangeRelatedQuerySet + + +class ValidityRangeRelatedExtensibleModel(ExtensibleModel): + """Add relation to validity range.""" + + objects = CurrentSiteManagerWithoutMigrations.from_queryset( + ValidityRangeRelatedQuerySet + )() + + validity = models.ForeignKey( + "chronos.ValidityRange", + on_delete=models.CASCADE, + related_name="+", + verbose_name=_("Linked validity range"), + null=True, + blank=True, + ) + + class Meta: + abstract = True diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py index 025d83ddfc73b409d11418013b2ea814d6cadab6..2883803533f495aa4cd68a9dc505ed9a8d9c6b58 100644 --- a/aleksis/apps/chronos/models.py +++ b/aleksis/apps/chronos/models.py @@ -33,14 +33,86 @@ from aleksis.apps.chronos.managers import ( LessonSubstitutionQuerySet, SupervisionQuerySet, TeacherPropertiesMixin, + ValidityRangeQuerySet, ) +from aleksis.apps.chronos.mixins import ValidityRangeRelatedExtensibleModel from aleksis.apps.chronos.util.format import format_m2m -from aleksis.core.mixins import ExtensibleModel +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 TimePeriod(ExtensibleModel): +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())) @@ -56,7 +128,7 @@ class TimePeriod(ExtensibleModel): @classmethod def get_times_dict(cls) -> Dict[int, Tuple[datetime, datetime]]: periods = {} - for period in cls.objects.all(): + for period in cls.objects.for_current_or_all().all(): periods[period.period] = (period.time_start, period.time_end) return periods @@ -127,27 +199,51 @@ class TimePeriod(ExtensibleModel): @classproperty def period_min(cls) -> int: - return cls.objects.aggregate(period__min=Coalesce(Min("period"), 1)).get("period__min") + 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.aggregate(period__max=Coalesce(Max("period"), 7)).get("period__max") + 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.aggregate(Min("time_start")).get("time_start__min") + 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.aggregate(Max("time_end")).get("time_end__max") + return ( + cls.objects.for_current_or_all() + .aggregate(Max("time_end")) + .get("time_end__max") + ) @classproperty def weekday_min(cls) -> int: - return cls.objects.aggregate(weekday__min=Coalesce(Min("weekday"), 0)).get("weekday__min") + 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.aggregate(weekday__max=Coalesce(Max("weekday"), 6)).get("weekday__max") + return ( + cls.objects.for_current_or_all() + .aggregate(weekday__max=Coalesce(Max("weekday"), 6)) + .get("weekday__max") + ) class Meta: unique_together = [["weekday", "period"]] @@ -186,7 +282,9 @@ class Room(ExtensibleModel): verbose_name_plural = _("Rooms") -class Lesson(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin): +class Lesson( + ValidityRangeRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin +): subject = models.ForeignKey( "Subject", on_delete=models.CASCADE, related_name="lessons", verbose_name=_("Subject"), ) @@ -198,13 +296,14 @@ class Lesson(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin): ) groups = models.ManyToManyField("core.Group", related_name="lessons", verbose_name=_("Groups")) - date_start = models.DateField(verbose_name=_("Start date"), null=True) - date_end = models.DateField(verbose_name=_("End date"), null=True) + 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.date_start.year - if week < int(self.date_start.strftime("%V")): - year += 1 + year = self.get_year(week) return CalendarWeek(year=year, week=week) @@ -212,8 +311,7 @@ class Lesson(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin): return f"{format_m2m(self.groups)}, {self.subject.short_name}, {format_m2m(self.teachers)}" class Meta: - ordering = ["date_start", "subject"] - indexes = [models.Index(fields=["date_start", "date_end"])] + ordering = ["validity__date_start", "subject"] verbose_name = _("Lesson") verbose_name_plural = _("Lessons") @@ -253,7 +351,7 @@ class LessonSubstitution(ExtensibleModel): @property def date(self): - week = CalendarWeek(week=self.week) + week = CalendarWeek(week=self.week, year=self.lesson_period.lesson.get_year()) return week[self.lesson_period.period.weekday] def __str__(self): @@ -262,7 +360,7 @@ class LessonSubstitution(ExtensibleModel): class Meta: unique_together = [["lesson_period", "week"]] ordering = [ - "lesson_period__lesson__date_start", + "lesson_period__lesson__validity__date_start", "week", "lesson_period__period__weekday", "lesson_period__period__period", @@ -362,7 +460,7 @@ class LessonPeriod(ExtensibleModel): class Meta: ordering = [ - "lesson__date_start", + "lesson__validity__date_start", "period__weekday", "period__period", "lesson__subject", @@ -427,7 +525,7 @@ class AbsenceReason(ExtensibleModel): verbose_name_plural = _("Absence reasons") -class Absence(ExtensibleModel): +class Absence(SchoolTermRelatedExtensibleModel): objects = CurrentSiteManager.from_queryset(AbsenceQuerySet)() reason = models.ForeignKey( @@ -499,7 +597,7 @@ class Absence(ExtensibleModel): verbose_name_plural = _("Absences") -class Exam(ExtensibleModel): +class Exam(SchoolTermRelatedExtensibleModel): lesson = models.ForeignKey( "Lesson", on_delete=models.CASCADE, related_name="exams", verbose_name=_("Lesson"), ) @@ -583,7 +681,7 @@ class SupervisionArea(ExtensibleModel): verbose_name_plural = _("Supervision areas") -class Break(ExtensibleModel): +class Break(ValidityRangeRelatedExtensibleModel): short_name = models.CharField(verbose_name=_("Short name"), max_length=255) name = models.CharField(verbose_name=_("Long name"), max_length=255) @@ -642,7 +740,7 @@ class Break(ExtensibleModel): verbose_name_plural = _("Breaks") -class Supervision(ExtensibleModel): +class Supervision(ValidityRangeRelatedExtensibleModel): objects = CurrentSiteManager.from_queryset(SupervisionQuerySet)() area = models.ForeignKey( @@ -708,7 +806,9 @@ class SupervisionSubstitution(ExtensibleModel): verbose_name_plural = _("Supervision substitutions") -class Event(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin): +class Event( + SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin +): label_ = "event" objects = CurrentSiteManager.from_queryset(EventQuerySet)() @@ -763,7 +863,7 @@ class Event(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin): verbose_name_plural = _("Events") -class ExtraLesson(ExtensibleModel, GroupPropertiesMixin): +class ExtraLesson(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin): label_ = "extra_lesson" objects = CurrentSiteManager.from_queryset(ExtraLessonQuerySet)() diff --git a/aleksis/apps/chronos/util/build.py b/aleksis/apps/chronos/util/build.py index a5290ebbbf2a18013acb98cf17b6be57752847cd..89b32ae3fdb60adedcfe582bfde8d0147677533b 100644 --- a/aleksis/apps/chronos/util/build.py +++ b/aleksis/apps/chronos/util/build.py @@ -124,7 +124,12 @@ def build_timetable( week = CalendarWeek.from_date(date_ref) else: week = date_ref - supervisions = Supervision.objects.all().annotate_week(week).filter_by_teacher(obj) + supervisions = ( + Supervision.objects.in_week(week) + .all() + .annotate_week(week) + .filter_by_teacher(obj) + ) if is_person: supervisions.filter_by_weekday(date_ref.weekday())