From 5408c9e647330e8fa5ba90b71fb35b4abfcdf049 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Tue, 8 Dec 2020 16:27:57 +0100
Subject: [PATCH] api: add add-event endpoint (#47337)

---
 chrono/api/serializers.py |  25 +++++-
 chrono/api/urls.py        |   5 ++
 chrono/api/views.py       |  96 ++++++++++++++-------
 tests/api/test_event.py   | 173 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 269 insertions(+), 30 deletions(-)

diff --git a/chrono/api/serializers.py b/chrono/api/serializers.py
index 485de7b..077eaa6 100644
--- a/chrono/api/serializers.py
+++ b/chrono/api/serializers.py
@@ -1,13 +1,13 @@
 from django.utils.translation import ugettext_lazy as _
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
 
-from chrono.agendas.models import AbsenceReason, Booking
+from chrono.agendas.models import AbsenceReason, Booking, Event
 
 
 class StringOrListField(serializers.ListField):
     def to_internal_value(self, data):
         if isinstance(data, str):
             data = [s.strip() for s in data.split(',') if s.strip()]
         return super().to_internal_value(data)
 
@@ -128,8 +128,31 @@ class DatetimesSerializer(DateRangeSerializer):
             )
         return attrs
 
 
 class MultipleAgendasDatetimesSerializer(DatetimesSerializer):
     agendas = CommaSeparatedStringField(
         required=True, child=serializers.SlugField(max_length=160, allow_blank=False)
     )
+
+
+class EventSerializer(serializers.ModelSerializer):
+    recurrence_days = CommaSeparatedStringField(
+        required=False, child=serializers.IntegerField(min_value=0, max_value=6)
+    )
+
+    class Meta:
+        model = Event
+        fields = [
+            'start_datetime',
+            'recurrence_days',
+            'recurrence_week_interval',
+            'recurrence_end_date',
+            'duration',
+            'publication_date',
+            'places',
+            'waiting_list_places',
+            'label',
+            'description',
+            'pricing',
+            'url',
+        ]
diff --git a/chrono/api/urls.py b/chrono/api/urls.py
index a1fd336..4179e66 100644
--- a/chrono/api/urls.py
+++ b/chrono/api/urls.py
@@ -59,16 +59,21 @@ urlpatterns = [
         views.event_bookings,
         name='api-event-bookings',
     ),
     url(
         r'^agenda/(?P<agenda_identifier>[\w-]+)/check/(?P<event_identifier>[\w:-]+)/$',
         views.event_check,
         name='api-event-check',
     ),
+    url(
+        r'^agenda/(?P<agenda_identifier>[\w-]+)/add-event/$',
+        views.agenda_add_event,
+        name='api-agenda-add-event',
+    ),
     url(
         r'^agenda/meetings/(?P<meeting_identifier>[\w-]+)/datetimes/$',
         views.meeting_datetimes,
         name='api-agenda-meeting-datetimes-legacy',
     ),
     url(r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/$', views.meeting_list, name='api-agenda-meetings'),
     url(
         r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/(?P<meeting_identifier>[\w-]+)/$',
diff --git a/chrono/api/views.py b/chrono/api/views.py
index f733a78..916f0ee 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -435,17 +435,17 @@ def get_event_text(event, agenda, day=None):
             )
         except (VariableDoesNotExist, TemplateSyntaxError):
             pass
     elif event.label and event.primary_event_id is not None:
         event_text = '%s (%s)' % (
             event.label,
             date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'),
         )
-    elif event.recurrence_days:
+    elif day is not None:
         event_text = _('%(weekday)s: %(event)s') % {
             'weekday': WEEKDAYS[day].capitalize(),
             'event': event_text,
         }
     return event_text
 
 
 def get_event_detail(
@@ -465,45 +465,58 @@ def get_event_detail(
         'text': get_event_text(event, agenda),
         'label': event.label or '',
         'date': format_response_date(event.start_datetime),
         'datetime': format_response_datetime(event.start_datetime),
         'description': event.description,
         'pricing': event.pricing,
         'url': event.url,
         'duration': event.duration,
-        'disabled': is_event_disabled(event, min_places=min_places, disable_booked=disable_booked),
-        'api': {
-            'bookings_url': request.build_absolute_uri(
-                reverse(
-                    'api-event-bookings',
-                    kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
-                )
-            ),
-            'fillslot_url': request.build_absolute_uri(
-                reverse(
-                    'api-fillslot',
-                    kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
-                )
-            ),
-            'status_url': request.build_absolute_uri(
-                reverse(
-                    'api-event-status',
-                    kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
-                )
-            ),
-            'check_url': request.build_absolute_uri(
-                reverse(
-                    'api-event-check',
-                    kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
-                )
-            ),
-        },
-        'places': get_event_places(event),
     }
+    if event.recurrence_days:
+        details.update(
+            {
+                'recurrence_days': event.recurrence_days,
+                'recurrence_week_interval': event.recurrence_week_interval,
+                'recurrence_end_date': event.recurrence_end_date,
+            }
+        )
+    else:
+        details.update(
+            {
+                'disabled': is_event_disabled(event, min_places=min_places, disable_booked=disable_booked),
+                'api': {
+                    'bookings_url': request.build_absolute_uri(
+                        reverse(
+                            'api-event-bookings',
+                            kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
+                        )
+                    ),
+                    'fillslot_url': request.build_absolute_uri(
+                        reverse(
+                            'api-fillslot',
+                            kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
+                        )
+                    ),
+                    'status_url': request.build_absolute_uri(
+                        reverse(
+                            'api-event-status',
+                            kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
+                        )
+                    ),
+                    'check_url': request.build_absolute_uri(
+                        reverse(
+                            'api-event-check',
+                            kwargs={'agenda_identifier': agenda.slug, 'event_identifier': event.slug},
+                        )
+                    ),
+                },
+                'places': get_event_places(event),
+            }
+        )
     if show_events is not None:
         details['api']['fillslot_url'] += '?events=%s' % show_events
     if booked_user_external_id:
         if getattr(event, 'user_places_count', 0) > 0:
             details['booked_for_external_user'] = 'main-list'
         elif getattr(event, 'user_waiting_places_count', 0) > 0:
             details['booked_for_external_user'] = 'waiting-list'
 
@@ -2402,8 +2415,33 @@ class BookingsStatistics(APIView):
                     'series': series,
                 },
                 'err': 0,
             }
         )
 
 
 bookings_statistics = BookingsStatistics.as_view()
+
+
+class AgendaAddEventView(APIView):
+    permission_classes = (permissions.IsAuthenticated,)
+    serializer_class = serializers.EventSerializer
+
+    def post(self, request, agenda_identifier):
+        agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
+
+        serializer = self.serializer_class(data=request.data)
+        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
+        event = Event.objects.create(agenda=agenda, **payload)
+        if event.recurrence_days and event.recurrence_end_date:
+            event.create_all_recurrences()
+        return Response({'err': 0, 'data': get_event_detail(request, event)})
+
+
+agenda_add_event = AgendaAddEventView.as_view()
diff --git a/tests/api/test_event.py b/tests/api/test_event.py
index 9a6f5d6..9ddf81a 100644
--- a/tests/api/test_event.py
+++ b/tests/api/test_event.py
@@ -153,8 +153,181 @@ def test_event_checked(app, user):
 
     # wrong kind
     agenda.kind = 'meetings'
     agenda.save()
     app.post('/api/agenda/%s/check/%s/' % (agenda.slug, event.slug), status=404)
     agenda.kind = 'virtual'
     agenda.save()
     app.post('/api/agenda/%s/check/%s/' % (agenda.slug, event.slug), status=404)
+
+
+def test_add_event(app, user):
+    api_url = '/api/agenda/%s/add-event/' % ('999')
+
+    # no authentication
+    resp = app.post(api_url, status=401)
+    assert resp.json['detail'] == 'Authentication credentials were not provided.'
+
+    # wrong password
+    app.authorization = ('Basic', ('john.doe', 'wrong'))
+    resp = app.post(api_url, status=401)
+    assert resp.json['detail'] == 'Invalid username/password.'
+
+    app.authorization = ('Basic', ('john.doe', 'password'))
+
+    # missing agenda
+    resp = app.post(api_url, status=404)
+    assert resp.json['detail'] == 'Not found.'
+
+    # using meeting agenda
+    meeting_agenda = Agenda(label='Foo bar Meeting', kind='meetings')
+    meeting_agenda.save()
+    api_url = '/api/agenda/%s/add-event/' % (meeting_agenda.slug)
+    resp = app.post(api_url, status=404)
+    assert resp.json['detail'] == 'Not found.'
+
+    agenda = Agenda(label='Foo bar')
+    agenda.maximal_booking_delay = 0
+    agenda.save()
+    api_url = '/api/agenda/%s/add-event/' % (agenda.slug)
+
+    # missing fields
+    resp = app.post(api_url, status=400)
+    assert resp.json['err']
+    assert resp.json['errors'] == {
+        'start_datetime': ['This field is required.'],
+        'places': ['This field is required.'],
+    }
+
+    # add with errors in datetime parts
+    params = {
+        'start_datetime': '2021-11-15 minuit',
+        'places': 10,
+    }
+    resp = app.post(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]
+
+    # add an event
+    params = {
+        'start_datetime': '2021-11-15 15:38',
+        'places': 10,
+    }
+    resp = app.post(api_url, params=params)
+    assert not resp.json['err']
+    assert resp.json['data']['id'] == 'foo-bar-event'
+    assert {'api', 'disabled', 'places'}.issubset(resp.json['data'].keys())
+    assert {'recurrence_days', 'recurrence_week_interval', 'recurrence_end_date'}.isdisjoint(
+        resp.json['data'].keys()
+    )
+    event = Event.objects.filter(agenda=agenda).get(slug='foo-bar-event')
+    assert str(event.start_datetime) == '2021-11-15 14:38:00+00:00'
+    assert str(event.start_datetime.tzinfo) == 'UTC'
+    assert event.places == 10
+    assert event.publication_date is None
+
+    # add with almost all optional managed fields
+    params = {
+        'start_datetime': '2021-11-15 15:38',
+        'duration': 42,
+        'publication_date': '2021-09-20',
+        'places': 11,
+        'waiting_list_places': 3,
+        'label': 'FOO camp',
+        'description': 'An event',
+        'pricing': 'free',
+        'url': 'http://example.org/foo/bar/?',
+    }
+    resp = app.post(api_url, params=params)
+    assert not resp.json['err']
+    assert resp.json['data']['id'] == 'foo-camp'
+    assert {'api', 'disabled', 'places'}.issubset(resp.json['data'].keys())
+    assert {'recurrence_days', 'recurrence_week_interval', 'recurrence_end_date'}.isdisjoint(
+        resp.json['data'].keys()
+    )
+    event = Event.objects.filter(agenda=agenda).get(slug='foo-camp')
+    assert event.duration == 42
+    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/?'
+
+    # add with errors in recurrence_days list
+    params = {
+        'start_datetime': '2021-11-15 15:38',
+        'places': 10,
+        'recurrence_days': 'oups',
+    }
+    resp = app.post(api_url, params=params, status=400)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == 'invalid payload'
+    assert resp.json['errors']['recurrence_days']['0'][0] == 'A valid integer is required.'
+    params = {
+        'start_datetime': '2021-11-15 15:38',
+        'places': 10,
+        'recurrence_days': '7',
+    }
+    resp = app.post(api_url, params=params, status=400)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == 'invalid payload'
+    assert resp.json['errors']['recurrence_days']['0'][0] == 'Ensure this value is less than or equal to 6.'
+
+    # add a recurrent event
+    params = {
+        'start_datetime': '2021-11-15 15:38',
+        'places': 12,
+        'recurrence_days': '0,3,5',
+        'recurrence_week_interval': '2',
+        'description': 'A recurrent event',
+    }
+    assert Event.objects.filter(agenda=agenda).count() == 2
+    resp = app.post(api_url, params=params)
+    assert Event.objects.filter(agenda=agenda).count() == 3
+    assert not resp.json['err']
+    assert resp.json['data']['id'] == 'foo-bar-event-1'
+    assert {'api', 'disabled', 'places'}.isdisjoint(resp.json['data'].keys())
+    assert {'recurrence_days', 'recurrence_week_interval', 'recurrence_end_date'}.issubset(
+        resp.json['data'].keys()
+    )
+    event = Event.objects.filter(agenda=agenda).get(slug='foo-bar-event-1')
+    assert event.description == 'A recurrent event'
+    assert event.recurrence_days == [0, 3, 5]
+    assert event.recurrence_week_interval == 2
+    assert event.recurrence_end_date is None
+
+    # add a recurrent event with end recurrence date creates 9 recurrences
+    params = {
+        'start_datetime': '2021-11-15 15:38',
+        'places': 13,
+        'recurrence_days': '0,3,5',  # Monday, Tuesday, Saturday
+        'recurrence_week_interval': '2',
+        'recurrence_end_date': '2021-12-27',
+        'description': 'A recurrent event having recurrences',
+    }
+    resp = app.post(api_url, params=params)
+    assert not resp.json['err']
+    assert resp.json['data']['id'] == 'foo-bar-event-2'
+    assert {'api', 'disabled', 'places'}.isdisjoint(resp.json['data'].keys())
+    assert {'recurrence_days', 'recurrence_week_interval', 'recurrence_end_date'}.issubset(
+        resp.json['data'].keys()
+    )
+    event = Event.objects.filter(agenda=agenda).get(slug='foo-bar-event-2')
+    assert Event.objects.filter(agenda=agenda).count() == 13
+    assert event.description == 'A recurrent event having recurrences'
+    assert event.recurrence_days == [0, 3, 5]
+    assert event.recurrence_week_interval == 2
+    assert event.recurrence_end_date == datetime.date(2021, 12, 27)
+    assert sorted(
+        str(x.start_datetime.date()) for x in Event.objects.all() if 'foo-bar-event-2--' in x.slug
+    ) == [
+        '2021-11-15',
+        '2021-11-18',
+        '2021-11-20',
+        '2021-11-29',
+        '2021-12-02',
+        '2021-12-04',
+        '2021-12-13',
+        '2021-12-16',
+        '2021-12-18',
+    ]
-- 
2.30.2

