diff --git a/aleksis/apps/lesrooster/apps.py b/aleksis/apps/lesrooster/apps.py index 17c326c24fbc1acc18f91da24f1bde8e3e51eb4d..b7fcfc2d3da34ded82ae3516ed552acbc694e72c 100644 --- a/aleksis/apps/lesrooster/apps.py +++ b/aleksis/apps/lesrooster/apps.py @@ -2,13 +2,7 @@ from django.db.models import signals from aleksis.core.util.apps import AppConfig -from .util.signal_handlers import ( - create_time_grid_for_new_validity_range, - m2m_changed_handler, - post_save_handler, - pre_delete_handler, - publish_validity_range, -) +from .util.signal_handlers import create_time_grid_for_new_validity_range class DefaultConfig(AppConfig): @@ -23,35 +17,6 @@ class DefaultConfig(AppConfig): copyright_info = (([2023], "Jonathan Weth", "dev@jonathanweth.de"),) def ready(self): - # Configure change tracking for models to sync changes with LessonEvent in Chronos - from .models import ( - Lesson, - Supervision, - ValidityRange, - ) - - models = [Lesson, Supervision] - - for model in models: - signals.post_save.connect( - post_save_handler, - sender=model, - ) - signals.m2m_changed.connect( - m2m_changed_handler, - sender=model.teachers.through, - ) - signals.pre_delete.connect(pre_delete_handler, sender=model) - - signals.m2m_changed.connect( - m2m_changed_handler, - sender=Lesson.rooms.through, - ) - signals.m2m_changed.connect( - m2m_changed_handler, - sender=Supervision.rooms.through, - ) + from .models import ValidityRange signals.post_save.connect(create_time_grid_for_new_validity_range, sender=ValidityRange) - - signals.post_save.connect(publish_validity_range, sender=ValidityRange) diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue new file mode 100644 index 0000000000000000000000000000000000000000..b7a4430cc5bdd753812b55288205ed13e1c81e67 --- /dev/null +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue @@ -0,0 +1,59 @@ +<template> + <ApolloMutation + v-if="item.status !== 'PUBLISHED'" + :mutation="publishValidityRange" + :variables="{ id: item.id }" + @done="onDone" + @error="handleMutationError" + tag="span" + > + <template #default="{ mutate, loading, error }"> + <confirm-dialog v-model="confirmDialog" @confirm="mutate()"> + <template #title> + {{ $t("lesrooster.validity_range.publish.confirm_title", item) }} + </template> + <template #text> + {{ + $t("lesrooster.validity_range.publish.confirm_explanation", item) + }} + </template> + <template #confirm> + {{ $t("lesrooster.validity_range.publish.confirm_button") }} + </template> + </confirm-dialog> + <secondary-action-button + icon-text="mdi-publish" + i18n-key="lesrooster.validity_range.publish.button" + @click="confirmDialog = true" + :loading="loading" + ></secondary-action-button> + </template> + </ApolloMutation> +</template> + +<script setup> +import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue"; +import ConfirmDialog from "aleksis.core/components/generic/dialogs/ConfirmDialog.vue"; +</script> +<script> +import { publishValidityRange } from "./validityRange.graphql"; +export default { + name: "PublishValidityRange", + data() { + return { + confirmDialog: false, + }; + }, + methods: { + onDone() { + this.$activateFrequentCeleryPolling(); + }, + }, + props: { + item: { + type: Object, + required: true, + }, + }, +}; +</script> diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue index 52a8156217b86c1f6bae0e71449681e4125c3693..3fe4e97ef80772e736ac1eb2359371e3fe524b24 100644 --- a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue @@ -1,5 +1,5 @@ <script setup> -import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue"; +import CRUDList from "aleksis.core/components/generic/CRUDList.vue"; import DateField from "aleksis.core/components/generic/forms/DateField.vue"; import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField.vue"; import TimeGridChip from "./TimeGridChip.vue"; @@ -9,11 +9,12 @@ import DialogObjectForm from "aleksis.core/components/generic/dialogs/DialogObje import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue"; import ValidityRangeStatusField from "./ValidityRangeStatusField.vue"; import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; +import PublishValidityRange from "./PublishValidityRange.vue"; </script> <template> <div> - <inline-c-r-u-d-list + <c-r-u-d-list :headers="headers" :i18n-key="i18nKey" create-item-i18n-key="lesrooster.validity_range.create_validity_range" @@ -24,24 +25,14 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; :default-item="defaultItem" :get-create-data="getCreateData" :get-patch-data="getPatchData" - filter + :enable-filter="true" show-expand + :enable-edit="true" ref="crudList" > <template #status="{ item }"> <validity-range-status-chip :value="item.status" /> </template> - <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #status.field="{ attrs, on }"> - <div aria-required="true"> - <validity-range-status-field - v-bind="attrs" - v-on="on" - required - :rules="required" - /> - </div> - </template> <template #schoolTerm="{ item }"> {{ item.schoolTerm.name }} @@ -92,6 +83,19 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; </template> <template #filters="{ attrs, on }"> + <validity-range-status-field + v-bind="attrs('status__iexact')" + v-on="on('status__iexact')" + :label="$t('lesrooster.validity_range.status_label')" + /> + + <school-term-field + v-bind="attrs('school_term')" + v-on="on('school_term')" + :label="$t('school_term.title')" + :enable-create="false" + /> + <date-field v-bind="attrs('date_end__gte')" v-on="on('date_end__gte')" @@ -105,6 +109,10 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; /> </template> + <template #actions="{ item }"> + <publish-validity-range :item="item" /> + </template> + <template #expanded-item="{ item }"> <v-sheet class="my-4"> <message-box type="error" v-if="item.timeGrids.length === 0"> @@ -155,7 +163,7 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue"; /> </v-sheet> </template> - </inline-c-r-u-d-list> + </c-r-u-d-list> <dialog-object-form is-create @@ -234,6 +242,7 @@ export default { { text: this.$t("lesrooster.validity_range.status_label"), value: "status", + disableEdit: true, }, { text: this.$t("school_term.title"), @@ -322,7 +331,6 @@ export default { dateStart: item.dateStart, dateEnd: item.dateEnd, schoolTerm: item.schoolTerm?.id, - status: item.status?.toLowerCase(), }; return Object.fromEntries( Object.entries(item).filter(([key, value]) => value !== undefined), diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql index b6ec43791ed725fa9ddfed3c564ac28c6a602e56..bd38117c01943ffff9bb385491545b9964d80391 100644 --- a/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql +++ b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql @@ -80,6 +80,32 @@ mutation updateValidityRanges($input: [BatchPatchValidityRangeInput]!) { } } +mutation publishValidityRange($id: ID!) { + publishValidityRange(id: $id) { + validityRange { + id + name + status + schoolTerm { + id + name + } + timeGrids { + id + group { + id + name + shortName + } + } + dateStart + dateEnd + canEdit + canDelete + } + } +} + query currentValidityRange { currentValidityRange { id diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json index 2a5da55f1a73095e9479c5609a6c4553a80ac5b7..9a73841fd9d09a333e582359d3f4f60dfb8b5b18 100644 --- a/aleksis/apps/lesrooster/frontend/messages/en.json +++ b/aleksis/apps/lesrooster/frontend/messages/en.json @@ -14,6 +14,12 @@ "draft": "Draft", "published": "Published" }, + "publish": { + "button": "Publish", + "confirm_title": "Are you sure that you want to publish the validity range \"{name}\"?", + "confirm_explanation": "Please be aware that this will publish the whole timetable and make it visible for everyone. Additionally, you won't be able to change the time grid, the course configs, and the timetable in this validity range after it's published.", + "confirm_button": "Publish" + }, "time_grid": { "generic": "Generic (catch-all)", "explanations": { diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py index 5bb91dde849294ac86e4347a9e11412667bde450..5bd43d2a64e971fd770e49daaae33675e259f46d 100644 --- a/aleksis/apps/lesrooster/models.py +++ b/aleksis/apps/lesrooster/models.py @@ -1,9 +1,11 @@ -from datetime import date, datetime +import logging +from datetime import date, datetime, timedelta from typing import Optional, Union from django.core.exceptions import ValidationError from django.db import models from django.db.models import F, Q, QuerySet +from django.http import HttpRequest from django.utils import timezone from django.utils.formats import date_format, time_format from django.utils.functional import classproperty @@ -12,13 +14,16 @@ from django.utils.translation import gettext_lazy as _ import recurrence from calendarweek import CalendarWeek from calendarweek.django import i18n_day_abbr_choices_lazy, i18n_day_name_choices_lazy +from model_utils import FieldTracker from recurrence.fields import RecurrenceField from aleksis.apps.chronos.managers import RoomPropertiesMixin, TeacherPropertiesMixin from aleksis.apps.chronos.models import LessonEvent, SupervisionEvent from aleksis.apps.cursus.models import Course, Subject +from aleksis.apps.lesrooster.tasks import sync_validity_range from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel, GlobalPermissionModel from aleksis.core.models import Group, Holiday, Person, Room, SchoolTerm +from aleksis.core.util.celery_progress import ProgressRecorder, render_progress_page from .managers import ValidityRangeManager, ValidityRangeQuerySet @@ -50,9 +55,11 @@ class ValidityRange(ExtensibleModel): verbose_name=_("Status"), max_length=255, choices=ValidityRangeStatus.choices, - default=ValidityRangeStatus.DRAFT, + default=ValidityRangeStatus.DRAFT.value, ) + status_tracker = FieldTracker(fields=["status", "date_start", "date_end", "school_term"]) + @property def published(self): return self.status == ValidityRangeStatus.PUBLISHED.value @@ -78,6 +85,10 @@ class ValidityRange(ExtensibleModel): def clean(self): """Ensure that there is only one validity range at each point of time.""" + + if self.status_tracker.changed().get("status", "") == ValidityRangeStatus.PUBLISHED.value: + raise ValidationError(_("You can't unpublish a validity range.")) + if self.date_end < self.date_start: raise ValidationError(_("The start date must be earlier than the end date.")) @@ -87,7 +98,36 @@ class ValidityRange(ExtensibleModel): ): raise ValidationError(_("The validity range must be within the school term.")) - if self.status == ValidityRangeStatus.PUBLISHED.value: + if self.published: + errors = {} + if "school_term" in self.status_tracker.changed(): + errors["school_term"] = _( + "The school term of a published validity range can't be changed." + ) + + if "date_start" in self.status_tracker.changed(): + if self.status_tracker.changed()["date_start"] < datetime.now().date(): + errors["date_start"] = _( + "You can't change the start date if the validity range is already active." + ) + elif self.date_start < datetime.now().date(): + errors["date_start"] = _("You can't set the start date to a date in the past.") + + if "date_end" in self.status_tracker.changed(): + if self.status_tracker.changed()["date_end"] < datetime.now().date(): + errors["date_end"] = _( + "You can't change the end date " + "if the validity range is already in the past." + ) + elif self.date_end < datetime.now().date(): + errors["date_end"] = _( + "To avoid data loss, the validity range can " + "be only shortened until the current day." + ) + + if errors: + raise ValidationError(errors) + qs = ValidityRange.objects.within_dates(self.date_start, self.date_end).filter( status=ValidityRangeStatus.PUBLISHED ) @@ -101,6 +141,49 @@ class ValidityRange(ExtensibleModel): ) ) + def publish(self, request: HttpRequest | None = None): + """Publish this validity range and sync all lessons/supervisions. + + :param request: Optional :class:`HttpRequest` to show progress of syncing in frontend + """ + self.status = ValidityRangeStatus.PUBLISHED.value + self.full_clean() + self.save() + self.sync(request=request) + + def sync(self, request: HttpRequest | None): + """Sync all lessons and supervisions of this validity range. + + :params request: Optional request to show progress of syncing in frontend + """ + if not self.published: + return + if not request: + self._sync() + else: + result = sync_validity_range.delay(self.pk) + return render_progress_page( + request, + task_result=result, + title=_("Publish validity range {}".format(self)), + progress_title=_( + "All lessons and supervisions in the validity range {} are being synced …" + ).format(self), + success_message=_("The validity range has been published successfully."), + error_message=_("There was a problem while publishing the validity range."), + ) + + def _sync(self, recorder: ProgressRecorder | None = None): + objs_to_update = list( + Lesson.objects.filter(slot_start__time_grid__validity_range=self) + ) + list(Supervision.objects.filter(break_slot__time_grid__validity_range=self)) + + iterate = recorder.iterate(objs_to_update) if recorder else objs_to_update + + for obj in iterate: + logging.info(f"Syncing object {obj} ({type(obj)}, {obj.pk})") + obj.sync() + def __str__(self) -> str: return self.name or f"{date_format(self.date_start)}–{date_format(self.date_end)}" @@ -203,7 +286,12 @@ class Slot(ExtensiblePolymorphicModel): return timezone.make_aware(datetime.combine(day, self.time_start)) def get_first_datetime(self) -> datetime: - return self.get_datetime_start(self.time_grid.validity_range.date_start) + start = self.get_datetime_start(self.time_grid.validity_range.date_start) + if start.date() < self.time_grid.validity_range.date_start: + start = self.get_datetime_start( + self.time_grid.validity_range.date_start + timedelta(days=7) + ) + return start def get_datetime_end(self, date_ref: Union[CalendarWeek, int, date]) -> datetime: """Get datetime of lesson end in a specific week or on a specific day.""" @@ -213,10 +301,17 @@ class Slot(ExtensiblePolymorphicModel): return timezone.make_aware(datetime.combine(day, self.time_end)) def get_last_datetime(self) -> datetime: - return self.get_datetime_end(self.time_grid.validity_range.date_end) - - def build_recurrence(self, *args, **kwargs) -> recurrence.Recurrence: + end = self.get_datetime_end(self.time_grid.validity_range.date_end) + if end.date() > self.time_grid.validity_range.date_end: + end = self.get_datetime_end(self.time_grid.validity_range.date_end - timedelta(days=7)) + return end + + def build_recurrence( + self, *args, slot_end: Optional["Slot"] = None, **kwargs + ) -> recurrence.Recurrence: """Build a recurrence for this slot respecting the validity range borders.""" + if not slot_end: + slot_end = self pattern = recurrence.Recurrence( dtstart=timezone.make_aware( datetime.combine(self.time_grid.validity_range.date_start, self.time_start) @@ -226,7 +321,7 @@ class Slot(ExtensiblePolymorphicModel): *args, **kwargs, until=timezone.make_aware( - datetime.combine(self.time_grid.validity_range.date_end, self.time_end) + datetime.combine(self.time_grid.validity_range.date_end, slot_end.time_end) ), ) ], @@ -329,7 +424,7 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): def build_recurrence(self, *args, **kwargs) -> "recurrence.Recurrence": """Build a recurrence for this lesson respecting the validity range borders.""" - return self.slot_start.build_recurrence(*args, **kwargs) + return self.slot_start.build_recurrence(*args, slot_end=self.slot_end, **kwargs) @property def real_recurrence(self) -> "recurrence.Recurrence": @@ -367,6 +462,8 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): if self.course: lesson_event.groups.set(self.course.groups.all()) + else: + lesson_event.groups.clear() lesson_event.teachers.set(self.teachers.all()) lesson_event.rooms.set(self.rooms.all()) diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py index b9a9221e699366aebfb380fd14600201275270a7..e8bd683f26defa9e0e20c491e0b6a7040decf7ad 100644 --- a/aleksis/apps/lesrooster/schema/__init__.py +++ b/aleksis/apps/lesrooster/schema/__init__.py @@ -62,6 +62,7 @@ from .timebound_course_config import ( TimeboundCourseConfigType, ) from .validity_range import ( + PublishValidityRangeMutation, ValidityRangeBatchCreateMutation, ValidityRangeBatchDeleteMutation, ValidityRangeBatchPatchMutation, @@ -138,14 +139,6 @@ class Query(graphene.ObjectType): ) return tccs - @staticmethod - def resolve_validity_ranges(root, info): - if not info.context.user.has_perm("lesrooster.view_validityrange_rule"): - return get_objects_for_user( - info.context.user, "lesrooster.view_validityrange", ValidityRange - ) - return ValidityRange.objects.all() - @staticmethod def resolve_time_grids(root, info): if not info.context.user.has_perm("lesrooster.view_timegrid_rule"): @@ -321,6 +314,7 @@ class Mutation(graphene.ObjectType): create_validity_ranges = ValidityRangeBatchCreateMutation.Field() delete_validity_ranges = ValidityRangeBatchDeleteMutation.Field() update_validity_ranges = ValidityRangeBatchPatchMutation.Field() + publish_validity_range = PublishValidityRangeMutation.Field() create_time_grids = TimeGridBatchCreateMutation.Field() delete_time_grids = TimeGridBatchDeleteMutation.Field() diff --git a/aleksis/apps/lesrooster/schema/validity_range.py b/aleksis/apps/lesrooster/schema/validity_range.py index 0d9a8bd3a36069352af3daec60440ae162be8a12..38a1cf2403b9a5d3c993a817e712904e0889241a 100644 --- a/aleksis/apps/lesrooster/schema/validity_range.py +++ b/aleksis/apps/lesrooster/schema/validity_range.py @@ -1,16 +1,14 @@ +from django.core.exceptions import PermissionDenied + import graphene from graphene_django.types import DjangoObjectType -from graphene_django_cud.mutations import ( - DjangoBatchCreateMutation, - DjangoBatchDeleteMutation, - DjangoBatchPatchMutation, -) from guardian.shortcuts import get_objects_for_user from aleksis.core.schema.base import ( + BaseBatchCreateMutation, + BaseBatchDeleteMutation, + BaseBatchPatchMutation, DjangoFilterMixin, - PermissionBatchDeleteMixin, - PermissionBatchPatchMixin, PermissionsTypeMixin, ) @@ -26,7 +24,7 @@ class ValidityRangeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp filter_fields = { "id": ["exact"], "school_term": ["exact", "in"], - "status": ["exact"], + "status": ["iexact"], "name": ["icontains", "exact"], "date_start": ["exact", "lt", "lte", "gt", "gte"], "date_end": ["exact", "lt", "lte", "gt", "gte"], @@ -41,7 +39,7 @@ class ValidityRangeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp return queryset -class ValidityRangeBatchCreateMutation(DjangoBatchCreateMutation): +class ValidityRangeBatchCreateMutation(BaseBatchCreateMutation): class Meta: model = ValidityRange permissions = ("lesrooster.create_validity_range_rule",) @@ -51,29 +49,72 @@ class ValidityRangeBatchCreateMutation(DjangoBatchCreateMutation): "name", "date_start", "date_end", - "status", "time_grids", ) - field_types = {"status": graphene.String()} -class ValidityRangeBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): +class ValidityRangeBatchDeleteMutation(BaseBatchDeleteMutation): class Meta: model = ValidityRange permissions = ("lesrooster.delete_validityrange_rule",) -class ValidityRangeBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): +class ValidityRangeBatchPatchMutation(BaseBatchPatchMutation): + @classmethod + def before_save(cls, root, info, input, updated_objects): # noqa: A002 + res = super().before_save(root, info, input, updated_objects) + + # Get changes and cache them for after_mutate + cls._changes = {} + for updated_obj in updated_objects: + if updated_obj.published: + cls._changes[updated_obj.id] = updated_obj.status_tracker.changed() + return res + + @classmethod + def after_mutate(cls, root, info, input, updated_objs, return_data): # noqa: A002 + res = super().after_mutate(root, info, input, updated_objs, return_data) + + # Sync validity range if date end has been changed + for updated_obj in updated_objs: + if updated_obj.published and updated_obj.id in cls._changes: + changes = cls._changes[updated_obj.id] + if "date_end" in changes: + updated_obj.sync(request=info.context) + del cls._changes + + return res + class Meta: model = ValidityRange - permissions = ("lesrooster.change_validityrange",) + permissions = ("lesrooster.edit_validityrange_rule",) only_fields = ( "id", "school_term", "name", "date_start", "date_end", - "status", "time_grids", ) - field_types = {"status": graphene.String()} + + +class PublishValidityRangeMutation(graphene.Mutation): + # No batch mutation as publishing can only be done for one validity range + + class Arguments: + id = graphene.ID() # noqa + + validity_range = graphene.Field(ValidityRangeType) + + @classmethod + def mutate(cls, root, info, id): # noqa + validity_range = ValidityRange.objects.get(pk=id) + + if ( + not info.context.user.has_perm("lesrooster.edit_validityrange_rule", validity_range) + or validity_range.published + ): + raise PermissionDenied() + validity_range.publish(request=info.context) + + return PublishValidityRangeMutation(validity_range=validity_range) diff --git a/aleksis/apps/lesrooster/tasks.py b/aleksis/apps/lesrooster/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..c9b11381506ccc29e97a3f9200f0add386cd1659 --- /dev/null +++ b/aleksis/apps/lesrooster/tasks.py @@ -0,0 +1,11 @@ +from aleksis.core.util.celery_progress import ProgressRecorder, recorded_task + + +@recorded_task +def sync_validity_range(validity_range: int, recorder: ProgressRecorder): + """Sync all lessons and supervisions of this validity range.""" + from .models import ValidityRange + + validity_range = ValidityRange.objects.get(pk=validity_range) + + validity_range._sync(recorder=recorder) diff --git a/aleksis/apps/lesrooster/tests/test_recurrence.py b/aleksis/apps/lesrooster/tests/test_recurrence.py index 78c204b385338c7d2f8c542a3d6e6189132e6876..93e85269b36e60638198a53b9d458786b9860c91 100644 --- a/aleksis/apps/lesrooster/tests/test_recurrence.py +++ b/aleksis/apps/lesrooster/tests/test_recurrence.py @@ -1,5 +1,4 @@ from datetime import date, datetime, time -from pprint import pprint from django.utils.timezone import get_current_timezone @@ -44,10 +43,11 @@ def test_slot_build_recurrence(time_grid): slot = Slot.objects.create( time_grid=time_grid, weekday=0, period=1, time_start=time(8, 0), time_end=time(9, 0) ) + slot_b = Slot.objects.create( + time_grid=time_grid, weekday=0, period=2, time_start=time(9, 0), time_end=time(10, 0) + ) rec = slot.build_recurrence(recurrence.WEEKLY) - pprint(rec.rrules[0].__dict__) - assert rec.dtstart == datetime(2024, 1, 1, 8, 0, tzinfo=get_current_timezone()) assert len(rec.rrules) == 1 @@ -56,24 +56,42 @@ def test_slot_build_recurrence(time_grid): assert rrule.freq == 2 assert rrule.interval == 1 + rec = slot.build_recurrence(recurrence.WEEKLY, slot_end=slot_b) + + assert rec.dtstart == datetime(2024, 1, 1, 8, 0, tzinfo=get_current_timezone()) + assert len(rec.rrules) == 1 + + rrule = rec.rrules[0] + assert rrule.until == datetime(2024, 6, 1, 10, 0, tzinfo=get_current_timezone()) + assert rrule.freq == 2 + assert rrule.interval == 1 + def test_lesson_recurrence(time_grid): - slot = Slot.objects.create( + slot_start = Slot.objects.create( time_grid=time_grid, weekday=0, period=1, time_start=time(8, 0), time_end=time(9, 0) ) + slot_end = Slot.objects.create( + time_grid=time_grid, weekday=0, period=2, time_start=time(9, 0), time_end=time(10, 0) + ) break_slot = BreakSlot.objects.create( time_grid=time_grid, weekday=0, time_start=time(9, 0), time_end=time(9, 15) ) - lesson = Lesson.objects.create( - slot_start=slot, - slot_end=slot, + lesson_a = Lesson.objects.create( + slot_start=slot_start, + slot_end=slot_end, ) - assert lesson.build_recurrence(recurrence.WEEKLY) == slot.build_recurrence(recurrence.WEEKLY) + assert lesson_a.build_recurrence(recurrence.WEEKLY) == slot_start.build_recurrence( + recurrence.WEEKLY, slot_end=slot_end + ) supervision = Supervision.objects.create(break_slot=break_slot) assert supervision.build_recurrence(recurrence.WEEKLY) == break_slot.build_recurrence( recurrence.WEEKLY ) + + +# TODO Test real_recurrence for supervision and lesson with holidays diff --git a/aleksis/apps/lesrooster/tests/test_sync.py b/aleksis/apps/lesrooster/tests/test_sync.py new file mode 100644 index 0000000000000000000000000000000000000000..017cc9bcca211e6793f4e8f41225d852cb9d62c7 --- /dev/null +++ b/aleksis/apps/lesrooster/tests/test_sync.py @@ -0,0 +1,194 @@ +from datetime import date, time + +import pytest +import recurrence +from calendarweek import CalendarWeek + +from aleksis.apps.cursus.models import Course, Subject +from aleksis.apps.lesrooster.models import ( + BreakSlot, + Lesson, + Room, + Slot, + Supervision, + TimeGrid, + ValidityRange, + ValidityRangeStatus, +) +from aleksis.core.models import Group, Person, SchoolTerm + +pytestmark = pytest.mark.django_db(databases=["default", "default_oot"]) + + +@pytest.fixture +def school_term(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + school_term = SchoolTerm.objects.get_or_create( + name="Test", date_start=date_start, date_end=date_end + )[0] + return school_term + + +@pytest.fixture +def validity_range(school_term): + validity_range = ValidityRange.objects.get_or_create( + school_term=school_term, date_start=school_term.date_start, date_end=school_term.date_end + )[0] + return validity_range + + +@pytest.fixture +def time_grid(validity_range): + return TimeGrid.objects.get_or_create(validity_range=validity_range, group=None)[0] + + +@pytest.fixture +def lesson(time_grid): + slot_a = Slot.objects.create( + time_grid=time_grid, weekday=0, period=1, time_start=time(8, 0), time_end=time(9, 0) + ) + slot_b = Slot.objects.create( + time_grid=time_grid, weekday=0, period=2, time_start=time(9, 0), time_end=time(10, 0) + ) + + subject = Subject.objects.create(name="Math", short_name="Ma") + + course_teachers = [ + Person.objects.create(first_name=f"course_{i}", last_name=f"{i}") for i in range(2) + ] + course_groups = [Group.objects.create(name=f"course_{i}") for i in range(2)] + + course_subject = Subject.objects.create(name="English", short_name="En") + course = Course.objects.create(name="Testcourse", subject=course_subject) + course.groups.set(course_groups) + course.teachers.set(course_teachers) + + teachers = [Person.objects.create(first_name=f"lesson_{i}", last_name=f"{i}") for i in range(2)] + rooms = [Room.objects.create(name=f"lesson_{i}", short_name=f"lesson_{i}") for i in range(2)] + + lesson = Lesson.objects.create( + course=course, subject=subject, slot_start=slot_a, slot_end=slot_b + ) + lesson.recurrence = lesson.build_recurrence(recurrence.WEEKLY) + lesson.save() + lesson.teachers.set(teachers) + lesson.rooms.set(rooms) + + return lesson + + +@pytest.fixture +def supervision(time_grid): + slot = BreakSlot.objects.create( + time_grid=time_grid, weekday=0, time_start=time(10, 0), time_end=time(10, 15) + ) + + teachers = [ + Person.objects.create(first_name=f"supervision_{i}", last_name=f"{i}") for i in range(2) + ] + rooms = [ + Room.objects.create(name=f"supervision_{i}", short_name=f"supervision_{i}") + for i in range(2) + ] + + supervision = Supervision.objects.create( + break_slot=slot, + ) + supervision.recurrence = supervision.build_recurrence(recurrence.WEEKLY) + supervision.save() + supervision.teachers.set(teachers) + supervision.rooms.set(rooms) + + return supervision + + +def test_sync_lesson(lesson): + assert lesson.lesson_event is None + + lesson.sync() + + assert lesson.lesson_event + + lesson_event = lesson.lesson_event + assert lesson_event.course == lesson.course + assert lesson_event.subject == lesson.subject + + assert list(lesson_event.groups.all()) == list(lesson.course.groups.all()) + assert list(lesson_event.teachers.all()) == list(lesson.teachers.all()) + assert list(lesson.rooms.all()) == list(lesson.rooms.all()) + + week_start = CalendarWeek.from_date(lesson.slot_start.time_grid.validity_range.date_start) + datetime_start = lesson.slot_start.get_datetime_start(week_start) + datetime_end = lesson.slot_end.get_datetime_end(week_start) + + assert lesson_event.datetime_start == datetime_start + assert lesson_event.datetime_end == datetime_end + + assert lesson_event.recurrences == lesson.real_recurrence + + lesson.course = None + lesson.save() + lesson.sync() + + assert len(lesson.lesson_event.groups.all()) == 0 + + +def test_sync_supervision(supervision): + assert supervision.supervision_event is None + + supervision.sync() + assert supervision.supervision_event + + supervision_event = supervision.supervision_event + assert list(supervision_event.rooms.all()) == list(supervision.rooms.all()) + assert list(supervision_event.teachers.all()) == list(supervision.teachers.all()) + + week_start = CalendarWeek.from_date(supervision.break_slot.time_grid.validity_range.date_start) + datetime_start = supervision.break_slot.get_datetime_start(week_start) + datetime_end = supervision.break_slot.get_datetime_end(week_start) + + assert supervision_event.datetime_start == datetime_start + assert supervision_event.datetime_end == datetime_end + + assert supervision_event.recurrences == supervision.real_recurrence + + +def test_sync_on_publish(lesson, supervision): + validity_range = lesson.slot_start.time_grid.validity_range + validity_range.publish() + + lesson.refresh_from_db() + supervision.refresh_from_db() + + assert lesson.lesson_event + assert supervision.supervision_event + + +def test_sync_on_date_end_changed(): + pass + + +def test_sync_on_date_start_changed(): + pass + + +def test_sync_async(lesson, mocker, rf, admin_user): + mock = mocker.patch("aleksis.apps.lesrooster.tasks.sync_validity_range.delay") + mocker.patch("aleksis.apps.lesrooster.models.render_progress_page") + + request = rf.get("/") + request.user = admin_user + + validity_range = lesson.slot_start.time_grid.validity_range + + validity_range.sync(request) + + assert not mock.called + + validity_range.status = ValidityRangeStatus.PUBLISHED + validity_range.save() + + validity_range.sync(request) + + assert mock.called diff --git a/aleksis/apps/lesrooster/tests/test_validity_range.py b/aleksis/apps/lesrooster/tests/test_validity_range.py new file mode 100644 index 0000000000000000000000000000000000000000..22723116a2b9ef044f6a2b253ed5c214f070ef8b --- /dev/null +++ b/aleksis/apps/lesrooster/tests/test_validity_range.py @@ -0,0 +1,232 @@ +from datetime import date, timedelta + +from django.core.exceptions import ValidationError + +import pytest +from freezegun import freeze_time + +from aleksis.apps.lesrooster.models import TimeGrid, ValidityRange, ValidityRangeStatus +from aleksis.core.models import SchoolTerm + +pytestmark = pytest.mark.django_db + + +def test_create_default_time_grid(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + validity_range = ValidityRange.objects.create( + school_term=school_term, date_start=date_start, date_end=date_end + ) + + assert TimeGrid.objects.filter(validity_range=validity_range, group=None).exists() + + +def test_current_validity_range(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + validity_range = ValidityRange.objects.create( + school_term=school_term, date_start=date_start, date_end=date_end + ) + + assert ValidityRange.get_current(date_end) == validity_range + assert ValidityRange.get_current(date_end + timedelta(days=1)) is None + + with freeze_time(date_start): + assert ValidityRange.current == validity_range + assert validity_range.is_current + + with freeze_time(date_end + timedelta(days=1)): + assert ValidityRange.current is None + assert not validity_range.is_current + + +def test_validity_range_date_start_before_date_end(): + date_start = date(2024, 1, 2) + date_end = date(2024, 1, 1) + + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + validity_range = ValidityRange( + school_term=school_term, date_start=date_start, date_end=date_end + ) + with pytest.raises( + ValidationError, match=r".*The start date must be earlier than the end date\..*" + ): + validity_range.full_clean() + + +def test_validity_range_within_school_term(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + dates_fail = [ + (date_start - timedelta(days=1), date_end), + (date_start, date_end + timedelta(days=1)), + (date_start - timedelta(days=1), date_end + timedelta(days=1)), + ] + + dates_success = [ + (date_start, date_end), + (date_start + timedelta(days=1), date_end), + (date_start, date_end - timedelta(days=1)), + (date_start + timedelta(days=1), date_end - timedelta(days=1)), + ] + + for d_start, d_end in dates_fail: + validity_range = ValidityRange(school_term=school_term, date_start=d_start, date_end=d_end) + with pytest.raises( + ValidationError, match=r".*The validity range must be within the school term\..*" + ): + validity_range.full_clean() + + for d_start, d_end in dates_success: + validity_range = ValidityRange(school_term=school_term, date_start=d_start, date_end=d_end) + validity_range.full_clean() + + +def test_validity_range_overlaps(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end) + + validity_range_1 = ValidityRange.objects.create( + date_start=date_start + timedelta(days=10), + date_end=date_end - timedelta(days=10), + school_term=school_term, + status=ValidityRangeStatus.PUBLISHED, + ) + + dates_fail = [ + (date_start, validity_range_1.date_start), + (date_start, date_end), + (date_start, validity_range_1.date_end), + (validity_range_1.date_start, validity_range_1.date_end), + (validity_range_1.date_end, date_end), + ] + + for d_start, d_end in dates_fail: + validity_range_2 = ValidityRange.objects.create( + date_start=d_start, date_end=d_end, school_term=school_term + ) + with pytest.raises( + ValidationError, + match=r".*There is already a published validity range " + r"for this time or a part of this time\..*", + ): + validity_range_2.publish() + + +def test_change_published_validity_range(): + date_start = date(2024, 1, 1) + date_end = date(2024, 6, 1) + school_term = SchoolTerm.objects.create( + name="Test", + date_start=date_start - timedelta(days=5), + date_end=date_end + timedelta(days=5), + ) + school_term_2 = SchoolTerm.objects.create( + name="Test 2", + date_start=date_end + timedelta(days=6), + date_end=date_end + timedelta(days=7), + ) + + validity_range = ValidityRange.objects.create( + date_start=date_start, + date_end=date_end, + school_term=school_term, + status=ValidityRangeStatus.PUBLISHED, + ) + + # School term + validity_range.refresh_from_db() + validity_range.school_term = school_term_2 + with pytest.raises(ValidationError): + validity_range.full_clean() + + # Name + validity_range.refresh_from_db() + validity_range.name = "Test" + validity_range.full_clean() + + # Status + validity_range.refresh_from_db() + validity_range.status = ValidityRangeStatus.DRAFT + with pytest.raises(ValidationError): + validity_range.full_clean() + + with freeze_time(date_start - timedelta(days=1)): # current date start is in the future + # Date start in the past + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start - timedelta(days=2) + with pytest.raises( + ValidationError, match=r".*You can't set the start date to a date in the past.*" + ): + validity_range.full_clean() + + # Date start today + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start - timedelta(days=1) + validity_range.full_clean() + + # Date start in the future + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start + timedelta(days=2) + validity_range.full_clean() + + with freeze_time(date_start + timedelta(days=1)): # current date start is in the past + # Date start in the past + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start - timedelta(days=2) + with pytest.raises( + ValidationError, + match=r".*You can't change the start date if the validity range is already active.*", + ): + validity_range.full_clean() + + # Date start in the future + validity_range.refresh_from_db() + validity_range.date_start = validity_range.date_start + timedelta(days=2) + with pytest.raises( + ValidationError, + match=r".*You can't change the start date if the validity range is already active.*", + ): + validity_range.full_clean() + + with freeze_time(date_end - timedelta(days=3)): # current date end is in the future + # Date end in the past + validity_range.refresh_from_db() + validity_range.date_end = validity_range.date_end - timedelta(days=4) + with pytest.raises( + ValidationError, + match=r".*To avoid data loss, the validity range can be only shortened until the current day.*", + ): + validity_range.full_clean() + + # Date end today + validity_range.refresh_from_db() + validity_range.date_end = validity_range.date_end - timedelta(days=3) + validity_range.full_clean() + + # Date end in the future + validity_range.refresh_from_db() + validity_range.date_end = validity_range.date_end - timedelta(days=2) + validity_range.full_clean() + + with freeze_time(date_end + timedelta(days=1)): # current date end is in the past + validity_range.refresh_from_db() + validity_range.date_end = validity_range.date_end - timedelta(days=2) + with pytest.raises( + ValidationError, + match=r".*You can't change the end date if the validity range is already in the past.*", + ): + validity_range.full_clean() + + +# TODO Test sync with date change diff --git a/aleksis/apps/lesrooster/util/signal_handlers.py b/aleksis/apps/lesrooster/util/signal_handlers.py index 6240a52421e92d8b2fdb6ba5876038a38acb2cf1..27331a25900eeb1559cad86de86fda526283490d 100644 --- a/aleksis/apps/lesrooster/util/signal_handlers.py +++ b/aleksis/apps/lesrooster/util/signal_handlers.py @@ -1,49 +1,5 @@ -import logging - - -def post_save_handler(sender, instance, created, **kwargs): - """Sync the instance with Chronos after it has been saved.""" - if hasattr(instance, "sync"): - logging.debug(f"Syncing {instance} (of type {sender}) after post_save signal") - instance.sync() - - -def m2m_changed_handler(sender, instance, action, **kwargs): - """Sync the instance with Chronos after a m2m relationship has been changed.""" - if hasattr(instance, "sync"): - logging.debug(f"Syncing {instance} (of type {sender}) after m2m_changed signal") - instance.sync() - - -def pre_delete_handler(sender, instance, **kwargs): - """Sync the instance with Chronos after it has been deleted.""" - if hasattr(instance, "lesson_event"): - logging.debug( - f"Delete lesson event {instance.lesson_event} after deletion of lesson {instance}" - ) - instance.lesson_event.delete() - elif hasattr(instance, "supervision_event"): - logging.debug( - f"Delete supervision event {instance.supervision_event} " - f"after deletion of lesson {instance}" - ) - instance.supervision_event.delete() - - def create_time_grid_for_new_validity_range(sender, instance, created, **kwargs): from ..models import TimeGrid # noqa if created: TimeGrid.objects.create(validity_range=instance) - - -def publish_validity_range(sender, instance, created, **kwargs): - from ..models import Lesson, Supervision - - # FIXME Move this to a background job - objs_to_update = list( - Lesson.objects.filter(slot_start__time_grid__validity_range=instance) - ) + list(Supervision.objects.filter(break_slot__time_grid__validity_range=instance)) - for obj in objs_to_update: - logging.info(f"Syncing object {obj} ({type(obj)}, {obj.pk})") - obj.sync() diff --git a/pyproject.toml b/pyproject.toml index 88c2857fa9e0d90248e687d7e4f09ea4e11aa029..7e212dacb9069eba49f3432f9e408d6e929f7783 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ pytest-cov = "^4.0.0" pytest-sugar = "^0.9.2" selenium = "<4.10.0" freezegun = "^1.1.0" +pytest-mock = "^3.14.0" [tool.poetry.group.docs] optional = true @@ -85,7 +86,7 @@ tabindex_no_positive = true [tool.ruff] -exclude = ["migrations", "tests"] +exclude = ["migrations"] line-length = 100 [tool.ruff.lint]