Repository: ephios-dev/ephios

Issue:
Copying events with overnight shifts creates broken copies
**Describe the bug**
When copying an event with shifts overnight to the next day, these shifts are created with wrong times.

**To Reproduce**

1. Create an event with the following shifts (as shown in the screenshot "original event")
    - 22:30-23:30 ("normal shift")
    - 23:30-00:30 ("overnight shift")
2. Create a copy of the event to the same day (using the configuration from the screenshot)
3. In contrast to the original event that ends at 01:00 (end of the overnight shift), the copy ends at 23:30 (end of the normal shift), as shown in the screenshots. However, the individual shift are both shown correctly on the copy.
4. The incorrect shift data also beaks the single day calendar view (see screenshot)

**Expected behavior**
When copying an event, all shifts including overnight shifts should be copied according to their configuration on the original event. The end time of the copied event should be identical to the end time of the latest shift end time.

**Screenshots**
Original event:  
![Original event](https://github.com/ephios-dev/ephios/assets/49586507/b29dad03-591d-46dc-ae6b-0327144df341)

Copy configuration:  
![Copy configuration](https://github.com/ephios-dev/ephios/assets/49586507/3fabb73b-13c1-4ddc-bf9e-501be1523ecf)

Copied event:  
![Copied event](https://github.com/ephios-dev/ephios/assets/49586507/abdef488-fb61-4b0a-9246-194155b5840e)

Broken single day calendar view:  
![Broken calendar view](https://github.com/ephios-dev/ephios/assets/49586507/5c5d86f8-e71e-4c52-802f-047bbb89aeee)

**Environment**
Tested with:
- ephios v0.16.3 (docker deployment with Postgres 12)
- ephios on commit 5025baa (local development instance with SQLite)

Both times using Firefox 124.0.1

**Additional context**
When editing the overnight shift on the copy event and saving it unchanged (i.e., just visiting the edit view and clicking save), the event seems to be fixed as issues described above can't be observed afterwards.



Discussion:

Resolving patch:
diff --git a/ephios/core/views/event.py b/ephios/core/views/event.py
--- a/ephios/core/views/event.py
+++ b/ephios/core/views/event.py
@@ -328,9 +328,9 @@ def _get_day_context(self):
             day=this_date.day,
             tzinfo=get_current_timezone(),
         )
-        to_time = from_time + timedelta(days=1)
+        to_time = from_time + timedelta(days=1, hours=3)  # show starts until 3am next day
         shifts = self._get_shifts_for_calendar().filter(
-            start_time__gte=from_time, start_time__lt=to_time
+            start_time__gte=from_time, start_time__lte=to_time
         )
 
         if self.filter_form.is_valid():
@@ -393,8 +393,10 @@ def _build_day_css_context(self, events, shifts):
         earliest_shift_start = min(shift.start_time for shift in shifts)
         latest_shift_end = max(shift.end_time for shift in shifts)
         # calculate timescale based on shortest shift
-        # to not make things too small, consider a maximum of 4 hours
-        shortest_shift_duration_in_hours = min(4, shortest_shift_duration.total_seconds() / 3600)
+        # to not make things too small, consider a maximum of 4 hours and a minimum of 15 minutes
+        shortest_shift_duration_in_hours = max(
+            0.25, min(4, shortest_shift_duration.total_seconds() / 3600)
+        )
         # seconds per em: the shortest shift should be 3600/300 = 12em high
         time_scaling_factor = int(300 * shortest_shift_duration_in_hours)
         for shift in shifts:
@@ -541,6 +543,7 @@ def setup(self, request, *args, **kwargs):
         self.object = self.get_object()
 
     def form_valid(self, form):
+        tz = get_current_timezone()
         occurrences = form.cleaned_data["recurrence"].between(
             datetime.now() - timedelta(days=1),
             datetime.now() + timedelta(days=7305),  # allow dates up to twenty years in the future
@@ -551,7 +554,7 @@ def form_valid(self, form):
         )
         for date in occurrences:
             event = self.get_object()
-            start_date = event.get_start_time().date()
+            start_date = event.get_start_time().astimezone(tz).date()
             shifts = list(event.shifts.all())
             event.pk = None
             event.save()
@@ -579,25 +582,25 @@ def form_valid(self, form):
             for shift in shifts:
                 shift.pk = None
                 # shifts on following days should have the same offset from the new date
-                offset = shift.start_time.date() - start_date
+                offset = shift.start_time.astimezone(tz).date() - start_date
                 # shifts ending on the next day should end on the next day to the new date
-                end_offset = shift.end_time.date() - shift.start_time.date()
-                current_tz = get_current_timezone()
-                shift.end_time = make_aware(
-                    datetime.combine(
-                        date.date() + offset + end_offset,
-                        shift.end_time.astimezone(current_tz).time(),
-                    )
+                end_offset = (
+                    shift.end_time.astimezone(tz).date() - shift.start_time.astimezone(tz).date()
                 )
-                shift.meeting_time = make_aware(
-                    datetime.combine(
-                        date.date() + offset, shift.meeting_time.astimezone(current_tz).time()
-                    )
+                shift.meeting_time = datetime.combine(
+                    date.date() + offset,
+                    shift.meeting_time.astimezone(tz).time(),
+                    tzinfo=tz,
                 )
-                shift.start_time = make_aware(
-                    datetime.combine(
-                        date.date() + offset, shift.start_time.astimezone(current_tz).time()
-                    )
+                shift.start_time = datetime.combine(
+                    date.date() + offset,
+                    shift.start_time.astimezone(tz).time(),
+                    tzinfo=tz,
+                )
+                shift.end_time = datetime.combine(
+                    date.date() + offset + end_offset,
+                    shift.end_time.astimezone(tz).time(),
+                    tzinfo=tz,
                 )
                 shift.event = event
                 shifts_to_create.append(shift)


Test patch:
diff --git a/tests/core/test_event_copy.py b/tests/core/test_event_copy.py
--- a/tests/core/test_event_copy.py
+++ b/tests/core/test_event_copy.py
@@ -1,7 +1,8 @@
-from datetime import datetime, timedelta
+from datetime import datetime, time, timedelta
 
 import recurrence
 from django.urls import reverse
+from django.utils import timezone
 from guardian.shortcuts import assign_perm
 
 from ephios.core.models import Event, Shift
@@ -145,3 +146,33 @@ def test_event_to_next_day_copy(self, django_app, planner, event_to_next_day, gr
             assert planners in get_groups_with_perms(
                 shift.event, only_with_perms_in=["change_event"]
             )
+
+    def test_copy_overnight_event_with_times_close_to_midnight(
+        self, django_app, planner, event, tz
+    ):
+        original_shift = event.shifts.first()
+        original_shift.start_time = datetime.combine(
+            original_shift.start_time.date(), time(hour=23, minute=30), tzinfo=tz
+        )
+        original_shift.end_time = datetime.combine(
+            original_shift.start_time.date() + timedelta(days=1), time(hour=0, minute=30), tzinfo=tz
+        )
+        original_shift.save()
+        response = django_app.get(reverse("core:event_copy", kwargs={"pk": event.id}), user=planner)
+        form = response.form
+        target_starttime = timezone.now() + timedelta(days=14)
+        recurr = recurrence.Recurrence(
+            dtstart=target_starttime,
+            rdates=[],
+        )
+        form["start_date"] = target_starttime.date()
+        form["recurrence"] = str(recurr)
+        form.submit()
+        copied_shift = Shift.objects.exclude(event=event).get()
+        assert copied_shift.start_time.astimezone(tz).date() == target_starttime.date()
+        assert copied_shift.start_time.astimezone(tz).time() == original_shift.start_time.time()
+        assert (
+            copied_shift.end_time.astimezone(tz).date()
+            == (target_starttime + timedelta(days=1)).date()
+        )
+        assert copied_shift.end_time.astimezone(tz).time() == original_shift.end_time.time()