From 4e97b71b008d934b4d306f86ec6a395c8571f8a0 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 21 Nov 2024 11:34:50 +0100
Subject: [PATCH 01/12] Cut existing absences on absence creation to avoid
 overlaps

---
 aleksis/apps/kolego/models/absence.py | 77 +++++++++++++++++++++++++++
 1 file changed, 77 insertions(+)

diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index a2c5892..7ac0443 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -1,3 +1,5 @@
+from datetime import datetime, time
+
 from django.db import models
 from django.db.models import Q, QuerySet
 from django.http import HttpRequest
@@ -145,6 +147,81 @@ class Absence(FreeBusy):
     def __str__(self):
         return f"{self.person} ({self.datetime_start} - {self.datetime_end})"
 
+    def save(self, *args, skip_overlap_handling=False, **kwargs):
+        if not skip_overlap_handling:
+            events_within = Absence.get_objects(
+                None,
+                {"person": self.person.pk},
+                start=self.datetime_start or self.date_start,
+                end=self.datetime_end or self.date_end,
+            )
+
+            # Convert dates of new event to datetimes in case dates are used
+            new_datetime_start = (
+                self.datetime_start
+                if self.datetime_start
+                else datetime.combine(self.date_start, time())
+            ).astimezone(self.timezone)
+            new_datetime_end = (
+                self.datetime_end
+                if self.datetime_end
+                else datetime.combine(self.date_end, datetime.max.time())
+            ).astimezone(self.timezone)
+
+            for event_within in events_within:
+                event_within_datetime_start = (
+                    Absence.value_start_datetime(event_within)
+                    if event_within.datetime_start
+                    else datetime.combine(Absence.value_start_datetime(event_within), time())
+                )
+                event_within_datetime_end = (
+                    Absence.value_end_datetime(event_within)
+                    if event_within.datetime_end
+                    else datetime.combine(
+                        Absence.value_end_datetime(event_within), datetime.max.time()
+                    )
+                )
+
+                if (
+                    new_datetime_start > event_within_datetime_start
+                    and new_datetime_end < event_within_datetime_end
+                ):
+                    # Cut existing event in two parts
+                    # First, cut end date of existing one
+                    event_within.datetime_end = new_datetime_start
+                    event_within.save(skip_overlap_handling=True)
+                    # Then, create new event filling up the remaining time span
+                    end_filler_event = event_within
+                    end_filler_event.pk = None
+                    end_filler_event.id = None
+                    end_filler_event._state.adding = True
+                    end_filler_event.datetime_start = new_datetime_end
+                    end_filler_event.datetime_end = event_within_datetime_end
+
+                    end_filler_event.save(skip_overlap_handling=True)
+                elif (
+                    new_datetime_start <= event_within_datetime_start
+                    and new_datetime_end >= event_within_datetime_end
+                ):
+                    # Delete existing event
+                    event_within.delete()
+                elif (
+                    new_datetime_start > event_within_datetime_start
+                    and new_datetime_end >= event_within_datetime_end
+                ):
+                    # Cut end of existing event
+                    event_within.datetime_end = new_datetime_start
+                    event_within.save(skip_overlap_handling=True)
+                elif (
+                    new_datetime_start <= event_within_datetime_start
+                    and new_datetime_end < event_within_datetime_end
+                ):
+                    # Cut start of existing event
+                    event_within.datetime_start = new_datetime_end
+                    event_within.save(skip_overlap_handling=True)
+
+        super().save(*args, **kwargs)
+
     class Meta:
         verbose_name = _("Absence")
         verbose_name_plural = _("Absences")
-- 
GitLab


From 66f22cddd4cc0522f2bdb92000dac0afd6f31a64 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 27 Nov 2024 11:41:42 +0100
Subject: [PATCH 02/12] Fix handling of new absences cutting existing ones in
 two halves

---
 aleksis/apps/kolego/models/absence.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index 7ac0443..4a7e663 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -190,10 +190,12 @@ class Absence(FreeBusy):
                     # First, cut end date of existing one
                     event_within.datetime_end = new_datetime_start
                     event_within.save(skip_overlap_handling=True)
-                    # Then, create new event filling up the remaining time span
+                    # Then, create new event based on existing one filling up the remaining time
                     end_filler_event = event_within
                     end_filler_event.pk = None
                     end_filler_event.id = None
+                    end_filler_event.calendarevent_ptr_id = None
+                    end_filler_event.freebusy_ptr_id = None
                     end_filler_event._state.adding = True
                     end_filler_event.datetime_start = new_datetime_end
                     end_filler_event.datetime_end = event_within_datetime_end
-- 
GitLab


From 178874bc1ea84c11a91a0b5f5391c6e8f1ed700c Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 27 Nov 2024 12:25:10 +0100
Subject: [PATCH 03/12] Handle overlapping absences with same reason correctly

---
 aleksis/apps/kolego/models/absence.py | 92 +++++++++++++++------------
 aleksis/apps/kolego/schema/absence.py | 67 +++++++++++++++++++
 2 files changed, 119 insertions(+), 40 deletions(-)

diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index 4a7e663..9c324fb 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -148,6 +148,8 @@ class Absence(FreeBusy):
         return f"{self.person} ({self.datetime_start} - {self.datetime_end})"
 
     def save(self, *args, skip_overlap_handling=False, **kwargs):
+        skip_save = False
+
         if not skip_overlap_handling:
             events_within = Absence.get_objects(
                 None,
@@ -182,47 +184,57 @@ class Absence(FreeBusy):
                     )
                 )
 
-                if (
-                    new_datetime_start > event_within_datetime_start
-                    and new_datetime_end < event_within_datetime_end
-                ):
-                    # Cut existing event in two parts
-                    # First, cut end date of existing one
-                    event_within.datetime_end = new_datetime_start
-                    event_within.save(skip_overlap_handling=True)
-                    # Then, create new event based on existing one filling up the remaining time
-                    end_filler_event = event_within
-                    end_filler_event.pk = None
-                    end_filler_event.id = None
-                    end_filler_event.calendarevent_ptr_id = None
-                    end_filler_event.freebusy_ptr_id = None
-                    end_filler_event._state.adding = True
-                    end_filler_event.datetime_start = new_datetime_end
-                    end_filler_event.datetime_end = event_within_datetime_end
-
-                    end_filler_event.save(skip_overlap_handling=True)
-                elif (
-                    new_datetime_start <= event_within_datetime_start
-                    and new_datetime_end >= event_within_datetime_end
-                ):
-                    # Delete existing event
-                    event_within.delete()
-                elif (
-                    new_datetime_start > event_within_datetime_start
-                    and new_datetime_end >= event_within_datetime_end
-                ):
-                    # Cut end of existing event
-                    event_within.datetime_end = new_datetime_start
-                    event_within.save(skip_overlap_handling=True)
-                elif (
-                    new_datetime_start <= event_within_datetime_start
-                    and new_datetime_end < event_within_datetime_end
-                ):
-                    # Cut start of existing event
-                    event_within.datetime_start = new_datetime_end
+                # If overlapping absence has the same reason, just extend it
+                if event_within.reason == self.reason:
+                    skip_save = True
+                    event_within.datetime_start = min(
+                        new_datetime_start, event_within_datetime_start
+                    )
+                    event_within.datetime_end = min(new_datetime_end, event_within_datetime_end)
                     event_within.save(skip_overlap_handling=True)
-
-        super().save(*args, **kwargs)
+                else:
+                    if (
+                        new_datetime_start > event_within_datetime_start
+                        and new_datetime_end < event_within_datetime_end
+                    ):
+                        # Cut existing event in two parts
+                        # First, cut end date of existing one
+                        event_within.datetime_end = new_datetime_start
+                        event_within.save(skip_overlap_handling=True)
+                        # Then, create new event based on existing one filling up the remaining time
+                        end_filler_event = event_within
+                        end_filler_event.pk = None
+                        end_filler_event.id = None
+                        end_filler_event.calendarevent_ptr_id = None
+                        end_filler_event.freebusy_ptr_id = None
+                        end_filler_event._state.adding = True
+                        end_filler_event.datetime_start = new_datetime_end
+                        end_filler_event.datetime_end = event_within_datetime_end
+
+                        end_filler_event.save(skip_overlap_handling=True)
+                    elif (
+                        new_datetime_start <= event_within_datetime_start
+                        and new_datetime_end >= event_within_datetime_end
+                    ):
+                        # Delete existing event
+                        event_within.delete()
+                    elif (
+                        new_datetime_start > event_within_datetime_start
+                        and new_datetime_end >= event_within_datetime_end
+                    ):
+                        # Cut end of existing event
+                        event_within.datetime_end = new_datetime_start
+                        event_within.save(skip_overlap_handling=True)
+                    elif (
+                        new_datetime_start <= event_within_datetime_start
+                        and new_datetime_end < event_within_datetime_end
+                    ):
+                        # Cut start of existing event
+                        event_within.datetime_start = new_datetime_end
+                        event_within.save(skip_overlap_handling=True)
+
+            if not skip_save:
+                super().save(*args, **kwargs)
 
     class Meta:
         verbose_name = _("Absence")
diff --git a/aleksis/apps/kolego/schema/absence.py b/aleksis/apps/kolego/schema/absence.py
index 775725f..8ddbcc8 100644
--- a/aleksis/apps/kolego/schema/absence.py
+++ b/aleksis/apps/kolego/schema/absence.py
@@ -1,3 +1,4 @@
+from datetime import datetime, time
 from typing import Iterable, Union
 
 from graphene_django.types import DjangoObjectType
@@ -87,6 +88,72 @@ class AbsenceBatchCreateMutation(BaseBatchCreateMutation):
         optional_fields = ("comment", "reason")
         permissions = ("kolego.create_absence_rule",)
 
+    @classmethod
+    def before_save(cls, root, info, input, created_objects):  # noqa
+        modified_created_objects = []
+
+        for obj in created_objects:
+            events_within = (
+                Absence.get_objects(
+                    None,
+                    {"person": obj.person.pk},
+                    start=obj.datetime_start or obj.date_start,
+                    end=obj.datetime_end or obj.date_end,
+                )
+                .order_by("pk")
+                .filter(reason=obj.reason)
+            )
+
+            if events_within.exists():
+                # Convert dates of new event to datetimes in case dates are used
+                new_datetime_start = (
+                    obj.datetime_start
+                    if obj.datetime_start
+                    else datetime.combine(obj.date_start, time())
+                ).astimezone(obj.timezone)
+                new_datetime_end = (
+                    obj.datetime_end
+                    if obj.datetime_end
+                    else datetime.combine(obj.date_end, datetime.max.time())
+                ).astimezone(obj.timezone)
+
+                events_within_datetime_start_list = [
+                    (
+                        Absence.value_start_datetime(event_within)
+                        if event_within.datetime_start
+                        else datetime.combine(Absence.value_start_datetime(event_within), time())
+                    )
+                    for event_within in events_within
+                ]
+
+                events_within_datetime_end_list = [
+                    (
+                        Absence.value_end_datetime(event_within)
+                        if event_within.datetime_end
+                        else datetime.combine(
+                            Absence.value_end_datetime(event_within), datetime.max.time()
+                        )
+                    ).astimezone(event_within.timezone)
+                    for event_within in events_within
+                ]
+
+                # Extend overlapping lesson with same reason
+                last_event_within = events_within.last()
+                last_event_within.datetime_start = min(
+                    new_datetime_start, *events_within_datetime_start_list
+                )
+                last_event_within.datetime_end = max(
+                    new_datetime_end, *events_within_datetime_end_list
+                )
+
+                events_within.exclude(pk=last_event_within.pk).delete()
+
+                modified_created_objects.append(last_event_within)
+            else:
+                modified_created_objects.append(obj)
+
+        return modified_created_objects
+
 
 class AbsenceBatchDeleteMutation(BaseBatchDeleteMutation):
     class Meta:
-- 
GitLab


From 94361e86e887226bbbcf25e36aa7cfd37ca2d2f9 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sat, 30 Nov 2024 14:53:05 +0100
Subject: [PATCH 04/12] Add basic tests

---
 .../apps/kolego/tests/models/test_absence.py  | 148 ++++++++++++++++++
 1 file changed, 148 insertions(+)
 create mode 100644 aleksis/apps/kolego/tests/models/test_absence.py

diff --git a/aleksis/apps/kolego/tests/models/test_absence.py b/aleksis/apps/kolego/tests/models/test_absence.py
new file mode 100644
index 0000000..8ec8660
--- /dev/null
+++ b/aleksis/apps/kolego/tests/models/test_absence.py
@@ -0,0 +1,148 @@
+from datetime import datetime, timezone
+
+import pytest
+from aleksis.apps.kolego.models import Absence, AbsenceReason
+from aleksis.core.models import Person
+
+from zoneinfo import ZoneInfo
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def absences_test_data():
+    datetime_start_existing_1 = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    datetime_end_existing_1 = datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    datetime_start_existing_2 = datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    datetime_end_existing_2 = datetime(2024, 1, 3, 19, 43, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+
+    reason_1 = AbsenceReason.objects.create(short_name="i", name="ill")
+    reason_2 = AbsenceReason.objects.create(short_name="e", name="excused")
+    reason_1.refresh_from_db()
+    reason_2.refresh_from_db()
+
+    person_1 = Person.objects.create(first_name="Maria", last_name="Montessori")
+    person_2 = Person.objects.create(first_name="Karl Moritz", last_name="Fleischer")
+    person_1.refresh_from_db()
+    person_2.refresh_from_db()
+
+    # Create existing absencess
+    existing_absence_1 = Absence.objects.create(datetime_start=datetime_start_existing_1, datetime_end=datetime_end_existing_1, person=person_1, reason=reason_1)
+    existing_absence_2 = Absence.objects.create(datetime_start=datetime_start_existing_2, datetime_end=datetime_end_existing_2, person=person_1, reason=reason_1)
+    existing_absence_1.refresh_from_db()
+    existing_absence_2.refresh_from_db()
+
+    return {
+        "datetime_start_existing_1": datetime_start_existing_1,
+        "datetime_end_existing_1": datetime_end_existing_1,
+        "datetime_start_existing_2": datetime_start_existing_2,
+        "datetime_end_existing_2": datetime_end_existing_2,
+        "reason_1": reason_1,
+        "reason_2": reason_2,
+        "person_1": person_1,
+        "person_2": person_2,
+        "existing_absence_1": existing_absence_1,
+        "existing_absence_2": existing_absence_2,
+    }
+
+
+# Create new absences in six scenarios in relation to existing absence 1 with same person and different absence reason
+
+# 1: new absence is fully inside exiting absence
+def test_overlapping_single_absence_different_reason_inside(absences_test_data):
+    datetime_start_new = datetime(2024, 1, 1, 14, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    datetime_end_new = datetime(2024, 1, 2, 8, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    new_absence = Absence.objects.create(datetime_start=datetime_start_new, datetime_end=datetime_end_new, person=absences_test_data["person_1"], reason=absences_test_data["reason_2"])
+    new_absence.refresh_from_db()
+
+    absences_test_data["existing_absence_1"].refresh_from_db()
+
+    assert absences_test_data["existing_absence_1"].datetime_start == datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
+    assert absences_test_data["existing_absence_1"].datetime_end == datetime(2024, 1, 1, 14, 0, tzinfo=timezone.utc)
+
+    assert new_absence.datetime_start == datetime(2024, 1, 1, 14, 0, tzinfo=timezone.utc)
+    assert new_absence.datetime_end == datetime(2024, 1, 2, 8, 0, tzinfo=timezone.utc)
+
+    assert len(Absence.objects.all()) == 4
+    assert Absence.objects.filter(datetime_start=datetime(2024, 1, 2, 8, 0, tzinfo=timezone.utc)).exists()
+    
+    existing_absence_1_part_2 = Absence.objects.get(datetime_start=datetime(2024, 1, 2, 8, 0, tzinfo=timezone.utc))
+
+    assert existing_absence_1_part_2.datetime_start == datetime(2024, 1, 2, 8, 0, tzinfo=timezone.utc)
+    assert existing_absence_1_part_2.datetime_end == datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc)
+
+
+# 2: new absence covers the same time span as the existing one
+def test_overlapping_single_absence_different_reason_same(absences_test_data):
+    datetime_start_new = absences_test_data["datetime_start_existing_1"]
+    datetime_end_new = absences_test_data["datetime_end_existing_1"]
+    new_absence = Absence.objects.create(datetime_start=datetime_start_new, datetime_end=datetime_end_new, person=absences_test_data["person_1"], reason=absences_test_data["reason_2"])
+    new_absence.refresh_from_db()
+
+    assert len(Absence.objects.all()) == 2
+    assert not Absence.objects.filter(datetime_start=datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc), datetime_end=datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc)).exclude(pk=new_absence.pk).exists()
+
+    assert new_absence.datetime_start == datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
+    assert new_absence.datetime_end == datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc)
+
+
+# 3: new absence starts earlier and ends later than the existing absence
+def test_overlapping_single_absence_different_reason_exceed(absences_test_data):
+    datetime_start_new = datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    datetime_end_new = datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    new_absence = Absence.objects.create(datetime_start=datetime_start_new, datetime_end=datetime_end_new, person=absences_test_data["person_1"], reason=absences_test_data["reason_2"])
+    new_absence.refresh_from_db()
+
+    assert len(Absence.objects.all()) == 2
+    assert not Absence.objects.filter(datetime_start=datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc), datetime_end=datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc)).exists()
+
+    assert new_absence.datetime_start == datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc)
+    assert new_absence.datetime_end == datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc)
+
+
+# 4: new absence starts before existing absence and ends within the existing absence
+def test_overlapping_single_absence_different_reason_cut_start(absences_test_data):
+    datetime_start_new = datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    datetime_end_new = datetime(2024, 1, 1, 14, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    new_absence = Absence.objects.create(datetime_start=datetime_start_new, datetime_end=datetime_end_new, person=absences_test_data["person_1"], reason=absences_test_data["reason_2"])
+    new_absence.refresh_from_db()
+
+    absences_test_data["existing_absence_1"].refresh_from_db()
+
+    assert absences_test_data["existing_absence_1"].datetime_start == datetime(2024, 1, 1, 14, 0, tzinfo=timezone.utc)
+    assert absences_test_data["existing_absence_1"].datetime_end == datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc)
+
+    assert new_absence.datetime_start == datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc)
+    assert new_absence.datetime_end == datetime(2024, 1, 1, 14, 0, tzinfo=timezone.utc)
+
+
+# 5: new absence starts within existing absence and ends after the existing absence
+def test_overlapping_single_absence_different_reason_cut_end(absences_test_data):
+    datetime_start_new = datetime(2024, 1, 2, 8, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    datetime_end_new = datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    new_absence = Absence.objects.create(datetime_start=datetime_start_new, datetime_end=datetime_end_new, person=absences_test_data["person_1"], reason=absences_test_data["reason_2"])
+    new_absence.refresh_from_db()
+
+    absences_test_data["existing_absence_1"].refresh_from_db()
+
+    assert absences_test_data["existing_absence_1"].datetime_start == datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
+    assert absences_test_data["existing_absence_1"].datetime_end == datetime(2024, 1, 2, 8, 0, tzinfo=timezone.utc)
+
+    assert new_absence.datetime_start == datetime(2024, 1, 2, 8, 0, tzinfo=timezone.utc)
+    assert new_absence.datetime_end == datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc)
+
+
+# 6: new absence is completely outside of existing absence
+def test_overlapping_single_absence_different_reason_outside(absences_test_data):
+    datetime_start_new = datetime(2024, 1, 1, 8, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    datetime_end_new = datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin"))
+    new_absence = Absence.objects.create(datetime_start=datetime_start_new, datetime_end=datetime_end_new, person=absences_test_data["person_1"], reason=absences_test_data["reason_2"])
+    new_absence.refresh_from_db()
+
+    absences_test_data["existing_absence_1"].refresh_from_db()
+
+    assert absences_test_data["existing_absence_1"].datetime_start == datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
+    assert absences_test_data["existing_absence_1"].datetime_end == datetime(2024, 1, 2, 10, 0, tzinfo=timezone.utc)
+
+    assert new_absence.datetime_start == datetime(2024, 1, 1, 8, 0, tzinfo=timezone.utc)
+    assert new_absence.datetime_end == datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc)
-- 
GitLab


From 7cf98d4050adb61fbf8aa9ed0fee46fd70173455 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sat, 30 Nov 2024 15:17:22 +0100
Subject: [PATCH 05/12] Fix handling of new absences outside of existing ones

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

diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index 9c324fb..ad4f778 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -220,6 +220,7 @@ class Absence(FreeBusy):
                         event_within.delete()
                     elif (
                         new_datetime_start > event_within_datetime_start
+                        and new_datetime_start < event_within_datetime_end
                         and new_datetime_end >= event_within_datetime_end
                     ):
                         # Cut end of existing event
@@ -228,6 +229,7 @@ class Absence(FreeBusy):
                     elif (
                         new_datetime_start <= event_within_datetime_start
                         and new_datetime_end < event_within_datetime_end
+                        and new_datetime_end > event_within_datetime_start
                     ):
                         # Cut start of existing event
                         event_within.datetime_start = new_datetime_end
-- 
GitLab


From 6bf75d9c087721cc61e1a49c2e14d8caa76b99bf Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sat, 30 Nov 2024 15:17:35 +0100
Subject: [PATCH 06/12] Fix call of parent save method

---
 aleksis/apps/kolego/models/absence.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index ad4f778..8fde74e 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -235,8 +235,8 @@ class Absence(FreeBusy):
                         event_within.datetime_start = new_datetime_end
                         event_within.save(skip_overlap_handling=True)
 
-            if not skip_save:
-                super().save(*args, **kwargs)
+        if not skip_save:
+            super().save(*args, **kwargs)
 
     class Meta:
         verbose_name = _("Absence")
-- 
GitLab


From f0729721b652cbdacd030a01926fd82453f4fd47 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sat, 30 Nov 2024 17:52:38 +0100
Subject: [PATCH 07/12] Fix handling of overlapping absences with same reason
 and person

---
 aleksis/apps/kolego/models/absence.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index 8fde74e..e9fd570 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -190,7 +190,7 @@ class Absence(FreeBusy):
                     event_within.datetime_start = min(
                         new_datetime_start, event_within_datetime_start
                     )
-                    event_within.datetime_end = min(new_datetime_end, event_within_datetime_end)
+                    event_within.datetime_end = max(new_datetime_end, event_within_datetime_end)
                     event_within.save(skip_overlap_handling=True)
                 else:
                     if (
-- 
GitLab


From 8ced4059db4b18c0d355581a78c0f2f503ef7ebc Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sat, 30 Nov 2024 17:53:24 +0100
Subject: [PATCH 08/12] Correctly return modified objects in mutation

---
 aleksis/apps/kolego/models/absence.py | 11 ++++
 aleksis/apps/kolego/schema/absence.py | 79 ++++++---------------------
 2 files changed, 27 insertions(+), 63 deletions(-)

diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index e9fd570..f47e5e9 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -149,6 +149,7 @@ class Absence(FreeBusy):
 
     def save(self, *args, skip_overlap_handling=False, **kwargs):
         skip_save = False
+        modified_absences = []
 
         if not skip_overlap_handling:
             events_within = Absence.get_objects(
@@ -192,6 +193,7 @@ class Absence(FreeBusy):
                     )
                     event_within.datetime_end = max(new_datetime_end, event_within_datetime_end)
                     event_within.save(skip_overlap_handling=True)
+                    modified_absences.append(event_within)
                 else:
                     if (
                         new_datetime_start > event_within_datetime_start
@@ -201,6 +203,7 @@ class Absence(FreeBusy):
                         # First, cut end date of existing one
                         event_within.datetime_end = new_datetime_start
                         event_within.save(skip_overlap_handling=True)
+                        modified_absences.append(event_within)
                         # Then, create new event based on existing one filling up the remaining time
                         end_filler_event = event_within
                         end_filler_event.pk = None
@@ -212,11 +215,14 @@ class Absence(FreeBusy):
                         end_filler_event.datetime_end = event_within_datetime_end
 
                         end_filler_event.save(skip_overlap_handling=True)
+                        modified_absences.append(end_filler_event)
                     elif (
                         new_datetime_start <= event_within_datetime_start
                         and new_datetime_end >= event_within_datetime_end
                     ):
                         # Delete existing event
+                        if event_within in modified_absences:
+                            modified_absences.delete(event_within)
                         event_within.delete()
                     elif (
                         new_datetime_start > event_within_datetime_start
@@ -226,6 +232,7 @@ class Absence(FreeBusy):
                         # Cut end of existing event
                         event_within.datetime_end = new_datetime_start
                         event_within.save(skip_overlap_handling=True)
+                        modified_absences.append(event_within)
                     elif (
                         new_datetime_start <= event_within_datetime_start
                         and new_datetime_end < event_within_datetime_end
@@ -234,9 +241,13 @@ class Absence(FreeBusy):
                         # Cut start of existing event
                         event_within.datetime_start = new_datetime_end
                         event_within.save(skip_overlap_handling=True)
+                        modified_absences.append(event_within)
+
+                    modified_absences.append(self)
 
         if not skip_save:
             super().save(*args, **kwargs)
+        self._modified_absences = modified_absences
 
     class Meta:
         verbose_name = _("Absence")
diff --git a/aleksis/apps/kolego/schema/absence.py b/aleksis/apps/kolego/schema/absence.py
index 8ddbcc8..10c6f30 100644
--- a/aleksis/apps/kolego/schema/absence.py
+++ b/aleksis/apps/kolego/schema/absence.py
@@ -89,70 +89,13 @@ class AbsenceBatchCreateMutation(BaseBatchCreateMutation):
         permissions = ("kolego.create_absence_rule",)
 
     @classmethod
-    def before_save(cls, root, info, input, created_objects):  # noqa
-        modified_created_objects = []
-
-        for obj in created_objects:
-            events_within = (
-                Absence.get_objects(
-                    None,
-                    {"person": obj.person.pk},
-                    start=obj.datetime_start or obj.date_start,
-                    end=obj.datetime_end or obj.date_end,
-                )
-                .order_by("pk")
-                .filter(reason=obj.reason)
-            )
+    def before_save(cls, root, info, input, created_objs):  # noqa
+        modified_absences = []
+
+        for absence in created_objs:
+            modified_absences += getattr(absence, "_modified_absences", [])
 
-            if events_within.exists():
-                # Convert dates of new event to datetimes in case dates are used
-                new_datetime_start = (
-                    obj.datetime_start
-                    if obj.datetime_start
-                    else datetime.combine(obj.date_start, time())
-                ).astimezone(obj.timezone)
-                new_datetime_end = (
-                    obj.datetime_end
-                    if obj.datetime_end
-                    else datetime.combine(obj.date_end, datetime.max.time())
-                ).astimezone(obj.timezone)
-
-                events_within_datetime_start_list = [
-                    (
-                        Absence.value_start_datetime(event_within)
-                        if event_within.datetime_start
-                        else datetime.combine(Absence.value_start_datetime(event_within), time())
-                    )
-                    for event_within in events_within
-                ]
-
-                events_within_datetime_end_list = [
-                    (
-                        Absence.value_end_datetime(event_within)
-                        if event_within.datetime_end
-                        else datetime.combine(
-                            Absence.value_end_datetime(event_within), datetime.max.time()
-                        )
-                    ).astimezone(event_within.timezone)
-                    for event_within in events_within
-                ]
-
-                # Extend overlapping lesson with same reason
-                last_event_within = events_within.last()
-                last_event_within.datetime_start = min(
-                    new_datetime_start, *events_within_datetime_start_list
-                )
-                last_event_within.datetime_end = max(
-                    new_datetime_end, *events_within_datetime_end_list
-                )
-
-                events_within.exclude(pk=last_event_within.pk).delete()
-
-                modified_created_objects.append(last_event_within)
-            else:
-                modified_created_objects.append(obj)
-
-        return modified_created_objects
+        return modified_absences
 
 
 class AbsenceBatchDeleteMutation(BaseBatchDeleteMutation):
@@ -176,6 +119,16 @@ class AbsenceBatchPatchMutation(BaseBatchPatchMutation):
         )
         permissions = ("kolego.edit_absence_rule",)
 
+    @classmethod
+    def after_mutate(cls, root, info, input, updated_objs, return_data):  # noqa
+        modified_absences = []
+
+        for absence in updated_objs:
+            modified_absences += getattr(absence, "_modified_absences", [])
+
+        return_data[cls._meta.return_field_name] = modified_absences
+        return return_data
+
 
 class AbsenceReasonBatchCreateMutation(BaseBatchCreateMutation):
     class Meta:
-- 
GitLab


From 4838125c2a89504ad1566a61745d5b111d4c8161 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sun, 1 Dec 2024 18:25:45 +0100
Subject: [PATCH 09/12] Add classmethod that gets/creates one absence for given
 time span

---
 aleksis/apps/kolego/models/absence.py | 40 +++++++++++++++++++++++----
 1 file changed, 35 insertions(+), 5 deletions(-)

diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index f47e5e9..b620e56 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -1,4 +1,4 @@
-from datetime import datetime, time
+from datetime import date, datetime, time
 
 from django.db import models
 from django.db.models import Q, QuerySet
@@ -11,7 +11,7 @@ from aleksis.core.managers import (
     RecurrencePolymorphicManager,
 )
 from aleksis.core.mixins import ExtensibleModel
-from aleksis.core.models import FreeBusy
+from aleksis.core.models import FreeBusy, Person
 
 from ..managers import AbsenceQuerySet
 
@@ -148,7 +148,7 @@ class Absence(FreeBusy):
         return f"{self.person} ({self.datetime_start} - {self.datetime_end})"
 
     def save(self, *args, skip_overlap_handling=False, **kwargs):
-        skip_save = False
+        extended_absence = None
         modified_absences = []
 
         if not skip_overlap_handling:
@@ -187,12 +187,12 @@ class Absence(FreeBusy):
 
                 # If overlapping absence has the same reason, just extend it
                 if event_within.reason == self.reason:
-                    skip_save = True
                     event_within.datetime_start = min(
                         new_datetime_start, event_within_datetime_start
                     )
                     event_within.datetime_end = max(new_datetime_end, event_within_datetime_end)
                     event_within.save(skip_overlap_handling=True)
+                    extended_absence = event_within
                     modified_absences.append(event_within)
                 else:
                     if (
@@ -245,10 +245,40 @@ class Absence(FreeBusy):
 
                     modified_absences.append(self)
 
-        if not skip_save:
+        if extended_absence is not None:
+            self._extended_absence = extended_absence
+        else:
             super().save(*args, **kwargs)
         self._modified_absences = modified_absences
 
+    @classmethod
+    def get_for_person_by_datetimes(
+        cls,
+        person: Person,
+        datetime_start: datetime | None = None,
+        datetime_end: datetime | None = None,
+        date_start: date | None = None,
+        date_end: date | None = None,
+        defaults: dict | None = None,
+        **kwargs,
+    ) -> "Absence":
+        data = {"person": person, **kwargs}
+
+        if date_start:
+            data["date_start"] = date_start
+            data["date_end"] = date_end
+        else:
+            data["datetime_start"] = datetime_start
+            data["datetime_end"] = datetime_end
+
+        absence, created = cls.objects.get_or_create(**data, defaults=defaults)
+
+        if created:
+            if hasattr(absence, "_extended_absence"):
+                return absence._extended_absence
+            return absence
+        return absence
+
     class Meta:
         verbose_name = _("Absence")
         verbose_name_plural = _("Absences")
-- 
GitLab


From c4ddcca20836bcd3fb5a29d24d8fb6b89e2b80dc Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sun, 1 Dec 2024 18:25:49 +0100
Subject: [PATCH 10/12] Reformat

---
 aleksis/apps/kolego/schema/absence.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/aleksis/apps/kolego/schema/absence.py b/aleksis/apps/kolego/schema/absence.py
index 10c6f30..bb5f298 100644
--- a/aleksis/apps/kolego/schema/absence.py
+++ b/aleksis/apps/kolego/schema/absence.py
@@ -1,4 +1,3 @@
-from datetime import datetime, time
 from typing import Iterable, Union
 
 from graphene_django.types import DjangoObjectType
-- 
GitLab


From 812c00fe4e4d6d6d700de88e2c1c93d0589c413d Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Sun, 1 Dec 2024 18:51:09 +0100
Subject: [PATCH 11/12] Refactor overlapping logic

---
 aleksis/apps/kolego/models/absence.py | 32 +++++++++++++--------------
 1 file changed, 15 insertions(+), 17 deletions(-)

diff --git a/aleksis/apps/kolego/models/absence.py b/aleksis/apps/kolego/models/absence.py
index b620e56..da1326d 100644
--- a/aleksis/apps/kolego/models/absence.py
+++ b/aleksis/apps/kolego/models/absence.py
@@ -147,42 +147,40 @@ class Absence(FreeBusy):
     def __str__(self):
         return f"{self.person} ({self.datetime_start} - {self.datetime_end})"
 
-    def save(self, *args, skip_overlap_handling=False, **kwargs):
+    def save(self, *args, skip_overlap_handling: bool = False, **kwargs):
         extended_absence = None
         modified_absences = []
 
         if not skip_overlap_handling:
-            events_within = Absence.get_objects(
-                None,
-                {"person": self.person.pk},
-                start=self.datetime_start or self.date_start,
-                end=self.datetime_end or self.date_end,
-            )
-
             # Convert dates of new event to datetimes in case dates are used
             new_datetime_start = (
                 self.datetime_start
                 if self.datetime_start
-                else datetime.combine(self.date_start, time())
+                else datetime.combine(self.date_start, time.min)
             ).astimezone(self.timezone)
             new_datetime_end = (
                 self.datetime_end
                 if self.datetime_end
-                else datetime.combine(self.date_end, datetime.max.time())
+                else datetime.combine(self.date_end, time.max)
             ).astimezone(self.timezone)
 
+            events_within = Absence.get_objects(
+                None,
+                {"person": self.person.pk},
+                start=new_datetime_start,
+                end=new_datetime_end,
+            )
+
             for event_within in events_within:
                 event_within_datetime_start = (
-                    Absence.value_start_datetime(event_within)
+                    event_within.datetime_start
                     if event_within.datetime_start
-                    else datetime.combine(Absence.value_start_datetime(event_within), time())
+                    else datetime.combine(event_within.date_start, time.min)
                 )
                 event_within_datetime_end = (
-                    Absence.value_end_datetime(event_within)
+                    event_within.datetime_end
                     if event_within.datetime_end
-                    else datetime.combine(
-                        Absence.value_end_datetime(event_within), datetime.max.time()
-                    )
+                    else datetime.combine(event_within.date_end, time.max)
                 )
 
                 # If overlapping absence has the same reason, just extend it
@@ -222,7 +220,7 @@ class Absence(FreeBusy):
                     ):
                         # Delete existing event
                         if event_within in modified_absences:
-                            modified_absences.delete(event_within)
+                            modified_absences.remove(event_within)
                         event_within.delete()
                     elif (
                         new_datetime_start > event_within_datetime_start
-- 
GitLab


From a7a6b029707f2fa712e8f05ba76b293dc60cfb7d Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Sun, 1 Dec 2024 19:00:39 +0100
Subject: [PATCH 12/12] Add migration for existing overlapping absences

---
 .../0005_fix_overlapping_absences.py          | 96 +++++++++++++++++++
 1 file changed, 96 insertions(+)
 create mode 100644 aleksis/apps/kolego/migrations/0005_fix_overlapping_absences.py

diff --git a/aleksis/apps/kolego/migrations/0005_fix_overlapping_absences.py b/aleksis/apps/kolego/migrations/0005_fix_overlapping_absences.py
new file mode 100644
index 0000000..244567a
--- /dev/null
+++ b/aleksis/apps/kolego/migrations/0005_fix_overlapping_absences.py
@@ -0,0 +1,96 @@
+# Generated by Django 4.2.13 on 2024-07-16 11:01
+from datetime import datetime, time
+
+from django.db import migrations, models
+from django.db.models import Q
+
+
+def _run_forward(apps, schema_editor):
+    Absence = apps.get_model("kolego", "Absence")
+    for absence in Absence.objects.order_by("-datetime_start", "-date_start"):
+        # Convert dates of new event to datetimes in case dates are used
+        new_datetime_start = (
+            absence.datetime_start
+            if absence.datetime_start
+            else datetime.combine(absence.date_start, time.min)
+        ).astimezone(absence.timezone)
+        new_datetime_end = (
+            absence.datetime_end
+            if absence.datetime_end
+            else datetime.combine(absence.date_end, time.max)
+        ).astimezone(absence.timezone)
+
+        events_within = Absence.objects.filter(
+            person=absence.person,
+        ).filter(
+            Q(datetime_start__lte=new_datetime_end, datetime_end__gte=new_datetime_start)
+            | Q(date_start__lte=new_datetime_end.date(), date_end__gte=new_datetime_start.date())
+        )
+
+        for event_within in events_within:
+            event_within_datetime_start = (
+                event_within.datetime_start
+                if event_within.datetime_start
+                else datetime.combine(event_within.date_start, time.min)
+            )
+            event_within_datetime_end = (
+                event_within.datetime_end
+                if event_within.datetime_end
+                else datetime.combine(event_within.date_end, time.max)
+            )
+
+            # If overlapping absence has the same reason, just extend it
+            if event_within.reason == absence.reason:
+                event_within.datetime_start = min(new_datetime_start, event_within_datetime_start)
+                event_within.datetime_end = max(new_datetime_end, event_within_datetime_end)
+                event_within.save()
+            else:
+                if (
+                    new_datetime_start > event_within_datetime_start
+                    and new_datetime_end < event_within_datetime_end
+                ):
+                    # Cut existing event in two parts
+                    # First, cut end date of existing one
+                    event_within.datetime_end = new_datetime_start
+                    event_within.save()
+                    # Then, create new event based on existing one filling up the remaining time
+                    end_filler_event = event_within
+                    end_filler_event.pk = None
+                    end_filler_event.id = None
+                    end_filler_event.calendarevent_ptr_id = None
+                    end_filler_event.freebusy_ptr_id = None
+                    end_filler_event._state.adding = True
+                    end_filler_event.datetime_start = new_datetime_end
+                    end_filler_event.datetime_end = event_within_datetime_end
+
+                    end_filler_event.save()
+                elif (
+                    new_datetime_start <= event_within_datetime_start
+                    and new_datetime_end >= event_within_datetime_end
+                ):
+                    # Delete existing event
+                    event_within.delete()
+                elif (
+                    new_datetime_start > event_within_datetime_start
+                    and new_datetime_start < event_within_datetime_end
+                    and new_datetime_end >= event_within_datetime_end
+                ):
+                    # Cut end of existing event
+                    event_within.datetime_end = new_datetime_start
+                    event_within.save()
+                elif (
+                    new_datetime_start <= event_within_datetime_start
+                    and new_datetime_end < event_within_datetime_end
+                    and new_datetime_end > event_within_datetime_start
+                ):
+                    # Cut start of existing event
+                    event_within.datetime_start = new_datetime_end
+                    event_within.save()
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("kolego", "0004_absencereasontag_absencereason_tags"),
+    ]
+
+    operations = [migrations.RunPython(_run_forward)]
-- 
GitLab