diff --git a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql index 269faa53c9f3bb62ea2a5d138c778279089d433f..016045303cde040117ada2a4c1f35862a96cf747 100644 --- a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql +++ b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql @@ -152,8 +152,8 @@ mutation carryOverSlots( } } -mutation copySlotsFromGrid($toTimeGrid: ID!, $fromTimeGrid: ID!) { - copySlotsFromGrid(timeGrid: $toTimeGrid, fromTimeGrid: $fromTimeGrid) { +mutation copySlotsFromGrid($fromTimeGrid: ID!, $toTimeGrid: ID!) { + copySlotsFromGrid(fromTimeGrid: $fromTimeGrid, toTimeGrid: $toTimeGrid) { result { id model diff --git a/aleksis/apps/lesrooster/managers.py b/aleksis/apps/lesrooster/managers.py index 10703b00c271ed9d9be8e74b639c47aa0d76d390..2b96c5352498c83724979bd88d23d25d2f25a056 100644 --- a/aleksis/apps/lesrooster/managers.py +++ b/aleksis/apps/lesrooster/managers.py @@ -1,7 +1,14 @@ +from typing import TYPE_CHECKING + from django.db.models import QuerySet +from reversion import create_revision, set_comment + from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations, DateRangeQuerySetMixin +if TYPE_CHECKING: + from .models import BreakSlot, Lesson, Slot, Supervision, TimeGrid, ValidityRange + class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin): """Custom query set for validity ranges.""" @@ -9,3 +16,133 @@ class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin): class ValidityRangeManager(AlekSISBaseManagerWithoutMigrations): """Manager for validity ranges.""" + + def copy_from_to( + self, source: ValidityRange, target: ValidityRange + ) -> dict[str, dict[any, any]]: + from .models import Lesson, Slot, Supervision, TimeGrid + + time_grid_obj_map = TimeGrid.objects.copy_from_to(source, target) + + slot_map = {} + for time_grid_source, time_grid_target in time_grid_obj_map.items(): + slot_map |= Slot.objects.copy_from_to(time_grid_source, time_grid_target) + + lesson_obj_map = Lesson.objects.copy_from_to(source, slot_map) + + supervision_obj_map = Supervision.objects.copy_from_to(source, slot_map) + + obj_map = dict( + time_grid=time_grid_obj_map, + slot=slot_map, + lesson=lesson_obj_map, + supervision=supervision_obj_map, + ) + return obj_map + + +class TimeGridManager(AlekSISBaseManagerWithoutMigrations): + """Manager for time grids.""" + + def copy_from_to( + self, source: ValidityRange, target: ValidityRange + ) -> list[tuple[TimeGrid, TimeGrid]]: + obj_map = [] + with create_revision(): + set_comment(f"Copy time grids from validity range {source.pk} to {target.pk}") + + for time_grid in source.time_grids.all(): + new_time_grid = self.create(validity_range=self, group=time_grid.group) + + obj_map.append((time_grid, new_time_grid)) + + return obj_map + + +class SlotManager(AlekSISBaseManagerWithoutMigrations): + """Manager for slots.""" + + def copy_from_to( + self, source: TimeGrid, target: TimeGrid + ) -> [dict[Slot, Slot], QuerySet[Slot]]: + obj_map = {} + with create_revision(): + set_comment(f"Copy slots from time grid {source.pk} to {target.pk}") + slots = self.filter(time_grid=source) + + update_or_created_objs = [] + + for slot in slots: + slot: BreakSlot | Slot + klass = slot.get_real_instance_class() + + defaults = { + "name": slot.name, + "time_start": slot.time_start, + "time_end": slot.time_end, + } + + obj, __ = klass.objects.update_or_create( + weekday=slot.weekday, time_grid=target, period=slot.period, defaults=defaults + ) + obj_map[slot] = obj + update_or_created_objs.append(obj) + + # Delete all slots in the time_grid that are not in the from_time_grid + objects_to_delete = self.filter(time_grid=target).exclude(id__in=update_or_created_objs) + objects_to_delete.delete() + + return obj_map, objects_to_delete + + +class LessonManager(AlekSISBaseManagerWithoutMigrations): + def copy_from_to( + self, source: ValidityRange, target_slot_map: dict[Slot, Slot] + ) -> dict[Lesson, Lesson]: + obj_map = {} + + lessons = self.filter(slot_start__validity_range=source) + for lesson in lessons: + new_slot_start = target_slot_map.get(lesson.slot_start) + new_slot_end = target_slot_map.get(lesson.slot_end) + if not new_slot_start or not new_slot_end: + continue + + new_lesson = self.create( + course=lesson.course, + slot_start=new_slot_start, + slot_end=new_slot_end, + recurrence=lesson.recurrence, + subject=lesson.subject, + ) + new_lesson.rooms.set(lesson.rooms.all()) + new_lesson.teachers.set(lesson.teachers.all()) + + obj_map[lesson] = new_lesson + + return obj_map + + +class SupervisionManager(AlekSISBaseManagerWithoutMigrations): + def copy_from_to( + self, source: ValidityRange, target_slot_map: dict[Slot, Slot] + ) -> dict[Supervision, Supervision]: + obj_map = {} + + supervisions = self.filter(break_slot__validity_range=source) + for supervision in supervisions: + new_slot = target_slot_map.get(supervision.break_slot) + if not new_slot: + continue + + new_supervision = self.create( + break_slot=supervision.break_slot, + recurrence=supervision.recurrence, + subject=supervision.subject, + ) + new_supervision.rooms.set(supervision.rooms.all()) + new_supervision.teachers.set(supervision.teachers.all()) + + obj_map[supervision] = new_supervision + + return obj_map diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py index e4007c2dcbff788061641dad73e8a4a8e67c7276..17554f2bb74b6a518a0b9b262affb8ef119429a1 100644 --- a/aleksis/apps/lesrooster/models.py +++ b/aleksis/apps/lesrooster/models.py @@ -20,7 +20,14 @@ from aleksis.apps.cursus.models import Course, Subject from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel, GlobalPermissionModel from aleksis.core.models import Group, Holiday, Person, Room, SchoolTerm -from .managers import ValidityRangeManager, ValidityRangeQuerySet +from .managers import ( + LessonManager, + SlotManager, + SupervisionManager, + TimeGridManager, + ValidityRangeManager, + ValidityRangeQuerySet, +) class ValidityRangeStatus(models.TextChoices): @@ -97,6 +104,11 @@ class ValidityRange(ExtensibleModel): ) ) + def clear(self): + """Drop all data in this validity range.""" + # Heads up: Other objects will be deleted through cascade + TimeGrid.objects.filter(validity_range=self).delete() + def __str__(self) -> str: return self.name or f"{date_format(self.date_start)}–{date_format(self.date_end)}" @@ -135,6 +147,8 @@ class TimeGrid(ExtensibleModel): null=True, ) + objects = TimeGridManager() + def __str__(self): if self.group: return f"{self.validity_range}: {self.group}" @@ -178,6 +192,8 @@ class Slot(ExtensiblePolymorphicModel): time_start = models.TimeField(verbose_name=_("Start time")) time_end = models.TimeField(verbose_name=_("End time")) + objects = SlotManager() + def __str__(self) -> str: if self.name: suffix = self.name @@ -287,6 +303,8 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): null=True, ) + objects = LessonManager() + def get_teachers(self) -> QuerySet[Person]: return self.teachers.all() @@ -413,6 +431,8 @@ class Supervision(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel): help_text=_("Leave empty for a single supervision."), ) + objects = SupervisionManager() + def get_teachers(self) -> QuerySet[Person]: return self.teachers.all() diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py index ca3170100b2298fdbc454068d1d5ab858bbc3f75..6586448340af5eb2e144bc5c78d2117535d37204 100644 --- a/aleksis/apps/lesrooster/schema/__init__.py +++ b/aleksis/apps/lesrooster/schema/__init__.py @@ -68,6 +68,7 @@ from .timebound_course_config import ( TimeboundCourseConfigType, ) from .validity_range import ( + CopyDataFromValidityRangeMutation, ValidityRangeBatchDeleteMutation, ValidityRangeBatchPatchMutation, ValidityRangeCreateMutation, @@ -293,8 +294,6 @@ class Mutation(graphene.ObjectType): create_timebound_course_config = TimeboundCourseConfigCreateMutation.Field() delete_timebound_course_config = TimeboundCourseConfigDeleteMutation.Field() update_timebound_course_configs = TimeboundCourseConfigBatchPatchMutation.Field() - carry_over_slots = CarryOverSlotsMutation.Field() - copy_slots_from_grid = CopySlotsFromDifferentTimeGridMutation.Field() create_validity_range = ValidityRangeCreateMutation.Field() delete_validity_range = ValidityRangeDeleteMutation.Field() @@ -316,3 +315,7 @@ class Mutation(graphene.ObjectType): delete_supervision = SupervisionDeleteMutation.Field() delete_supervisions = SupervisionBatchDeleteMutation.Field() update_supervisions = SupervisionBatchPatchMutation.Field() + + carry_over_slots = CarryOverSlotsMutation.Field() + copy_slots_from_grid = CopySlotsFromDifferentTimeGridMutation.Field() + copy_data_from_validty_range = CopyDataFromValidityRangeMutation.Field() diff --git a/aleksis/apps/lesrooster/schema/slot.py b/aleksis/apps/lesrooster/schema/slot.py index 6c2de6a501613bd058dd72a141912d644ee01b87..02a00e6f2caf04f5b0cc8577a4b1af4b030970f6 100644 --- a/aleksis/apps/lesrooster/schema/slot.py +++ b/aleksis/apps/lesrooster/schema/slot.py @@ -9,6 +9,7 @@ from graphene_django_cud.mutations import ( DjangoCreateMutation, ) from guardian.shortcuts import get_objects_for_user +from reversion import create_revision, set_comment, set_user from aleksis.core.schema.base import ( DeleteMutation, @@ -18,7 +19,7 @@ from aleksis.core.schema.base import ( PermissionsTypeMixin, ) -from ..models import BreakSlot, Slot, TimeGrid +from ..models import Slot, TimeGrid slot_filters = { "id": ["exact"], @@ -153,44 +154,26 @@ class CarryOverSlotsMutation(graphene.Mutation): class CopySlotsFromDifferentTimeGridMutation(graphene.Mutation): class Arguments: - time_grid = graphene.ID() from_time_grid = graphene.ID() + to_time_grid = graphene.ID() deleted = graphene.List(graphene.ID) result = graphene.List(SlotType) @classmethod - def mutate(cls, root, info, time_grid, from_time_grid): + def mutate(cls, root, info, from_time_grid, to_time_grid): if not info.context.user.has_perm("lesrooster.edit_slot_rule"): raise PermissionDenied() - time_grid = TimeGrid.objects.get(id=time_grid) from_time_grid = TimeGrid.objects.get(id=from_time_grid) + to_time_grid = TimeGrid.objects.get(id=to_time_grid) - # Check for each slot in the from_time_grid if it exists in the time_grid, if not, create it - slots = Slot.objects.filter(time_grid=from_time_grid) - - result = [] - - for slot in slots: - slot: BreakSlot | Slot - klass = slot.get_real_instance_class() - - defaults = {"name": slot.name, "time_start": slot.time_start, "time_end": slot.time_end} - - result.append( - klass.objects.update_or_create( - weekday=slot.weekday, time_grid=time_grid, period=slot.period, defaults=defaults - )[0].id - ) + with create_revision(): + set_user(info.context.user) + set_comment(f"Copy slots from time grid {from_time_grid.pk} to {to_time_grid.pk}") + __, deleted = Slot.objects.copy_from_to(from_time_grid, to_time_grid) - # Delete all slots in the time_grid that are not in the from_time_grid - objects_to_delete = Slot.objects.filter(time_grid=time_grid).exclude(id__in=result) - objects_to_delete.delete() - - deleted = objects_to_delete.values_list("id", flat=True) - - return CopySlotsFromDifferentTimeGridMutation( - deleted=deleted, - result=Slot.objects.filter(time_grid=time_grid).non_polymorphic(), + return cls( + deleted=deleted.values_list("id", flat=True), + result=Slot.objects.filter(time_grid=to_time_grid).non_polymorphic(), ) diff --git a/aleksis/apps/lesrooster/schema/validity_range.py b/aleksis/apps/lesrooster/schema/validity_range.py index 040fc2778846be7d0e32354bd534fef0034b8880..7d439f2a41ab18aa11fdd651c74c06caf410e914 100644 --- a/aleksis/apps/lesrooster/schema/validity_range.py +++ b/aleksis/apps/lesrooster/schema/validity_range.py @@ -1,3 +1,5 @@ +from django.core.exceptions import PermissionDenied + import graphene from graphene_django.types import DjangoObjectType from graphene_django_cud.mutations import ( @@ -6,6 +8,7 @@ from graphene_django_cud.mutations import ( DjangoCreateMutation, ) from guardian.shortcuts import get_objects_for_user +from reversion import create_revision, set_comment, set_user from aleksis.core.schema.base import ( DeleteMutation, @@ -15,7 +18,7 @@ from aleksis.core.schema.base import ( PermissionsTypeMixin, ) -from ..models import ValidityRange +from ..models import TimeGrid, ValidityRange class ValidityRangeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): @@ -81,3 +84,29 @@ class ValidityRangeBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatc "time_grids", ) field_types = {"status": graphene.String()} + + +class CopyDataFromValidityRangeMutation(graphene.Mutation): + class Arguments: + from_validty_range = graphene.ID() + to_validity_range = graphene.ID() + + result = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, from_validity_range, to_validity_range): + if not info.context.user.has_perm("lesrooster.edit_slot_rule"): + raise PermissionDenied() + + from_validity_range = TimeGrid.objects.get(id=from_validity_range) + to_validity_range = TimeGrid.objects.get(id=to_validity_range) + + with create_revision(): + set_user(info.context.user) + set_comment( + f"Copy data from validity range {from_validity_range.pk} to {to_validity_range.pk}" + ) + to_validity_range.clear() + ValidityRange.objects.copy_from_to(from_validity_range, to_validity_range) + + return cls(result=True)