From 119250b898d37505992bc9b754f11d2e257733f6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Mon, 7 Feb 2022 09:59:20 +0100
Subject: [PATCH 2/3] api: cancel booking in events fillslots - instead of
 delete (#61066)

---
 chrono/api/views.py        | 39 ++++++++++++++++-----
 tests/api/test_fillslot.py | 69 ++++++++++++++++++++++++++++++++++++--
 2 files changed, 97 insertions(+), 11 deletions(-)

diff --git a/chrono/api/views.py b/chrono/api/views.py
index 3cef17b..160f7c2 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -1779,7 +1779,7 @@ class EventsFillslots(APIView):
         if end_datetime:
             already_booked_events = already_booked_events.filter(start_datetime__lt=end_datetime)
         agendas_by_ids = self.get_agendas_by_ids()
-        events_to_unbook_ids = []
+        events_to_unbook = []
         events_in_request_ids = [e.pk for e in events]
         for event in already_booked_events:
             if event.pk in events_in_request_ids:
@@ -1793,9 +1793,19 @@ class EventsFillslots(APIView):
                 continue
             if agenda.max_booking_datetime and event.start_datetime > agenda.max_booking_datetime:
                 continue
-            events_to_unbook_ids.append(event.pk)
+            events_to_unbook.append(event)
 
-        events = events.exclude(booking__user_external_id=user_external_id)
+        # outdated bookings to remove (cancelled bookings to replace by an active booking)
+        events_cancelled_to_delete = events.filter(
+            booking__user_external_id=user_external_id,
+            booking__cancellation_datetime__isnull=False,
+        )
+        # book only events without active booking for the user
+        events = events.exclude(
+            pk__in=Booking.objects.filter(
+                event__in=events, user_external_id=user_external_id, cancellation_datetime__isnull=True
+            ).values('event')
+        )
 
         full_events = [str(event) for event in events.filter(full=True)]
         if full_events:
@@ -1814,10 +1824,21 @@ class EventsFillslots(APIView):
         extra_data = {k: v for k, v in request.data.items() if k not in payload}
         bookings = [make_booking(event, payload, extra_data) for event in events]
 
+        bookings_to_cancel = Booking.objects.filter(
+            user_external_id=user_external_id, event__in=events_to_unbook, cancellation_datetime__isnull=True
+        )
+
         with transaction.atomic():
-            deleted_count = Booking.objects.filter(
-                user_external_id=user_external_id, event__in=events_to_unbook_ids
-            ).delete()[0]
+            # cancel existing bookings
+            cancelled_count = 0
+            for booking in bookings_to_cancel:
+                booking.cancel()
+                cancelled_count += 1
+            # and delete outdated cancelled bookings
+            Booking.objects.filter(
+                user_external_id=user_external_id, event__in=events_cancelled_to_delete
+            ).delete()
+            # create missing bookings
             Booking.objects.bulk_create(bookings)
 
         response = {
@@ -1830,7 +1851,7 @@ class EventsFillslots(APIView):
                 get_event_detail(request, x, multiple_agendas=self.multiple_agendas)
                 for x in waiting_list_events
             ],
-            'cancelled_booking_count': deleted_count,
+            'cancelled_booking_count': cancelled_count,
         }
         return Response(response)
 
@@ -1838,7 +1859,9 @@ class EventsFillslots(APIView):
         return get_events_from_slots(payload['slots'], request, self.agenda, payload)
 
     def get_already_booked_events(self, user_external_id):
-        return self.agenda.event_set.filter(booking__user_external_id=user_external_id)
+        return self.agenda.event_set.filter(
+            booking__user_external_id=user_external_id, booking__cancellation_datetime__isnull=True
+        )
 
     def get_agendas_by_ids(self):
         return {self.agenda.pk: self.agenda}
diff --git a/tests/api/test_fillslot.py b/tests/api/test_fillslot.py
index 5aaa4e7..1fd99c1 100644
--- a/tests/api/test_fillslot.py
+++ b/tests/api/test_fillslot.py
@@ -3341,9 +3341,9 @@ def test_api_events_fillslots(app, user):
     assert resp.json['booking_count'] == 1
     assert resp.json['cancelled_booking_count'] == 1
 
-    user_bookings = Booking.objects.filter(user_external_id='user_id')
+    user_bookings = Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True)
     assert {b.event.slug for b in user_bookings} == {'event-2', 'event-3'}
-    assert event.booking_set.count() == 2
+    assert event.booking_set.filter(cancellation_datetime__isnull=True).count() == 2
     assert second_event.booking_set.count() == 2
     assert third_event.booking_set.count() == 1
 
@@ -3370,12 +3370,75 @@ def test_api_events_fillslots(app, user):
     params['slots'] = ''
     resp = app.post_json(fillslots_url, params=params)
     assert resp.json['cancelled_booking_count'] == 3
-    assert Booking.objects.filter(user_external_id='user_id').count() == 0
+    assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 0
 
     resp = app.post('/api/agenda/foobar/events/fillslots/', status=404)
     resp = app.post('/api/agenda/0/events/fillslots/', status=404)
 
 
+@pytest.mark.freeze_time('2021-09-06 12:00')
+def test_api_events_fillslots_with_cancelled(app, user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='events')
+    event_1 = Event.objects.create(
+        label='Event 1',
+        start_datetime=now() + datetime.timedelta(days=1),
+        places=2,
+        agenda=agenda,
+    )
+    event_2 = Event.objects.create(
+        label='Event 2',
+        start_datetime=now() + datetime.timedelta(days=2),
+        places=2,
+        agenda=agenda,
+    )
+    Event.objects.create(
+        label='Event 3',
+        start_datetime=now() + datetime.timedelta(days=3),
+        places=2,
+        agenda=agenda,
+    )
+
+    # create cancelled booking for the user
+    booking_1 = Booking.objects.create(event=event_1, user_external_id='user_id')
+    booking_1.cancel()
+    assert booking_1.cancellation_datetime is not None
+    # and non cancelled booking for the user
+    booking_2 = Booking.objects.create(event=event_2, user_external_id='user_id')
+    assert booking_2.cancellation_datetime is None
+    # and bookings for another user
+    Booking.objects.create(event=event_1, user_external_id='user_id_foobar')
+    other_booking = Booking.objects.create(event=event_2, user_external_id='user_id_foobar')
+    other_booking.cancel()
+
+    app.authorization = ('Basic', ('john.doe', 'password'))
+    fillslots_url = '/api/agenda/%s/events/fillslots/' % agenda.slug
+
+    params = {'user_external_id': 'user_id', 'slots': 'event-1,event-2,event-3'}
+    resp = app.post_json(fillslots_url, params=params)
+    assert resp.json['booking_count'] == 2
+    assert len(resp.json['booked_events']) == 2
+    assert resp.json['cancelled_booking_count'] == 0
+    assert resp.json['booked_events'][0]['id'] == 'event-1'
+    assert resp.json['booked_events'][1]['id'] == 'event-3'
+    assert Booking.objects.filter(user_external_id='user_id').count() == 3
+    assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 3
+
+    assert Booking.objects.filter(pk=booking_1.pk).exists() is False  # cancelled booking deleted
+    booking_2.refresh_from_db()
+    assert booking_2.cancellation_datetime is None
+
+    params = {'user_external_id': 'user_id', 'slots': 'event-3'}
+    resp = app.post_json(fillslots_url, params=params)
+    assert resp.json['booking_count'] == 0
+    assert resp.json['cancelled_booking_count'] == 2
+    assert Booking.objects.filter(user_external_id='user_id').count() == 3
+    assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 1
+
+    assert Booking.objects.filter(pk=booking_1.pk).exists() is False  # cancelled booking deleted
+    booking_2.refresh_from_db()
+    assert booking_2.cancellation_datetime is not None
+
+
 def test_api_events_fillslots_check_delays(app, user):
     agenda = Agenda.objects.create(
         label='Foo bar', kind='events', minimal_booking_delay=0, maximal_booking_delay=7
-- 
2.34.1

