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