From 4d694b7b52ab9071a473732f4856607521b64b27 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Thu, 30 Sep 2021 00:23:31 +0200
Subject: [PATCH 1/2] api: add an enpoint to patch an event (#57305)

---
 chrono/api/urls.py      |   5 ++
 chrono/api/views.py     |  85 ++++++++++++++++++++++++
 tests/api/test_event.py | 140 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 230 insertions(+)

diff --git a/chrono/api/urls.py b/chrono/api/urls.py
index 4179e66..ab1063e 100644
--- a/chrono/api/urls.py
+++ b/chrono/api/urls.py
@@ -44,16 +44,21 @@ urlpatterns = [
         views.recurring_events_list,
         name='api-agenda-recurring-events',
     ),
     url(
         r'^agenda/(?P<agenda_identifier>[\w-]+)/recurring-events/fillslots/$',
         views.recurring_fillslots,
         name='api-recurring-fillslots',
     ),
+    url(
+        r'^agenda/(?P<agenda_identifier>[\w-]+)/event/(?P<event_identifier>[\w:-]+)/$',
+        views.events,
+        name='api-event',
+    ),
     url(
         r'^agenda/(?P<agenda_identifier>[\w-]+)/status/(?P<event_identifier>[\w:-]+)/$',
         views.event_status,
         name='api-event-status',
     ),
     url(
         r'^agenda/(?P<agenda_identifier>[\w-]+)/bookings/(?P<event_identifier>[\w:-]+)/$',
         views.event_bookings,
diff --git a/chrono/api/views.py b/chrono/api/views.py
index 064b65c..6e2f453 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -2159,16 +2159,101 @@ class ResizeBooking(APIView):
     def success(self, booking):
         response = {'err': 0, 'booking_id': booking.pk}
         return Response(response)
 
 
 resize_booking = ResizeBooking.as_view()
 
 
+class Events(APIView):
+    permission_classes = (permissions.IsAuthenticated,)
+    serializer_class = serializers.EventSerializer
+
+    def get_object(self, agenda_identifier, event_identifier):
+        try:
+            agenda = Agenda.objects.get(slug=agenda_identifier, kind='events')
+        except Agenda.DoesNotExist:
+            try:
+                # legacy access by agenda id
+                agenda = Agenda.objects.get(pk=agenda_identifier, kind='events')
+            except (ValueError, Agenda.DoesNotExist):
+                raise Http404()
+        if ':' in event_identifier:
+            return get_event_recurrence(agenda, event_identifier)
+        try:
+            return agenda.event_set.get(slug=event_identifier)
+        except Event.DoesNotExist:
+            try:
+                # legacy access by event id
+                return agenda.event_set.get(pk=event_identifier)
+            except (ValueError, Event.DoesNotExist):
+                raise Http404()
+
+    def patch(self, request, agenda_identifier=None, event_identifier=None, format=None):
+        event = self.get_object(agenda_identifier, event_identifier)
+        serializer = self.serializer_class(event, data=request.data, partial=True)
+        if not serializer.is_valid():
+            raise APIError(
+                _('invalid payload'),
+                err_class='invalid payload',
+                errors=serializer.errors,
+                http_status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        payload = serializer.validated_data
+        protected_fields = ['start_datetime', 'recurrence_days', 'recurrence_week_interval']
+        changed_data = []
+        for field in serializer.fields.keys():
+            if payload.get(field) and payload.get(field) != getattr(event, field):
+                changed_data.append(field)
+
+        if event.recurrence_days and event.has_recurrences_booked():
+            errors = {}
+            for field in changed_data:
+                if field in protected_fields:
+                    errors[field] = [
+                        _(
+                            'This field cannot be modified because some recurrences have bookings attached to them.'
+                        )
+                    ]
+            if 'recurrence_end_date' in changed_data and event.has_recurrences_booked(
+                after=payload['recurrence_end_date']
+            ):
+                errors['recurrence_end_date'] = [_('Bookings exist after this date.')]
+            if errors:
+                raise APIError(
+                    _('invalid payload'),
+                    err_class='invalid payload',
+                    errors=errors,
+                    http_status=status.HTTP_400_BAD_REQUEST,
+                )
+
+        with transaction.atomic():
+            if any(field for field in changed_data if field in protected_fields):
+                event.recurrences.all().delete()
+            elif event.recurrence_days:
+                protected_fields.append('recurrence_end_date')
+                update_fields = {
+                    field: value for field, value in payload.items() if field not in protected_fields
+                }
+                event.recurrences.update(**update_fields)
+
+            event = serializer.save()
+            if event.recurrence_end_date:
+                event.recurrences.filter(start_datetime__gt=event.recurrence_end_date).delete()
+                excluded_datetimes = [evt.datetime_slug for evt in event.recurrences.all()]
+                event.create_all_recurrences(excluded_datetimes)
+
+        return Response({'err': 0, 'data': get_event_detail(request, event)})
+
+
+events = Events.as_view()
+
+
 class EventStatus(APIView):
     permission_classes = (permissions.IsAuthenticated,)
 
     def get_object(self, agenda_identifier, event_identifier):
         try:
             agenda = Agenda.objects.get(slug=agenda_identifier, kind='events')
         except Agenda.DoesNotExist:
             try:
diff --git a/tests/api/test_event.py b/tests/api/test_event.py
index 9ddf81a..8a027fc 100644
--- a/tests/api/test_event.py
+++ b/tests/api/test_event.py
@@ -326,8 +326,148 @@ def test_add_event(app, user):
         '2021-11-20',
         '2021-11-29',
         '2021-12-02',
         '2021-12-04',
         '2021-12-13',
         '2021-12-16',
         '2021-12-18',
     ]
+
+
+def test_update_event(app, user):
+    api_url = '/api/agenda/%s/event/%s/' % ('nop', 'nop')
+
+    # no authentication
+    resp = app.patch(api_url, status=401)
+    assert resp.json['detail'] == 'Authentication credentials were not provided.'
+
+    # wrong password
+    app.authorization = ('Basic', ('john.doe', 'wrong'))
+    resp = app.patch(api_url, status=401)
+    assert resp.json['detail'] == 'Invalid username/password.'
+
+    app.authorization = ('Basic', ('john.doe', 'password'))
+
+    # missing agenda
+    resp = app.patch(api_url, status=404)
+    assert resp.json['detail'] == 'Not found.'
+
+    meeting_agenda = Agenda.objects.create(label='Foo bar Meeting', kind='meetings')
+
+    # using meeting agenda
+    api_url = '/api/agenda/%s/event/%s/' % (meeting_agenda.slug, 'nop')
+    resp = app.patch(api_url, status=404)
+    assert resp.json['detail'] == 'Not found.'
+
+    agenda = Agenda.objects.create(label='Foo bar')
+
+    # missing event
+    api_url = '/api/agenda/%s/event/%s/' % (agenda.slug, 'nop')
+    resp = app.patch(api_url, status=404)
+    assert resp.json['detail'] == 'Not found.'
+
+    # missing recurring event
+    api_url = '/api/agenda/%s/event/%s/' % (agenda.slug, 'nop:2021-11-15-1538')
+    resp = app.patch(api_url, status=400)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == 'unknown recurring event slug: nop'
+
+    event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, waiting_list_places=5)
+    api_url = '/api/agenda/%s/event/%s/' % (agenda.slug, event.slug)
+
+    # update with errors in datetime parts
+    params = {
+        'start_datetime': '2021-11-15 minuit',
+        'recurrence_days': '7, 8',
+    }
+    resp = app.patch(api_url, params=params, status=400)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == 'invalid payload'
+    assert 'Datetime has wrong format' in resp.json['errors']['start_datetime'][0]
+    assert resp.json['errors']['recurrence_days']['0'][0] == 'Ensure this value is less than or equal to 6.'
+
+    # update with almost all optional managed fields
+    params = {
+        'start_datetime': '2021-11-15 15:38',
+        'duration': 42,
+        'publication_date': '2021-09-20',
+        'places': 8,
+        'waiting_list_places': 3,
+        'label': 'FOO camp',
+        'description': 'An event',
+        'pricing': 'free',
+        'url': 'http://example.org/foo/bar/?',
+    }
+    resp = app.patch(api_url, params=params)
+    assert not resp.json['err']
+    event = Event.objects.filter(agenda=agenda).get(slug=event.slug)
+    assert event.duration == 42
+    assert event.places == 8
+    assert event.waiting_list_places == 3
+    assert event.label == 'FOO camp'
+    assert event.description == 'An event'
+    assert event.pricing == 'free'
+    assert event.url == 'http://example.org/foo/bar/?'
+
+    # update event as a recurring event
+    params = {
+        'recurrence_days': '0,3,5',
+        'recurrence_week_interval': 2,
+        'recurrence_end_date': '2021-12-27',
+    }
+    resp = app.patch(api_url, params=params)
+    assert not resp.json['err']
+    event = Event.objects.filter(agenda=agenda).get(slug=event.slug)
+    assert event.recurrences.count() == 9
+    assert [x.places for x in event.recurrences.all()] == [8] * 9
+
+    booking = Booking.objects.create(event=event.recurrences.all()[2])
+
+    # update unprotected fields
+    params = {
+        'places': 7,
+    }
+    resp = app.patch(api_url, params=params)
+    assert not resp.json['err']
+    event = Event.objects.filter(agenda=agenda).get(slug=event.slug)
+    assert [x.places for x in event.recurrences.all()] == [7] * 9
+
+    # try to update recurring event protected fields
+    params = {
+        'recurrence_days': '1,2',
+        'recurrence_week_interval': 1,
+    }
+    resp = app.patch(api_url, params=params, status=400)
+    assert resp.json['err']
+    assert 'cannot be modified' in resp.json['errors']['recurrence_days'][0]
+    assert 'cannot be modified' in resp.json['errors']['recurrence_week_interval'][0]
+
+    assert booking.event.start_datetime.date() == datetime.date(2021, 11, 20)
+
+    # try to reduce recurrence end date before booked event
+    params = {
+        'recurrence_end_date': '2021-11-20',
+    }
+    resp = app.patch(api_url, params=params, status=400)
+    assert resp.json['err']
+    assert resp.json['errors']['recurrence_end_date'][0] == 'Bookings exist after this date.'
+
+    # reduce recurrence end date after booked event
+    params = {
+        'recurrence_end_date': '2021-11-21',
+    }
+    resp = app.patch(api_url, params=params)
+    assert not resp.json['err']
+    event = Event.objects.filter(agenda=agenda).get(slug=event.slug)
+    assert event.recurrences.count() == 3
+
+    booking.cancel()
+
+    # update no more protected fields
+    params = {
+        'recurrence_days': '1,2,3,4,5',
+        'recurrence_week_interval': 1,
+    }
+    resp = app.patch(api_url, params=params)
+    assert not resp.json['err']
+    event = Event.objects.filter(agenda=agenda).get(slug=event.slug)
+    assert event.recurrences.count() == 5
-- 
2.30.2

