Skip to content
Snippets Groups Projects
Verified Commit f839aae1 authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Merge branch 'master' into 51-pdf-export-for-timetables

parents d518c48e 8edc56a5
No related branches found
No related tags found
1 merge request!77Resolve "PDF export for timetables"
Pipeline #6742 passed
......@@ -10,4 +10,10 @@ include:
- project: "AlekSIS/official/AlekSIS"
file: /ci/build/dist.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/deploy/pypi.yml
file: "/ci/deploy/trigger_dist.yml"
- project: "AlekSIS/official/AlekSIS"
file: "/ci/docker/image.yml"
- project: "AlekSIS/official/AlekSIS"
file: /ci/publish/pypi.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/deploy/review.yml
ARG APPS="AlekSIS-App-Chronos"
FROM registry.edugit.org/aleksis/official/aleksis-core:master
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Optional, Union
from typing import Dict, Iterable, List, Optional, Union
from django.contrib.sites.managers import CurrentSiteManager as _CurrentSiteManager
from django.db import models
from django.db.models import Count, F, Q, QuerySet
from django.db.models import Count, ExpressionWrapper, F, Func, Q, QuerySet, Value
from django.db.models.fields import DateField
from django.db.models.functions import Concat
from calendarweek import CalendarWeek
from aleksis.apps.chronos.util.date import week_weekday_from_date
from aleksis.apps.chronos.util.date import week_weekday_from_date, week_weekday_to_date
from aleksis.core.managers import DateRangeQuerySetMixin, SchoolTermRelatedQuerySet
from aleksis.core.models import Group, Person
from aleksis.core.util.core_helpers import get_site_preferences
......@@ -298,6 +300,13 @@ class LessonDataQuerySet(models.QuerySet, WeekQuerySetMixin):
| 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]):
"""Filter for all lessons given by a certain teacher."""
qs1 = self.filter(**{self._period_path + "lesson__teachers": teacher})
......@@ -366,28 +375,100 @@ class LessonDataQuerySet(models.QuerySet, WeekQuerySetMixin):
return lesson_periods
def next_lesson(self, reference: "LessonPeriod", offset: Optional[int] = 1) -> "LessonPeriod":
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.
"""
index = list(self.values_list("id", flat=True)).index(reference.id)
# 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 > self.count() - 1:
next_index %= self.count()
if next_index > len(pks) - 1:
next_index %= len(pks)
week = reference._week + 1
elif next_index < 0:
next_index = self.count() + next_index
next_index = len(pks) + next_index
week = reference._week - 1
else:
week = reference._week
week = CalendarWeek(week=week, year=reference.lesson.get_year(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
)
return self.annotate_week(week).all()[next_index]
# 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):
# 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):
......@@ -503,6 +584,13 @@ class DateRangeQuerySetMixin:
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."""
......@@ -532,7 +620,12 @@ class AbsenceQuerySet(DateRangeQuerySetMixin, SchoolTermRelatedQuerySet):
class HolidayQuerySet(QuerySet, DateRangeQuerySetMixin):
"""QuerySet with custom query methods for holidays."""
pass
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):
......@@ -589,6 +682,10 @@ class TimetableQuerySet(models.QuerySet):
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)
......@@ -647,22 +744,32 @@ class ExtraLessonQuerySet(TimetableQuerySet, SchoolTermRelatedQuerySet, GroupByP
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,
year__gte=week_start.year,
year__lte=week_end.year,
period__weekday__gte=start.weekday(),
period__weekday__lte=end.weekday(),
)
return self.annotate_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 annotate_day(self):
weekday_to_date = ExpressionWrapper(
Func(
Concat(F("year"), F("week")),
Value("IYYYIW"),
output_field=DateField(),
function="TO_DATE",
)
+ F("period__weekday"),
output_field=DateField(),
)
return self.annotate(day=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.annotate_day().exclude(q)
class GroupPropertiesMixin:
"""Mixin for common group properties.
......@@ -699,4 +806,8 @@ class TeacherPropertiesMixin:
@property
def teacher_names(self, sep: Optional[str] = ", ") -> str:
return sep.join([teacher.full_name for teacher in self.teachers.all()])
return sep.join([teacher.full_name for teacher in self.get_teachers().all()])
@property
def teacher_short_names(self, sep: str = ", ") -> str:
return sep.join([teacher.short_name for teacher in self.get_teachers().all()])
......@@ -41,6 +41,7 @@ class Migration(migrations.Migration):
),
],
options={
"default_permissions": (),
"permissions": (
("view_all_timetables", "Can view all timetables"),
("view_timetable_overview", "Can view timetable overview"),
......
......@@ -34,7 +34,8 @@ class ValidityRangeRelatedExtensibleModel(ExtensibleModel):
class WeekRelatedMixin:
@property
def date(self) -> date:
return week_weekday_to_date(self.calendar_week, self.lesson_period.period.weekday)
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:
......@@ -42,6 +43,11 @@ class WeekRelatedMixin:
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`.
......
......@@ -83,7 +83,7 @@ def lesson_periods_as_teacher(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."""
return LessonPeriod.objects.order_by("period__period").on_day(day).filter_from_person(self)
return LessonPeriod.objects.on_day(day).filter_from_person(self).order_by("period__period")
@Person.method
......@@ -97,6 +97,12 @@ def _adjacent_lesson(
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):
......
......@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from typing import Dict, List, Optional, Tuple, Union
from typing import Dict, Iterator, List, Optional, Tuple, Union
from django.core.exceptions import ValidationError
from django.db import models
......@@ -48,7 +48,11 @@ from aleksis.apps.chronos.mixins import (
from aleksis.apps.chronos.util.date import get_current_year
from aleksis.apps.chronos.util.format import format_m2m
from aleksis.core.managers import CurrentSiteManagerWithoutMigrations
from aleksis.core.mixins import ExtensibleModel, SchoolTermRelatedExtensibleModel
from aleksis.core.mixins import (
ExtensibleModel,
GlobalPermissionModel,
SchoolTermRelatedExtensibleModel,
)
from aleksis.core.models import DashboardWidget, SchoolTerm
from aleksis.core.util.core_helpers import has_person
......@@ -217,6 +221,14 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
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:
......@@ -344,6 +356,10 @@ class Lesson(ValidityRangeRelatedExtensibleModel, GroupPropertiesMixin, TeacherP
return CalendarWeek(year=year, week=week)
def get_teachers(self) -> models.query.QuerySet:
"""Get teachers relation."""
return self.teachers
def __str__(self):
return f"{format_m2m(self.groups)}, {self.subject.short_name}, {format_m2m(self.teachers)}"
......@@ -353,7 +369,7 @@ class Lesson(ValidityRangeRelatedExtensibleModel, GroupPropertiesMixin, TeacherP
verbose_name_plural = _("Lessons")
class LessonSubstitution(ExtensibleModel, WeekRelatedMixin):
class LessonSubstitution(ExtensibleModel, TeacherPropertiesMixin, WeekRelatedMixin):
objects = LessonSubstitutionManager.from_queryset(LessonSubstitutionQuerySet)()
week = models.IntegerField(verbose_name=_("Week"), default=CalendarWeek.current_week)
......@@ -413,7 +429,7 @@ class LessonSubstitution(ExtensibleModel, WeekRelatedMixin):
verbose_name_plural = _("Lesson substitutions")
class LessonPeriod(ExtensibleModel, WeekAnnotationMixin):
class LessonPeriod(WeekAnnotationMixin, TeacherPropertiesMixin, ExtensibleModel):
label_ = "lesson_period"
objects = LessonPeriodManager.from_queryset(LessonPeriodQuerySet)()
......@@ -441,14 +457,16 @@ class LessonPeriod(ExtensibleModel, WeekAnnotationMixin):
return None
def get_subject(self) -> Optional[Subject]:
if self.get_substitution() and self.get_substitution().subject:
return self.get_substitution().subject
sub = self.get_substitution()
if sub and sub.subject:
return sub.subject
else:
return self.lesson.subject
def get_teachers(self) -> models.query.QuerySet:
if self.get_substitution():
return self.get_substitution().teachers
sub = self.get_substitution()
if sub and sub.teachers.all():
return sub.teachers
else:
return self.lesson.teachers
......@@ -458,12 +476,19 @@ class LessonPeriod(ExtensibleModel, WeekAnnotationMixin):
else:
return self.room
def get_teacher_names(self, sep: Optional[str] = ", ") -> str:
return sep.join([teacher.full_name for teacher in self.get_teachers().all()])
def get_groups(self) -> models.query.QuerySet:
return self.lesson.groups
@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}"
......@@ -676,6 +701,11 @@ class Holiday(ExtensibleModel):
date_end = models.DateField(verbose_name=_("End date"), null=True)
comments = models.TextField(verbose_name=_("Comments"), blank=True, null=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) -> Optional["Holiday"]:
holidays = cls.objects.on_day(day)
......@@ -691,13 +721,13 @@ class Holiday(ExtensibleModel):
for weekday in range(TimePeriod.weekday_min, TimePeriod.weekday_max + 1):
holiday_date = week[weekday]
holidays = list(
filtered_holidays = list(
filter(
lambda h: holiday_date >= h.date_start and holiday_date <= h.date_end, holidays,
)
)
if holidays:
per_weekday[weekday] = holidays[0]
if filtered_holidays:
per_weekday[weekday] = filtered_holidays[0]
return per_weekday
......@@ -895,20 +925,70 @@ class Event(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPrope
return _(f"Event {self.pk}")
@property
def period_from_on_day(self) -> int:
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.period_min
return TimePeriod.from_period(TimePeriod.period_min, day)
else:
return self.period_from.period
return self.period_from
@property
def period_to_on_day(self) -> int:
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.period_max
return TimePeriod.from_period(TimePeriod.period_max, day)
else:
return self.period_to.period
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
class Meta:
ordering = ["date_start"]
......@@ -917,7 +997,9 @@ class Event(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPrope
verbose_name_plural = _("Events")
class ExtraLesson(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, WeekRelatedMixin):
class ExtraLesson(
GroupPropertiesMixin, TeacherPropertiesMixin, WeekRelatedMixin, SchoolTermRelatedExtensibleModel
):
label_ = "extra_lesson"
objects = ExtraLessonManager.from_queryset(ExtraLessonQuerySet)()
......@@ -949,12 +1031,24 @@ class ExtraLesson(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, WeekRe
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
class Meta:
verbose_name = _("Extra lesson")
verbose_name_plural = _("Extra lessons")
class ChronosGlobalPermissions(ExtensibleModel):
class ChronosGlobalPermissions(GlobalPermissionModel):
class Meta:
managed = False
permissions = (
......
......@@ -127,7 +127,7 @@ def build_timetable(
for period in range(period_from, period_to + 1):
if period not in events_per_period:
events_per_period[period] = [] if is_person else {}
events_per_period[period] = {} if is_week else []
if is_week and weekday not in events_per_period[period]:
events_per_period[period][weekday] = []
......
This diff is collapsed.
......@@ -31,7 +31,7 @@ secondary = true
[tool.poetry.dependencies]
python = "^3.7"
calendarweek = "^0.4.6"
calendarweek = "^0.5.0"
aleksis-core = "^2.0a3.dev0"
[tool.poetry.dev-dependencies]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment