From fbd9733164bc5256ac062431dd1dbbcbc35a5e28 Mon Sep 17 00:00:00 2001 From: Thomas NOEL Date: Tue, 5 Jun 2018 00:20:48 +0200 Subject: [PATCH] api: handle lock on fillslots and datetimes (#17685) --- chrono/agendas/models.py | 3 ++ chrono/api/views.py | 51 ++++++++++++++++++++++----- chrono/settings.py | 3 ++ tests/test_api.py | 75 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 7160ab4..c468a4b 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -355,6 +355,9 @@ class Booking(models.Model): user_name = models.CharField(max_length=250, blank=True) backoffice_url = models.URLField(blank=True) + lock_code = models.CharField(max_length=64, blank=True) + lock_expiration_datetime = models.DateTimeField(null=True) + def save(self, *args, **kwargs): with transaction.atomic(): super(Booking, self).save(*args, **kwargs) diff --git a/chrono/api/views.py b/chrono/api/views.py index 597356b..d25d32b 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -19,6 +19,7 @@ from copy import deepcopy import datetime import operator +from django.conf import settings from django.core.urlresolvers import reverse from django.http import Http404 from django.shortcuts import get_object_or_404 @@ -43,7 +44,7 @@ def get_exceptions_by_desk(agenda): return exceptions_by_desk -def get_all_slots(agenda, meeting_type): +def get_all_slots(agenda, meeting_type, lock_code=''): min_datetime = now() + datetime.timedelta(days=agenda.minimal_booking_delay) max_datetime = now() + datetime.timedelta(days=agenda.maximal_booking_delay) min_datetime = min_datetime.replace(hour=0, minute=0, second=0, microsecond=0) @@ -75,11 +76,15 @@ def get_all_slots(agenda, meeting_type): begin, end = interval open_slots_by_desk[desk].remove_overlap(localtime(begin), localtime(end)) - for event in agenda.event_set.filter( - agenda=agenda, start_datetime__gte=min_datetime, - start_datetime__lte=max_datetime + datetime.timedelta(meeting_type.duration)).select_related( - 'meeting_type').exclude( - booking__cancellation_datetime__isnull=False): + concerned_events = agenda.event_set.filter( + agenda=agenda, + start_datetime__gte=min_datetime, + start_datetime__lte=max_datetime + datetime.timedelta(meeting_type.duration) + ).select_related('meeting_type') + booked_events = concerned_events.exclude(booking__cancellation_datetime__isnull=False) + if lock_code: + booked_events = concerned_events.exclude(booking__lock_code=lock_code) + for event in booked_events: for slot in open_slots_by_desk[event.desk_id].search_data(event.start_datetime, event.end_datetime): slot.full = True @@ -218,11 +223,13 @@ class MeetingDatetimes(APIView): except (ValueError, MeetingType.DoesNotExist): raise Http404() + lock_code = request.query_params.get('lock_code', '') + agenda = meeting_type.agenda now_datetime = now() - slots = get_all_slots(agenda, meeting_type) + slots = get_all_slots(agenda, meeting_type, lock_code) entries = {} for slot in slots: if slot.start_datetime < now_datetime: @@ -311,6 +318,10 @@ class SlotSerializer(serializers.Serializer): backoffice_url = serializers.URLField(allow_blank=True) count = serializers.IntegerField(min_value=1) + lock_code = serializers.CharField(max_length=64, allow_blank=True) + lock_duration = serializers.IntegerField(min_value=0) # in seconds + confirm_after_lock = serializers.BooleanField() + class SlotsSerializer(SlotSerializer): ''' @@ -370,6 +381,18 @@ class Fillslots(APIView): else: places_count = 1 + lock_code = '' + lock_expiration_datetime = None + confirm_after_lock = False + if 'lock_code' in payload: + lock_code = payload['lock_code'] or '' + if 'lock_duration' in payload: + lock_duration = payload['lock_duration'] + else: + lock_duration = settings.CHRONO_LOCK_DURATION + lock_expiration_datetime = now() + datetime.timedelta(seconds=lock_duration) + confirm_after_lock = payload.get('confirm_after_lock') or False + extra_data = {} for k, v in request.data.items(): if k not in serializer.validated_data: @@ -392,7 +415,7 @@ class Fillslots(APIView): datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M'))) # get all free slots and separate them by desk - all_slots = get_all_slots(agenda, MeetingType.objects.get(id=meeting_type_id)) + all_slots = get_all_slots(agenda, MeetingType.objects.get(id=meeting_type_id), lock_code) all_slots = [slot for slot in all_slots if not slot.full] datetimes_by_desk = defaultdict(set) for slot in all_slots: @@ -438,6 +461,7 @@ class Fillslots(APIView): # now we have a list of events, book them. primary_booking = None + new_bookings = [] for event in events: for i in range(places_count): new_booking = Booking(event_id=event.id, @@ -448,10 +472,21 @@ class Fillslots(APIView): extra_data=extra_data) if primary_booking is not None: new_booking.primary_booking = primary_booking + if lock_code and not confirm_after_lock: + new_booking.lock_code = lock_code + new_booking.lock_expiration_datetime = lock_expiration_datetime new_booking.save() + new_bookings.append(new_booking.id) if primary_booking is None: primary_booking = new_booking + # remove past locks and related fake events + if lock_code: + old_bookings = Booking.objects.filter(lock_code=lock_code).exclude(id__in=new_bookings) + if agenda.kind == 'meetings': + Event.objects.filter(booking__in=old_bookings).delete() + old_bookings.delete() + response = { 'err': 0, 'in_waiting_list': in_waiting_list, diff --git a/chrono/settings.py b/chrono/settings.py index 2532db9..e7d2682 100644 --- a/chrono/settings.py +++ b/chrono/settings.py @@ -164,6 +164,9 @@ MELLON_IDENTITY_PROVIDERS = [] # (see http://docs.python-requests.org/en/master/user/advanced/#proxies) REQUESTS_PROXIES = None +# default lock duration, in seconds +CHRONO_LOCK_DURATION = 10*60 + local_settings_file = os.environ.get('CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')) if os.path.exists(local_settings_file): diff --git a/tests/test_api.py b/tests/test_api.py index 7319645..3519899 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1541,3 +1541,78 @@ 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 + + +def test_booking_api_meeting_lock_and_confirm(app, meetings_agenda, user): + agenda_id = meetings_agenda.slug + meeting_type = MeetingType.objects.get(agenda=meetings_agenda) + + # list free slots, with or without a lock + resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) + free_slots = len(resp.json['data']) + resp = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=OTHERLOCK' % meeting_type.id) + assert free_slots == len(resp.json['data']) + resp = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=MYLOCK' % meeting_type.id) + assert free_slots == len(resp.json['data']) + + # lock a slot + event_id = resp.json['data'][2]['id'] + assert urlparse.urlparse(resp.json['data'][2]['api']['fillslot_url'] + ).path == '/api/agenda/%s/fillslot/%s/' % (meetings_agenda.slug, event_id) + app.authorization = ('Basic', ('john.doe', 'password')) + resp_lock = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), + params={'lock_code': 'MYLOCK'}) + assert Booking.objects.count() == 1 + assert Booking.objects.all()[0].lock_code == 'MYLOCK' + assert Booking.objects.all()[0].lock_expiration_datetime is not None + + # list free slots: one is locked ... + resp2 = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) + assert free_slots == len([x for x in resp2.json['data']]) + assert len([x for x in resp2.json['data'] if x.get('disabled')]) == 1 + + resp2 = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=OTHERLOCK' % meeting_type.id) + assert free_slots == len([x for x in resp2.json['data']]) + assert len([x for x in resp2.json['data'] if x.get('disabled')]) == 1 + + # ... unless it's MYLOCK + resp2 = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=MYLOCK' % meeting_type.id) + assert free_slots == len([x for x in resp2.json['data']]) + assert len([x for x in resp2.json['data'] if x.get('disabled')]) == 0 + + # can't lock the same timeslot ... + resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), + params={'lock_code': 'OTHERLOCK'}) + assert resp_booking.json['err'] == 1 + assert resp_booking.json['reason'] == 'no more desk available' + + # ... unless with MYLOCK (aka "relock") + resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), + params={'lock_code': 'MYLOCK'}) + assert resp_booking.json['err'] == 0 + assert Booking.objects.count() == 1 + assert Booking.objects.all()[0].lock_code == 'MYLOCK' + assert Booking.objects.all()[0].lock_expiration_datetime is not None + + # can't book the slot ... + resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id)) + assert resp_booking.json['err'] == 1 + assert resp_booking.json['reason'] == 'no more desk available' + + resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), + params={'confirm_after_lock': True}) + assert resp_booking.json['err'] == 1 + assert resp_booking.json['reason'] == 'no more desk available' + + resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), + params={'lock_code': 'OTHERLOCK', 'confirm_after_lock': True}) + assert resp_booking.json['err'] == 1 + assert resp_booking.json['reason'] == 'no more desk available' + + # ... unless with MYLOCK (aka "confirm") + resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), + params={'lock_code': 'MYLOCK', 'confirm_after_lock': True}) + assert resp_booking.json['err'] == 0 + assert Booking.objects.count() == 1 + assert Booking.objects.all()[0].lock_code == '' + assert Booking.objects.all()[0].lock_expiration_datetime is None -- 2.17.0