diff --git a/aleksis/apps/chronos/admin.py b/aleksis/apps/chronos/admin.py index 9da4770d092663c4baa2c5a9e770677305ea0121..2ed048d96d6cdb40a440e22a4a3fcb421b330013 100644 --- a/aleksis/apps/chronos/admin.py +++ b/aleksis/apps/chronos/admin.py @@ -1,218 +1,9 @@ # noqa from django.contrib import admin -from django.utils.html import format_html - -from guardian.admin import GuardedModelAdmin - -from aleksis.core.models import Room from .models import ( - Absence, - AbsenceReason, AutomaticPlan, - Break, - Event, - ExtraLesson, - Holiday, - Lesson, - LessonPeriod, - LessonSubstitution, - Subject, - Supervision, - SupervisionArea, - SupervisionSubstitution, - TimePeriod, - ValidityRange, ) -from .util.format import format_date_period, format_m2m - - -def colour_badge(fg: str, bg: str, val: str): - html = """ - <div style=" - color: {}; - background-color: {}; - padding-top: 3px; - padding-bottom: 4px; - text-align: center; - border-radius: 3px; - ">{}</span> - """ - return format_html(html, fg, bg, val) - - -class AbsenceReasonAdmin(admin.ModelAdmin): - list_display = ("short_name", "name") - list_display_links = ("short_name", "name") - - -admin.site.register(AbsenceReason, AbsenceReasonAdmin) - - -class AbsenceAdmin(admin.ModelAdmin): - def start(self, obj): - return format_date_period(obj.date_start, obj.period_from) - - def end(self, obj): - return format_date_period(obj.date_end, obj.period_to) - - list_display = ("__str__", "reason", "start", "end") - - -admin.site.register(Absence, AbsenceAdmin) - - -class SupervisionInline(admin.TabularInline): - model = Supervision - - -class BreakAdmin(admin.ModelAdmin): - list_display = ("__str__", "after_period", "before_period") - inlines = [SupervisionInline] - - -admin.site.register(Break, BreakAdmin) - - -class EventAdmin(admin.ModelAdmin): - def start(self, obj): - return format_date_period(obj.date_start, obj.period_from) - - def end(self, obj): - return format_date_period(obj.date_end, obj.period_to) - - def _groups(self, obj): - return format_m2m(obj.groups) - - def _teachers(self, obj): - return format_m2m(obj.teachers) - - def _rooms(self, obj): - return format_m2m(obj.rooms) - - filter_horizontal = ("groups", "teachers", "rooms") - list_display = ("__str__", "_groups", "_teachers", "_rooms", "start", "end") - - -admin.site.register(Event, EventAdmin) - - -class ExtraLessonAdmin(admin.ModelAdmin): - def _groups(self, obj): - return format_m2m(obj.groups) - - def _teachers(self, obj): - return format_m2m(obj.teachers) - - list_display = ("week", "period", "subject", "_groups", "_teachers", "room") - - -admin.site.register(ExtraLesson, ExtraLessonAdmin) - - -class HolidayAdmin(admin.ModelAdmin): - list_display = ("title", "date_start", "date_end") - - -admin.site.register(Holiday, HolidayAdmin) - - -class LessonPeriodInline(admin.TabularInline): - model = LessonPeriod - - -class LessonSubstitutionAdmin(admin.ModelAdmin): - list_display = ("lesson_period", "week", "date") - list_display_links = ("lesson_period", "week", "date") - filter_horizontal = ("teachers",) - - -admin.site.register(LessonSubstitution, LessonSubstitutionAdmin) - - -class LessonAdmin(admin.ModelAdmin): - def _groups(self, obj): - return format_m2m(obj.groups) - - def _teachers(self, obj): - return format_m2m(obj.teachers) - - filter_horizontal = ["teachers", "groups"] - inlines = [LessonPeriodInline] - list_filter = ("subject", "groups", "groups__parent_groups", "teachers") - list_display = ("_groups", "subject", "_teachers") - - -admin.site.register(Lesson, LessonAdmin) - - -class RoomAdmin(GuardedModelAdmin): - list_display = ("short_name", "name") - list_display_links = ("short_name", "name") - - -admin.site.register(Room, RoomAdmin) - - -class SubjectAdmin(admin.ModelAdmin): - def _colour(self, obj): - return colour_badge( - obj.colour_fg, - obj.colour_bg, - obj.short_name, - ) - - list_display = ("short_name", "name", "_colour") - list_display_links = ("short_name", "name") - - -admin.site.register(Subject, SubjectAdmin) - - -class SupervisionAreaAdmin(admin.ModelAdmin): - def _colour(self, obj): - return colour_badge( - obj.colour_fg, - obj.colour_bg, - obj.short_name, - ) - - list_display = ("short_name", "name", "_colour") - list_display_links = ("short_name", "name") - inlines = [SupervisionInline] - - -admin.site.register(SupervisionArea, SupervisionAreaAdmin) - - -class SupervisionSubstitutionAdmin(admin.ModelAdmin): - list_display = ("supervision", "date") - - -admin.site.register(SupervisionSubstitution, SupervisionSubstitutionAdmin) - - -class SupervisionAdmin(admin.ModelAdmin): - list_display = ("break_item", "area", "teacher") - - -admin.site.register(Supervision, SupervisionAdmin) - - -class TimePeriodAdmin(admin.ModelAdmin): - list_display = ("weekday", "period", "time_start", "time_end") - list_display_links = ("weekday", "period") - - -admin.site.register(TimePeriod, TimePeriodAdmin) - - -class ValidityRangeAdmin(admin.ModelAdmin): - list_display = ("__str__", "date_start", "date_end") - list_display_links = ("__str__", "date_start", "date_end") - - -admin.site.register(ValidityRange, ValidityRangeAdmin) admin.site.register(AutomaticPlan) diff --git a/aleksis/apps/chronos/apps.py b/aleksis/apps/chronos/apps.py index 377cefd6c1a90969c1c6c650da75fad34d3f2ae5..8eeee15eb1aebf7170021a338a22ca7c0cc5db68 100644 --- a/aleksis/apps/chronos/apps.py +++ b/aleksis/apps/chronos/apps.py @@ -1,6 +1,3 @@ -from typing import Any, Optional - -import django.apps from django.db import transaction from reversion.signals import post_revision_commit @@ -37,75 +34,3 @@ class ChronosConfig(AppConfig): transaction.on_commit(lambda: handle_new_revision.delay(revision.pk)) post_revision_commit.connect(_handle_post_revision_commit, weak=False) - - def _ensure_notification_task(self): - """Update or create the task for sending notifications.""" - from django.conf import settings # noqa - - from celery import schedules - from django_celery_beat.models import CrontabSchedule, PeriodicTask - - from aleksis.core.util.core_helpers import get_site_preferences - - time_for_sending = get_site_preferences()["chronos__time_for_sending_notifications"] - active = get_site_preferences()["chronos__send_notifications_site"] - - if active: - schedule = schedules.crontab( - minute=str(time_for_sending.minute), hour=str(time_for_sending.hour) - ) - schedule = CrontabSchedule.from_schedule(schedule) - schedule.timezone = settings.TIME_ZONE - schedule.save() - - possible_periodic_tasks = PeriodicTask.objects.filter( - task="chronos_send_notifications_for_next_day" - ) - - if not active: - possible_periodic_tasks.delete() - - elif possible_periodic_tasks.exists(): - task = possible_periodic_tasks[0] - for d_task in possible_periodic_tasks: - if d_task != task: - d_task.delete() - - if task.crontab != schedule: - task.interval, task.solar, task.clocked = None, None, None - task.crontab = schedule - task.save() - - elif active: - PeriodicTask.objects.get_or_create( - task="chronos_send_notifications_for_next_day", - crontab=schedule, - defaults=dict(name="Send notifications for next day (automatic schedule)"), - ) - - def preference_updated( - self, - sender: Any, - section: Optional[str] = None, - name: Optional[str] = None, - old_value: Optional[Any] = None, - new_value: Optional[Any] = None, - **kwargs, - ) -> None: - if section == "chronos" and name in ( - "send_notifications_site", - "time_for_sending_notifications", - ): - self._ensure_notification_task() - - def post_migrate( - self, - app_config: django.apps.AppConfig, - verbosity: int, - interactive: bool, - using: str, - **kwargs, - ) -> None: - super().post_migrate(app_config, verbosity, interactive, using, **kwargs) - # Ensure that the notification task is created after setting up AlekSIS - self._ensure_notification_task() diff --git a/aleksis/apps/chronos/managers.py b/aleksis/apps/chronos/managers.py index 6b2f4c3cc7a6ec190624e8db5c69c3edd3c818d6..e4387402ec83f8887e4d447962e99c0b8385e866 100644 --- a/aleksis/apps/chronos/managers.py +++ b/aleksis/apps/chronos/managers.py @@ -1,77 +1,13 @@ -from collections.abc import Iterable -from datetime import date, datetime, timedelta from enum import Enum -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union -from django.db import models -from django.db.models import ExpressionWrapper, F, Func, Q, QuerySet, Value -from django.db.models.fields import DateField -from django.db.models.functions import Concat +from django.db.models import Q -from calendarweek import CalendarWeek - -from aleksis.apps.chronos.util.date import week_weekday_from_date, week_weekday_to_date from aleksis.apps.cursus.models import Course from aleksis.core.managers import ( - AlekSISBaseManagerWithoutMigrations, - DateRangeQuerySetMixin, RecurrencePolymorphicQuerySet, - SchoolTermRelatedQuerySet, ) from aleksis.core.models import Group, Person, Room -from aleksis.core.util.core_helpers import get_site_preferences - -if TYPE_CHECKING: - from .models import Holiday, LessonPeriod, ValidityRange - - -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=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 TimetableType(Enum): @@ -86,786 +22,6 @@ class TimetableType(Enum): return cls.__members__.get(s.upper()) -class LessonPeriodManager(AlekSISBaseManagerWithoutMigrations): - """Manager adding specific methods to lesson periods.""" - - def get_queryset(self): - """Ensure all related lesson data is loaded as well.""" - return ( - super() - .get_queryset() - .select_related( - "lesson", - "lesson__subject", - "period", - "room", - "lesson__validity", - "lesson__validity__school_term", - ) - .prefetch_related( - "lesson__groups", - "lesson__groups__parent_groups", - "lesson__teachers", - "substitutions", - ) - ) - - -class LessonSubstitutionManager(AlekSISBaseManagerWithoutMigrations): - """Manager adding specific methods to lesson substitutions.""" - - def get_queryset(self): - """Ensure all related lesson data is loaded as well.""" - return ( - super() - .get_queryset() - .select_related( - "lesson_period", - "lesson_period__lesson", - "lesson_period__lesson__subject", - "subject", - "lesson_period__period", - "room", - "lesson_period__room", - ) - .prefetch_related( - "lesson_period__lesson__groups", - "lesson_period__lesson__groups__parent_groups", - "teachers", - "lesson_period__lesson__teachers", - ) - ) - - -class SupervisionManager(AlekSISBaseManagerWithoutMigrations): - """Manager adding specific methods to supervisions.""" - - def get_queryset(self): - """Ensure all related data is loaded as well.""" - return ( - super() - .get_queryset() - .select_related( - "teacher", - "area", - "break_item", - "break_item__after_period", - "break_item__before_period", - ) - ) - - -class SupervisionSubstitutionManager(AlekSISBaseManagerWithoutMigrations): - """Manager adding specific methods to supervision substitutions.""" - - def get_queryset(self): - """Ensure all related data is loaded as well.""" - return ( - super() - .get_queryset() - .select_related( - "teacher", - "supervision", - "supervision__teacher", - "supervision__area", - "supervision__break_item", - "supervision__break_item__after_period", - "supervision__break_item__before_period", - ) - ) - - -class EventManager(AlekSISBaseManagerWithoutMigrations): - """Manager adding specific methods to events.""" - - def get_queryset(self): - """Ensure all related data is loaded as well.""" - return ( - super() - .get_queryset() - .select_related("period_from", "period_to") - .prefetch_related( - "groups", - "groups__school_term", - "groups__parent_groups", - "teachers", - "rooms", - ) - ) - - -class ExtraLessonManager(AlekSISBaseManagerWithoutMigrations): - """Manager adding specific methods to extra lessons.""" - - def get_queryset(self): - """Ensure all related data is loaded as well.""" - return ( - super() - .get_queryset() - .select_related("room", "period", "subject") - .prefetch_related( - "groups", - "groups__school_term", - "groups__parent_groups", - "teachers", - ) - ) - - -class BreakManager(AlekSISBaseManagerWithoutMigrations): - """Manager adding specific methods to breaks.""" - - def get_queryset(self): - """Ensure all related data is loaded as well.""" - return super().get_queryset().select_related("before_period", "after_period") - - -class WeekQuerySetMixin: - def annotate_week(self, week: Union[CalendarWeek]): - """Annotate all lessons in the QuerySet with the number of the provided calendar week.""" - return self.annotate( - _week=models.Value(week.week, models.IntegerField()), - _year=models.Value(week.year, models.IntegerField()), - ) - - def alias_week(self, week: Union[CalendarWeek]): - """Add an alias to all lessons in the QuerySet with the number of the calendar week.""" - return self.alias( - _week=models.Value(week.week, models.IntegerField()), - _year=models.Value(week.year, models.IntegerField()), - ) - - -class GroupByPeriodsMixin: - def group_by_periods(self, is_week: bool = False) -> dict: - """Group a QuerySet of objects with attribute period by period numbers and weekdays.""" - per_period = {} - for obj in self: - period = obj.period.period - weekday = obj.period.weekday - - if period not in per_period: - per_period[period] = [] if not is_week else {} - - if is_week and weekday not in per_period[period]: - per_period[period][weekday] = [] - - if not is_week: - per_period[period].append(obj) - else: - per_period[period][weekday].append(obj) - - return per_period - - -class LessonDataQuerySet(models.QuerySet, WeekQuerySetMixin): - """Overrides default QuerySet to add specific methods for lesson data.""" - - # Overridden in the subclasses. Swaps the paths to the base lesson period - # and to any substitutions depending on whether the query is run on a - # lesson period or a substitution - _period_path = None - _subst_path = None - - def within_dates(self, start: date, end: date): - """Filter for all lessons within a date range.""" - return self.filter( - **{ - self._period_path + "lesson__validity__date_start__lte": start, - self._period_path + "lesson__validity__date_end__gte": end, - } - ) - - def in_week(self, wanted_week: CalendarWeek): - """Filter for all lessons within a calendar week.""" - return self.within_dates( - wanted_week[0] + timedelta(days=1) * (F(self._period_path + "period__weekday")), - wanted_week[0] + timedelta(days=1) * (F(self._period_path + "period__weekday")), - ).annotate_week(wanted_week) - - def on_day(self, day: date): - """Filter for all lessons on a certain day.""" - week, weekday = week_weekday_from_date(day) - - return ( - self.within_dates(day, day) - .filter(**{self._period_path + "period__weekday": weekday}) - .annotate_week(week) - ) - - def at_time(self, when: Optional[datetime] = None): - """Filter for the lessons taking place at a certain point in time.""" - now = when or datetime.now() - week, weekday = week_weekday_from_date(now.date()) - - return self.filter( - **{ - 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(), - } - ).annotate_week(week) - - def filter_participant(self, person: Union[Person, int]): - """Filter for all lessons a participant (student) attends.""" - return self.filter(Q(**{self._period_path + "lesson__groups__members": person})) - - def filter_group(self, group: Union[Group, int]): - """Filter for all lessons a group (class) regularly attends.""" - if isinstance(group, int): - group = Group.objects.get(pk=group) - - if group.parent_groups.all(): - # Prevent to show lessons multiple times - return self.filter(Q(**{self._period_path + "lesson__groups": group})) - else: - return self.filter( - Q(**{self._period_path + "lesson__groups": group}) - | Q(**{self._period_path + "lesson__groups__parent_groups": group}) - ) - - def filter_groups(self, groups: Iterable[Group]) -> QuerySet: - """Filter for all lessons one of the groups regularly attends.""" - return self.filter( - Q(**{self._period_path + "lesson__groups__in": groups}) - | Q(**{self._period_path + "lesson__groups__parent_groups__in": groups}) - ) - - def filter_teacher(self, teacher: Union[Person, int], is_smart: bool = True): - """Filter for all lessons given by a certain teacher.""" - qs1 = self.filter(**{self._period_path + "lesson__teachers": teacher}) - qs2 = self.filter( - **{ - self._subst_path + "teachers": teacher, - self._subst_path + "week": F("_week"), - self._subst_path + "year": F("_year"), - } - ) - - if is_smart: - return qs1.union(qs2) - else: - return qs1 - - def filter_room(self, room: Union["Room", int], is_smart: bool = True): - """Filter for all lessons taking part in a certain room.""" - qs1 = self.filter(**{self._period_path + "room": room}) - qs2 = self.filter( - **{ - self._subst_path + "room": room, - self._subst_path + "week": F("_week"), - self._subst_path + "year": F("_year"), - } - ) - - if is_smart: - return qs1.union(qs2) - else: - return qs1 - - def filter_from_type( - self, type_: TimetableType, obj: Union[Person, Group, "Room", int], is_smart: bool = True - ) -> Optional[models.QuerySet]: - """Filter lesson data for a group, teacher or room by provided type.""" - if type_ == TimetableType.GROUP: - return self.filter_group(obj) - elif type_ == TimetableType.TEACHER: - return self.filter_teacher(obj, is_smart=is_smart) - elif type_ == TimetableType.ROOM: - return self.filter_room(obj, is_smart=is_smart) - else: - return None - - def filter_from_person(self, person: Person) -> Optional[models.QuerySet]: - """Filter lesson data for a person.""" - type_ = person.timetable_type - - if type_ == TimetableType.TEACHER: - # Teacher - - return self.filter_teacher(person) - - elif type_ == TimetableType.GROUP: - # Student - - return self.filter_participant(person) - - else: - # If no student or teacher - return None - - def daily_lessons_for_person( - self, person: Person, wanted_day: date - ) -> Optional[models.QuerySet]: - """Filter lesson data on a day by a person.""" - if person.timetable_type is None: - return None - - lesson_periods = self.on_day(wanted_day).filter_from_person(person) - - return lesson_periods - - def group_by_validity(self) -> dict["ValidityRange", list["LessonPeriod"]]: - """Group lesson periods by validity range as dictionary.""" - lesson_periods_by_validity = {} - for lesson_period in self: - lesson_periods_by_validity.setdefault(lesson_period.lesson.validity, []) - lesson_periods_by_validity[lesson_period.lesson.validity].append(lesson_period) - return lesson_periods_by_validity - - def next_lesson( - self, reference: "LessonPeriod", offset: Optional[int] = 1 - ) -> Optional["LessonPeriod"]: - """Get another lesson in an ordered set of lessons. - - By default, it returns the next lesson in the set. By passing the offset argument, - the n-th next lesson can be selected. By passing a negative number, the n-th - previous lesson can be selected. - - This function will handle week, year and validity range changes automatically - if the queryset contains enough lesson data. - """ - # Group lesson periods by validity to handle validity range changes correctly - lesson_periods_by_validity = self.group_by_validity() - validity_ranges = list(lesson_periods_by_validity.keys()) - - # List with lesson periods in the validity range of the reference lesson period - current_lesson_periods = lesson_periods_by_validity[reference.lesson.validity] - pks = [lesson_period.pk for lesson_period in current_lesson_periods] - - # Position of the reference lesson period - index = pks.index(reference.id) - - next_index = index + offset - if next_index > len(pks) - 1: - next_index %= len(pks) - week = reference._week + 1 - elif next_index < 0: - next_index = len(pks) + next_index - week = reference._week - 1 - else: - week = reference._week - - # Check if selected week makes a year change necessary - year = reference._year - if week < 1: - year -= 1 - week = CalendarWeek.get_last_week_of_year(year).week - elif week > CalendarWeek.get_last_week_of_year(year).week: - year += 1 - week = 1 - - # Get the next lesson period in this validity range and it's date - # to check whether the validity range has to be changed - week = CalendarWeek(week=week, year=year) - next_lesson_period = current_lesson_periods[next_index] - next_lesson_period_date = week_weekday_to_date(week, next_lesson_period.period.weekday) - - validity_index = validity_ranges.index(next_lesson_period.lesson.validity) - - # If date of next lesson period is out of validity range (smaller) ... - if next_lesson_period_date < next_lesson_period.lesson.validity.date_start: - # ... we have to get the lesson period from the previous validity range - if validity_index == 0: - # There are no validity ranges (and thus no lessons) - # in the school term before this lesson period - return None - - # Get new validity range and last lesson period of this validity range - new_validity = validity_ranges[validity_index - 1] - next_lesson_period = lesson_periods_by_validity[new_validity][-1] - - # Build new week with the date from the new validity range/lesson period - week = CalendarWeek( - week=new_validity.date_end.isocalendar()[1], year=new_validity.date_end.year - ) - - # If date of next lesson period is out of validity range (larger) ... - elif next_lesson_period_date > next_lesson_period.lesson.validity.date_end: - # ... we have to get the lesson period from the next validity range - if validity_index >= len(validity_ranges) - 1: - # There are no validity ranges (and thus no lessons) - # in the school term after this lesson period - return None - - # Get new validity range and first lesson period of this validity range - new_validity = validity_ranges[validity_index + 1] - next_lesson_period = lesson_periods_by_validity[new_validity][0] - - # Build new week with the date from the new validity range/lesson period - week = CalendarWeek( - week=new_validity.date_start.isocalendar()[1], year=new_validity.date_start.year - ) - - # Do a new query here to be able to annotate the new week - return self.annotate_week(week).get(pk=next_lesson_period.pk) - - -class LessonPeriodQuerySet(LessonDataQuerySet, GroupByPeriodsMixin): - """QuerySet with custom query methods for lesson periods.""" - - _period_path = "" - _subst_path = "substitutions__" - - -class LessonSubstitutionQuerySet(LessonDataQuerySet): - """QuerySet with custom query methods for substitutions.""" - - _period_path = "lesson_period__" - _subst_path = "" - - def within_dates(self, start: date, end: date): - """Filter for all substitutions within a date range.""" - start_week = CalendarWeek.from_date(start) - end_week = CalendarWeek.from_date(end) - return self.filter( - week__gte=start_week.week, - week__lte=end_week.week, - year__gte=start_week.year, - year__lte=end_week.year, - ).filter( - Q( - week=start_week.week, - year=start_week.year, - lesson_period__period__weekday__gte=start.weekday(), - ) - | Q( - week=end_week.week, - year=end_week.year, - lesson_period__period__weekday__lte=end.weekday(), - ) - | ( - ~Q(week=start_week.week, year=start_week.year) - & ~Q(week=end_week.week, year=end_week.year) - ) - ) - - def in_week(self, wanted_week: CalendarWeek): - """Filter for all lessons within a calendar week.""" - return self.filter(week=wanted_week.week, year=wanted_week.year).annotate_week(wanted_week) - - def on_day(self, day: date): - """Filter for all lessons on a certain day.""" - week, weekday = week_weekday_from_date(day) - - return self.in_week(week).filter(lesson_period__period__weekday=weekday) - - def at_time(self, when: Optional[datetime] = None): - """Filter for the lessons taking place at a certain point in time.""" - now = when or datetime.now() - - return self.on_day(now.date()).filter( - lesson_period__period__time_start__lte=now.time(), - lesson_period__period__time_end__gte=now.time(), - ) - - def affected_lessons(self): - """Return all lessons which are affected by selected substitutions.""" - from .models import Lesson # noaq - - return Lesson.objects.filter(lesson_periods__substitutions__in=self).distinct() - - def affected_teachers(self): - """Get affected teachers. - - Return all teachers which are affected by - selected substitutions (as substituted or substituting). - """ - return ( - Person.objects.filter( - Q(lessons_as_teacher__in=self.affected_lessons()) | Q(lesson_substitutions__in=self) - ) - .distinct() - .order_by("short_name") - ) - - def affected_groups(self): - """Return all groups which are affected by selected substitutions.""" - return ( - Group.objects.filter(lessons__in=self.affected_lessons()) - .distinct() - .order_by("short_name") - ) - - -class DateRangeQuerySetMixin: - """QuerySet with custom query methods for models with date and period ranges. - - Filterable fields: date_start, date_end, period_from, period_to - """ - - def within_dates(self, start: date, end: date): - """Filter for all events within a date range.""" - return self.filter(date_start__lte=end, date_end__gte=start) - - def in_week(self, wanted_week: CalendarWeek): - """Filter for all events within a calendar week.""" - return self.within_dates(wanted_week[0], wanted_week[6]) - - def on_day(self, day: date): - """Filter for all events on a certain day.""" - return self.within_dates(day, day) - - def at_time(self, when: Optional[datetime] = None): - """Filter for the events taking place at a certain point in time.""" - now = when or datetime.now() - - return self.on_day(now.date()).filter( - period_from__time_start__lte=now.time(), period_to__time_end__gte=now.time() - ) - - def exclude_holidays(self, holidays: Iterable["Holiday"]) -> QuerySet: - """Exclude all objects which are in the provided holidays.""" - q = Q() - for holiday in holidays: - q = q | Q(date_start__lte=holiday.date_end, date_end__gte=holiday.date_start) - return self.exclude(q) - - -class AbsenceQuerySet(DateRangeQuerySetMixin, SchoolTermRelatedQuerySet): - """QuerySet with custom query methods for absences.""" - - def absent_teachers(self): - return Person.objects.filter(absences__in=self).distinct().order_by("short_name") - - def absent_groups(self): - return Group.objects.filter(absences__in=self).distinct().order_by("short_name") - - def absent_rooms(self): - return Person.objects.filter(absences__in=self).distinct().order_by("short_name") - - -class HolidayQuerySet(QuerySet, DateRangeQuerySetMixin): - """QuerySet with custom query methods for holidays.""" - - def get_all_days(self) -> list[date]: - """Get all days included in the selected holidays.""" - holiday_days = [] - for holiday in self: - holiday_days += list(holiday.get_days()) - return holiday_days - - -class SupervisionQuerySet(ValidityRangeRelatedQuerySet, WeekQuerySetMixin): - """QuerySet with custom query methods for supervisions.""" - - def filter_by_weekday(self, weekday: int): - """Filter supervisions by weekday.""" - return self.filter( - Q(break_item__before_period__weekday=weekday) - | Q(break_item__after_period__weekday=weekday) - ) - - def filter_by_teacher(self, teacher: Union[Person, int]): - """Filter for all supervisions given by a certain teacher.""" - if self.count() > 0: - if hasattr(self[0], "_week"): - week = CalendarWeek(week=self[0]._week, year=self[0]._year) - else: - week = CalendarWeek.current_week() - - dates = [week[w] for w in range(0, 7)] - - return self.filter( - Q(substitutions__teacher=teacher, substitutions__date__in=dates) - | Q(teacher=teacher) - ) - - return self - - -class TimetableQuerySet(models.QuerySet): - """Common query set methods for objects in timetables. - - Models need following fields: - - groups - - teachers - - rooms (_multiple_rooms=True)/room (_multiple_rooms=False) - """ - - _multiple_rooms = True - - def filter_participant(self, person: Union[Person, int]): - """Filter for all objects a participant (student) attends.""" - return self.filter(Q(groups__members=person)) - - def filter_group(self, group: Union[Group, int]): - """Filter for all objects a group (class) attends.""" - if isinstance(group, int): - group = Group.objects.get(pk=group) - - if group.parent_groups.all(): - # Prevent to show lessons multiple times - return self.filter(groups=group) - else: - return self.filter(Q(groups=group) | Q(groups__parent_groups=group)) - - def filter_groups(self, groups: Iterable[Group]) -> QuerySet: - """Filter for all objects one of the groups attends.""" - return self.filter(Q(groups__in=groups) | Q(groups__parent_groups__in=groups)).distinct() - - def filter_teacher(self, teacher: Union[Person, int]): - """Filter for all lessons given by a certain teacher.""" - return self.filter(teachers=teacher) - - def filter_room(self, room: Union["Room", int]): - """Filter for all objects taking part in a certain room.""" - if self._multiple_rooms: - return self.filter(rooms=room) - else: - return self.filter(room=room) - - def filter_from_type( - self, type_: TimetableType, obj: Union[Group, Person, "Room", int] - ) -> Optional[models.QuerySet]: - """Filter data for a group, teacher or room by provided type.""" - if type_ == TimetableType.GROUP: - return self.filter_group(obj) - elif type_ == TimetableType.TEACHER: - return self.filter_teacher(obj) - elif type_ == TimetableType.ROOM: - return self.filter_room(obj) - else: - return None - - def filter_from_person(self, person: Person) -> Optional[models.QuerySet]: - """Filter data by person.""" - type_ = person.timetable_type - - if type_ == TimetableType.TEACHER: - # Teacher - - return self.filter_teacher(person) - - elif type_ == TimetableType.GROUP: - # Student - - return self.filter_participant(person) - - else: - # If no student or teacher - return None - - -class EventQuerySet(DateRangeQuerySetMixin, SchoolTermRelatedQuerySet, TimetableQuerySet): - """QuerySet with custom query methods for events.""" - - def annotate_day(self, day: date): - """Annotate all events in the QuerySet with the provided date.""" - return self.annotate(_date=models.Value(day, models.DateField())) - - def alias_day(self, day: date): - """Add an alias to all events in the QuerySet with the provided date.""" - return self.alias(_date=models.Value(day, models.DateField())) - - -class ExtraLessonQuerySet(TimetableQuerySet, SchoolTermRelatedQuerySet, GroupByPeriodsMixin): - """QuerySet with custom query methods for extra lessons.""" - - _multiple_rooms = False - - def within_dates(self, start: date, end: date): - """Filter all extra lessons within a specific time range.""" - return self.alias_day().filter(day__gte=start, day__lte=end) - - def on_day(self, day: date): - """Filter all extra lessons on a day.""" - return self.within_dates(day, day) - - def _get_weekday_to_date(self): - """Get DB function to convert a weekday to a date.""" - return ExpressionWrapper( - Func( - Concat(F("year"), F("week")), - Value("IYYYIW"), - output_field=DateField(), - function="TO_DATE", - ) - + F("period__weekday"), - output_field=DateField(), - ) - - def annotate_day(self): - return self.annotate(day=self._get_weekday_to_date()) - - def alias_day(self): - return self.alias(day=self._get_weekday_to_date()) - - def exclude_holidays(self, holidays: Iterable["Holiday"]) -> QuerySet: - """Exclude all extra lessons which are in the provided holidays.""" - q = Q() - for holiday in holidays: - q = q | Q(day__lte=holiday.date_end, day__gte=holiday.date_start) - return self.alias_day().exclude(q) - - -class GroupPropertiesMixin: - """Mixin for common group properties. - - Necessary method: `get_groups` - """ - - @property - def group_names(self, sep: Optional[str] = ", ") -> str: - return sep.join([group.short_name for group in self.get_groups()]) - - @property - def group_short_names(self, sep: Optional[str] = ", ") -> str: - return sep.join([group.short_name for group in self.get_groups()]) - - @property - def groups_to_show(self) -> QuerySet[Group]: - groups = self.get_groups() - if ( - groups.count() == 1 - and groups[0].parent_groups.all() - and get_site_preferences()["chronos__use_parent_groups"] - ): - return groups[0].parent_groups.all() - else: - return groups - - @property - def groups_to_show_names(self, sep: Optional[str] = ", ") -> str: - return sep.join([group.short_name for group in self.groups_to_show]) - - @property - def groups_to_show_short_names(self, sep: Optional[str] = ", ") -> str: - return sep.join([group.short_name for group in self.groups_to_show]) - - -class TeacherPropertiesMixin: - """Mixin for common teacher properties. - - Necessary method: `get_teachers` - """ - - @property - def teacher_names(self, sep: Optional[str] = ", ") -> str: - return sep.join([teacher.full_name for teacher in self.get_teachers()]) - - @property - def teacher_short_names(self, sep: str = ", ") -> str: - return sep.join([teacher.short_name for teacher in self.get_teachers()]) - - -class RoomPropertiesMixin: - """Mixin for common room properties. - - Necessary method: `get_rooms` - """ - - @property - def room_names(self, sep: Optional[str] = ", ") -> str: - return sep.join([room.name for room in self.get_rooms()]) - - @property - def room_short_names(self, sep: str = ", ") -> str: - return sep.join([room.short_name for room in self.get_rooms()]) - - class LessonEventQuerySet(RecurrencePolymorphicQuerySet): """Queryset with special query methods for lesson events.""" diff --git a/aleksis/apps/chronos/migrations/0001_initial.py b/aleksis/apps/chronos/migrations/0001_initial.py index 758f099cade786ea7cbab3cb6a4616847fa2c2d6..cf9aea3066896d3724d8f7757a00cb136f3ffc80 100644 --- a/aleksis/apps/chronos/migrations/0001_initial.py +++ b/aleksis/apps/chronos/migrations/0001_initial.py @@ -116,8 +116,6 @@ class Migration(migrations.Migration): }, bases=( models.Model, - aleksis.apps.chronos.managers.GroupPropertiesMixin, - aleksis.apps.chronos.managers.TeacherPropertiesMixin, ), managers=[("objects", aleksis.core.managers.AlekSISBaseManager()),], ), @@ -699,7 +697,7 @@ class Migration(migrations.Migration): "verbose_name": "Extra lesson", "verbose_name_plural": "Extra lessons", }, - bases=(models.Model, aleksis.apps.chronos.managers.GroupPropertiesMixin), + bases=(models.Model,), ), migrations.CreateModel( name="Exam", @@ -832,8 +830,6 @@ class Migration(migrations.Migration): }, bases=( models.Model, - aleksis.apps.chronos.managers.GroupPropertiesMixin, - aleksis.apps.chronos.managers.TeacherPropertiesMixin, ), ), migrations.AddField( diff --git a/aleksis/apps/chronos/migrations/0004_substitution_extra_lesson_year.py b/aleksis/apps/chronos/migrations/0004_substitution_extra_lesson_year.py index cb7383592d167f7d3aa4bf203aaa8cee503d7551..eeb02c4822fd609781ac8b02839511237114a0a2 100644 --- a/aleksis/apps/chronos/migrations/0004_substitution_extra_lesson_year.py +++ b/aleksis/apps/chronos/migrations/0004_substitution_extra_lesson_year.py @@ -1,8 +1,7 @@ # Generated by Django 3.0.9 on 2020-08-13 14:06 from django.db import migrations, models - -import aleksis.apps.chronos.util.date +from django.utils import timezone def migrate_data(apps, schema_editor): @@ -19,7 +18,7 @@ def migrate_data(apps, schema_editor): sub.save() for extra_lesson in ExtraLesson.objects.using(db_alias).all(): - year = aleksis.apps.chronos.util.date.get_current_year() + year = timezone.now().year extra_lesson.year = year extra_lesson.save() @@ -36,7 +35,7 @@ class Migration(migrations.Migration): model_name="extralesson", name="year", field=models.IntegerField( - default=aleksis.apps.chronos.util.date.get_current_year, + default=lambda: timezone.now().year, verbose_name="Year", ), ), @@ -44,7 +43,7 @@ class Migration(migrations.Migration): model_name="lessonsubstitution", name="year", field=models.IntegerField( - default=aleksis.apps.chronos.util.date.get_current_year, + default=lambda: timezone.now().year, verbose_name="Year", ), ), diff --git a/aleksis/apps/chronos/migrations/0018_check_new_models.py b/aleksis/apps/chronos/migrations/0018_check_new_models.py new file mode 100644 index 0000000000000000000000000000000000000000..144a1af5ce41b1313157ac45c75aa55facf4f224 --- /dev/null +++ b/aleksis/apps/chronos/migrations/0018_check_new_models.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + +from django.apps import apps as global_apps + +def check_for_migration(apps, schema_editor): + if global_apps.is_installed('aleksis.apps.lesrooster'): + return + + ValidityRange = apps.get_model('chronos', 'ValidityRange') + Subject = apps.get_model('chronos', 'Subject') + AbsenceReason = apps.get_model('chronos', 'AbsenceReason') + Absence = apps.get_model('chronos', 'Absence') + Holiday = apps.get_model('chronos', 'Holiday') + SupervisionArea = apps.get_model('chronos', 'SupervisionArea') + + model_types = [ValidityRange, Subject, AbsenceReason, Absence, Holiday, SupervisionArea] + + for model in model_types: + if model.objects.exists(): + raise RuntimeError("You have legacy data. Please install AlekSIS-App-Lesrooster to migrate them.") + +class Migration(migrations.Migration): + + dependencies = [ + ('chronos', '0017_optional_slot_number'), + ] + + operations = [ + migrations.RunPython(check_for_migration), + ] diff --git a/aleksis/apps/chronos/migrations/0019_remove_old_models.py b/aleksis/apps/chronos/migrations/0019_remove_old_models.py new file mode 100644 index 0000000000000000000000000000000000000000..f6668aabe3248e1717a2b08fb9cdf90f8a37a4c9 --- /dev/null +++ b/aleksis/apps/chronos/migrations/0019_remove_old_models.py @@ -0,0 +1,260 @@ +# Generated by Django 5.0.8 on 2024-08-14 13:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chronos', '0018_check_new_models'), + ] + + operations = [ + migrations.RemoveField( + model_name='absence', + name='group', + ), + migrations.RemoveField( + model_name='absence', + name='period_from', + ), + migrations.RemoveField( + model_name='absence', + name='period_to', + ), + migrations.RemoveField( + model_name='absence', + name='reason', + ), + migrations.RemoveField( + model_name='absence', + name='room', + ), + migrations.RemoveField( + model_name='absence', + name='school_term', + ), + migrations.RemoveField( + model_name='absence', + name='teacher', + ), + migrations.RemoveField( + model_name='break', + name='after_period', + ), + migrations.RemoveField( + model_name='break', + name='before_period', + ), + migrations.RemoveField( + model_name='break', + name='validity', + ), + migrations.RemoveField( + model_name='supervision', + name='break_item', + ), + migrations.RemoveField( + model_name='event', + name='groups', + ), + migrations.RemoveField( + model_name='event', + name='period_from', + ), + migrations.RemoveField( + model_name='event', + name='period_to', + ), + migrations.RemoveField( + model_name='event', + name='rooms', + ), + migrations.RemoveField( + model_name='event', + name='school_term', + ), + migrations.RemoveField( + model_name='event', + name='teachers', + ), + migrations.RemoveField( + model_name='exam', + name='lesson', + ), + migrations.RemoveField( + model_name='exam', + name='period_from', + ), + migrations.RemoveField( + model_name='exam', + name='period_to', + ), + migrations.RemoveField( + model_name='exam', + name='school_term', + ), + migrations.RemoveField( + model_name='extralesson', + name='exam', + ), + migrations.RemoveField( + model_name='extralesson', + name='groups', + ), + migrations.RemoveField( + model_name='extralesson', + name='period', + ), + migrations.RemoveField( + model_name='extralesson', + name='room', + ), + migrations.RemoveField( + model_name='extralesson', + name='school_term', + ), + migrations.RemoveField( + model_name='extralesson', + name='subject', + ), + migrations.RemoveField( + model_name='extralesson', + name='teachers', + ), + migrations.DeleteModel( + name='Holiday', + ), + migrations.RemoveField( + model_name='lesson', + name='groups', + ), + migrations.RemoveField( + model_name='lesson', + name='periods', + ), + migrations.RemoveField( + model_name='lesson', + name='subject', + ), + migrations.RemoveField( + model_name='lesson', + name='teachers', + ), + migrations.RemoveField( + model_name='lesson', + name='validity', + ), + migrations.RemoveField( + model_name='lessonperiod', + name='lesson', + ), + migrations.RemoveField( + model_name='lessonperiod', + name='period', + ), + migrations.RemoveField( + model_name='lessonperiod', + name='room', + ), + migrations.RemoveField( + model_name='lessonsubstitution', + name='lesson_period', + ), + migrations.RemoveField( + model_name='lessonsubstitution', + name='room', + ), + migrations.RemoveField( + model_name='lessonsubstitution', + name='subject', + ), + migrations.RemoveField( + model_name='lessonsubstitution', + name='teachers', + ), + migrations.RemoveField( + model_name='supervision', + name='area', + ), + migrations.RemoveField( + model_name='supervision', + name='teacher', + ), + migrations.RemoveField( + model_name='supervision', + name='validity', + ), + migrations.RemoveField( + model_name='supervisionsubstitution', + name='supervision', + ), + migrations.RemoveField( + model_name='supervisionsubstitution', + name='teacher', + ), + migrations.RemoveField( + model_name='timeperiod', + name='validity', + ), + migrations.RemoveField( + model_name='validityrange', + name='school_term', + ), + migrations.DeleteModel( + name='TimetableWidget', + ), + migrations.AlterModelOptions( + name='chronosglobalpermissions', + options={'managed': False, 'permissions': (('view_all_room_timetables', 'Can view all room timetables'), ('view_all_group_timetables', 'Can view all group timetables'), ('view_all_person_timetables', 'Can view all person timetables'), ('view_timetable_overview', 'Can view timetable overview'), ('view_substitutions', 'Can view substitutions table'))}, + ), + migrations.AlterModelOptions( + name='supervisionevent', + options={'base_manager_name': 'objects'}, + ), + migrations.DeleteModel( + name='AbsenceReason', + ), + migrations.DeleteModel( + name='Absence', + ), + migrations.DeleteModel( + name='Break', + ), + migrations.DeleteModel( + name='Event', + ), + migrations.DeleteModel( + name='Exam', + ), + migrations.DeleteModel( + name='ExtraLesson', + ), + migrations.DeleteModel( + name='Lesson', + ), + migrations.DeleteModel( + name='LessonPeriod', + ), + migrations.DeleteModel( + name='Subject', + ), + migrations.DeleteModel( + name='LessonSubstitution', + ), + migrations.DeleteModel( + name='SupervisionArea', + ), + migrations.DeleteModel( + name='Supervision', + ), + migrations.DeleteModel( + name='SupervisionSubstitution', + ), + migrations.DeleteModel( + name='TimePeriod', + ), + migrations.DeleteModel( + name='ValidityRange', + ), + ] diff --git a/aleksis/apps/chronos/mixins.py b/aleksis/apps/chronos/mixins.py deleted file mode 100644 index 8cb4dab29467236f950b5b9ff56f5fa7ab7da435..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/mixins.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import date -from typing import Union - -from django.db import models -from django.utils.translation import gettext as _ - -from calendarweek import CalendarWeek - -from aleksis.apps.chronos.util.date import week_weekday_to_date -from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations -from aleksis.core.mixins import ExtensibleModel - -from .managers import ValidityRangeRelatedQuerySet - - -class ValidityRangeRelatedExtensibleModel(ExtensibleModel): - """Add relation to validity range.""" - - objects = AlekSISBaseManagerWithoutMigrations.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 - - -class WeekRelatedMixin: - @property - def date(self) -> date: - period = self.lesson_period.period if hasattr(self, "lesson_period") else self.period - return week_weekday_to_date(self.calendar_week, period.weekday) - - @property - def calendar_week(self) -> CalendarWeek: - return CalendarWeek(week=self.week, year=self.year) - - -class WeekAnnotationMixin: - def annotate_week(self, week: CalendarWeek): - """Annotate this lesson with the number of the provided calendar week.""" - self._week = week.week - self._year = week.year - - @property - def week(self) -> Union[CalendarWeek, None]: - """Get annotated week as `CalendarWeek`. - - Defaults to `None` if no week is annotated. - """ - if hasattr(self, "_week"): - return CalendarWeek(week=self._week, year=self._year) - else: - return None diff --git a/aleksis/apps/chronos/model_extensions.py b/aleksis/apps/chronos/model_extensions.py index 8f43350ee5a5ab019bad41f9674737e414c9d2f3..457ef9ef8d0183bf5ddfa5c6799a7e4d14db20d5 100644 --- a/aleksis/apps/chronos/model_extensions.py +++ b/aleksis/apps/chronos/model_extensions.py @@ -1,142 +1,6 @@ -from datetime import date -from typing import Optional, Union - -from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ -from reversion.models import Revision - -from aleksis.core.models import Announcement, Group, Person -from aleksis.core.util.core_helpers import get_site_preferences - -from .managers import TimetableType -from .models import Lesson, LessonPeriod -from .util.change_tracker import timetable_data_changed -from .util.notifications import send_notifications_for_object - - -@Person.property_ -def is_teacher(self): - """Check if the user has lessons as a teacher.""" - return self.lesson_periods_as_teacher.exists() - - -@Person.property_ -def timetable_type(self) -> Optional[TimetableType]: - """Return which type of timetable this user has.""" - if self.is_teacher: - return TimetableType.TEACHER - elif self.primary_group: - return TimetableType.GROUP - else: - return None - - -@Person.property_ -def timetable_object(self) -> Optional[Union[Group, Person]]: - """Return the object which has the user's timetable.""" - type_ = self.timetable_type - - if type_ == TimetableType.TEACHER: - return self - elif type_ == TimetableType.GROUP: - return self.primary_group - else: - return None - - -@Person.property_ -def lessons_as_participant(self): - """Return a `QuerySet` containing all `Lesson`s this person participates in (as student). - - .. note:: Only available when AlekSIS-App-Chronos is installed. - - :Date: 2019-11-07 - :Authors: - - Dominik George <dominik.george@teckids.org> - """ - return Lesson.objects.filter(groups__members=self) - - -@Person.property_ -def lesson_periods_as_participant(self): - """Return a `QuerySet` containing all `LessonPeriod`s this person participates in (as student). - - .. note:: Only available when AlekSIS-App-Chronos is installed. - - :Date: 2019-11-07 - :Authors: - - Dominik George <dominik.george@teckids.org> - """ - return LessonPeriod.objects.filter(lesson__groups__members=self) - - -@Person.property_ -def lesson_periods_as_teacher(self): - """Return a `QuerySet` containing all `Lesson`s this person gives (as teacher). - - .. note:: Only available when AlekSIS-App-Chronos is installed. - - :Date: 2019-11-07 - :Authors: - - Dominik George <dominik.george@teckids.org> - """ - return LessonPeriod.objects.filter(lesson__teachers=self) - - -@Person.method -def lessons_on_day(self, day: date): - """Get all lessons of this person (either as participant or teacher) on the given day.""" - qs = LessonPeriod.objects.on_day(day).filter_from_person(self) - if qs: - # This is a union queryset, so order by must be after the union. - return qs.order_by("period__period") - return None - - -@Person.method -def _adjacent_lesson( - self, lesson_period: "LessonPeriod", day: date, offset: int = 1 -) -> Union["LessonPeriod", None]: - """Get next/previous lesson of the person (either as participant or teacher) on the same day.""" - daily_lessons = self.lessons_on_day(day) - - if not daily_lessons: - return None - - ids = list(daily_lessons.values_list("id", flat=True)) - - # Check if the lesson period is one of the person's lesson periods on this day - # and return None if it's not so - if lesson_period.pk not in ids: - return None - - index = ids.index(lesson_period.pk) - - if (offset > 0 and index + offset < len(ids)) or (offset < 0 and index >= -offset): - return daily_lessons[index + offset] - else: - return None - - -@Person.method -def next_lesson(self, lesson_period: "LessonPeriod", day: date) -> Union["LessonPeriod", None]: - """Get next lesson of the person (either as participant or teacher) on the same day.""" - return self._adjacent_lesson(lesson_period, day) - - -@Person.method -def previous_lesson(self, lesson_period: "LessonPeriod", day: date) -> Union["LessonPeriod", None]: - """Get previous lesson of the person (either as participant or teacher) on the same day.""" - return self._adjacent_lesson(lesson_period, day, offset=-1) - - -def for_timetables(cls): - """Return all announcements that should be shown in timetable views.""" - return cls.objects.all() - - -Announcement.class_method(for_timetables) +from aleksis.core.models import Group, Person # Dynamically add extra permissions to Group and Person models in core # Note: requires migrate afterwards @@ -148,16 +12,3 @@ Person.add_permission( "view_person_timetable", _("Can view person timetable"), ) - - -@receiver(timetable_data_changed) -def send_notifications(sender: Revision, **kwargs): - """Send notifications to users about the changes.""" - if not get_site_preferences()["chronos__send_notifications_site"]: - return - - for change in sender.changes.values(): - if change.deleted: - continue - - send_notifications_for_object(change.instance) diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py index 3189999febab65467e7cd76ad6e273f438be089b..d2ca3cdd4c77e26f716c3a51af883fe1eb16cc75 100644 --- a/aleksis/apps/chronos/models.py +++ b/aleksis/apps/chronos/models.py @@ -2,1187 +2,41 @@ from __future__ import annotations import itertools -from collections.abc import Iterable, Iterator -from datetime import date, datetime, time, timedelta -from itertools import chain +from collections.abc import Iterable +from datetime import date from typing import Any from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import PermissionDenied, ValidationError +from django.core.exceptions import PermissionDenied from django.core.validators import MinValueValidator from django.db import models -from django.db.models import Max, Min, Q, QuerySet -from django.db.models.functions import Coalesce +from django.db.models import QuerySet from django.dispatch import receiver from django.http import HttpRequest from django.template.loader import render_to_string -from django.urls import reverse from django.utils import timezone -from django.utils.formats import date_format -from django.utils.functional import classproperty from django.utils.translation import gettext_lazy as _ -from cache_memoize import cache_memoize -from calendarweek.django import CalendarWeek, i18n_day_abbr_choices_lazy, i18n_day_name_choices_lazy -from colorfield.fields import ColorField -from model_utils import FieldTracker from reversion.models import Revision, Version from aleksis.apps.chronos.managers import ( - AbsenceQuerySet, - BreakManager, - EventManager, - EventQuerySet, - ExtraLessonManager, - ExtraLessonQuerySet, - GroupPropertiesMixin, - HolidayQuerySet, LessonEventQuerySet, - LessonPeriodManager, - LessonPeriodQuerySet, - LessonSubstitutionManager, - LessonSubstitutionQuerySet, SupervisionEventQuerySet, - SupervisionManager, - SupervisionQuerySet, - SupervisionSubstitutionManager, - TeacherPropertiesMixin, - ValidityRangeQuerySet, -) -from aleksis.apps.chronos.mixins import ( - ValidityRangeRelatedExtensibleModel, - WeekAnnotationMixin, - WeekRelatedMixin, ) from aleksis.apps.chronos.util.change_tracker import _get_substitution_models, substitutions_changed -from aleksis.apps.chronos.util.date import get_current_year -from aleksis.apps.chronos.util.format import format_m2m from aleksis.apps.cursus import models as cursus_models from aleksis.apps.cursus.models import Course from aleksis.apps.resint.models import LiveDocument from aleksis.core.managers import ( - AlekSISBaseManagerWithoutMigrations, RecurrencePolymorphicManager, ) from aleksis.core.mixins import ( - ExtensibleModel, GlobalPermissionModel, - SchoolTermRelatedExtensibleModel, ) -from aleksis.core.models import CalendarEvent, Group, Person, Room, SchoolTerm +from aleksis.core.models import CalendarEvent, Group, Person, Room from aleksis.core.util.core_helpers import get_site_preferences, has_person -class ValidityRange(ExtensibleModel): - """Validity range model. - - This is used to link data to a validity range. - """ - - objects = AlekSISBaseManagerWithoutMigrations.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 - @cache_memoize(3600) - def get_current(cls, day: date | None = 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 and ( - 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 = 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") - constraints = [ - models.UniqueConstraint( - fields=["school_term", "date_start", "date_end"], name="unique_dates_per_term" - ), - ] - indexes = [ - models.Index(fields=["date_start", "date_end"], name="validity_date_start_date_end") - ] - - -class TimePeriod(ValidityRangeRelatedExtensibleModel): - WEEKDAY_CHOICES = i18n_day_name_choices_lazy() - WEEKDAY_CHOICES_SHORT = i18n_day_abbr_choices_lazy() - - weekday = models.PositiveSmallIntegerField( - verbose_name=_("Week day"), choices=i18n_day_name_choices_lazy() - ) - 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: CalendarWeek | None = 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, date_ref: CalendarWeek | int | date | None = None) -> datetime: - """Get datetime of lesson start in a specific week.""" - day = date_ref if isinstance(date_ref, date) else self.get_date(date_ref) - return datetime.combine(day, self.time_start) - - def get_datetime_end(self, date_ref: CalendarWeek | int | date | None = None) -> datetime: - """Get datetime of lesson end in a specific week.""" - day = date_ref if isinstance(date_ref, date) else self.get_date(date_ref) - return datetime.combine(day, self.time_end) - - @classmethod - def get_next_relevant_day( - cls, day: date | None = None, time: time | None = 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 and 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_relevant_week_from_datetime(cls, when: datetime | None = 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) or ( - 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]: - """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 - - @classmethod - def from_period(cls, period: int, day: date) -> TimePeriod: - """Get `TimePeriod` object for a period on a specific date. - - This will respect the relation to validity ranges. - """ - return cls.objects.on_day(day).filter(period=period, weekday=day.weekday()).first() - - @classproperty - @cache_memoize(3600) - def period_min(cls) -> int: - return ( - cls.objects.for_current_or_all() - .aggregate(period__min=Coalesce(Min("period"), 1)) - .get("period__min") - ) - - @classproperty - @cache_memoize(3600) - def period_max(cls) -> int: - return ( - cls.objects.for_current_or_all() - .aggregate(period__max=Coalesce(Max("period"), 7)) - .get("period__max") - ) - - @classproperty - @cache_memoize(3600) - def time_min(cls) -> time | None: - return cls.objects.for_current_or_all().aggregate(Min("time_start")).get("time_start__min") - - @classproperty - @cache_memoize(3600) - def time_max(cls) -> time | None: - return cls.objects.for_current_or_all().aggregate(Max("time_end")).get("time_end__max") - - @classproperty - @cache_memoize(3600) - def weekday_min(cls) -> int: - return ( - cls.objects.for_current_or_all() - .aggregate(weekday__min=Coalesce(Min("weekday"), 0)) - .get("weekday__min") - ) - - @classproperty - @cache_memoize(3600) - def weekday_max(cls) -> int: - return ( - cls.objects.for_current_or_all() - .aggregate(weekday__max=Coalesce(Max("weekday"), 6)) - .get("weekday__max") - ) - - @classproperty - @cache_memoize(3600) - def period_choices(cls) -> list[tuple[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 = [("", "")] + [ - (period, f"{period}.") for period in time_periods.values_list("period", flat=True) - ] - - return period_choices - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["weekday", "period", "validity"], name="unique_period_per_range" - ), - ] - 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) - - 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 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 get_teachers(self) -> models.query.QuerySet: - """Get teachers relation.""" - return self.teachers - - @property - def _equal_lessons(self): - """Get all lesson periods with equal lessons in the whole school term.""" - - qs = Lesson.objects.filter( - subject=self.subject, - validity__school_term=self.validity.school_term, - ) - for group in self.groups.all(): - qs = qs.filter(groups=group) - return qs - - 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, TeacherPropertiesMixin, WeekRelatedMixin): - objects = LessonSubstitutionManager.from_queryset(LessonSubstitutionQuerySet)() - - tracker = FieldTracker() - - 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( - "core.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) - - 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] - - @property - def time_range(self) -> (timezone.datetime, timezone.datetime): - """Get the time range of this substitution.""" - return timezone.datetime.combine( - self.date, self.lesson_period.period.time_start - ), timezone.datetime.combine(self.date, self.lesson_period.period.time_end) - - def get_teachers(self): - return self.teachers - - def __str__(self): - return f"{self.lesson_period}, {date_format(self.date)}" - - class Meta: - 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", - ), - models.UniqueConstraint( - fields=["lesson_period", "week", "year"], name="unique_period_per_week" - ), - ] - indexes = [ - models.Index(fields=["week", "year"], name="substitution_week_year"), - models.Index(fields=["lesson_period"], name="substitution_lesson_period"), - ] - verbose_name = _("Lesson substitution") - verbose_name_plural = _("Lesson substitutions") - - -class LessonPeriod(WeekAnnotationMixin, TeacherPropertiesMixin, ExtensibleModel): - 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( - "core.Room", - models.CASCADE, - null=True, - related_name="lesson_periods", - verbose_name=_("Room"), - ) - - def get_substitution(self, week: CalendarWeek | None = 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) -> Subject | None: - sub = self.get_substitution() - if sub and sub.subject: - return sub.subject - else: - return self.lesson.subject - - def get_teachers(self) -> models.query.QuerySet: - sub = self.get_substitution() - if sub and sub.teachers.all(): - return sub.teachers - else: - return self.lesson.teachers - - def get_room(self) -> Room | None: - if self.get_substitution() and self.get_substitution().room: - return self.get_substitution().room - else: - return self.room - - def get_groups(self) -> models.query.QuerySet: - return self.lesson.groups - - @property - def group_names(self): - """Get group names as joined string.""" - return self.lesson.group_names - - @property - def group_short_names(self): - """Get group short names as joined string.""" - return self.lesson.group_short_names - - def __str__(self) -> str: - return f"{self.period}, {self.lesson}" - - @property - def _equal_lesson_periods(self): - """Get all lesson periods with equal lessons in the whole school term.""" - - return LessonPeriod.objects.filter(lesson__in=self.lesson._equal_lessons) - - @property - def next(self) -> LessonPeriod: # noqa - """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_lesson_periods.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_lesson_periods.next_lesson(self, -1) - - def is_replaced_by_event( - self, events: Iterable[Event], groups: Iterable[Group] | None = None - ) -> bool: - """Check if this lesson period is replaced by an event.""" - groups_of_event = set(chain(*[event.groups.all() for event in events])) - - if groups: - # If the current group is a part of the event, - # there are no other lessons for the group. - groups = set(groups) - if groups.issubset(groups_of_event): - return True - else: - groups_lesson_period = set(self.lesson.groups.all()) - - # The lesson period isn't replacable if the lesson has no groups at all - if not groups_lesson_period: - return False - - # This lesson period is replaced by an event ... - # ... if all groups of this lesson period are a part of the event ... - if groups_lesson_period.issubset(groups_of_event): - return True - - all_parent_groups = set( - chain(*[group.parent_groups.all() for group in groups_lesson_period]) - ) - # ... or if all parent groups of this lesson period are a part of the event. - if all_parent_groups.issubset(groups_of_event): - return True - - class Meta: - ordering = [ - "lesson__validity__date_start", - "period__weekday", - "period__period", - "lesson__subject", - ] - indexes = [ - models.Index(fields=["lesson", "period"], name="lesson_period_lesson_period"), - models.Index(fields=["room"], include=["lesson", "period"], name="lesson_period_room"), - ] - verbose_name = _("Lesson period") - verbose_name_plural = _("Lesson periods") - - -class AbsenceReason(ExtensibleModel): - short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True) - name = models.CharField(verbose_name=_("Name"), blank=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 = AlekSISBaseManagerWithoutMigrations.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( - "core.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) - - 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")) - period_from = models.ForeignKey( - "TimePeriod", - on_delete=models.CASCADE, - verbose_name=_("Start period"), - related_name="+", - ) - period_to = models.ForeignKey( - "TimePeriod", - on_delete=models.CASCADE, - verbose_name=_("End period"), - related_name="+", - ) - - title = models.CharField(verbose_name=_("Title"), max_length=255, blank=True) - comment = models.TextField(verbose_name=_("Comment"), blank=True) - - class Meta: - ordering = ["date"] - indexes = [models.Index(fields=["date"])] - verbose_name = _("Exam") - verbose_name_plural = _("Exams") - - -class Holiday(ExtensibleModel): - objects = AlekSISBaseManagerWithoutMigrations.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) - - def get_days(self) -> Iterator[date]: - delta = self.date_end - self.date_start - for i in range(delta.days + 1): - yield self.date_start + timedelta(days=i) - - @classmethod - def on_day(cls, day: date) -> Holiday | None: - holidays = cls.objects.on_day(day) - if holidays.exists(): - return holidays[0] - else: - return None - - @classmethod - def in_week(cls, week: CalendarWeek) -> dict[int, Holiday | None]: - per_weekday = {} - holidays = Holiday.objects.in_week(week) - - for weekday in range(TimePeriod.weekday_min, TimePeriod.weekday_max + 1): - holiday_date = week[weekday] - filtered_holidays = list( - filter( - lambda h: holiday_date >= h.date_start and holiday_date <= h.date_end, - holidays, - ) - ) - if filtered_holidays: - per_weekday[weekday] = filtered_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, unique=True) - 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): - objects = BreakManager() - - 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") - constraints = [ - models.UniqueConstraint( - fields=["validity", "short_name"], name="unique_short_name_per_validity_break" - ), - ] - - -class Supervision(ValidityRangeRelatedExtensibleModel, WeekAnnotationMixin): - objects = SupervisionManager.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_calendar_week(self, week: int): - year = self.get_year(week) - - return CalendarWeek(year=year, week=week) - - def get_substitution(self, week: CalendarWeek | None = None) -> SupervisionSubstitution | None: - 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): - objects = SupervisionSubstitutionManager() - - tracker = FieldTracker() - - 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] - - @property - def time_range(self) -> (timezone.datetime, timezone.datetime): - """Get the time range of this supervision substitution.""" - return timezone.datetime.combine( - self.date, - self.supervision.break_item.time_start or self.supervision.break_item.time_end, - ), timezone.datetime.combine( - self.date, - self.supervision.break_item.time_end or self.supervision.break_item.time_start, - ) - - 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" - - tracker = FieldTracker() - - objects = EventManager.from_queryset(EventQuerySet)() - - title = models.CharField(verbose_name=_("Title"), max_length=255, blank=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("core.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 _("Event {pk}").format(pk=self.pk) - - def get_period_min(self, day) -> int: - return ( - TimePeriod.objects.on_day(day) - .aggregate(period__min=Coalesce(Min("period"), 1)) - .get("period__min") - ) - - def get_period_max(self, day) -> int: - return ( - TimePeriod.objects.on_day(day) - .aggregate(period__max=Coalesce(Max("period"), 7)) - .get("period__max") - ) - - @property - def raw_period_from_on_day(self) -> TimePeriod: - """Get start period on the annotated day (as TimePeriod object). - - If there is no date annotated, it will use the current date. - """ - day = getattr(self, "_date", timezone.now().date()) - if day != self.date_start: - return TimePeriod.from_period(self.get_period_min(day), day) - else: - return self.period_from - - @property - def raw_period_to_on_day(self) -> TimePeriod: - """Get end period on the annotated day (as TimePeriod object). - - If there is no date annotated, it will use the current date. - """ - day = getattr(self, "_date", timezone.now().date()) - if day != self.date_end: - return TimePeriod.from_period(self.get_period_max(day), day) - else: - return self.period_to - - @property - def period_from_on_day(self) -> int: - """Get start period on the annotated day (as period number). - - If there is no date annotated, it will use the current date. - """ - return self.raw_period_from_on_day.period - - @property - def period_to_on_day(self) -> int: - """Get end period on the annotated day (as period number). - - If there is no date annotated, it will use the current date. - """ - return self.raw_period_to_on_day.period - - def get_start_weekday(self, week: CalendarWeek) -> int: - """Get start date of an event in a specific week.""" - if self.date_start < week[TimePeriod.weekday_min]: - return TimePeriod.weekday_min - else: - return self.date_start.weekday() - - def get_end_weekday(self, week: CalendarWeek) -> int: - """Get end date of an event in a specific week.""" - if self.date_end > week[TimePeriod.weekday_max]: - return TimePeriod.weekday_max - else: - return self.date_end.weekday() - - def annotate_day(self, day: date): - """Annotate event with the provided date.""" - self._date = day - - def get_groups(self) -> models.query.QuerySet: - """Get groups relation.""" - return self.groups - - def get_teachers(self) -> models.query.QuerySet: - """Get teachers relation.""" - return self.teachers - - @property - def time_range(self) -> (timezone.datetime, timezone.datetime): - """Get the time range of this event.""" - return timezone.datetime.combine( - self.date_start, self.period_from.time_start - ), timezone.datetime.combine(self.date_end, self.period_to.time_end) - - class Meta: - ordering = ["date_start"] - indexes = [ - models.Index( - fields=["date_start", "date_end"], - include=["period_from", "period_to"], - name="event_date_start_date_end", - ) - ] - verbose_name = _("Event") - verbose_name_plural = _("Events") - - -class ExtraLesson( - GroupPropertiesMixin, TeacherPropertiesMixin, WeekRelatedMixin, SchoolTermRelatedExtensibleModel -): - label_ = "extra_lesson" - - tracker = FieldTracker() - - objects = ExtraLessonManager.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( - "core.Room", - models.CASCADE, - null=True, - related_name="extra_lessons", - verbose_name=_("Room"), - ) - - comment = models.CharField(verbose_name=_("Comment"), blank=True, max_length=255) - - exam = models.ForeignKey( - "Exam", - on_delete=models.CASCADE, - verbose_name=_("Related exam"), - related_name="extra_lessons", - blank=True, - null=True, - ) - - def __str__(self): - return f"{self.week}, {self.period}, {self.subject}" - - def get_groups(self) -> models.query.QuerySet: - """Get groups relation.""" - return self.groups - - def get_teachers(self) -> models.query.QuerySet: - """Get teachers relation.""" - return self.teachers - - def get_subject(self) -> Subject: - """Get subject.""" - return self.subject - - @property - def time_range(self) -> (timezone.datetime, timezone.datetime): - """Get the time range of this extra lesson.""" - return timezone.datetime.combine( - self.date, self.period.time_start - ), timezone.datetime.combine(self.date, self.period.time_end) - - class Meta: - verbose_name = _("Extra lesson") - verbose_name_plural = _("Extra lessons") - indexes = [models.Index(fields=["week", "year"], name="extra_lesson_week_year")] - - class AutomaticPlan(LiveDocument): """Model for configuring automatically updated PDF substitution plans.""" @@ -1294,8 +148,7 @@ class ChronosGlobalPermissions(GlobalPermissionModel): ("view_all_group_timetables", _("Can view all group timetables")), ("view_all_person_timetables", _("Can view all person timetables")), ("view_timetable_overview", _("Can view timetable overview")), - ("view_lessons_day", _("Can view all lessons per day")), - ("view_supervisions_day", _("Can view all supervisions per day")), + ("view_substitutions", _("Can view substitutions table")), ) diff --git a/aleksis/apps/chronos/preferences.py b/aleksis/apps/chronos/preferences.py index b3dc8362091693b313bda2d5f9493d8afa7e7701..a2bd26ef9eac55d0ddca38091619b3d008279803 100644 --- a/aleksis/apps/chronos/preferences.py +++ b/aleksis/apps/chronos/preferences.py @@ -16,7 +16,7 @@ from dynamic_preferences.types import ( ) from aleksis.core.models import GroupType -from aleksis.core.registries import person_preferences_registry, site_preferences_registry +from aleksis.core.registries import site_preferences_registry chronos = Section("chronos", verbose_name=_("Timetables")) @@ -34,27 +34,6 @@ class UseParentGroups(BooleanPreference): ) -@person_preferences_registry.register -class ShortenGroups(BooleanPreference): - section = chronos - name = "shorten_groups" - default = True - verbose_name = _("Shorten groups in timetable views") - help_text = _("If there are more groups than the set limit, they will be collapsed.") - - -@site_preferences_registry.register -class ShortenGroupsLimit(IntegerPreference): - section = chronos - name = "shorten_groups_limit" - default = 4 - verbose_name = _("Limit of groups for shortening of groups") - help_text = _( - "If a user activates shortening of groups," - "they will be collapsed if there are more groups than this limit." - ) - - @site_preferences_registry.register class SubstitutionsRelevantDays(MultipleChoicePreference): """Relevant days which have substitution plans.""" @@ -110,44 +89,6 @@ class AffectedGroupsUseParentGroups(BooleanPreference): ) -@site_preferences_registry.register -class DaysInAdvanceNotifications(IntegerPreference): - section = chronos - name = "days_in_advance_notifications" - default = 1 - verbose_name = _("How many days in advance users should be notified about timetable changes?") - - -@site_preferences_registry.register -class TimeForSendingNotifications(TimePreference): - section = chronos - name = "time_for_sending_notifications" - default = time(17, 00) - verbose_name = _("Time for sending notifications about timetable changes") - required = True - help_text = _( - "This is only used for scheduling notifications " - "which doesn't affect the time period configured above. " - "All other notifications affecting the next days are sent immediately." - ) - - -@site_preferences_registry.register -class SendNotifications(BooleanPreference): - section = chronos - name = "send_notifications_site" - default = True - verbose_name = _("Send notifications for current timetable changes") - - -@person_preferences_registry.register -class SendNotificationsPerson(BooleanPreference): - section = chronos - name = "send_notifications" - default = True - verbose_name = _("Send notifications for current timetable changes") - - @site_preferences_registry.register class GroupTypesTimetables(ModelMultipleChoicePreference): section = chronos diff --git a/aleksis/apps/chronos/rules.py b/aleksis/apps/chronos/rules.py index 18b7ac4ba8da17bac80a0b52b85b26e832f60533..c7d0065c6bc2c6180a0fa81188484472e6ed8af7 100644 --- a/aleksis/apps/chronos/rules.py +++ b/aleksis/apps/chronos/rules.py @@ -6,7 +6,7 @@ from aleksis.core.util.predicates import ( has_person, ) -from .util.predicates import has_any_timetable_object, has_room_timetable_perm, has_timetable_perm +from .util.predicates import has_any_timetable_object, has_timetable_perm # View timetable overview view_timetable_overview_predicate = has_person & ( @@ -14,55 +14,27 @@ view_timetable_overview_predicate = has_person & ( ) add_perm("chronos.view_timetable_overview_rule", view_timetable_overview_predicate) -# View my timetable -add_perm("chronos.view_my_timetable_rule", has_person) - # View timetable view_timetable_predicate = has_person & has_timetable_perm add_perm("chronos.view_timetable_rule", view_timetable_predicate) -# View all lessons per day -view_lessons_day_predicate = has_person & has_global_perm("chronos.view_lessons_day") -add_perm("chronos.view_lessons_day_rule", view_lessons_day_predicate) # Edit substition edit_substitution_predicate = has_person & ( - has_global_perm("chronos.change_lessonsubstitution") - | has_object_perm("chronos.change_lessonsubstitution") + has_global_perm("chronos.change_lessonevent") | has_object_perm("chronos.change_lessonevent") ) add_perm("chronos.edit_substitution_rule", edit_substitution_predicate) # Delete substitution delete_substitution_predicate = has_person & ( - has_global_perm("chronos.delete_lessonsubstitution") - | has_object_perm("chronos.delete_lessonsubstitution") + has_global_perm("chronos.delete_lessonevent") | has_object_perm("chronos.delete_lessonevent") ) add_perm("chronos.delete_substitution_rule", delete_substitution_predicate) # View substitutions -view_substitutions_predicate = has_person & (has_global_perm("chronos.view_lessonsubstitution")) +view_substitutions_predicate = has_person & (has_global_perm("chronos.view_substitutions")) add_perm("chronos.view_substitutions_rule", view_substitutions_predicate) -# View all supervisions per day -view_supervisions_day_predicate = has_person & has_global_perm("chronos.view_supervisions_day") -add_perm("chronos.view_supervisions_day_rule", view_supervisions_day_predicate) - -# Edit supervision substitution -edit_supervision_substitution_predicate = has_person & ( - has_global_perm("chronos.change_supervisionsubstitution") -) -add_perm("chronos.edit_supervision_substitution_rule", edit_supervision_substitution_predicate) - -# Delete supervision substitution -delete_supervision_substitution_predicate = has_person & ( - has_global_perm("chronos.delete_supervisionsubstitution") -) -add_perm("chronos.delete_supervision_substitution_rule", delete_supervision_substitution_predicate) - -# View room (timetable) -view_room_predicate = has_person & has_room_timetable_perm -add_perm("chronos.view_room_rule", view_room_predicate) - # View parent menu entry view_menu_predicate = has_person & (view_timetable_overview_predicate) add_perm("chronos.view_menu_rule", view_menu_predicate) diff --git a/aleksis/apps/chronos/static/css/chronos/timetable_print.css b/aleksis/apps/chronos/static/css/chronos/timetable_print.css deleted file mode 100644 index 343864ae919cb68ab0bb4571d1736fd87aa5a573..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/static/css/chronos/timetable_print.css +++ /dev/null @@ -1,34 +0,0 @@ -.timetable-plan .row, -.timetable-plan .col { - display: flex; - padding: 0rem; -} - -.timetable-plan .row { - margin-bottom: 0rem; -} - -.lesson-card, -.timetable-title-card { - margin: 0; - display: flex; - flex-grow: 1; - min-height: 40px; - box-shadow: none; - border: 1px solid black; - margin-right: -1px; - margin-top: -1px; - border-radius: 0px; - font-size: 11px; -} -.lesson-card .card-content > div { - padding: 1px; -} - -.card .card-title { - font-size: 18px; -} - -.timetable-title-card .card-content { - padding: 7px; -} diff --git a/aleksis/apps/chronos/static/js/chronos/date_select.js b/aleksis/apps/chronos/static/js/chronos/date_select.js deleted file mode 100644 index 04a0cf0e6623c772c007ca8f4a85e41038f0c48c..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/static/js/chronos/date_select.js +++ /dev/null @@ -1,21 +0,0 @@ -var data = getJSONScript("datepicker_data"); -var activeDate = new Date(data.date); - -function updateDatepicker() { - $("#date").val(formatDate(activeDate)); -} - -function loadNew() { - window.location.href = data.dest + formatDateForDjango(activeDate); -} - -function onDateChanged() { - activeDate = M.Datepicker.getInstance($("#date")).date; - loadNew(); -} - -$(document).ready(function () { - $("#date").change(onDateChanged); - - updateDatepicker(); -}); diff --git a/aleksis/apps/chronos/static/js/chronos/week_select.js b/aleksis/apps/chronos/static/js/chronos/week_select.js deleted file mode 100644 index 1854b17d284b4bcb8953a97a3e049d51821d3368..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/static/js/chronos/week_select.js +++ /dev/null @@ -1,21 +0,0 @@ -var data = getJSONScript("week_select"); - -function goToCalendarWeek(cw, year) { - window.location.href = data.dest.replace("year", year).replace("cw", cw); -} - -function onCalendarWeekChanged(where) { - goToCalendarWeek($(where).val(), data.year); -} - -$(document).ready(function () { - $("#calendar-week-1").change(function () { - onCalendarWeekChanged("#calendar-week-1"); - }); - $("#calendar-week-2").change(function () { - onCalendarWeekChanged("#calendar-week-2"); - }); - $("#calendar-week-3").change(function () { - onCalendarWeekChanged("#calendar-week-3"); - }); -}); diff --git a/aleksis/apps/chronos/templatetags/__init__.py b/aleksis/apps/chronos/templatetags/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/aleksis/apps/chronos/templatetags/common.py b/aleksis/apps/chronos/templatetags/common.py deleted file mode 100644 index f3b4ca74d952a6f9fda5034360aa517f2fea475c..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/templatetags/common.py +++ /dev/null @@ -1,33 +0,0 @@ -from django import template - -register = template.Library() - - -class SetVarNode(template.Node): - def __init__(self, var_name, var_value): - self.var_name = var_name - self.var_value = var_value - - def render(self, context): - try: - value = template.Variable(self.var_value).resolve(context) - except template.VariableDoesNotExist: - value = "" - context[self.var_name] = value - - return "" - - -@register.tag(name="set") -def set_var(parser, token): - """Set var. - - {% set some_var = '123' %} - """ - parts = token.split_contents() - if len(parts) < 4: - raise template.TemplateSyntaxError( - "'set' tag must be of the form: {% set <var_name> = <var_value> %}" - ) - - return SetVarNode(parts[1], parts[3]) diff --git a/aleksis/apps/chronos/templatetags/week_helpers.py b/aleksis/apps/chronos/templatetags/week_helpers.py deleted file mode 100644 index cdba9b3f014913a2525b3ca59bfc514dc0e98ef1..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/templatetags/week_helpers.py +++ /dev/null @@ -1,55 +0,0 @@ -from datetime import date, datetime -from typing import Optional, Union - -from django import template -from django.db.models.query import QuerySet - -from aleksis.apps.chronos.util.date import CalendarWeek, week_period_to_date, week_weekday_to_date - -register = template.Library() - - -@register.filter -def week_start(week: CalendarWeek) -> date: - return week[0] - - -@register.filter -def week_end(week: CalendarWeek) -> date: - return week[-1] - - -@register.filter -def only_week(qs: QuerySet, week: Optional[CalendarWeek]) -> QuerySet: - wanted_week = week or CalendarWeek() - return qs.filter(week=wanted_week.week, year=wanted_week.year) - - -@register.simple_tag -def weekday_to_date(week: CalendarWeek, weekday: int) -> date: - return week_weekday_to_date(week, weekday) - - -@register.simple_tag -def period_to_date(week: CalendarWeek, period) -> date: - return week_period_to_date(week, period) - - -@register.simple_tag -def period_to_time_start(date_ref: Union[CalendarWeek, int, date], period) -> date: - return period.get_datetime_start(date_ref) - - -@register.simple_tag -def period_to_time_end(date_ref: Union[CalendarWeek, int, date], period) -> date: - return period.get_datetime_end(date_ref) - - -@register.simple_tag -def today() -> date: - return date.today() - - -@register.simple_tag -def now_datetime() -> datetime: - return datetime.now() diff --git a/aleksis/apps/chronos/util/build.py b/aleksis/apps/chronos/util/build.py index dd24a0452b93b3e34785e71f5ccdd5b58b908baa..66168ce2496758b6525c98fb31afbd5c686a84b5 100644 --- a/aleksis/apps/chronos/util/build.py +++ b/aleksis/apps/chronos/util/build.py @@ -1,385 +1,7 @@ -from collections import OrderedDict from datetime import date, datetime, time -from typing import Union -from django.apps import apps - -from calendarweek import CalendarWeek - -from aleksis.apps.chronos.managers import TimetableType -from aleksis.apps.chronos.models import SupervisionEvent -from aleksis.core.models import Group, Person, Room - -LessonPeriod = apps.get_model("chronos", "LessonPeriod") -LessonEvent = apps.get_model("chronos", "LessonEvent") -TimePeriod = apps.get_model("chronos", "TimePeriod") -Break = apps.get_model("chronos", "Break") -Supervision = apps.get_model("chronos", "Supervision") -LessonSubstitution = apps.get_model("chronos", "LessonSubstitution") -SupervisionSubstitution = apps.get_model("chronos", "SupervisionSubstitution") -Event = apps.get_model("chronos", "Event") -Holiday = apps.get_model("chronos", "Holiday") -ExtraLesson = apps.get_model("chronos", "ExtraLesson") - - -def build_timetable( - type_: Union[TimetableType, str], - obj: Union[Group, Room, Person], - date_ref: Union[CalendarWeek, date], - with_holidays: bool = True, -): - needed_breaks = [] - - is_person = False - if type_ == "person": - is_person = True - type_ = obj.timetable_type - - is_week = False - if isinstance(date_ref, CalendarWeek): - is_week = True - - if type_ is None: - return None - - # Get matching holidays - if is_week: - holidays_per_weekday = Holiday.in_week(date_ref) if with_holidays else {} - else: - holiday = Holiday.on_day(date_ref) if with_holidays else None - - # Get matching lesson periods - lesson_periods = LessonPeriod.objects - lesson_periods = ( - lesson_periods.select_related(None) - .select_related("lesson", "lesson__subject", "period", "room") - .only( - "lesson", - "period", - "room", - "lesson__subject", - "period__weekday", - "period__period", - "lesson__subject__short_name", - "lesson__subject__name", - "lesson__subject__colour_fg", - "lesson__subject__colour_bg", - "room__short_name", - "room__name", - ) - ) - - if is_week: - lesson_periods = lesson_periods.in_week(date_ref) - else: - lesson_periods = lesson_periods.on_day(date_ref) - - if is_person: - lesson_periods = lesson_periods.filter_from_person(obj) - else: - lesson_periods = lesson_periods.filter_from_type(type_, obj, is_smart=with_holidays) - - # Sort lesson periods in a dict - lesson_periods_per_period = lesson_periods.group_by_periods(is_week=is_week) - - # Get events - extra_lessons = ExtraLesson.objects - if is_week: - extra_lessons = extra_lessons.filter(week=date_ref.week, year=date_ref.year) - else: - extra_lessons = extra_lessons.on_day(date_ref) - if is_person: - extra_lessons = extra_lessons.filter_from_person(obj) - else: - extra_lessons = extra_lessons.filter_from_type(type_, obj) - - extra_lessons = extra_lessons.only( - "week", - "year", - "period", - "subject", - "room", - "comment", - "period__weekday", - "period__period", - "subject__short_name", - "subject__name", - "subject__colour_fg", - "subject__colour_bg", - "room__short_name", - "room__name", - ) - - # Sort lesson periods in a dict - extra_lessons_per_period = extra_lessons.group_by_periods(is_week=is_week) - - # Get events - events = Event.objects - events = events.in_week(date_ref) if is_week else events.on_day(date_ref) - - events = events.only( - "id", - "title", - "date_start", - "date_end", - "period_from", - "period_to", - "period_from__weekday", - "period_from__period", - "period_to__weekday", - "period_to__period", - ) - - if is_person: - events_to_display = events.filter_from_person(obj) - else: - events_to_display = events.filter_from_type(type_, obj) - - # Sort events in a dict - events_per_period = {} - events_for_replacement_per_period = {} - for event in events: - if is_week and event.date_start < date_ref[TimePeriod.weekday_min]: - # If start date not in current week, set weekday and period to min - weekday_from = TimePeriod.weekday_min - period_from_first_weekday = TimePeriod.period_min - else: - weekday_from = event.date_start.weekday() - period_from_first_weekday = event.period_from.period - - if is_week and event.date_end > date_ref[TimePeriod.weekday_max]: - # If end date not in current week, set weekday and period to max - weekday_to = TimePeriod.weekday_max - period_to_last_weekday = TimePeriod.period_max - else: - weekday_to = event.date_end.weekday() - period_to_last_weekday = event.period_to.period - - for weekday in range(weekday_from, weekday_to + 1): - if not is_week and weekday != date_ref.weekday(): - # If daily timetable for person, skip other weekdays - continue - - # If start day, use start period else use min period - period_from = ( - period_from_first_weekday if weekday == weekday_from else TimePeriod.period_min - ) - - # If end day, use end period else use max period - period_to = period_to_last_weekday if weekday == weekday_to else TimePeriod.periox_max - - for period in range(period_from, period_to + 1): - # The following events are possibly replacing some lesson periods - if period not in events_for_replacement_per_period: - events_for_replacement_per_period[period] = {} if is_week else [] - - if is_week and weekday not in events_for_replacement_per_period[period]: - events_for_replacement_per_period[period][weekday] = [] - - if not is_week: - events_for_replacement_per_period[period].append(event) - else: - events_for_replacement_per_period[period][weekday].append(event) - - # and the following will be displayed in the timetable - if event in events_to_display: - if period not in events_per_period: - events_per_period[period] = {} if is_week else [] - - if is_week and weekday not in events_per_period[period]: - events_per_period[period][weekday] = [] - - if not is_week: - events_per_period[period].append(event) - else: - events_per_period[period][weekday].append(event) - - if type_ == TimetableType.TEACHER: - # Get matching supervisions - week = CalendarWeek.from_date(date_ref) if not is_week else date_ref - supervisions = ( - Supervision.objects.in_week(week) - .all() - .annotate_week(week) - .filter_by_teacher(obj) - .only( - "area", - "break_item", - "teacher", - "area", - "area__short_name", - "area__name", - "area__colour_fg", - "area__colour_bg", - "break_item__short_name", - "break_item__name", - "break_item__after_period__period", - "break_item__after_period__weekday", - "break_item__before_period__period", - "break_item__before_period__weekday", - "teacher__short_name", - "teacher__first_name", - "teacher__last_name", - ) - ) - - if not is_week: - supervisions = supervisions.filter_by_weekday(date_ref.weekday()) - - supervisions_per_period_after = {} - for supervision in supervisions: - weekday = supervision.break_item.weekday - period_after_break = supervision.break_item.before_period_number - - if period_after_break not in needed_breaks: - needed_breaks.append(period_after_break) - - if is_week and period_after_break not in supervisions_per_period_after: - supervisions_per_period_after[period_after_break] = {} - - if not is_week: - supervisions_per_period_after[period_after_break] = supervision - else: - supervisions_per_period_after[period_after_break][weekday] = supervision - - # Get ordered breaks - breaks = OrderedDict(sorted(Break.get_breaks_dict().items())) - - rows = [] - for period, break_ in breaks.items(): # period is period after break - # Break - if type_ == TimetableType.TEACHER and period in needed_breaks: - row = { - "type": "break", - "after_period": break_.after_period_number, - "before_period": break_.before_period_number, - "time_start": break_.time_start, - "time_end": break_.time_end, - } - - if is_week: - cols = [] - - for weekday in range(TimePeriod.weekday_min, TimePeriod.weekday_max + 1): - col = None - if ( - period in supervisions_per_period_after - and weekday not in holidays_per_weekday - ) and weekday in supervisions_per_period_after[period]: - col = supervisions_per_period_after[period][weekday] - cols.append(col) - - row["cols"] = cols - else: - col = None - if period in supervisions_per_period_after and not holiday: - col = supervisions_per_period_after[period] - row["col"] = col - rows.append(row) - - # Period - if period <= TimePeriod.period_max: - row = { - "type": "period", - "period": period, - "time_start": break_.before_period.time_start, - "time_end": break_.before_period.time_end, - } - - if is_week: - cols = [] - for weekday in range(TimePeriod.weekday_min, TimePeriod.weekday_max + 1): - # Skip this period if there are holidays - if weekday in holidays_per_weekday: - cols.append([]) - continue - - col = [] - - events_for_this_period = ( - events_per_period[period].get(weekday, []) - if period in events_per_period - else [] - ) - events_for_replacement_for_this_period = ( - events_for_replacement_per_period[period].get(weekday, []) - if period in events_for_replacement_per_period - else [] - ) - lesson_periods_for_this_period = ( - lesson_periods_per_period[period].get(weekday, []) - if period in lesson_periods_per_period - else [] - ) - - # Add lesson periods - if lesson_periods_for_this_period: - if events_for_replacement_for_this_period: - # If there is a event in this period, - # we have to check whether the actual lesson is taking place. - - for lesson_period in lesson_periods_for_this_period: - replaced_by_event = lesson_period.is_replaced_by_event( - events_for_replacement_for_this_period, - [obj] if type_ == TimetableType.GROUP else None, - ) - lesson_period.replaced_by_event = replaced_by_event - if not replaced_by_event or ( - replaced_by_event and type_ != TimetableType.GROUP - ): - col.append(lesson_period) - - else: - col += lesson_periods_for_this_period - - # Add extra lessons - if period in extra_lessons_per_period: - col += extra_lessons_per_period[period].get(weekday, []) - - # Add events - col += events_for_this_period - - cols.append(col) - - row["cols"] = cols - else: - col = [] - - # Skip this period if there are holidays - if holiday: - continue - - events_for_this_period = events_per_period.get(period, []) - events_for_replacement_for_this_period = events_for_replacement_per_period.get( - period, [] - ) - lesson_periods_for_this_period = lesson_periods_per_period.get(period, []) - - # Add lesson periods - if lesson_periods_for_this_period: - if events_for_replacement_for_this_period: - # If there is a event in this period, - # we have to check whether the actual lesson is taking place. - - lesson_periods_to_keep = [] - for lesson_period in lesson_periods_for_this_period: - if not lesson_period.is_replaced_by_event( - events_for_replacement_for_this_period - ): - lesson_periods_to_keep.append(lesson_period) - col += lesson_periods_to_keep - else: - col += lesson_periods_for_this_period - - # Add events and extra lessons - col += extra_lessons_per_period.get(period, []) - col += events_for_this_period - - row["col"] = col - - rows.append(row) - - return rows +from aleksis.apps.chronos.models import LessonEvent, SupervisionEvent +from aleksis.core.models import Group, Person def build_substitutions_list(wanted_day: date) -> tuple[list[dict], set[Person], set[Group]]: @@ -433,23 +55,3 @@ def build_substitutions_list(wanted_day: date) -> tuple[list[dict], set[Person], rows.sort(key=lambda row: row["sort_a"] + row["sort_b"]) return rows, affected_teachers, affected_groups - - -def build_weekdays( - base: list[tuple[int, str]], wanted_week: CalendarWeek, with_holidays: bool = True -) -> list[dict]: - if with_holidays: - holidays_per_weekday = Holiday.in_week(wanted_week) - - weekdays = [] - for key, name in base[TimePeriod.weekday_min : TimePeriod.weekday_max + 1]: - weekday = { - "key": key, - "name": name, - "date": wanted_week[key], - } - if with_holidays: - weekday["holiday"] = holidays_per_weekday[key] if key in holidays_per_weekday else None - weekdays.append(weekday) - - return weekdays diff --git a/aleksis/apps/chronos/util/chronos_helpers.py b/aleksis/apps/chronos/util/chronos_helpers.py index 8e70ceae4b383dfcd72eb3aad80afd12d2f08175..1dc23b62eabcf3f499ab6b5d03d4030b223dc7a8 100644 --- a/aleksis/apps/chronos/util/chronos_helpers.py +++ b/aleksis/apps/chronos/util/chronos_helpers.py @@ -2,8 +2,6 @@ from datetime import date, datetime, timedelta from typing import TYPE_CHECKING, Optional from django.db.models import Count, Q -from django.http import HttpRequest, HttpResponseNotFound -from django.shortcuts import get_object_or_404 from guardian.core import ObjectPermissionChecker @@ -11,13 +9,6 @@ from aleksis.core.models import Announcement, Group, Person, Room from aleksis.core.util.core_helpers import get_site_preferences from aleksis.core.util.predicates import check_global_permission -from ..managers import TimetableType -from ..models import ( - LessonPeriod, - LessonSubstitution, - Supervision, - SupervisionSubstitution, -) from .build import build_substitutions_list if TYPE_CHECKING: @@ -26,42 +17,6 @@ if TYPE_CHECKING: User = get_user_model() # noqa -def get_el_by_pk( - request: HttpRequest, - type_: str, - pk: int, - prefetch: bool = False, - *args, - **kwargs, -): - if type_ == TimetableType.GROUP.value: - return get_object_or_404( - Group.objects.prefetch_related("owners", "parent_groups") if prefetch else Group, - pk=pk, - ) - elif type_ == TimetableType.TEACHER.value: - return get_object_or_404(Person, pk=pk) - elif type_ == TimetableType.ROOM.value: - return get_object_or_404(Room, pk=pk) - else: - return HttpResponseNotFound() - - -def get_substitution_by_id(request: HttpRequest, id_: int, week: int): - lesson_period = get_object_or_404(LessonPeriod, pk=id_) - wanted_week = lesson_period.lesson.get_calendar_week(week) - - return LessonSubstitution.objects.filter( - week=wanted_week.week, year=wanted_week.year, lesson_period=lesson_period - ).first() - - -def get_supervision_substitution_by_id(request: HttpRequest, id_: int, date: datetime.date): - supervision = get_object_or_404(Supervision, pk=id_) - - return SupervisionSubstitution.objects.filter(date=date, supervision=supervision).first() - - def get_teachers(user: "User"): """Get the teachers whose timetables are allowed to be seen by current user.""" checker = ObjectPermissionChecker(user) diff --git a/aleksis/apps/chronos/util/date.py b/aleksis/apps/chronos/util/date.py deleted file mode 100644 index 9a2eb10592509b12f4e915a5e115b96d95ce939a..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/util/date.py +++ /dev/null @@ -1,39 +0,0 @@ -from datetime import date - -from django.utils import timezone - -from calendarweek import CalendarWeek - - -def week_weekday_from_date(when: date) -> tuple[CalendarWeek, int]: - """Return a tuple of week and weekday from a given date.""" - return (CalendarWeek.from_date(when), when.weekday()) - - -def week_weekday_to_date(week: CalendarWeek, weekday: int) -> date: - """Return a date object for one day in a calendar week.""" - return week[weekday] - - -def week_period_to_date(week: CalendarWeek, period) -> date: - """Return the date of a lesson period in a given week.""" - return period.get_date(week) - - -def get_weeks_for_year(year: int) -> list[CalendarWeek]: - """Generate all weeks for one year.""" - weeks = [] - - # Go for all weeks in year and create week list - current_week = CalendarWeek(year=year, week=1) - - while current_week.year == year: - weeks.append(current_week) - current_week += 1 - - return weeks - - -def get_current_year() -> int: - """Get current year.""" - return timezone.now().year diff --git a/aleksis/apps/chronos/util/format.py b/aleksis/apps/chronos/util/format.py deleted file mode 100644 index 0dd640f3c6e892741eb81d8de61421681160c9c9..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/util/format.py +++ /dev/null @@ -1,17 +0,0 @@ -from datetime import date -from typing import TYPE_CHECKING - -from django.utils.formats import date_format - -if TYPE_CHECKING: - from ..models import TimePeriod - - -def format_m2m(f, attr: str = "short_name") -> str: - """Join a attribute of all elements of a ManyToManyField.""" - return ", ".join([getattr(x, attr) for x in f.all()]) - - -def format_date_period(day: date, period: "TimePeriod") -> str: - """Format date and time period.""" - return f"{date_format(day)}, {period.period}." diff --git a/aleksis/apps/chronos/util/js.py b/aleksis/apps/chronos/util/js.py deleted file mode 100644 index 96612411ac9d4c25772c258f688706ab583b22cb..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/util/js.py +++ /dev/null @@ -1,7 +0,0 @@ -from datetime import date, datetime, time - - -def date_unix(value: date) -> int: - """Convert a date object to an UNIX timestamp.""" - value = datetime.combine(value, time(hour=0, minute=0)) - return int(value.timestamp()) * 1000 diff --git a/aleksis/apps/chronos/util/notifications.py b/aleksis/apps/chronos/util/notifications.py deleted file mode 100644 index 847dc599ab47699ec4867d22822f81c8f1ec00ff..0000000000000000000000000000000000000000 --- a/aleksis/apps/chronos/util/notifications.py +++ /dev/null @@ -1,210 +0,0 @@ -from datetime import datetime, timedelta -from typing import Union -from urllib.parse import urljoin - -from django.conf import settings -from django.urls import reverse -from django.utils import timezone -from django.utils.formats import date_format -from django.utils.translation import gettext_lazy as _ -from django.utils.translation import ngettext - -import zoneinfo - -from aleksis.core.models import Notification, Person -from aleksis.core.util.core_helpers import get_site_preferences - -from ..models import Event, ExtraLesson, LessonSubstitution, SupervisionSubstitution - - -def send_notifications_for_object( - instance: Union[ExtraLesson, LessonSubstitution, Event, SupervisionSubstitution], -): - """Send notifications for a change object.""" - recipients = [] - if isinstance(instance, LessonSubstitution): - recipients += instance.lesson_period.lesson.teachers.all() - recipients += instance.teachers.all() - recipients += Person.objects.filter( - member_of__in=instance.lesson_period.lesson.groups.all() - ) - elif isinstance(instance, (Event, ExtraLesson)): - recipients += instance.teachers.all() - recipients += Person.objects.filter(member_of__in=instance.groups.all()) - elif isinstance(instance, SupervisionSubstitution): - recipients.append(instance.teacher) - recipients.append(instance.supervision.teacher) - - description = "" - if isinstance(instance, LessonSubstitution): - # Date, lesson, subject - subject = instance.lesson_period.lesson.subject - day = instance.date - period = instance.lesson_period.period - - if instance.cancelled: - description += ( - _( - "The {subject} lesson in the {period}. period on {day} has been cancelled." - ).format(subject=subject.name, period=period.period, day=date_format(day)) - + " " - ) - else: - description += ( - _( - "The {subject} lesson in the {period}. period " - "on {day} has some current changes." - ).format(subject=subject.name, period=period.period, day=date_format(day)) - + " " - ) - - if instance.teachers.all(): - description += ( - ngettext( - "The teacher {old} is substituted by {new}.", - "The teachers {old} are substituted by {new}.", - instance.teachers.count(), - ).format( - old=instance.lesson_period.lesson.teacher_names, - new=instance.teacher_names, - ) - + " " - ) - - if instance.subject: - description += ( - _("The subject is changed to {subject}.").format(subject=instance.subject.name) - + " " - ) - - if instance.room: - description += ( - _("The lesson is moved from {old} to {new}.").format( - old=instance.lesson_period.room.name, - new=instance.room.name, - ) - + " " - ) - - if instance.comment: - description += ( - _("There is an additional comment: {comment}.").format(comment=instance.comment) - + " " - ) - - elif isinstance(instance, Event): - if instance.date_start != instance.date_end: - description += ( - _( - "There is an event that starts on {date_start}, {period_from}. period " - "and ends on {date_end}, {period_to}. period:" - ).format( - date_start=date_format(instance.date_start), - date_end=date_format(instance.date_end), - period_from=instance.period_from.period, - period_to=instance.period_to.period, - ) - + "\n" - ) - else: - description += ( - _( - "There is an event on {date} from the " - "{period_from}. period to the {period_to}. period:" - ).format( - date=date_format(instance.date_start), - period_from=instance.period_from.period, - period_to=instance.period_to.period, - ) - + "\n" - ) - - if instance.groups.all(): - description += _("Groups: {groups}").format(groups=instance.group_names) + "\n" - if instance.teachers.all(): - description += _("Teachers: {teachers}").format(teachers=instance.teacher_names) + "\n" - if instance.rooms.all(): - description += ( - _("Rooms: {rooms}").format( - rooms=", ".join([room.name for room in instance.rooms.all()]) - ) - + "\n" - ) - elif isinstance(instance, ExtraLesson): - description += ( - _("There is an extra lesson on {date} in the {period}. period:").format( - date=date_format(instance.date), - period=instance.period.period, - ) - + "\n" - ) - - if instance.groups.all(): - description += _("Groups: {groups}").format(groups=instance.group_names) + "\n" - if instance.room: - description += _("Subject: {subject}").format(subject=instance.subject.name) + "\n" - if instance.teachers.all(): - description += _("Teachers: {teachers}").format(teachers=instance.teacher_names) + "\n" - if instance.room: - description += _("Room: {room}").format(room=instance.room.name) + "\n" - if instance.comment: - description += _("Comment: {comment}.").format(comment=instance.comment) + "\n" - elif isinstance(instance, SupervisionSubstitution): - description += _( - "The supervision of {old} on {date} between the {period_from}. period " - "and the {period_to}. period in the area {area} is substituted by {new}." - ).format( - old=instance.supervision.teacher.full_name, - date=date_format(instance.date), - period_from=instance.supervision.break_item.after_period_number, - period_to=instance.supervision.break_item.before_period_number, - area=instance.supervision.area.name, - new=instance.teacher.full_name, - ) - - day = instance.date if hasattr(instance, "date") else instance.date_start - - url = urljoin( - settings.BASE_URL, - reverse( - "my_timetable_by_date", - args=[day.year, day.month, day.day], - ), - ) - - dt_start, dt_end = instance.time_range - dt_start = dt_start.replace(tzinfo=zoneinfo.ZoneInfo(settings.TIME_ZONE)) - dt_end = dt_end.replace(tzinfo=zoneinfo.ZoneInfo(settings.TIME_ZONE)) - - send_time = get_site_preferences()["chronos__time_for_sending_notifications"] - number_of_days = get_site_preferences()["chronos__days_in_advance_notifications"] - - start_range = timezone.now().replace(hour=send_time.hour, minute=send_time.minute) - if timezone.now().time() > send_time: - start_range = start_range - timedelta(days=1) - end_range = start_range + timedelta(days=number_of_days) - - if dt_start < start_range and dt_end < end_range: - # Skip this, because the change is in the past - return - - if dt_start <= end_range and dt_end >= start_range: - # Send immediately - send_at = timezone.now() - else: - # Schedule for later - send_at = datetime.combine( - dt_start.date() - timedelta(days=number_of_days), send_time - ).replace(tzinfo=zoneinfo.ZoneInfo(settings.TIME_ZONE)) - - for recipient in recipients: - if recipient.preferences["chronos__send_notifications"]: - n = Notification( - recipient=recipient, - sender=_("Timetable"), - title=_("There are current changes to your timetable."), - description=description, - link=url, - send_at=send_at, - ) - n.save()