From 878f8193efa001af2ff0638f9aefaa8e03766779 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 22 Jan 2024 21:03:22 +0100
Subject: [PATCH 01/12] Sync events on publishing of validity ranges

---
 aleksis/apps/lesrooster/apps.py               | 39 +---------------
 aleksis/apps/lesrooster/models.py             | 26 +++++++++++
 .../apps/lesrooster/schema/validity_range.py  |  6 +++
 .../apps/lesrooster/util/signal_handlers.py   | 44 -------------------
 4 files changed, 34 insertions(+), 81 deletions(-)

diff --git a/aleksis/apps/lesrooster/apps.py b/aleksis/apps/lesrooster/apps.py
index 17c326c2..b7fcfc2d 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/models.py b/aleksis/apps/lesrooster/models.py
index 5bb91dde..899d8a0c 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -1,3 +1,4 @@
+import logging
 from datetime import date, datetime
 from typing import Optional, Union
 
@@ -12,6 +13,7 @@ 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
@@ -53,6 +55,8 @@ class ValidityRange(ExtensibleModel):
         default=ValidityRangeStatus.DRAFT,
     )
 
+    status_tracker = FieldTracker(fields=["status"])
+
     @property
     def published(self):
         return self.status == ValidityRangeStatus.PUBLISHED.value
@@ -78,6 +82,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(_("A published validity range can't be changed."))
+
         if self.date_end < self.date_start:
             raise ValidationError(_("The start date must be earlier than the end date."))
 
@@ -101,6 +109,24 @@ class ValidityRange(ExtensibleModel):
                     )
                 )
 
+    def publish(self):
+        self.status = ValidityRangeStatus.PUBLISHED
+        self.save()
+
+        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))
+            + list(Substitution.objects.filter(lesson__slot_start__time_grid__validity_range=self))
+            + list(
+                SupervisionSubstitution.objects.filter(
+                    supervision__break_slot__time_grid__validity_range=self
+                )
+            )
+        )
+        for obj in objs_to_update:
+            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)}"
 
diff --git a/aleksis/apps/lesrooster/schema/validity_range.py b/aleksis/apps/lesrooster/schema/validity_range.py
index 0d9a8bd3..3da88248 100644
--- a/aleksis/apps/lesrooster/schema/validity_range.py
+++ b/aleksis/apps/lesrooster/schema/validity_range.py
@@ -77,3 +77,9 @@ class ValidityRangeBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatc
             "time_grids",
         )
         field_types = {"status": graphene.String()}
+
+    @classmethod
+    def validate(cls, root, info, input, obj=None, id=None):  # noqa: A002
+        print(obj, obj.__dict__, input)
+
+        super().validate(root, info, input, obj=obj, id=id)
diff --git a/aleksis/apps/lesrooster/util/signal_handlers.py b/aleksis/apps/lesrooster/util/signal_handlers.py
index 6240a524..27331a25 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()
-- 
GitLab


From d6654a664651a18918baf04a1ca17ad544f910c4 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Wed, 28 Feb 2024 18:18:13 +0100
Subject: [PATCH 02/12] Improve validation of validity ranges and add tests for
 it

diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index be46abf..2c1589b 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -55,7 +55,7 @@ class ValidityRange(ExtensibleModel):
         default=ValidityRangeStatus.DRAFT,
     )

-    status_tracker = FieldTracker(fields=["status"])
+    status_tracker = FieldTracker(fields=["status", "date_start", "date_end", "school_term"])

     @property
     def published(self):
@@ -84,7 +84,7 @@ class ValidityRange(ExtensibleModel):
         """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(_("A published validity range can't be changed."))
+            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."))
@@ -95,7 +95,35 @@ 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 today."
+                    )
+
+            if errors:
+                raise ValidationError(errors)
+
             qs = ValidityRange.objects.within_dates(self.date_start, self.date_end).filter(
                 status=ValidityRangeStatus.PUBLISHED
             )
@@ -111,8 +139,11 @@ class ValidityRange(ExtensibleModel):

     def publish(self):
         self.status = ValidityRangeStatus.PUBLISHED
+        self.full_clean()
         self.save()
+        self._sync()

+    def _sync(self):
         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))
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 0000000..356be46
--- /dev/null
+++ b/aleksis/apps/lesrooster/tests/test_validity_range.py
@@ -0,0 +1,152 @@
+from datetime import date, timedelta
+import pytest
+
+pytestmark = pytest.mark.django_db
+
+from aleksis.core.models import SchoolTerm
+from aleksis.apps.lesrooster.models import ValidityRange, ValidityRangeStatus
+from django.core.exceptions import ValidationError
+from freezegun import freeze_time
+
+
+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 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 today.*"):
+            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
---
 aleksis/apps/lesrooster/models.py             |  37 ++++-
 .../lesrooster/tests/test_validity_range.py   | 152 ++++++++++++++++++
 2 files changed, 186 insertions(+), 3 deletions(-)
 create mode 100644 aleksis/apps/lesrooster/tests/test_validity_range.py

diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 899d8a0c..1fdf8794 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -55,7 +55,7 @@ class ValidityRange(ExtensibleModel):
         default=ValidityRangeStatus.DRAFT,
     )
 
-    status_tracker = FieldTracker(fields=["status"])
+    status_tracker = FieldTracker(fields=["status", "date_start", "date_end", "school_term"])
 
     @property
     def published(self):
@@ -84,7 +84,7 @@ class ValidityRange(ExtensibleModel):
         """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(_("A published validity range can't be changed."))
+            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."))
@@ -95,7 +95,35 @@ 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 today."
+                    )
+
+            if errors:
+                raise ValidationError(errors)
+
             qs = ValidityRange.objects.within_dates(self.date_start, self.date_end).filter(
                 status=ValidityRangeStatus.PUBLISHED
             )
@@ -111,8 +139,11 @@ class ValidityRange(ExtensibleModel):
 
     def publish(self):
         self.status = ValidityRangeStatus.PUBLISHED
+        self.full_clean()
         self.save()
+        self._sync()
 
+    def _sync(self):
         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))
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 00000000..356be460
--- /dev/null
+++ b/aleksis/apps/lesrooster/tests/test_validity_range.py
@@ -0,0 +1,152 @@
+from datetime import date, timedelta
+import pytest
+
+pytestmark = pytest.mark.django_db
+
+from aleksis.core.models import SchoolTerm
+from aleksis.apps.lesrooster.models import ValidityRange, ValidityRangeStatus
+from django.core.exceptions import ValidationError
+from freezegun import freeze_time
+
+
+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 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 today.*"):
+            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
-- 
GitLab


From c97c282d29338de315314ea2ebe5ee46a95a5d28 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Wed, 28 Feb 2024 19:43:39 +0100
Subject: [PATCH 03/12] Add tests for default timegrid and current validity
 range

---
 .../lesrooster/tests/test_validity_range.py   | 32 ++++++++++++++++++-
 1 file changed, 31 insertions(+), 1 deletion(-)

diff --git a/aleksis/apps/lesrooster/tests/test_validity_range.py b/aleksis/apps/lesrooster/tests/test_validity_range.py
index 356be460..6654a74b 100644
--- a/aleksis/apps/lesrooster/tests/test_validity_range.py
+++ b/aleksis/apps/lesrooster/tests/test_validity_range.py
@@ -4,11 +4,40 @@ import pytest
 pytestmark = pytest.mark.django_db
 
 from aleksis.core.models import SchoolTerm
-from aleksis.apps.lesrooster.models import ValidityRange, ValidityRangeStatus
+from aleksis.apps.lesrooster.models import ValidityRange, ValidityRangeStatus, TimeGrid
 from django.core.exceptions import ValidationError
 from freezegun import freeze_time
 
 
+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)) == None
+
+    with freeze_time(date_start):
+        assert ValidityRange.current == validity_range
+    
+    with freeze_time(date_end + timedelta(days=1)):
+        assert ValidityRange.current == None
+
+
 def test_validity_range_date_start_before_date_end():
     date_start = date(2024, 1, 2)
     date_end = date(2024,1,1)
@@ -19,6 +48,7 @@ def test_validity_range_date_start_before_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)
-- 
GitLab


From e744eba56a78e70b7612898cb17aec1e78888978 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Fri, 15 Mar 2024 09:53:30 +0100
Subject: [PATCH 04/12] [Validity ranges] Use CRUDList instead of
 InlineCRUDList, fix filtering and deactivate editing of status

---
 .../validity_range/ValidityRange.vue          | 35 ++++++++++---------
 aleksis/apps/lesrooster/schema/__init__.py    |  8 -----
 .../apps/lesrooster/schema/validity_range.py  | 13 +------
 3 files changed, 20 insertions(+), 36 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue
index 52a81562..259455c0 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";
@@ -13,7 +13,7 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue";
 
 <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 +24,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 +82,19 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue";
       </template>
 
       <template #filters="{ attrs, on }">
+        <validity-range-status-field
+          v-bind="attrs('status__exact')"
+          v-on="on('status__exact')"
+          :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')"
@@ -155,7 +158,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 +237,7 @@ export default {
         {
           text: this.$t("lesrooster.validity_range.status_label"),
           value: "status",
+          disableEdit: true,
         },
         {
           text: this.$t("school_term.title"),
@@ -322,7 +326,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/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index b9a9221e..c798b6a5 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -138,14 +138,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"):
diff --git a/aleksis/apps/lesrooster/schema/validity_range.py b/aleksis/apps/lesrooster/schema/validity_range.py
index 3da88248..6114fbc2 100644
--- a/aleksis/apps/lesrooster/schema/validity_range.py
+++ b/aleksis/apps/lesrooster/schema/validity_range.py
@@ -1,4 +1,3 @@
-import graphene
 from graphene_django.types import DjangoObjectType
 from graphene_django_cud.mutations import (
     DjangoBatchCreateMutation,
@@ -51,10 +50,8 @@ class ValidityRangeBatchCreateMutation(DjangoBatchCreateMutation):
             "name",
             "date_start",
             "date_end",
-            "status",
             "time_grids",
         )
-        field_types = {"status": graphene.String()}
 
 
 class ValidityRangeBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
@@ -66,20 +63,12 @@ class ValidityRangeBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDe
 class ValidityRangeBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
     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()}
-
-    @classmethod
-    def validate(cls, root, info, input, obj=None, id=None):  # noqa: A002
-        print(obj, obj.__dict__, input)
-
-        super().validate(root, info, input, obj=obj, id=id)
-- 
GitLab


From 83097ee2e7c4f4ce0960d381f92fe133b95843ed Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Fri, 15 Mar 2024 09:59:53 +0100
Subject: [PATCH 05/12] Introduce task for syncing a published validity range

---
 aleksis/apps/lesrooster/models.py          |  56 +++++++---
 aleksis/apps/lesrooster/tasks.py           |  11 ++
 aleksis/apps/lesrooster/tests/test_sync.py | 115 +++++++++++++++++++++
 3 files changed, 167 insertions(+), 15 deletions(-)
 create mode 100644 aleksis/apps/lesrooster/tasks.py
 create mode 100644 aleksis/apps/lesrooster/tests/test_sync.py

diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 1fdf8794..56fa6253 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -5,6 +5,7 @@ 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
@@ -19,8 +20,10 @@ 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
 
@@ -118,7 +121,8 @@ class ValidityRange(ExtensibleModel):
                     )
                 elif self.date_end < datetime.now().date():
                     errors["date_end"] = _(
-                        "To avoid data loss, the validity range can be only shortened until today."
+                        "To avoid data loss, the validity range can "
+                        "be only shortened until the current day."
                     )
 
             if errors:
@@ -137,24 +141,46 @@ class ValidityRange(ExtensibleModel):
                     )
                 )
 
-    def publish(self):
+    def publish(self, request: HttpRequest | 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
         self.full_clean()
         self.save()
-        self._sync()
-
-    def _sync(self):
-        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))
-            + list(Substitution.objects.filter(lesson__slot_start__time_grid__validity_range=self))
-            + list(
-                SupervisionSubstitution.objects.filter(
-                    supervision__break_slot__time_grid__validity_range=self
-                )
+        self.sync(request=request)
+
+    # FIXME Execute sync on date start/date end change
+    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(
+                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."),
             )
-        )
-        for obj in objs_to_update:
+
+    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()
 
diff --git a/aleksis/apps/lesrooster/tasks.py b/aleksis/apps/lesrooster/tasks.py
new file mode 100644
index 00000000..c9b11381
--- /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_sync.py b/aleksis/apps/lesrooster/tests/test_sync.py
new file mode 100644
index 00000000..ca9eafdf
--- /dev/null
+++ b/aleksis/apps/lesrooster/tests/test_sync.py
@@ -0,0 +1,115 @@
+from datetime import date, time, timedelta, datetime
+import pytest
+
+from aleksis.apps.lesrooster.schema import slot
+
+pytestmark = pytest.mark.django_db
+
+from aleksis.core.models import SchoolTerm, Group, Person
+from aleksis.apps.lesrooster.models import ValidityRange, ValidityRangeStatus, TimeGrid, Lesson, Slot, Room
+from django.core.exceptions import ValidationError
+from freezegun import freeze_time
+from aleksis.apps.cursus.models import Subject, Course
+import recurrence
+from django.utils import timezone
+
+from calendarweek import CalendarWeek
+
+@pytest.fixture
+def 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)
+    return school_term
+
+@pytest.fixture
+def validity_range(school_term):
+    validity_range = ValidityRange.objects.create(school_term=school_term, date_start=school_term.date_start, date_end=school_term.date_end)
+    return validity_range
+
+@pytest.fixture
+def time_grid(validity_range):
+    return TimeGrid.objects.get(validity_range=validity_range, group=None)
+
+def test_sync_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)
+
+  
+    pattern = recurrence.Recurrence(
+                    dtstart=timezone.make_aware(
+                        datetime.combine(time_grid.validity_range.date_start, slot_a.time_start)
+                    ),
+                    rrules=[
+             recurrence.Rule(
+                            recurrence.WEEKLY,
+                            until=timezone.make_aware(
+                                datetime.combine(time_grid.validity_range.date_end, slot_b.time_end)
+                            ),
+                        )
+                    ],
+                )
+
+
+    teachers = [Person.objects.create(first_name=f"lesson_{i}", last_name=f"{i}") for i in range(2)]
+    groups = [Group.objects.create(name=f"lesson_{i}") for i in range(2)]
+    rooms = [Room.objects.create(name=f"lesson_{i}", short_name=f"lesson_{i}") for i in range(2)]
+
+    l = Lesson.objects.create(
+        course=course,
+        subject=subject,
+        slot_start=slot_a,
+        slot_end=slot_b,
+        recurrence=pattern
+    
+    )
+    l.teachers.set(teachers)
+    l.rooms.set(rooms)
+
+
+    assert l.lesson_event is None
+
+    l.sync()
+
+    assert l.lesson_event
+
+    le = l.lesson_event
+    assert le.course == course
+    assert le.subject == subject
+
+    week_start = CalendarWeek.from_date(time_grid.validity_range.date_start)
+    datetime_start = slot_a.get_datetime_start(week_start)
+    datetime_end = slot_b.get_datetime_end(week_start)
+
+    assert le.datetime_start == datetime_start
+    assert le.datetime_end == datetime_end
+
+
+
+def test_sync_on_publish():
+
+
+    pass
+
+
+def test_sync_on_date_end_changed():
+    pass
+
+def test_sync_on_date_start_changed():
+    pass
+
+def test_sync_sync():
+    pass
+
+def test_sync_async():
+    pass
\ No newline at end of file
-- 
GitLab


From 1f2ee1bcfd5fa514dbacd554e8aefc55aa0af92f Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Tue, 19 Mar 2024 18:04:45 +0100
Subject: [PATCH 06/12] Fix status filter for validity ranges

---
 .../frontend/components/validity_range/ValidityRange.vue      | 4 ++--
 aleksis/apps/lesrooster/schema/validity_range.py              | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue
index 259455c0..a4391479 100644
--- a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue
@@ -83,8 +83,8 @@ import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue";
 
       <template #filters="{ attrs, on }">
         <validity-range-status-field
-          v-bind="attrs('status__exact')"
-          v-on="on('status__exact')"
+          v-bind="attrs('status__iexact')"
+          v-on="on('status__iexact')"
           :label="$t('lesrooster.validity_range.status_label')"
         />
 
diff --git a/aleksis/apps/lesrooster/schema/validity_range.py b/aleksis/apps/lesrooster/schema/validity_range.py
index 6114fbc2..886870f1 100644
--- a/aleksis/apps/lesrooster/schema/validity_range.py
+++ b/aleksis/apps/lesrooster/schema/validity_range.py
@@ -25,7 +25,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"],
-- 
GitLab


From 29309413692d82ec6a9af438e98d550865991a63 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Tue, 19 Mar 2024 21:22:15 +0100
Subject: [PATCH 07/12] Add dedicated button for publishing validity ranges

---
 .../validity_range/PublishValidityRange.vue   | 59 +++++++++++++++++++
 .../validity_range/ValidityRange.vue          |  5 ++
 .../validity_range/validityRange.graphql      | 26 ++++++++
 .../apps/lesrooster/frontend/messages/en.json |  6 ++
 aleksis/apps/lesrooster/models.py             |  7 ++-
 aleksis/apps/lesrooster/schema/__init__.py    |  2 +
 .../apps/lesrooster/schema/validity_range.py  | 25 ++++++++
 7 files changed, 127 insertions(+), 3 deletions(-)
 create mode 100644 aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue

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 00000000..46e94082
--- /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"
+    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() {
+      console.log("ON DONE RUN");
+      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 a4391479..3fe4e97e 100644
--- a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue
@@ -9,6 +9,7 @@ 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>
@@ -108,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">
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql
index b6ec4379..bd38117c 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 2a5da55f..9a73841f 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 56fa6253..7daa5020 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -55,7 +55,7 @@ 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"])
@@ -146,7 +146,7 @@ class ValidityRange(ExtensibleModel):
 
         :param request: Optional :class:`HttpRequest` to show progress of syncing in frontend
         """
-        self.status = ValidityRangeStatus.PUBLISHED
+        self.status = ValidityRangeStatus.PUBLISHED.value
         self.full_clean()
         self.save()
         self.sync(request=request)
@@ -164,7 +164,8 @@ class ValidityRange(ExtensibleModel):
         else:
             result = sync_validity_range.delay(self.pk)
             return render_progress_page(
-                result,
+                request,
+                task_result=result,
                 title=_("Publish validity range {}".format(self)),
                 progress_title=_(
                     "All lessons and supervisions in the validity range {} are being synced …"
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index c798b6a5..e8bd683f 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,
@@ -313,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 886870f1..d9d18b59 100644
--- a/aleksis/apps/lesrooster/schema/validity_range.py
+++ b/aleksis/apps/lesrooster/schema/validity_range.py
@@ -1,3 +1,6 @@
+from django.core.exceptions import PermissionDenied
+
+import graphene
 from graphene_django.types import DjangoObjectType
 from graphene_django_cud.mutations import (
     DjangoBatchCreateMutation,
@@ -72,3 +75,25 @@ class ValidityRangeBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatc
             "date_end",
             "time_grids",
         )
+
+
+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)
-- 
GitLab


From 3782e520f157909ec79925a81f4633901a1e5090 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 22 Apr 2024 22:24:59 +0200
Subject: [PATCH 08/12] Reformat and fix tests

---
 aleksis/apps/lesrooster/models.py             |   2 +-
 aleksis/apps/lesrooster/tests/test_sync.py    | 112 ++++++++-------
 .../lesrooster/tests/test_validity_range.py   | 136 ++++++++++++------
 3 files changed, 152 insertions(+), 98 deletions(-)

diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 7daa5020..1f06b9e7 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -141,7 +141,7 @@ class ValidityRange(ExtensibleModel):
                     )
                 )
 
-    def publish(self, request: HttpRequest | None):
+    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
diff --git a/aleksis/apps/lesrooster/tests/test_sync.py b/aleksis/apps/lesrooster/tests/test_sync.py
index ca9eafdf..9738060a 100644
--- a/aleksis/apps/lesrooster/tests/test_sync.py
+++ b/aleksis/apps/lesrooster/tests/test_sync.py
@@ -1,43 +1,58 @@
-from datetime import date, time, timedelta, datetime
+from datetime import date, datetime, time
+
+from django.utils import timezone
+
 import pytest
+import recurrence
+from calendarweek import CalendarWeek
 
-from aleksis.apps.lesrooster.schema import slot
+from aleksis.apps.cursus.models import Course, Subject
+from aleksis.apps.lesrooster.models import (
+    Lesson,
+    Room,
+    Slot,
+    TimeGrid,
+    ValidityRange,
+)
+from aleksis.core.models import Group, Person, SchoolTerm
 
 pytestmark = pytest.mark.django_db
 
-from aleksis.core.models import SchoolTerm, Group, Person
-from aleksis.apps.lesrooster.models import ValidityRange, ValidityRangeStatus, TimeGrid, Lesson, Slot, Room
-from django.core.exceptions import ValidationError
-from freezegun import freeze_time
-from aleksis.apps.cursus.models import Subject, Course
-import recurrence
-from django.utils import timezone
-
-from calendarweek import CalendarWeek
 
 @pytest.fixture
 def school_term():
     date_start = date(2024, 1, 1)
-    date_end = date(2024,6,1)
+    date_end = date(2024, 6, 1)
     school_term = SchoolTerm.objects.create(name="Test", date_start=date_start, date_end=date_end)
     return school_term
 
+
 @pytest.fixture
 def validity_range(school_term):
-    validity_range = ValidityRange.objects.create(school_term=school_term, date_start=school_term.date_start, date_end=school_term.date_end)
+    validity_range = ValidityRange.objects.create(
+        school_term=school_term, date_start=school_term.date_start, date_end=school_term.date_end
+    )
     return validity_range
 
+
 @pytest.fixture
 def time_grid(validity_range):
     return TimeGrid.objects.get(validity_range=validity_range, group=None)
 
+
 def test_sync_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 ))
+    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_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")
@@ -45,71 +60,62 @@ def test_sync_lesson(time_grid):
     course.groups.set(course_groups)
     course.teachers.set(course_teachers)
 
-  
     pattern = recurrence.Recurrence(
-                    dtstart=timezone.make_aware(
-                        datetime.combine(time_grid.validity_range.date_start, slot_a.time_start)
-                    ),
-                    rrules=[
-             recurrence.Rule(
-                            recurrence.WEEKLY,
-                            until=timezone.make_aware(
-                                datetime.combine(time_grid.validity_range.date_end, slot_b.time_end)
-                            ),
-                        )
-                    ],
-                )
-
+        dtstart=timezone.make_aware(
+            datetime.combine(time_grid.validity_range.date_start, slot_a.time_start)
+        ),
+        rrules=[
+            recurrence.Rule(
+                recurrence.WEEKLY,
+                until=timezone.make_aware(
+                    datetime.combine(time_grid.validity_range.date_end, slot_b.time_end)
+                ),
+            )
+        ],
+    )
 
     teachers = [Person.objects.create(first_name=f"lesson_{i}", last_name=f"{i}") for i in range(2)]
-    groups = [Group.objects.create(name=f"lesson_{i}") for i in range(2)]
     rooms = [Room.objects.create(name=f"lesson_{i}", short_name=f"lesson_{i}") for i in range(2)]
 
-    l = Lesson.objects.create(
-        course=course,
-        subject=subject,
-        slot_start=slot_a,
-        slot_end=slot_b,
-        recurrence=pattern
-    
+    lesson = Lesson.objects.create(
+        course=course, subject=subject, slot_start=slot_a, slot_end=slot_b, recurrence=pattern
     )
-    l.teachers.set(teachers)
-    l.rooms.set(rooms)
-
+    lesson.teachers.set(teachers)
+    lesson.rooms.set(rooms)
 
-    assert l.lesson_event is None
+    assert lesson.lesson_event is None
 
-    l.sync()
+    lesson.sync()
 
-    assert l.lesson_event
+    assert lesson.lesson_event
 
-    le = l.lesson_event
-    assert le.course == course
-    assert le.subject == subject
+    lesson_event = lesson.lesson_event
+    assert lesson_event.course == course
+    assert lesson_event.subject == subject
 
     week_start = CalendarWeek.from_date(time_grid.validity_range.date_start)
     datetime_start = slot_a.get_datetime_start(week_start)
     datetime_end = slot_b.get_datetime_end(week_start)
 
-    assert le.datetime_start == datetime_start
-    assert le.datetime_end == datetime_end
-
+    assert lesson_event.datetime_start == datetime_start
+    assert lesson_event.datetime_end == datetime_end
 
 
 def test_sync_on_publish():
-
-
     pass
 
 
 def test_sync_on_date_end_changed():
     pass
 
+
 def test_sync_on_date_start_changed():
     pass
 
+
 def test_sync_sync():
     pass
 
+
 def test_sync_async():
-    pass
\ No newline at end of file
+    pass
diff --git a/aleksis/apps/lesrooster/tests/test_validity_range.py b/aleksis/apps/lesrooster/tests/test_validity_range.py
index 6654a74b..6d53d167 100644
--- a/aleksis/apps/lesrooster/tests/test_validity_range.py
+++ b/aleksis/apps/lesrooster/tests/test_validity_range.py
@@ -1,78 +1,89 @@
 from datetime import date, timedelta
-import pytest
-
-pytestmark = pytest.mark.django_db
 
-from aleksis.core.models import SchoolTerm
-from aleksis.apps.lesrooster.models import ValidityRange, ValidityRangeStatus, TimeGrid
 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)
+    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)
+    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)
+    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)
+    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)) == None
+    assert ValidityRange.get_current(date_end + timedelta(days=1)) is None
 
     with freeze_time(date_start):
         assert ValidityRange.current == validity_range
-    
+
     with freeze_time(date_end + timedelta(days=1)):
-        assert ValidityRange.current == None
+        assert ValidityRange.current is None
 
 
 def test_validity_range_date_start_before_date_end():
     date_start = date(2024, 1, 2)
-    date_end = date(2024,1,1)
+    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 = 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)
+    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))
+        (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))
+        (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\..*"):
+        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()
@@ -80,33 +91,57 @@ def test_validity_range_within_school_term():
 
 def test_validity_range_overlaps():
     date_start = date(2024, 1, 1)
-    date_end = date(2024,6,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)
+    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)
+        (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 for this time or a part of this time\..*"):
+        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))
+    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,
+    )
 
-    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
@@ -117,49 +152,59 @@ def test_change_published_validity_range():
     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
+    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.*"):
+        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()    
+        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
+    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.*"):
+        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.*"):
+        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
+
+    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 today.*"):
+        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
@@ -172,10 +217,13 @@ def test_change_published_validity_range():
         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
+    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.*"):
+        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()
 
 
-- 
GitLab


From 0a81bd77dc5abe4bf903a4bed51511dbd65ef761 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Tue, 23 Apr 2024 17:12:25 +0200
Subject: [PATCH 09/12] Fix build_recurrence method to use correct until value

---
 aleksis/apps/lesrooster/models.py | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 1f06b9e7..31e1bd7d 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -299,8 +299,12 @@ class Slot(ExtensiblePolymorphicModel):
     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:
+    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)
@@ -310,7 +314,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)
                     ),
                 )
             ],
@@ -413,7 +417,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":
-- 
GitLab


From 722795d29e2a84778adb8f25e505872123361145 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Tue, 23 Apr 2024 17:29:06 +0200
Subject: [PATCH 10/12] Extend tests for validity range and syncing

---
 .../apps/lesrooster/tests/test_recurrence.py  |  34 ++++-
 aleksis/apps/lesrooster/tests/test_sync.py    | 143 +++++++++++++-----
 .../lesrooster/tests/test_validity_range.py   |   2 +
 pyproject.toml                                |   3 +-
 4 files changed, 138 insertions(+), 44 deletions(-)

diff --git a/aleksis/apps/lesrooster/tests/test_recurrence.py b/aleksis/apps/lesrooster/tests/test_recurrence.py
index 78c204b3..93e85269 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
index 9738060a..017cc9bc 100644
--- a/aleksis/apps/lesrooster/tests/test_sync.py
+++ b/aleksis/apps/lesrooster/tests/test_sync.py
@@ -1,6 +1,4 @@
-from datetime import date, datetime, time
-
-from django.utils import timezone
+from datetime import date, time
 
 import pytest
 import recurrence
@@ -8,39 +6,45 @@ 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
+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.create(name="Test", date_start=date_start, date_end=date_end)
+    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.create(
+    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(validity_range=validity_range, group=None)
+    return TimeGrid.objects.get_or_create(validity_range=validity_range, group=None)[0]
 
 
-def test_sync_lesson(time_grid):
+@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)
     )
@@ -60,29 +64,46 @@ def test_sync_lesson(time_grid):
     course.groups.set(course_groups)
     course.teachers.set(course_teachers)
 
-    pattern = recurrence.Recurrence(
-        dtstart=timezone.make_aware(
-            datetime.combine(time_grid.validity_range.date_start, slot_a.time_start)
-        ),
-        rrules=[
-            recurrence.Rule(
-                recurrence.WEEKLY,
-                until=timezone.make_aware(
-                    datetime.combine(time_grid.validity_range.date_end, slot_b.time_end)
-                ),
-            )
-        ],
-    )
-
     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, recurrence=pattern
+        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()
@@ -90,19 +111,58 @@ def test_sync_lesson(time_grid):
     assert lesson.lesson_event
 
     lesson_event = lesson.lesson_event
-    assert lesson_event.course == course
-    assert lesson_event.subject == subject
+    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(time_grid.validity_range.date_start)
-    datetime_start = slot_a.get_datetime_start(week_start)
-    datetime_end = slot_b.get_datetime_end(week_start)
+    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
 
-def test_sync_on_publish():
-    pass
+    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():
@@ -113,9 +173,22 @@ def test_sync_on_date_start_changed():
     pass
 
 
-def test_sync_sync():
-    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
 
-def test_sync_async():
-    pass
+    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
index 6d53d167..22723116 100644
--- a/aleksis/apps/lesrooster/tests/test_validity_range.py
+++ b/aleksis/apps/lesrooster/tests/test_validity_range.py
@@ -39,9 +39,11 @@ def test_current_validity_range():
 
     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():
diff --git a/pyproject.toml b/pyproject.toml
index 88c2857f..7e212dac 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]
-- 
GitLab


From f65733e1c91357b5a79da66af99e7c97d5386b97 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Tue, 23 Apr 2024 17:29:21 +0200
Subject: [PATCH 11/12] Clear groups if no course is set

---
 aleksis/apps/lesrooster/models.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 31e1bd7d..72455e5f 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -455,6 +455,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())
 
-- 
GitLab


From 1bc1f933b9331e27a848857a83a5ed2925dda3ff Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Sun, 21 Jul 2024 17:34:49 +0200
Subject: [PATCH 12/12] Fix some bugs with publishing and changing of validity
 ranges

---
 .../validity_range/PublishValidityRange.vue   |  2 +-
 aleksis/apps/lesrooster/models.py             | 15 +++++--
 .../apps/lesrooster/schema/validity_range.py  | 41 ++++++++++++++-----
 3 files changed, 43 insertions(+), 15 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue
index 46e94082..b7a4430c 100644
--- a/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/PublishValidityRange.vue
@@ -4,6 +4,7 @@
     :mutation="publishValidityRange"
     :variables="{ id: item.id }"
     @done="onDone"
+    @error="handleMutationError"
     tag="span"
   >
     <template #default="{ mutate, loading, error }">
@@ -45,7 +46,6 @@ export default {
   },
   methods: {
     onDone() {
-      console.log("ON DONE RUN");
       this.$activateFrequentCeleryPolling();
     },
   },
diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 72455e5f..5bd43d2a 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -1,5 +1,5 @@
 import logging
-from datetime import date, datetime
+from datetime import date, datetime, timedelta
 from typing import Optional, Union
 
 from django.core.exceptions import ValidationError
@@ -151,7 +151,6 @@ class ValidityRange(ExtensibleModel):
         self.save()
         self.sync(request=request)
 
-    # FIXME Execute sync on date start/date end change
     def sync(self, request: HttpRequest | None):
         """Sync all lessons and supervisions of this validity range.
 
@@ -287,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."""
@@ -297,7 +301,10 @@ 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)
+        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
diff --git a/aleksis/apps/lesrooster/schema/validity_range.py b/aleksis/apps/lesrooster/schema/validity_range.py
index d9d18b59..38a1cf24 100644
--- a/aleksis/apps/lesrooster/schema/validity_range.py
+++ b/aleksis/apps/lesrooster/schema/validity_range.py
@@ -2,17 +2,13 @@ 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,
 )
 
@@ -43,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",)
@@ -57,13 +53,38 @@ class ValidityRangeBatchCreateMutation(DjangoBatchCreateMutation):
         )
 
 
-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.edit_validityrange_rule",)
-- 
GitLab