From 4c79457a2c7a4b81b02cd76f3367998f55f67d0a Mon Sep 17 00:00:00 2001
From: Jari Rentsch <jarrentsch@gmail.com>
Date: Fri, 21 Jun 2024 17:56:18 +0200
Subject: [PATCH 1/4] Prevent creating second recurring event When an event is
 already part of a recurring event, we should not allow another recurring
 event to be created from this event. Organizers should use the copying
 instead

---
 championship/tests/test_recurring_events.py | 10 ++++++++++
 championship/views/recurring_events.py      |  3 +++
 2 files changed, 13 insertions(+)

diff --git a/championship/tests/test_recurring_events.py b/championship/tests/test_recurring_events.py
index 23543549..794299de 100644
--- a/championship/tests/test_recurring_events.py
+++ b/championship/tests/test_recurring_events.py
@@ -766,6 +766,16 @@ class RecurringEventViewTest(TestCase):
         )
         self.assertEqual(response.status_code, 403)
 
+    def test_forbid_create_second_recurring_event(self):
+        """When an event already has a recurring event, we should not allow creating another one."""
+        recurring_event = RecurringEventFactory()
+        self.event.recurring_event = recurring_event
+        self.event.save()
+        response = self.client.post(
+            reverse("recurring_event_create", args=[self.event.id]), self.data
+        )
+        self.assertEqual(response.status_code, 403)
+
     @freeze_time("2024-06-01")
     def test_copy_recurring_event(self):
         # Create a recurring event every wednesday for May
diff --git a/championship/views/recurring_events.py b/championship/views/recurring_events.py
index 74744796..7e55a2ea 100644
--- a/championship/views/recurring_events.py
+++ b/championship/views/recurring_events.py
@@ -206,6 +206,9 @@ class RecurringEventCreateView(LoginRequiredMixin, RecurringEventFormMixin, View
 
     def dispatch(self, request, *args, **kwargs):
         self.event = get_object_or_404(Event, pk=kwargs["event_id"])
+        # If the event is already part of a recurring event, we don't allow to create a new one
+        if self.event.recurring_event_id:
+            return HttpResponseForbidden()
         if self.event.organizer.user != request.user:
             return HttpResponseForbidden()
         return super().dispatch(request, *args, **kwargs)
-- 
GitLab


From 25af3bb15a6bb6ce17b294cd7fb154eddb80abb1 Mon Sep 17 00:00:00 2001
From: Jari Rentsch <jarrentsch@gmail.com>
Date: Fri, 21 Jun 2024 18:08:01 +0200
Subject: [PATCH 2/4] Copy Events with RecurringEvent Copied events should not
 be linked to the recurring event of the event they copied. Otherwise they
 will be rescheduled

---
 championship/tests/test_events_create.py | 20 ++++++++++++++++++++
 championship/views/events.py             |  1 +
 2 files changed, 21 insertions(+)

diff --git a/championship/tests/test_events_create.py b/championship/tests/test_events_create.py
index 12c84e85..90fc3937 100644
--- a/championship/tests/test_events_create.py
+++ b/championship/tests/test_events_create.py
@@ -25,6 +25,7 @@ from championship.factories import (
     EventFactory,
     EventOrganizerFactory,
     EventPlayerResultFactory,
+    RecurringEventFactory,
 )
 from championship.models import Address, Event, EventOrganizer
 
@@ -348,3 +349,22 @@ class EventCopyTestCase(TestCase):
         initial_address = response.context["form"].initial["address"]
         self.assertNotEqual(not_default_address, self.organizer.default_address)
         self.assertEqual(not_default_address.id, initial_address)
+
+    def test_copy_event_with_recurring_event(self):
+        self.login()
+        recurring_event = RecurringEventFactory()
+        event = EventFactory(recurring_event=recurring_event)
+
+        data = {
+            "name": "Test Event",
+            "url": "https://test.example",
+            "date": "11/26/2022",
+            "format": "LEGACY",
+            "category": "PREMIER",
+        }
+
+        self.client.post(reverse("event_copy", args=[event.id]), data=data)
+        events = Event.objects.all()
+        self.assertEqual(len(events), 2)
+        self.assertEqual(events[0].recurring_event, recurring_event)
+        self.assertIsNone(events[1].recurring_event)
diff --git a/championship/views/events.py b/championship/views/events.py
index dc912b50..100e4157 100644
--- a/championship/views/events.py
+++ b/championship/views/events.py
@@ -122,6 +122,7 @@ class CopyEventView(LoginRequiredMixin, UpdateView):
     def form_valid(self, form):
         event = form.save(commit=False)
         event.pk = None  # Force Django to create a new instance
+        event.recurring_event = None
         event.organizer = self.request.user.eventorganizer
         event.save()
         messages.success(self.request, "Successfully created event!")
-- 
GitLab


From 3767f9e7600a75b4a7e163609ee9e23684612408 Mon Sep 17 00:00:00 2001
From: Jari Rentsch <jarrentsch@gmail.com>
Date: Fri, 21 Jun 2024 18:15:49 +0200
Subject: [PATCH 3/4] Create Recurring Event without dates When the user by
 mistake creates a schedule that doesn't contain any dates, we need to show an
 error

---
 championship/tests/test_recurring_events.py | 12 +++++++++
 championship/views/recurring_events.py      | 30 ++++++++++++++++++---
 2 files changed, 39 insertions(+), 3 deletions(-)

diff --git a/championship/tests/test_recurring_events.py b/championship/tests/test_recurring_events.py
index 794299de..860221b5 100644
--- a/championship/tests/test_recurring_events.py
+++ b/championship/tests/test_recurring_events.py
@@ -29,6 +29,7 @@ from championship.factories import (
 )
 from championship.models import Event, RecurrenceRule, RecurringEvent
 from championship.views import calculate_recurrence_dates, reschedule
+from championship.views.recurring_events import NoDatesError
 
 
 class RecurringEventModelTest(TestCase):
@@ -776,6 +777,17 @@ class RecurringEventViewTest(TestCase):
         )
         self.assertEqual(response.status_code, 403)
 
+    def test_create_recurring_event_without_dates(self):
+        # RecurringEvent is only 1 day long and Friday doesn't occur on that day.
+        self.data["end_date"] = "2024-06-01"
+        response = self.client.post(
+            reverse("recurring_event_create", args=[self.event.id]), self.data
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, NoDatesError.ui_message)
+        # No recurring event was created
+        self.assertEqual(RecurringEvent.objects.count(), 0)
+
     @freeze_time("2024-06-01")
     def test_copy_recurring_event(self):
         # Create a recurring event every wednesday for May
diff --git a/championship/views/recurring_events.py b/championship/views/recurring_events.py
index 7e55a2ea..f829fd16 100644
--- a/championship/views/recurring_events.py
+++ b/championship/views/recurring_events.py
@@ -98,6 +98,19 @@ def calculate_recurrence_dates(
     return all_dates, regional_dates
 
 
+class NoLinkedEventError(ValueError):
+    pass
+
+
+class NoDatesError(ValueError):
+    ui_message = "Between the start date and end date we cannot schedule any events based on the recurrence rules provided."
+
+    def __init__(self):
+        super().__init__(
+            "'calculate_recurrence_dates' didn't find any dates based on the given recurrence rules between the start and end dates"
+        )
+
+
 @transaction.atomic
 def reschedule(recurring_event: RecurringEvent):
     """
@@ -118,12 +131,15 @@ def reschedule(recurring_event: RecurringEvent):
         default_event = recurring_event.event_set.last()
 
     if not default_event:
-        raise ValueError(
+        raise NoLinkedEventError(
             "Rescheduling a recurring event requires at least one event linked to it."
         )
 
     all_dates, regional_dates = calculate_recurrence_dates(recurring_event)
 
+    if not all_dates:
+        raise NoDatesError()
+
     def find_event_for_same_week(events, given_date) -> Event | None:
         # Compares year and week number
         given_week = given_date.isocalendar()[:2]
@@ -187,8 +203,16 @@ class RecurringEventFormMixin:
                 rule = form.save(commit=False)
                 rule.recurring_event = recurring_event
                 rule.save()
-
-            reschedule(recurring_event)
+            try:
+                reschedule(recurring_event)
+            except NoDatesError as e:
+                # If there are no dates to schedule, we delete the recurring event and show an error message
+                recurring_event.delete()
+                messages.error(
+                    request,
+                    e.ui_message,
+                )
+                return self.render_forms(recurring_event_form, recurrence_rule_formset)
             messages.success(request, "Event series successfully scheduled.")
             return redirect(
                 reverse(
-- 
GitLab


From a41e78c40846e9314a3ca42dd5aae52de85201f8 Mon Sep 17 00:00:00 2001
From: Jari Rentsch <jarrentsch@gmail.com>
Date: Sat, 22 Jun 2024 11:44:39 +0200
Subject: [PATCH 4/4] Duplicated events with results Previously when
 rescheduling a series, events with results would be duplicated. So one copy
 with results and one without would exist on the same date.

---
 championship/tests/test_recurring_events.py | 40 +++++++++++++++++++++
 championship/views/recurring_events.py      | 12 ++++---
 2 files changed, 48 insertions(+), 4 deletions(-)

diff --git a/championship/tests/test_recurring_events.py b/championship/tests/test_recurring_events.py
index 860221b5..8bfb51e3 100644
--- a/championship/tests/test_recurring_events.py
+++ b/championship/tests/test_recurring_events.py
@@ -301,6 +301,46 @@ class RecurrenceEventCreationTest(TestCase):
             ],
         )
 
+    @freeze_time("2024-06-01")
+    def test_event_with_results_shouldnt_be_scheduled_twice(self):
+        """When we schedule an event series, add results to a few events and then reschedule the series,
+        the events with results should remain the same and not be duplicated/rescheduled.
+        """
+        recurring_event = RecurringEventFactory(
+            end_date=datetime.date.today() + datetime.timedelta(days=30),
+        )
+        RecurrenceRuleFactory(
+            weekday=RecurrenceRule.Weekday.WEDNESDAY,
+            week=RecurrenceRule.Week.EVERY,
+            type=RecurrenceRule.Type.SCHEDULE,
+            recurring_event=recurring_event,
+        )
+        event = EventFactory(
+            recurring_event=recurring_event,
+            date=datetime.date.today(),
+        )
+        reschedule(recurring_event)
+        events = Event.objects.all()
+        # add results to the first 3 events
+        results = [EventPlayerResultFactory(event=event) for event in events[:3]]
+        reschedule(recurring_event)
+        events = Event.objects.all()
+        dates = [event.date for event in events]
+        self.assertEqual(
+            dates,
+            [
+                datetime.date(2024, 6, 5),
+                datetime.date(2024, 6, 12),
+                datetime.date(2024, 6, 19),
+                datetime.date(2024, 6, 26),
+            ],
+        )
+        for index, result in enumerate(results):
+            event = events[index]
+            results_of_event = event.eventplayerresult_set.all()
+            self.assertEqual(len(results_of_event), 1)
+            self.assertEqual(results_of_event[0], result)
+
     def test_reschedule_more_events(self):
         """Test that we can reschedule more events than before.
         - We create a series of events on Wednesdays for a month.
diff --git a/championship/views/recurring_events.py b/championship/views/recurring_events.py
index f829fd16..05f130b6 100644
--- a/championship/views/recurring_events.py
+++ b/championship/views/recurring_events.py
@@ -119,12 +119,13 @@ def reschedule(recurring_event: RecurringEvent):
     Events rescheduled in the same week keep their primary key (and thus also their URL).
     """
 
-    events_to_reschedule: list[Event] = list(
-        recurring_event.event_set.annotate(
-            result_cnt=Count("eventplayerresult")
-        ).filter(result_cnt=0)
+    events: list[Event] = list(
+        recurring_event.event_set.annotate(result_cnt=Count("eventplayerresult")).all()
     )
 
+    events_to_reschedule = [event for event in events if event.result_cnt == 0]
+    dates_of_events_to_keep = [event.date for event in events if event.result_cnt > 0]
+
     if events_to_reschedule:
         default_event = copy.deepcopy(events_to_reschedule[-1])
     else:
@@ -151,6 +152,9 @@ def reschedule(recurring_event: RecurringEvent):
         return None
 
     for date in all_dates:
+        # If an event we keep (with results entered) is scheduled for this date, we skip it
+        if date in dates_of_events_to_keep:
+            continue
         # Try to find an event for the same week, else create a copy
         event = find_event_for_same_week(events_to_reschedule, date)
         if not event:
-- 
GitLab