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