From 60b517d4f4544e272ac86b3a31372d2c6902d807 Mon Sep 17 00:00:00 2001 From: Serghei Mihai Date: Tue, 17 Jul 2018 10:51:58 +0200 Subject: [PATCH] api: add booking ics view (#22930) --- chrono/agendas/models.py | 24 ++++++++++++ chrono/api/urls.py | 2 + chrono/api/views.py | 24 +++++++++++- tests/test_api.py | 83 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 34be3c7..82c0d73 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -376,6 +376,30 @@ class Booking(models.Model): self.secondary_booking_set.update(in_waiting_list=False) self.save() + def get_ics(self, request=None): + ics = vobject.iCalendar() + ics.add('prodid').value = '-//Entr\'ouvert//NON SGML Publik' + vevent = vobject.newFromBehavior('vevent') + vevent.add('uid').value = '%s-%s-%s' % (self.event.start_datetime.isoformat(), self.event.agenda.pk, self.pk) + + vevent.add('summary').value = self.label + vevent.add('dtstart').value = self.event.start_datetime + if self.user_name: + vevent.add('attendee').value = self.user_name + if self.event.meeting_type: + vevent.add('dtend').value = self.event.start_datetime + datetime.timedelta(minutes=self.event.meeting_type.duration) + + if self.backoffice_url: + vevent.add('url').value = self.backoffice_url + + for field in ('description', 'location', 'comment'): + field_value = request and request.GET.get(field) or self.extra_data.get(field) + if field_value: + vevent.add(field).value = field_value + ics.add(vevent) + return ics.serialize() + + @python_2_unicode_compatible class Desk(models.Model): diff --git a/chrono/api/urls.py b/chrono/api/urls.py index 2383cc9..a7b88b9 100644 --- a/chrono/api/urls.py +++ b/chrono/api/urls.py @@ -44,4 +44,6 @@ urlpatterns = [ name='api-cancel-booking'), url(r'booking/(?P\w+)/accept/$', views.accept_booking, name='api-accept-booking'), + url(r'booking/(?P\w+)/ics/$', views.booking_ics, + name='api-booking-ics'), ] diff --git a/chrono/api/views.py b/chrono/api/views.py index 88f4428..de310ac 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -18,7 +18,7 @@ from collections import defaultdict import datetime from django.core.urlresolvers import reverse -from django.http import Http404 +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from django.utils.dateparse import parse_date from django.utils.encoding import force_text @@ -456,7 +456,9 @@ class Fillslots(APIView): 'datetime': localtime(events[0].start_datetime), 'api': { 'cancel_url': request.build_absolute_uri( - reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id})) + reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id})), + 'ics_url': request.build_absolute_uri( + reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.id})), } } if in_waiting_list: @@ -566,3 +568,21 @@ class SlotStatus(APIView): return Response(response) slot_status = SlotStatus.as_view() + + +class BookingICS(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def get(self, request, booking_pk=None, format=None): + booking = get_object_or_404(Booking, id=booking_pk) + if booking.cancellation_datetime: + return Response({'err': 1, 'reason': 'booking is cancelled'}, + status=status.HTTP_400_BAD_REQUEST) + if booking.in_waiting_list: + return Response({'err': 2, 'reason': 'booking is in waiting list'}, + status=status.HTTP_400_BAD_REQUEST) + response = HttpResponse(booking.get_ics(request), content_type='text/calendar') + response['Content-Disposition'] = 'inline; filename=%s.ics;' % booking.pk + return response + +booking_ics = BookingICS.as_view() diff --git a/tests/test_api.py b/tests/test_api.py index 7319645..e8b3fd3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -329,7 +329,9 @@ def test_booking_api(app, some_data, user): assert resp.json['datetime'] == localtime(event.start_datetime).isoformat() assert 'accept_url' not in resp.json['api'] assert 'cancel_url' in resp.json['api'] + assert 'ics_url' in resp.json['api'] assert urlparse.urlparse(resp.json['api']['cancel_url']).netloc + assert urlparse.urlparse(resp.json['api']['ics_url']).netloc assert Booking.objects.count() == 1 resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.id, event.id)) @@ -370,6 +372,83 @@ def test_booking_api(app, some_data, user): resp = app.post('/api/agenda/233/fillslot/%s/' % event.id, status=404) +def test_booking_ics(app, some_data, meetings_agenda, user): + agenda = Agenda.objects.filter(label=u'Foo bar')[0] + event = [x for x in Event.objects.filter(agenda=agenda) if x.in_bookable_period()][0] + app.authorization = ('Basic', ('john.doe', 'password')) + resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id)) + + assert Booking.objects.count() == 1 + assert 'ics_url' in resp.json['api'] + assert urlparse.urlparse(resp.json['api']['cancel_url']).netloc + assert urlparse.urlparse(resp.json['api']['ics_url']).netloc + + formatted_start_date = event.start_datetime.strftime('%Y%m%dT%H%M%S') + booking_ics = Booking.objects.get(id=resp.json['booking_id']).get_ics() + assert 'UID:%s-%s-%s\r\n' % (event.start_datetime.isoformat(), agenda.pk, resp.json['booking_id']) in booking_ics + assert 'SUMMARY:\r\n' in booking_ics + assert 'DTSTART:%sZ\r\n' % formatted_start_date in booking_ics + assert 'DTEDND:' not in booking_ics + + # test with additional data + resp = app.post_json('/api/agenda/%s/fillslot/%s/' % (agenda.id, event.id), + params={'label': 'foo', 'user_name': 'bar', 'backoffice_url': 'http://example.net/'}) + assert Booking.objects.count() == 2 + booking_ics = Booking.objects.get(id=resp.json['booking_id']).get_ics() + assert 'SUMMARY:foo\r\n' in booking_ics + assert 'ATTENDEE:bar\r\n' in booking_ics + assert 'URL:http://example.net/\r\n' in booking_ics + + # extra data stored in extra_data field + resp = app.post_json('/api/agenda/%s/fillslot/%s/' % (agenda.id, event.id), + params={'label': 'l', 'user_name': 'u', 'backoffice_url': '', 'location': 'bar', + 'comment': 'booking comment', 'description': 'booking description'}) + assert Booking.objects.count() == 3 + booking_id = resp.json['booking_id'] + booking = Booking.objects.get(id=booking_id) + booking_ics = booking.get_ics() + assert 'COMMENT:booking comment\r\n' in booking_ics + assert 'LOCATION:bar\r\n' in booking_ics + assert 'DESCRIPTION:booking description\r\n' in booking_ics + + # unauthenticated + app.authorization = None + app.get('/api/booking/%s/ics/' % resp.json['booking_id'], status=403) + + app.authorization = ('Basic', ('john.doe', 'password')) + resp = app.get('/api/booking/%s/ics/' % resp.json['booking_id']) + assert resp.headers['Content-Type'] == 'text/calendar' + assert resp.headers['Content-Disposition'] == 'inline; filename=%s.ics;' % booking_id + + resp = app.get('/api/booking/%s/ics/?description=custom booking description&location=custom booking location&comment=custom comment' % booking_id) + assert 'DESCRIPTION:custom booking description\r\n' in resp.text + assert 'LOCATION:custom booking location\r\n' in resp.text + assert 'COMMENT:custom comment\r\n' in resp.text + + booking.in_waiting_list = True + booking.save() + resp = app.get('/api/booking/%s/ics/' % booking_id, status=400) + assert resp.json['reason'] == 'booking is in waiting list' + + booking.cancellation_datetime = now() + booking.save() + resp = app.get('/api/booking/%s/ics/' % booking_id, status=400) + assert resp.json['reason'] == 'booking is cancelled' + + meetings_agenda_id = Agenda.objects.filter(label=u'Foo bar Meeting')[0].id + meeting_type = MeetingType.objects.get(agenda=meetings_agenda) + resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) + event = resp.json['data'][2] + resp = app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda_id, event['id'])) + assert Booking.objects.count() == 4 + assert 'ics_url' in resp.json['api'] + booking = Booking.objects.get(id=resp.json['booking_id']) + booking_ics = booking.get_ics() + start = booking.event.start_datetime.strftime('%Y%m%dT%H%M%S') + end = (booking.event.start_datetime + datetime.timedelta(minutes=booking.event.meeting_type.duration)).strftime('%Y%m%dT%H%M%S') + assert "DTSTART:%sZ\r\n" % start in booking_ics + assert "DTEND:%sZ\r\n" % end in booking_ics + def test_booking_api_fillslots(app, some_data, user): agenda = Agenda.objects.filter(label=u'Foo bar')[0] events_ids = [x.id for x in Event.objects.filter(agenda=agenda) if x.in_bookable_period()] @@ -779,8 +858,10 @@ def test_waiting_list_booking(app, some_data, user): assert resp.json['in_waiting_list'] is True assert 'accept_url' in resp.json['api'] assert 'cancel_url' in resp.json['api'] + assert 'ics_url' in resp.json['api'] assert urlparse.urlparse(resp.json['api']['accept_url']).netloc assert urlparse.urlparse(resp.json['api']['cancel_url']).netloc + assert urlparse.urlparse(resp.json['api']['ics_url']).netloc # cancel a booking that was not on the waiting list booking = Booking.objects.filter(event=event, in_waiting_list=False)[0] @@ -933,6 +1014,7 @@ def test_multiple_booking_api_fillslots(app, some_data, user): assert resp.json['datetime'] == localtime(events[0].start_datetime).isoformat() assert 'accept_url' not in resp.json['api'] assert 'cancel_url' in resp.json['api'] + assert 'ics_url' in resp.json['api'] for event in events: assert Event.objects.get(id=event.id).booked_places == 3 @@ -1541,3 +1623,4 @@ def test_datetimes_api_meetings_agenda_start_hour_change(app, meetings_agenda): # them. resp = app.get(api_url) assert len([x for x in resp.json['data'] if x['disabled']]) == 2 + # -- 2.18.0