From bbf2d5e5420b8aa2a6610a029f2b4c738107eb86 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 4 Feb 2022 16:34:38 +0100
Subject: [PATCH 1/3] api: cancel booking in recurring fillslot - instead of
 delete (#61066)

---
 chrono/api/views.py        |  35 ++-
 tests/api/test_fillslot.py | 589 ++++++++++++++++++++++++++++++++++---
 2 files changed, 580 insertions(+), 44 deletions(-)

diff --git a/chrono/api/views.py b/chrono/api/views.py
index 02f3573..3cef17b 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -1647,8 +1647,22 @@ class RecurringFillslots(APIView):
                 payload['slots'], start_datetime, end_datetime, user_external_id
             ).values_list('pk', flat=True)
 
-        events_to_book = events_to_book.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_to_book.filter(
+            booking__user_external_id=user_external_id,
+            booking__cancellation_datetime__isnull=False,
+            full=False,
+        )
+        # book only events without active booking for the user
+        events_to_book = events_to_book.exclude(
+            pk__in=Booking.objects.filter(
+                event__in=events_to_book,
+                user_external_id=user_external_id,
+                cancellation_datetime__isnull=True,
+            ).values('event')
+        )
 
+        # exclude full events
         full_events = list(events_to_book.filter(full=True))
         events_to_book = events_to_book.filter(full=False)
 
@@ -1662,16 +1676,27 @@ class RecurringFillslots(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_to_book]
 
+        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
-            ).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 = {
             'err': 0,
             'booking_count': len(bookings),
-            'cancelled_booking_count': deleted_count,
+            'cancelled_booking_count': cancelled_count,
             'full_events': [get_event_detail(request, x, multiple_agendas=True) for x in full_events],
         }
         if payload.get('include_booked_events_detail'):
diff --git a/tests/api/test_fillslot.py b/tests/api/test_fillslot.py
index b70dd31..5aaa4e7 100644
--- a/tests/api/test_fillslot.py
+++ b/tests/api/test_fillslot.py
@@ -2371,7 +2371,115 @@ def test_recurring_events_api_fillslots_waiting_list(app, user, freezer):
     assert events.filter(booked_waiting_list_places=2).count() == 5
 
 
-def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
+def test_recurring_events_api_fillslots_book_with_cancelled(app, user, freezer):
+    freezer.move_to('2021-09-06 12:00')
+    agenda = Agenda.objects.create(label='Foo bar', kind='events')
+    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
+    event = Event.objects.create(
+        label='Event',
+        start_datetime=now(),
+        recurrence_days=[0, 1],  # Monday, Tuesday
+        places=1,
+        waiting_list_places=1,
+        agenda=agenda,
+        recurrence_end_date=now() + datetime.timedelta(days=21),
+    )
+    event.create_all_recurrences()
+
+    # create cancelled bookings for the user
+    event_1_0 = event.get_or_create_event_recurrence(event.start_datetime)
+    booking_1_0 = Booking.objects.create(event=event_1_0, user_external_id='user_id')
+    booking_1_0.cancel()
+    assert booking_1_0.cancellation_datetime is not None
+    event_1_1 = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=1))
+    booking_1_1 = Booking.objects.create(event=event_1_1, user_external_id='user_id')
+    booking_1_1.cancel()
+    assert booking_1_1.cancellation_datetime is not None
+    # and non cancelled bookings for the user
+    event_2_0 = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=7))
+    booking_2_0 = Booking.objects.create(event=event_2_0, user_external_id='user_id')
+    assert booking_2_0.cancellation_datetime is None
+    event_2_1 = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=8))
+    booking_2_1 = Booking.objects.create(event=event_2_1, user_external_id='user_id')
+    assert booking_2_1.cancellation_datetime is None
+    # and bookings for another user
+    Booking.objects.create(event=event_1_0, user_external_id='user_id_foobar')
+    other_booking = Booking.objects.create(event=event_2_0, user_external_id='user_id_foobar')
+    other_booking.cancel()
+
+    app.authorization = ('Basic', ('john.doe', 'password'))
+    fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=%s&action=book' % agenda.slug
+    params = {'user_external_id': 'user_id'}
+
+    # Book Monday
+    params['slots'] = 'foo-bar@event:0'
+    resp = app.post_json(fillslots_url, params=params)
+    assert resp.json['booking_count'] == 2
+    assert resp.json['cancelled_booking_count'] == 0
+    assert Booking.objects.filter(user_external_id='user_id').count() == 5
+    assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 4
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=2).count() == 3
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=2,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 3
+    )
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=3).count() == 2
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=3,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 1
+    )
+
+    assert Booking.objects.filter(pk=booking_1_0.pk).exists() is False  # cancelled booking deleted
+    booking_1_1.refresh_from_db()
+    booking_2_0.refresh_from_db()
+    booking_2_1.refresh_from_db()
+    assert booking_1_1.cancellation_datetime is not None
+    assert booking_2_0.cancellation_datetime is None
+    assert booking_2_1.cancellation_datetime is None
+
+    # Book Tuesday
+    params['slots'] = 'foo-bar@event:1'
+    resp = app.post_json(fillslots_url, params=params)
+    assert resp.json['booking_count'] == 2
+    assert resp.json['cancelled_booking_count'] == 0
+    assert Booking.objects.filter(user_external_id='user_id').count() == 6
+    assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 6
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=2).count() == 3
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=2,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 3
+    )
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=3).count() == 3
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=3,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 3
+    )
+
+    assert Booking.objects.filter(pk=booking_1_0.pk).exists() is False  # cancelled booking deleted
+    assert Booking.objects.filter(pk=booking_1_1.pk).exists() is False  # cancelled booking deleted
+    booking_2_0.refresh_from_db()
+    booking_2_1.refresh_from_db()
+    assert booking_2_0.cancellation_datetime is None
+    assert booking_2_1.cancellation_datetime is None
+
+
+def test_recurring_events_api_fillslots_update(app, user, freezer):
     freezer.move_to('2021-09-06 12:00')
     agenda = Agenda.objects.create(label='Foo bar', kind='events')
     Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
@@ -2413,24 +2521,56 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
     resp = app.post_json(fillslots_url, params=params)
     assert resp.json['booking_count'] == 52
     assert resp.json['cancelled_booking_count'] == 104
-    assert Booking.objects.count() == 104
-    assert Booking.objects.filter(event__start_datetime__week_day=2).count() == 52
-    assert Booking.objects.filter(event__start_datetime__week_day=3).count() == 52
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 104
+    assert (
+        Booking.objects.filter(
+            event__start_datetime__week_day=2,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 52
+    )
+    assert (
+        Booking.objects.filter(
+            event__start_datetime__week_day=3,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 52
+    )
 
     # Booking again does nothing
     resp = app.post_json(fillslots_url, params=params)
     assert resp.json['booking_count'] == 0
     assert resp.json['cancelled_booking_count'] == 0
-    assert Booking.objects.count() == 104
+    assert Booking.objects.count() == 208
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 104
 
     params = {'user_external_id': 'user_id_2'}
     params['slots'] = 'foo-bar@event:0,foo-bar@event:3'
     resp = app.post_json(fillslots_url, params=params)
     assert resp.json['booking_count'] == 104
     assert resp.json['cancelled_booking_count'] == 0
-    assert Booking.objects.count() == 208
-    assert Booking.objects.filter(event__start_datetime__week_day=2).count() == 104
-    assert Booking.objects.filter(event__start_datetime__week_day=5).count() == 52
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 208
+    assert (
+        Booking.objects.filter(
+            event__start_datetime__week_day=2,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 104
+    )
+    assert (
+        Booking.objects.filter(
+            event__start_datetime__week_day=3,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 52
+    )
+    assert (
+        Booking.objects.filter(
+            event__start_datetime__week_day=5,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 52
+    )
     events = Event.objects.filter(primary_event__isnull=False)
     assert events.filter(booked_places=1).count() == 156
     assert events.filter(booked_waiting_list_places=1).count() == 52
@@ -2439,9 +2579,28 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
     resp = app.post_json(fillslots_url, params=params)
     assert resp.json['booking_count'] == 104
     assert resp.json['cancelled_booking_count'] == 104
-    assert Booking.objects.count() == 208
-    assert Booking.objects.filter(event__start_datetime__week_day=3).count() == 104
-    assert Booking.objects.filter(event__start_datetime__week_day=6).count() == 52
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 208
+    assert (
+        Booking.objects.filter(
+            event__start_datetime__week_day=2,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 52
+    )
+    assert (
+        Booking.objects.filter(
+            event__start_datetime__week_day=3,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 104
+    )
+    assert (
+        Booking.objects.filter(
+            event__start_datetime__week_day=6,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 52
+    )
     events = Event.objects.filter(primary_event__isnull=False)
     assert events.filter(booked_places=1).count() == 156
     assert events.filter(booked_waiting_list_places=1).count() == 52
@@ -2450,7 +2609,13 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
     resp = app.post_json(fillslots_url + '&date_end=2022-01-01', params=params)
     assert resp.json['booking_count'] == 0
     assert resp.json['cancelled_booking_count'] == 70
-    assert Booking.objects.filter(user_external_id='user_id_2').count() == 34
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id_2',
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 34
+    )
 
     # past bookings are left in place
     freezer.move_to('2021-10-04 12:00')  # first Monday of october
@@ -2458,14 +2623,33 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
     resp = app.post_json(fillslots_url + '&date_start=2021-10-11&date_end=2022-01-01', params=params)
     assert resp.json['booking_count'] == 0
     assert resp.json['cancelled_booking_count'] == 2  # only first week of october was cancelled
-    assert Booking.objects.filter(user_external_id='user_id_2').count() == 32
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id_2',
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 32
+    )
 
     # passing empty slots cancels all bookings
     params['slots'] = ''
     resp = app.post_json(fillslots_url, params=params)
     assert resp.json['cancelled_booking_count'] == 24
-    assert Booking.objects.filter(user_external_id='user_id_2').count() == 8
-    assert Booking.objects.filter(user_external_id='user_id_2', event__start_datetime__gt=now()).count() == 0
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id_2',
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 8
+    )
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id_2',
+            event__start_datetime__gt=now(),
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 0
+    )
 
     # only recurring events are impacted
     normal_event = Event.objects.create(
@@ -2477,7 +2661,114 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
     assert Booking.objects.filter(user_external_id='user_id', event=normal_event).count() == 1
 
 
-def test_recurring_events_api_unbook(app, user, freezer):
+def test_recurring_events_api_fillslots_update_with_cancelled(app, user, freezer):
+    freezer.move_to('2021-09-06 12:00')
+    agenda = Agenda.objects.create(label='Foo bar', kind='events')
+    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
+    event = Event.objects.create(
+        label='Event',
+        start_datetime=now(),
+        recurrence_days=[0, 1],  # Monday, Tuesday
+        places=1,
+        waiting_list_places=1,
+        agenda=agenda,
+        recurrence_end_date=now() + datetime.timedelta(days=21),
+    )
+    event.create_all_recurrences()
+
+    # create cancelled bookings for the user
+    event_1_0 = event.get_or_create_event_recurrence(event.start_datetime)
+    booking_1_0 = Booking.objects.create(event=event_1_0, user_external_id='user_id')
+    booking_1_0.cancel()
+    assert booking_1_0.cancellation_datetime is not None
+    event_1_1 = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=1))
+    booking_1_1 = Booking.objects.create(event=event_1_1, user_external_id='user_id')
+    booking_1_1.cancel()
+    assert booking_1_1.cancellation_datetime is not None
+    # and non cancelled bookings for the user
+    event_2_0 = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=7))
+    booking_2_0 = Booking.objects.create(event=event_2_0, user_external_id='user_id')
+    assert booking_2_0.cancellation_datetime is None
+    event_2_1 = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=8))
+    booking_2_1 = Booking.objects.create(event=event_2_1, user_external_id='user_id')
+    assert booking_2_1.cancellation_datetime is None
+    # and bookings for another user
+    Booking.objects.create(event=event_1_0, user_external_id='user_id_foobar')
+    other_booking = Booking.objects.create(event=event_2_0, user_external_id='user_id_foobar')
+    other_booking.cancel()
+
+    app.authorization = ('Basic', ('john.doe', 'password'))
+    fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=%s&action=update' % agenda.slug
+    params = {'user_external_id': 'user_id'}
+
+    # Book Monday
+    params['slots'] = 'foo-bar@event:0'
+    resp = app.post_json(fillslots_url, params=params)
+    assert resp.json['booking_count'] == 2
+    assert resp.json['cancelled_booking_count'] == 1
+    assert Booking.objects.filter(user_external_id='user_id').count() == 5
+    assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 3
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=2).count() == 3
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=2,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 3
+    )
+    assert Booking.objects.filter(event__start_datetime__week_day=3).count() == 2
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=3,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 0
+    )
+
+    assert Booking.objects.filter(pk=booking_1_0.pk).exists() is False  # cancelled booking deleted
+    booking_1_1.refresh_from_db()
+    booking_2_0.refresh_from_db()
+    booking_2_1.refresh_from_db()
+    assert booking_1_1.cancellation_datetime is not None
+    assert booking_2_0.cancellation_datetime is None
+    assert booking_2_1.cancellation_datetime is not None
+
+    # Book Tuesday
+    params['slots'] = 'foo-bar@event:1'
+    resp = app.post_json(fillslots_url, params=params)
+    assert resp.json['booking_count'] == 3
+    assert resp.json['cancelled_booking_count'] == 3
+    assert Booking.objects.filter(user_external_id='user_id').count() == 6
+    assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 3
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=2).count() == 3
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=2,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 0
+    )
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=3).count() == 3
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=3,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 3
+    )
+
+    assert Booking.objects.filter(pk=booking_1_0.pk).exists() is False  # cancelled booking deleted
+    assert Booking.objects.filter(pk=booking_1_1.pk).exists() is False  # cancelled booking deleted
+    assert Booking.objects.filter(pk=booking_2_1.pk).exists() is False  # cancelled booking deleted
+    booking_2_0.refresh_from_db()
+    assert booking_2_0.cancellation_datetime is not None
+
+
+def test_recurring_events_api_fillslots_unbook(app, user, freezer):
     freezer.move_to('2021-09-06 12:00')
     agenda = Agenda.objects.create(label='Foo bar', kind='events')
     Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
@@ -2518,9 +2809,29 @@ def test_recurring_events_api_unbook(app, user, freezer):
     assert resp.json['booking_count'] == 0
     assert resp.json['cancelled_booking_count'] == 52
 
-    assert Booking.objects.count() == 104
-    assert Booking.objects.filter(event__primary_event=event).count() == 52
-    assert Booking.objects.filter(event__primary_event=sunday_event).count() == 52
+    assert Booking.objects.count() == 156
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 104
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 52
+    )
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event,
+            cancellation_datetime__isnull=False,
+        ).count()
+        == 52
+    )
+    assert (
+        Booking.objects.filter(
+            event__primary_event=sunday_event,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 52
+    )
 
     params['slots'] = 'foo-bar@sunday-event:6'
     resp = app.post_json(
@@ -2529,15 +2840,42 @@ def test_recurring_events_api_unbook(app, user, freezer):
     assert resp.json['booking_count'] == 0
     assert resp.json['cancelled_booking_count'] == 9
 
-    assert Booking.objects.count() == 95
-    assert Booking.objects.filter(event__primary_event=event).count() == 52
-    assert Booking.objects.filter(event__primary_event=sunday_event).count() == 43
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 95
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 52
+    )
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event,
+            cancellation_datetime__isnull=False,
+        ).count()
+        == 52
+    )
+    assert (
+        Booking.objects.filter(
+            event__primary_event=sunday_event,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 43
+    )
+    assert (
+        Booking.objects.filter(
+            event__primary_event=sunday_event,
+            cancellation_datetime__isnull=False,
+        ).count()
+        == 9
+    )
     assert not Booking.objects.filter(
         event__primary_event=sunday_event,
         event__start_datetime__range=(
             datetime.date(year=2022, month=1, day=1),
             datetime.date(year=2022, month=3, day=1),
         ),
+        cancellation_datetime__isnull=True,
     ).exists()
 
     freezer.move_to('2022-01-01 12:00')
@@ -2547,12 +2885,26 @@ def test_recurring_events_api_unbook(app, user, freezer):
     assert resp.json['booking_count'] == 0
     assert resp.json['cancelled_booking_count'] == 35
 
-    assert Booking.objects.count() == 60
-    assert Booking.objects.filter(event__primary_event=event).count() == 17
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 60
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 17
+    )
     assert not Booking.objects.filter(
-        event__primary_event=event, event__start_datetime__gt=datetime.date(year=2022, month=1, day=1)
+        event__primary_event=event,
+        event__start_datetime__gt=datetime.date(year=2022, month=1, day=1),
+        cancellation_datetime__isnull=True,
     ).exists()
-    assert Booking.objects.filter(event__primary_event=sunday_event).count() == 43
+    assert (
+        Booking.objects.filter(
+            event__primary_event=sunday_event,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 43
+    )
 
     # unbooking when there are no bookings does nothing
     params['user_external_id'] = 'user_id_2'
@@ -2561,6 +2913,117 @@ def test_recurring_events_api_unbook(app, user, freezer):
     assert resp.json['cancelled_booking_count'] == 0
 
 
+def test_recurring_events_api_fillslots_unbook_with_cancelled(app, user, freezer):
+    freezer.move_to('2021-09-06 12:00')
+    agenda = Agenda.objects.create(label='Foo bar', kind='events')
+    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
+    event = Event.objects.create(
+        label='Event',
+        start_datetime=now(),
+        recurrence_days=[0, 1],  # Monday, Tuesday
+        places=1,
+        waiting_list_places=1,
+        agenda=agenda,
+        recurrence_end_date=now() + datetime.timedelta(days=21),
+    )
+    event.create_all_recurrences()
+
+    # create cancelled bookings for the user
+    event_1_0 = event.get_or_create_event_recurrence(event.start_datetime)
+    booking_1_0 = Booking.objects.create(event=event_1_0, user_external_id='user_id')
+    booking_1_0.cancel()
+    assert booking_1_0.cancellation_datetime is not None
+    event_1_1 = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=1))
+    booking_1_1 = Booking.objects.create(event=event_1_1, user_external_id='user_id')
+    booking_1_1.cancel()
+    assert booking_1_1.cancellation_datetime is not None
+    # and non cancelled bookings for the user
+    event_2_0 = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=7))
+    booking_2_0 = Booking.objects.create(event=event_2_0, user_external_id='user_id')
+    assert booking_2_0.cancellation_datetime is None
+    event_2_1 = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=8))
+    booking_2_1 = Booking.objects.create(event=event_2_1, user_external_id='user_id')
+    assert booking_2_1.cancellation_datetime is None
+    # and bookings for another user
+    Booking.objects.create(event=event_1_0, user_external_id='user_id_foobar')
+    other_booking = Booking.objects.create(event=event_2_0, user_external_id='user_id_foobar')
+    other_booking.cancel()
+
+    app.authorization = ('Basic', ('john.doe', 'password'))
+    fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=%s&action=unbook' % agenda.slug
+    params = {'user_external_id': 'user_id'}
+
+    # unbook Monday
+    params['slots'] = 'foo-bar@event:0'
+    resp = app.post_json(fillslots_url, params=params)
+    assert resp.json['booking_count'] == 0
+    assert resp.json['cancelled_booking_count'] == 1
+    assert Booking.objects.filter(user_external_id='user_id').count() == 4
+    assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 1
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=2).count() == 2
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=2,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 0
+    )
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=3).count() == 2
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=3,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 1
+    )
+
+    booking_1_0.refresh_from_db()
+    booking_1_1.refresh_from_db()
+    booking_2_0.refresh_from_db()
+    booking_2_1.refresh_from_db()
+    assert booking_1_0.cancellation_datetime is not None
+    assert booking_1_1.cancellation_datetime is not None
+    assert booking_2_0.cancellation_datetime is not None
+    assert booking_2_1.cancellation_datetime is None
+
+    # unbook Tuesday
+    params['slots'] = 'foo-bar@event:1'
+    resp = app.post_json(fillslots_url, params=params)
+    assert resp.json['booking_count'] == 0
+    assert resp.json['cancelled_booking_count'] == 1
+    assert Booking.objects.filter(user_external_id='user_id').count() == 4
+    assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 0
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=2).count() == 2
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=2,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 0
+    )
+    assert Booking.objects.filter(user_external_id='user_id', event__start_datetime__week_day=3).count() == 2
+    assert (
+        Booking.objects.filter(
+            user_external_id='user_id',
+            event__start_datetime__week_day=3,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 0
+    )
+
+    booking_1_0.refresh_from_db()
+    booking_1_1.refresh_from_db()
+    booking_2_0.refresh_from_db()
+    booking_2_1.refresh_from_db()
+    assert booking_1_0.cancellation_datetime is not None
+    assert booking_1_1.cancellation_datetime is not None
+    assert booking_2_0.cancellation_datetime is not None
+    assert booking_2_1.cancellation_datetime is not None
+
+
 @pytest.mark.freeze_time('2021-09-06 12:00')
 def test_recurring_events_api_fillslots_subscribed(app, user):
     category = Category.objects.create(label='Category A')
@@ -2629,12 +3092,20 @@ def test_recurring_events_api_fillslots_subscribed(app, user):
     resp = app.post_json(fillslots_url % 'all', params=params)
     assert resp.json['booking_count'] == 12
     assert resp.json['cancelled_booking_count'] == 10
-    assert Booking.objects.count() == 12
-    booked_events_first_agenda = Event.objects.filter(primary_event=event, booking__isnull=False)
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 12
+    booked_events_first_agenda = Event.objects.filter(
+        primary_event=event,
+        booking__isnull=False,
+        booking__cancellation_datetime__isnull=True,
+    )
     assert [
         x.strftime('%d/%m/%Y') for x in booked_events_first_agenda.values_list('start_datetime', flat=True)
     ] == ['21/09/2021', '28/09/2021', '05/10/2021', '12/10/2021', '19/10/2021']
-    booked_events_second_agenda = Event.objects.filter(primary_event=sunday_event, booking__isnull=False)
+    booked_events_second_agenda = Event.objects.filter(
+        primary_event=sunday_event,
+        booking__isnull=False,
+        booking__cancellation_datetime__isnull=True,
+    )
     assert [
         x.strftime('%d/%m/%Y') for x in booked_events_second_agenda.values_list('start_datetime', flat=True)
     ] == ['19/12/2021', '26/12/2021', '02/01/2022', '09/01/2022', '16/01/2022', '23/01/2022', '30/01/2022']
@@ -2656,8 +3127,12 @@ def test_recurring_events_api_fillslots_subscribed(app, user):
     params = {'user_external_id': 'yyy', 'slots': 'second-agenda@sunday-event:6'}
     resp = app.post_json(fillslots_url % 'category-b', params=params)
     assert resp.json['booking_count'] == 3
-    assert Booking.objects.count() == 15
-    booked_events_user_yyy = Event.objects.filter(primary_event=sunday_event, booking__user_external_id='yyy')
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 15
+    booked_events_user_yyy = Event.objects.filter(
+        primary_event=sunday_event,
+        booking__user_external_id='yyy',
+        booking__cancellation_datetime__isnull=True,
+    )
     assert [
         x.strftime('%d/%m/%Y') for x in booked_events_user_yyy.values_list('start_datetime', flat=True)
     ] == ['12/09/2021', '07/11/2021', '14/11/2021']
@@ -2727,10 +3202,28 @@ def test_recurring_events_api_fillslots_multiple_agendas(app, user):
     assert resp.json['booking_count'] == 0
     assert resp.json['cancelled_booking_count'] == 2
 
-    assert Booking.objects.count() == 19
-    assert Booking.objects.filter(event__primary_event=event_a).count() == 12
-    assert Booking.objects.filter(event__primary_event=event_b).count() == 0
-    assert Booking.objects.filter(event__primary_event=event_c).count() == 7
+    assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 19
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event_a,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 12
+    )
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event_b,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 0
+    )
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event_c,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 7
+    )
 
     # update bookings
     params = {'user_external_id': 'user_id', 'slots': 'first-agenda@b:1'}
@@ -2738,9 +3231,27 @@ def test_recurring_events_api_fillslots_multiple_agendas(app, user):
 
     assert resp.json['booking_count'] == 5
     assert resp.json['cancelled_booking_count'] == 19
-    assert Booking.objects.filter(event__primary_event=event_a).count() == 0
-    assert Booking.objects.filter(event__primary_event=event_b).count() == 5
-    assert Booking.objects.filter(event__primary_event=event_c).count() == 0
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event_a,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 0
+    )
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event_b,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 5
+    )
+    assert (
+        Booking.objects.filter(
+            event__primary_event=event_c,
+            cancellation_datetime__isnull=True,
+        ).count()
+        == 0
+    )
 
     # error if slot's agenda is not in querystring
     resp = app.post_json(fillslots_url % ('update', 'second-agenda'), params=params, status=400)
@@ -2772,7 +3283,7 @@ def test_recurring_events_api_fillslots_multiple_agendas_queries(app, user):
             params={'slots': events_to_book, 'user_external_id': 'user'},
         )
         assert resp.json['booking_count'] == 180
-        assert len(ctx.captured_queries) == 16
+        assert len(ctx.captured_queries) == 17
 
 
 @pytest.mark.freeze_time('2021-09-06 12:00')
-- 
2.34.1

