Skip to content
Snippets Groups Projects
managers.py 16.7 KiB
Newer Older
Tom Teichler's avatar
Tom Teichler committed
from datetime import date, datetime, timedelta
Tom Teichler's avatar
Tom Teichler committed
from typing import Optional, Union
Jonathan Weth's avatar
Jonathan Weth committed

Hangzhi Yu's avatar
Hangzhi Yu committed
from django.contrib.sites.managers import CurrentSiteManager as _CurrentSiteManager
Jonathan Weth's avatar
Jonathan Weth committed
from django.db import models
Tom Teichler's avatar
Tom Teichler committed
from django.db.models import Count, F, Q
Jonathan Weth's avatar
Jonathan Weth committed

Tom Teichler's avatar
Tom Teichler committed
from calendarweek import CalendarWeek

from aleksis.apps.chronos.util.date import week_weekday_from_date
from aleksis.core.models import Group, Person
Jonathan Weth's avatar
Jonathan Weth committed
from aleksis.core.util.core_helpers import get_site_preferences

Hangzhi Yu's avatar
Hangzhi Yu committed
class CurrentSiteManager(_CurrentSiteManager):
    use_in_migrations = False

class TimetableType(Enum):
    """Enum for different types of timetables."""

    GROUP = "group"
    TEACHER = "teacher"
    ROOM = "room"

    @classmethod
    def from_string(cls, s: Optional[str]):
        return cls.__members__.get(s.upper())

class LessonPeriodManager(CurrentSiteManager):
Tom Teichler's avatar
Tom Teichler committed
    """Manager adding specific methods to lesson periods."""
Tom Teichler's avatar
Tom Teichler committed
        """Ensure all related lesson data is loaded as well."""
        return (
            super()
            .get_queryset()
            .select_related("lesson", "lesson__subject", "period", "room")
            .prefetch_related("lesson__groups", "lesson__teachers", "substitutions")
        )


class LessonSubstitutionManager(CurrentSiteManager):
Tom Teichler's avatar
Tom Teichler committed
    """Manager adding specific methods to lesson substitutions."""
Tom Teichler's avatar
Tom Teichler committed
        """Ensure all related lesson data is loaded as well."""
        return (
            super()
            .get_queryset()
            .select_related(
                "lesson_period",
                "lesson_period__lesson",
                "subject",
                "lesson_period__period",
                "room",
            )
            .prefetch_related("lesson_period__lesson__groups", "teachers")
        )


Nik | Klampfradler's avatar
Nik | Klampfradler committed
class WeekQuerySetMixin:
    def annotate_week(self, week: Union[CalendarWeek, int]):
Tom Teichler's avatar
Tom Teichler committed
        """Annotate all lessons in the QuerySet with the number of the provided calendar week."""
        if isinstance(week, int):
            week = CalendarWeek(week=week)
        return self.annotate(
            _week=models.Value(week.week, models.IntegerField()),
Jonathan Weth's avatar
Jonathan Weth committed
            _year=models.Value(week.year, models.IntegerField()),
Jonathan Weth's avatar
Jonathan Weth committed
class GroupByPeriodsMixin:
    def group_by_periods(self, is_person: 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 is_person else {}

            if not is_person and weekday not in per_period[period]:
                per_period[period][weekday] = []

            if is_person:
                per_period[period].append(obj)
            else:
                per_period[period][weekday].append(obj)

        return per_period


Nik | Klampfradler's avatar
Nik | Klampfradler committed
class LessonDataQuerySet(models.QuerySet, WeekQuerySetMixin):
Tom Teichler's avatar
Tom Teichler committed
    """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):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all lessons within a date range."""
        return self.filter(
            **{
                self._period_path + "lesson__date_start__lte": start,
                self._period_path + "lesson__date_end__gte": end,
            }
        )

    def in_week(self, wanted_week: CalendarWeek):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all lessons within a calendar week."""
        return self.within_dates(
Jonathan Weth's avatar
Jonathan Weth committed
            wanted_week[0]
            + timedelta(days=1) * (F(self._period_path + "period__weekday") - 1),
            wanted_week[0]
            + timedelta(days=1) * (F(self._period_path + "period__weekday") - 1),
        ).annotate_week(wanted_week)

    def on_day(self, day: date):
Tom Teichler's avatar
Tom Teichler committed
        """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):
Tom Teichler's avatar
Tom Teichler committed
        """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__date_start__lte": now.date(),
                self._period_path + "lesson__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]):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all lessons a participant (student) attends."""
        return self.filter(
            Q(**{self._period_path + "lesson__groups__members": person})
Jonathan Weth's avatar
Jonathan Weth committed
            | Q(
                **{self._period_path + "lesson__groups__parent_groups__members": person}
            )
        )

    def filter_group(self, group: Union[Group, int]):
Tom Teichler's avatar
Tom Teichler committed
        """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_teacher(self, teacher: Union[Person, int]):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all lessons given by a certain teacher."""
        qs1 = self.filter(**{self._period_path + "lesson__teachers": teacher})
Tom Teichler's avatar
Tom Teichler committed
        qs2 = self.filter(
Jonathan Weth's avatar
Jonathan Weth committed
            **{
                self._subst_path + "teachers": teacher,
                self._subst_path + "week": F("_week"),
            }
Tom Teichler's avatar
Tom Teichler committed
        )
Jonathan Weth's avatar
Jonathan Weth committed
    def filter_room(self, room: Union["Room", int]):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all lessons taking part in a certain room."""
        qs1 = self.filter(**{self._period_path + "room": room})
Tom Teichler's avatar
Tom Teichler committed
        qs2 = self.filter(
            **{self._subst_path + "room": room, self._subst_path + "week": F("_week"),}
        )
Jonathan Weth's avatar
Jonathan Weth committed
    def filter_from_type(
        self, type_: TimetableType, pk: int
    ) -> Optional[models.QuerySet]:
        """Filter lesson data for a group, teacher or room by provided type."""
        if type_ == TimetableType.GROUP:
            return self.filter_group(pk)
        elif type_ == TimetableType.TEACHER:
            return self.filter_teacher(pk)
        elif type_ == TimetableType.ROOM:
            return self.filter_room(pk)
        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(lesson__groups__members=person)

        else:
            # If no student or teacher
            return None

Tom Teichler's avatar
Tom Teichler committed
    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

Jonathan Weth's avatar
Jonathan Weth committed
    def next_lesson(
        self, reference: "LessonPeriod", offset: Optional[int] = 1
    ) -> "LessonPeriod":
Tom Teichler's avatar
Tom Teichler committed
        """Get another lesson in an ordered set of lessons.
Nik | Klampfradler's avatar
Nik | Klampfradler committed

        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.
        """
        index = list(self.values_list("id", flat=True)).index(reference.id)

        next_index = index + offset
        if next_index > self.count() - 1:
            next_index %= self.count()
            week = reference._week + 1
        elif next_index < 0:
            next_index = self.count() + next_index
            week = reference._week - 1
Nik | Klampfradler's avatar
Nik | Klampfradler committed
        else:
            week = reference._week

        return self.annotate_week(week).all()[next_index]


Jonathan Weth's avatar
Jonathan Weth committed
class LessonPeriodQuerySet(LessonDataQuerySet, GroupByPeriodsMixin):
    """QuerySet with custom query methods for lesson periods."""

Nik | Klampfradler's avatar
Nik | Klampfradler committed
    _period_path = ""
    _subst_path = "substitutions__"


class LessonSubstitutionQuerySet(LessonDataQuerySet):
    """QuerySet with custom query methods for substitutions."""

    _period_path = "lesson_period__"
    _subst_path = ""

    def affected_lessons(self):
Tom Teichler's avatar
Tom Teichler committed
        """Return all lessons which are affected by selected substitutions."""
Tom Teichler's avatar
Tom Teichler committed
        from .models import Lesson  # noaq

        return Lesson.objects.filter(lesson_periods__substitutions__in=self)

    def affected_teachers(self):
Tom Teichler's avatar
Tom Teichler committed
        """Get affected teachers.

        Return all teachers which are affected by
        selected substitutions (as substituted or substituting).
        """
        return Person.objects.filter(
Jonathan Weth's avatar
Jonathan Weth committed
            Q(lessons_as_teacher__in=self.affected_lessons())
            | Q(lesson_substitutions__in=self)
        ).annotate(lessons_count=Count("lessons_as_teacher"))

    def affected_groups(self):
Tom Teichler's avatar
Tom Teichler committed
        """Return all groups which are affected by selected substitutions."""
        return Group.objects.filter(lessons__in=self.affected_lessons()).annotate(
            lessons_count=Count("lessons")
        )


class DateRangeQuerySet(models.QuerySet):
    """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):
Tom Teichler's avatar
Tom Teichler committed
        """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):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all events within a calendar week."""
        return self.within_dates(wanted_week[0], wanted_week[6])

    def on_day(self, day: date):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all events on a certain day."""
        return self.within_dates(day, day)

    def at_time(self, when: Optional[datetime] = None):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for the events taking place at a certain point in time."""
        now = when or datetime.now()

        return self.on_day(now.date()).filter(
Tom Teichler's avatar
Tom Teichler committed
            period_from__time_start__lte=now.time(), period_to__time_end__gte=now.time()
        )


class AbsenceQuerySet(DateRangeQuerySet):
    """QuerySet with custom query methods for absences."""

    def absent_teachers(self):
Jonathan Weth's avatar
Jonathan Weth committed
        return Person.objects.filter(absences__in=self).annotate(
            absences_count=Count("absences")
        )
Jonathan Weth's avatar
Jonathan Weth committed
        return Group.objects.filter(absences__in=self).annotate(
            absences_count=Count("absences")
        )
Jonathan Weth's avatar
Jonathan Weth committed
        return Person.objects.filter(absences__in=self).annotate(
            absences_count=Count("absences")
        )


class HolidayQuerySet(DateRangeQuerySet):
    """QuerySet with custom query methods for holidays."""
Tom Teichler's avatar
Tom Teichler committed

Nik | Klampfradler's avatar
Nik | Klampfradler committed
class SupervisionQuerySet(models.QuerySet, WeekQuerySetMixin):
    """QuerySet with custom query methods for supervisions."""

    def filter_by_weekday(self, weekday: int):
        """Filter supervisions by weekday."""
        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]):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all supervisions given by a certain teacher."""
        if self.count() > 0:
            if hasattr(self[0], "_week"):
                week = CalendarWeek(week=self[0]._week)
            else:
                week = CalendarWeek.current_week()

            dates = [week[w] for w in range(0, 7)]

Tom Teichler's avatar
Tom Teichler committed
            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.
Tom Teichler's avatar
Tom Teichler committed
    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]):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all objects a participant (student) attends."""
Jonathan Weth's avatar
Jonathan Weth committed
        return self.filter(Q(groups__members=person))

    def filter_group(self, group: Union[Group, int]):
Tom Teichler's avatar
Tom Teichler committed
        """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_teacher(self, teacher: Union[Person, int]):
Tom Teichler's avatar
Tom Teichler committed
        """Filter for all lessons given by a certain teacher."""
        return self.filter(teachers=teacher)

    def filter_room(self, room: Union["Room", int]):
Tom Teichler's avatar
Tom Teichler committed
        """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)

Jonathan Weth's avatar
Jonathan Weth committed
    def filter_from_type(
        self, type_: TimetableType, pk: int
    ) -> Optional[models.QuerySet]:
        """Filter data for a group, teacher or room by provided type."""
        if type_ == TimetableType.GROUP:
            return self.filter_group(pk)
        elif type_ == TimetableType.TEACHER:
            return self.filter_teacher(pk)
        elif type_ == TimetableType.ROOM:
            return self.filter_room(pk)
        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(DateRangeQuerySet, TimetableQuerySet):
    """QuerySet with custom query methods for events."""

    def annotate_day(self, day: date):
Tom Teichler's avatar
Tom Teichler committed
        """Annotate all events in the QuerySet with the provided date."""
        return self.annotate(_date=models.Value(day, models.DateField()))


Jonathan Weth's avatar
Jonathan Weth committed
class ExtraLessonQuerySet(TimetableQuerySet, 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."""
        week_start = CalendarWeek.from_date(start)
        week_end = CalendarWeek.from_date(end)

        return self.filter(
            week__gte=week_start.week,
            week__lte=week_end.week,
            period__weekday__gte=start.weekday(),
            period__weekday__lte=end.weekday(),
        )

Tom Teichler's avatar
Tom Teichler committed
    def on_day(self, day: date):
        """Filter all extra lessons on a day."""
        return self.within_dates(day, day)


class GroupPropertiesMixin:
    """Mixin for common group properties.

    Needed field: `groups`
    """

    @property
    def group_names(self, sep: Optional[str] = ", ") -> str:
        return sep.join([group.short_name for group in self.groups.all()])

    @property
    def groups_to_show(self) -> models.QuerySet:
        groups = self.groups.all()
Tom Teichler's avatar
Tom Teichler committed
        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])


class TeacherPropertiesMixin:
    """Mixin for common teacher properties.

    Needed field: `teacher`
    """

    @property
    def teacher_names(self, sep: Optional[str] = ", ") -> str:
        return sep.join([teacher.full_name for teacher in self.teachers.all()])