diff --git a/chrono/api/views.py b/chrono/api/views.py index 10806962..cc8fadc8 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -1320,7 +1320,7 @@ class Fillslots(APIView): def post(self, request, agenda_identifier=None, event_identifier=None, format=None): return self.fillslot(request=request, agenda_identifier=agenda_identifier, format=format) - def fillslot(self, request, agenda_identifier=None, slots=None, format=None): + def fillslot(self, request, agenda_identifier=None, slots=None, format=None, retry=False): slots = slots or [] multiple_booking = bool(not slots) try: @@ -1550,25 +1550,41 @@ class Fillslots(APIView): if (event.booked_places + places_count) > event.places: raise APIError(N_('sold out')) - with transaction.atomic(): - if to_cancel_booking: - cancelled_booking_id = to_cancel_booking.pk - to_cancel_booking.cancel() + try: + with transaction.atomic(): + if to_cancel_booking: + cancelled_booking_id = to_cancel_booking.pk + to_cancel_booking.cancel() - # now we have a list of events, book them. - primary_booking = None - for event in events: - if agenda.accept_meetings(): - event.save() - if resources: - event.resources.add(*resources) - for dummy in range(places_count): - new_booking = make_booking( - event, payload, extra_data, primary_booking, in_waiting_list, color - ) - new_booking.save() - if primary_booking is None: - primary_booking = new_booking + # now we have a list of events, book them. + primary_booking = None + for event in events: + if agenda.accept_meetings(): + event.save() + if resources: + event.resources.add(*resources) + for dummy in range(places_count): + new_booking = make_booking( + event, payload, extra_data, primary_booking, in_waiting_list, color + ) + new_booking.save() + if primary_booking is None: + primary_booking = new_booking + except IntegrityError as e: + if 'tstzrange_constraint' in str(e): + # "optimistic concurrency control", between our availability + # check with get_all_slots() and now, new event can have been + # created and conflict with the events we want to create, and + # so we get an IntegrityError exception. In this case we + # restart the fillslot() from the begginning to redo the + # availability check and return a proper error to the client. + # + # To prevent looping, we raise an APIError during the second run + # of fillslot(). + if retry: + raise APIError(N_('no more desk available')) + return self.fillslot(request, agenda_identifier=agenda_identifier, slots=slots, retry=True) + raise response = { 'err': 0, diff --git a/tests/api/fillslot/test_all.py b/tests/api/fillslot/test_all.py index 05f09e9a..f678ec87 100644 --- a/tests/api/fillslot/test_all.py +++ b/tests/api/fillslot/test_all.py @@ -3,7 +3,7 @@ import urllib.parse as urlparse from unittest import mock import pytest -from django.db import connection +from django.db import IntegrityError, connection from django.test.utils import CaptureQueriesContext from django.utils.timezone import localtime, now @@ -172,6 +172,44 @@ def test_booking_api(app, user): resp = app.post('/api/agenda/0/fillslot/%s/' % event.id, status=404) +def test_booking_api_event_exclusion_constraint(app, user): + agenda = Agenda.objects.create(label='Foo bar', kind='meetings') + meeting_type = MeetingType.objects.create(agenda=agenda, slug='foo-bar') + desk = Desk.objects.create(agenda=agenda, slug='desk') + tomorrow = now() + datetime.timedelta(days=1) + TimePeriod.objects.create( + weekday=tomorrow.date().weekday(), + start_time=datetime.time(9, 0), + end_time=datetime.time(17, 00), + desk=desk, + ) + resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) + event_id = resp.json['data'][2]['id'] + + app.authorization = ('Basic', ('john.doe', 'password')) + + original_save = Event.save + global first_call # pylint: disable=global-variable-undefined + first_call = True + + def wrapped(*args, **kwargs): + global first_call # pylint: disable=global-variable-undefined + if first_call is True: + first_call = False + raise IntegrityError('tstzrange_constraint') + return original_save(*args, **kwargs) + + with mock.patch('chrono.agendas.models.Event.save', autospec=True) as mock_create: + mock_create.side_effect = wrapped + resp = app.post_json( + '/api/agenda/%s/fillslot/%s/' % (agenda.slug, event_id), + ) + assert len(mock_create.call_args_list) == 2 + assert resp.json['err'] == 0 + assert Event.objects.count() == 1 + assert Booking.objects.count() == 1 + + def test_booking_api_extra_emails(app, user): agenda = Agenda.objects.create(label='Foo bar', kind='events') event = Event.objects.create(