From df65de8eff18c7fa364e85d5a48463288b2008ea Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Tue, 16 Mar 2021 14:29:41 +0100 Subject: [PATCH] api: add lock_code parameter to fillslot and datetimes (#17685) --- .../agendas/migrations/0077_expirablelock.py | 64 +++++ chrono/agendas/models.py | 16 ++ chrono/api/views.py | 152 +++++++++-- chrono/settings.py | 3 + tests/test_api.py | 235 +++++++++++++++++- tests/test_locks.py | 103 ++++++++ 6 files changed, 551 insertions(+), 22 deletions(-) create mode 100644 chrono/agendas/migrations/0077_expirablelock.py create mode 100644 tests/test_locks.py diff --git a/chrono/agendas/migrations/0077_expirablelock.py b/chrono/agendas/migrations/0077_expirablelock.py new file mode 100644 index 0000000..dcae625 --- /dev/null +++ b/chrono/agendas/migrations/0077_expirablelock.py @@ -0,0 +1,64 @@ +# Generated by Django 2.2.19 on 2021-03-16 13:44 + +from django.db import migrations, models +import django.db.models.deletion + +sql_forwards = """ +ALTER TABLE agendas_expirablelock +ADD CONSTRAINT lock_desk_constraint +EXCLUDE USING GIST(desk_id WITH =, tstzrange(start_datetime, end_datetime) WITH &&) + WHERE (desk_id IS NOT NULL); +ALTER TABLE agendas_expirablelock +ADD CONSTRAINT lock_resource_constraint +EXCLUDE USING GIST(resource_id WITH =, tstzrange(start_datetime, end_datetime) WITH &&) + WHERE (resource_id IS NOT NULL); +""" + +sql_backwards = """ +ALTER TABLE agendas_expirablelock DROP CONSTRAINT lock_desk_constraint; +ALTER TABLE agendas_expirablelock DROP CONSTRAINT lock_resource_constraint; +""" + + +class Migration(migrations.Migration): + dependencies = [ + ('agendas', '0076_event_recurrence_end_date'), + ] + + operations = [ + migrations.CreateModel( + name='ExpirableLock', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ('lock_code', models.CharField(max_length=64, verbose_name='Lock code')), + ('lock_expiration_datetime', models.DateTimeField(verbose_name='Lock expiration time')), + ('start_datetime', models.DateTimeField(verbose_name='Start')), + ('end_datetime', models.DateTimeField(verbose_name='End')), + ( + 'agenda', + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.Agenda' + ), + ), + ( + 'desk', + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.Desk' + ), + ), + ( + 'resource', + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.Resource' + ), + ), + ], + options={ + 'index_together': {('start_datetime', 'end_datetime')}, + }, + ), + migrations.RunSQL(sql=sql_forwards, reverse_sql=sql_backwards), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 02cda68..497ed4e 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -2373,3 +2373,19 @@ class AgendaReminderSettings(models.Model): 'send_sms': self.send_sms, 'sms_extra_info': self.sms_extra_info, } + + +class ExpirableLock(models.Model): + desk = models.ForeignKey(Desk, null=True, on_delete=models.CASCADE) + resource = models.ForeignKey(Resource, null=True, on_delete=models.CASCADE) + agenda = models.ForeignKey(Agenda, null=True, on_delete=models.CASCADE) + lock_code = models.CharField(_('Lock code'), max_length=64, blank=False) + lock_expiration_datetime = models.DateTimeField(_('Lock expiration time')) + start_datetime = models.DateTimeField(_('Start')) + end_datetime = models.DateTimeField(_('End')) + + class Meta: + index_together = (('start_datetime', 'end_datetime'),) + + def as_interval(self): + return Interval(self.start_datetime, self.end_datetime) diff --git a/chrono/api/views.py b/chrono/api/views.py index e18b5c0..7eb4d0d 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -19,7 +19,8 @@ import datetime import itertools import uuid -from django.db import transaction +from django.conf import settings +from django.db import transaction, IntegrityError from django.db.models import Prefetch, Q from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 @@ -38,7 +39,16 @@ from rest_framework.generics import ListAPIView from rest_framework.views import APIView from chrono.api.utils import Response, APIError -from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriodException, Desk, BookingColor +from ..agendas.models import ( + Agenda, + Event, + Booking, + MeetingType, + TimePeriodException, + Desk, + BookingColor, + ExpirableLock, +) from ..interval import IntervalSet @@ -79,6 +89,7 @@ def get_all_slots( start_datetime=None, end_datetime=None, excluded_user_external_id=None, + lock_code=None, ): """Get all occupation state of all possible slots for the given agenda (of its real agendas for a virtual agenda) and the given meeting_type. @@ -96,7 +107,9 @@ def get_all_slots( min/max_datetime; for each time slot check its status in the exclusion and bookings sets. If it is excluded, ignore it completely. - It if is booked, report the slot as full. + If it is booked, report the slot as full. + If it is booked but match the lock code, report the slot as open. + """ resources = resources or [] # virtual agendas have one constraint : @@ -257,6 +270,26 @@ def get_all_slots( for event_start_datetime, event_duration in booked_events ) + # delete old locks + ExpirableLock.objects.filter(lock_expiration_datetime__lt=now()).delete() + # aggregate non-expired locked time slots + desk_locked_intervals = collections.defaultdict(lambda: IntervalSet()) + resource_locked_intervals = IntervalSet() + q = Q(agenda__in=agendas) + if resources: + q |= Q(resource__in=resources) + for lock in ( + ExpirableLock.objects + # only lock related to on of the agenda or the resource + .filter(q) + .exclude(lock_code=lock_code) + .order_by('start_datetime', 'end_datetime') + ): + if lock.desk: + desk_locked_intervals[lock.desk_id].add(lock.start_datetime, lock.end_datetime) + if resources and lock.resource: + resource_locked_intervals.add(lock.start_datetime, lock.end_datetime) + unique_booked = {} for time_period in base_agenda.get_effective_time_periods(): duration = ( @@ -303,7 +336,12 @@ def get_all_slots( # slot is full if an already booked event overlaps it # check resources first - booked = resources_bookings.overlaps(start_datetime, end_datetime) + booked = False + if resources: + if not booked: + booked = resources_bookings.overlaps(start_datetime, end_datetime) + if not booked: + booked = resource_locked_intervals.overlaps(start_datetime, end_datetime) # then check user boookings if not booked: booked = user_bookings.overlaps(start_datetime, end_datetime) @@ -312,6 +350,9 @@ def get_all_slots( booked = desk.id in bookings and bookings[desk.id].overlaps( start_datetime, end_datetime ) + # then locks + if not booked and desk.id in desk_locked_intervals: + booked = desk_locked_intervals[desk.id].overlaps(start_datetime, end_datetime) if unique and unique_booked.get(timestamp) is booked: continue unique_booked[timestamp] = booked @@ -673,6 +714,14 @@ class MeetingDatetimes(APIView): user_external_id = request.GET.get('exclude_user_external_id') or None + lock_code = request.GET.get('lock_code', None) + if lock_code == '': + raise APIError( + _('lock_code must not be empty'), + err_class='lock_code must not be empty', + http_status=status.HTTP_400_BAD_REQUEST, + ) + # Generate an unique slot for each possible meeting [start_datetime, # end_datetime] range. # First use get_all_slots() to get each possible meeting by desk and @@ -695,6 +744,7 @@ class MeetingDatetimes(APIView): start_datetime=start_datetime, end_datetime=end_datetime, excluded_user_external_id=user_external_id, + lock_code=lock_code, ) ) for slot in sorted(all_slots, key=lambda slot: slot[:3]): @@ -872,6 +922,12 @@ class SlotSerializer(serializers.Serializer): force_waiting_list = serializers.BooleanField(default=False) use_color_for = serializers.CharField(max_length=250, allow_blank=True) + lock_code = serializers.CharField(max_length=64, required=False, allow_blank=True) + lock_duration = serializers.IntegerField( + min_value=0, default=lambda: settings.CHRONO_LOCK_DURATION + ) # in seconds + confirm_after_lock = serializers.BooleanField(default=False) + class StringOrListField(serializers.ListField): def to_internal_value(self, data): @@ -917,6 +973,15 @@ class Fillslots(APIView): ) payload = serializer.validated_data + lock_code = payload.get('lock_code') + if lock_code == '': # lock_code should be absent or a non-empty string + raise APIError( + _('lock_code cannot be empty'), + err_class='invalid payload', + errors=serializer.errors, + http_status=status.HTTP_400_BAD_REQUEST, + ) + if 'slots' in payload: slots = payload['slots'] if not slots: @@ -1039,6 +1104,7 @@ class Fillslots(APIView): meeting_type, resources=resources, excluded_user_external_id=user_external_id if exclude_user else None, + lock_code=lock_code, ), key=lambda slot: slot.start_datetime, ) @@ -1116,20 +1182,69 @@ class Fillslots(APIView): # booking requires real Event objects (not lazy Timeslots); # create them now, with data from the slots and the desk we found. events = [] - for start_datetime in datetimes: - event = Event.objects.create( - agenda=available_desk.agenda, - slug=str(uuid.uuid4()), # set slug to avoid queries during slug generation - meeting_type=meeting_type, - start_datetime=start_datetime, - full=False, - places=1, - desk=available_desk, - ) - if resources: - event.resources.add(*resources) - events.append(event) + if not lock_code or payload.get('confirm_after_lock'): + for start_datetime in datetimes: + event = Event.objects.create( + agenda=available_desk.agenda, + slug=str(uuid.uuid4()), # set slug to avoid queries during slug generation + meeting_type=meeting_type, + start_datetime=start_datetime, + full=False, + places=1, + desk=available_desk, + ) + if resources: + event.resources.add(*resources) + events.append(event) + else: + # remove existing locks + ExpirableLock.objects.filter(lock_code=lock_code).delete() + + # create new locks + lock_duration = payload.get('lock_duration') + if not lock_duration or lock_duration < settings.CHRONO_LOCK_DURATION: + lock_duration = settings.CHRONO_LOCK_DURATION + lock_expiration_datetime = now() + datetime.timedelta(seconds=lock_duration) + meeting_duration = datetime.timedelta(minutes=meeting_type.duration) + locks = [] + for start_datetime in datetimes: + locks.append( + ExpirableLock( + desk=available_desk, + agenda=available_desk.agenda, + lock_code=lock_code, + lock_expiration_datetime=lock_expiration_datetime, + start_datetime=start_datetime, + end_datetime=start_datetime + meeting_duration, + ) + ) + for resource in resources: + locks.append( + ExpirableLock( + resource=resource, + lock_code=lock_code, + lock_expiration_datetime=lock_expiration_datetime, + start_datetime=start_datetime, + end_datetime=start_datetime + meeting_duration, + ) + ) + try: + with transaction.atomic(): + ExpirableLock.objects.bulk_create(locks) + except IntegrityError: + raise APIError( + _('no more desk available'), + err_class='no more desk available', + ) + else: + return Response({'err': 0}) else: + if lock_code: + raise APIError( + _('lock_code does not work with events'), + err_class='lock_code does not work with events', + http_status=status.HTTP_400_BAD_REQUEST, + ) # convert event recurrence identifiers to real event slugs for i, slot in enumerate(slots.copy()): if ':' not in slot: @@ -1195,6 +1310,9 @@ class Fillslots(APIView): cancelled_booking_id = to_cancel_booking.pk to_cancel_booking.cancel() + if lock_code: + ExpirableLock.objects.filter(lock_code=lock_code).delete() + # now we have a list of events, book them. primary_booking = None for event in events: diff --git a/chrono/settings.py b/chrono/settings.py index f4a334a..14a5d58 100644 --- a/chrono/settings.py +++ b/chrono/settings.py @@ -168,6 +168,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 + # timeout used in python-requests call, in seconds # we use 28s by default: timeout just before web server, which is usually 30s REQUESTS_TIMEOUT = 28 diff --git a/tests/test_api.py b/tests/test_api.py index 8bab2e0..4511d7c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -25,6 +25,7 @@ from chrono.agendas.models import ( UnavailabilityCalendar, VirtualMember, BookingColor, + ExpirableLock, ) import chrono.api.views @@ -849,7 +850,7 @@ def test_datetimes_api_meetings_agenda_with_resources(app): ) with CaptureQueriesContext(connection) as ctx: resp = app.get(api_url) - assert len(ctx.captured_queries) == 10 + assert len(ctx.captured_queries) == 12 assert len(resp.json['data']) == 32 assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [ '%s 09:00:00' % tomorrow_str, @@ -1062,7 +1063,7 @@ def test_datetimes_api_meetings_agenda_exclude_slots(app): '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug), params={'exclude_user_external_id': '42'}, ) - assert len(ctx.captured_queries) == 9 + assert len(ctx.captured_queries) == 11 assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900' assert resp.json['data'][0]['disabled'] is True assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000' @@ -4921,7 +4922,7 @@ def test_virtual_agendas_meetings_datetimes_multiple_agendas(app, mock_now): with CaptureQueriesContext(connection) as ctx: resp = app.get(api_url) assert len(resp.json['data']) == 12 - assert len(ctx.captured_queries) == 12 + assert len(ctx.captured_queries) == 14 # simulate booking dt = datetime.datetime.strptime(resp.json['data'][2]['id'].split(':')[1], '%Y-%m-%d-%H%M') @@ -5050,7 +5051,7 @@ def test_virtual_agendas_meetings_datetimes_exclude_slots(app): '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug), params={'exclude_user_external_id': '42'}, ) - assert len(ctx.captured_queries) == 13 + assert len(ctx.captured_queries) == 15 assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900' assert resp.json['data'][0]['disabled'] is True assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000' @@ -5450,7 +5451,7 @@ def test_unavailabilitycalendar_meetings_datetimes(app, user): # 2 slots are gone with CaptureQueriesContext(connection) as ctx: resp2 = app.get(datetimes_url) - assert len(ctx.captured_queries) == 10 + assert len(ctx.captured_queries) == 12 assert len(resp.json['data']) == len(resp2.json['data']) + 2 # add a standard desk exception @@ -6045,3 +6046,227 @@ def test_recurring_events_api_various_times(app, user, mock_now): new_event = Booking.objects.get(pk=resp.json['booking_id']).event assert event.start_datetime == new_event.start_datetime + + +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')) + app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), params={'lock_code': 'MYLOCK'}) + assert Booking.objects.count() == 0 + assert ExpirableLock.objects.count() == 1 + assert ( + ExpirableLock.objects.filter( + agenda=meetings_agenda, + desk=meetings_agenda.desk_set.get(), + lock_code='MYLOCK', + lock_expiration_datetime__isnull=False, + ).count() + == 1 + ) + + # 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() == 0 + assert ExpirableLock.objects.count() == 1 + assert ( + ExpirableLock.objects.filter( + agenda=meetings_agenda, + desk=meetings_agenda.desk_set.get(), + lock_code='MYLOCK', + lock_expiration_datetime__isnull=False, + ).count() + == 1 + ) + + # 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 ExpirableLock.objects.count() == 0 + + +def test_booking_api_meeting_lock_and_confirm_with_resource(app, meetings_agenda, user): + resource1 = Resource.objects.create(label='Resource 1', slug='re1') + resource2 = Resource.objects.create(label='Resource 2', slug='re2') + meetings_agenda.resources.add(resource1, resource2) + 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/?resources=re1' % meeting_type.id) + free_slots = len(resp.json['data']) + resp = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=OTHERLOCK&resources=re1' % meeting_type.id) + assert free_slots == len(resp.json['data']) + resp = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=MYLOCK&resources=re1' % 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')) + app.post( + '/api/agenda/%s/fillslot/%s/?resources=re1' % (agenda_id, event_id), params={'lock_code': 'MYLOCK'} + ) + assert Booking.objects.count() == 0 + assert ExpirableLock.objects.count() == 2 + assert ( + ExpirableLock.objects.filter( + agenda=meetings_agenda, + desk=meetings_agenda.desk_set.get(), + resource__isnull=True, + lock_code='MYLOCK', + lock_expiration_datetime__isnull=False, + ).count() + == 1 + ) + assert ( + ExpirableLock.objects.filter( + agenda__isnull=True, + desk__isnull=True, + resource=resource1, + lock_code='MYLOCK', + lock_expiration_datetime__isnull=False, + ).count() + == 1 + ) + old_lock_ids = set(ExpirableLock.objects.values_list('id', flat=True)) + + # list free slots: one is locked ... + resp2 = app.get('/api/agenda/meetings/%s/datetimes/?resources=re1' % 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&resources=re1' % 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&resources=re1' % 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/?resources=re1' % (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/?resources=re1' % (agenda_id, event_id), params={'lock_code': 'MYLOCK'} + ) + assert resp_booking.json['err'] == 0 + assert Booking.objects.count() == 0 + assert ExpirableLock.objects.count() == 2 + assert ( + ExpirableLock.objects.filter( + agenda=meetings_agenda, + desk=meetings_agenda.desk_set.get(), + lock_code='MYLOCK', + lock_expiration_datetime__isnull=False, + ).count() + == 1 + ) + assert ( + ExpirableLock.objects.filter( + agenda__isnull=True, + desk__isnull=True, + resource=resource1, + lock_code='MYLOCK', + lock_expiration_datetime__isnull=False, + ).count() + == 1 + ) + new_lock_ids = set(ExpirableLock.objects.values_list('id', flat=True)) + assert not (old_lock_ids & new_lock_ids) + + # can't book the slot ... + resp_booking = app.post('/api/agenda/%s/fillslot/%s/?resources=re1' % (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/?resources=re1' % (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/?resources=re1' % (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/?resources=re1' % (agenda_id, event_id), + params={'lock_code': 'MYLOCK', 'confirm_after_lock': True}, + ) + assert resp_booking.json['err'] == 0 + assert Booking.objects.count() == 1 + assert ExpirableLock.objects.count() == 0 diff --git a/tests/test_locks.py b/tests/test_locks.py new file mode 100644 index 0000000..7ec73dc --- /dev/null +++ b/tests/test_locks.py @@ -0,0 +1,103 @@ +from argparse import Namespace +import datetime + +from django.utils.timezone import now +from django.db import transaction, IntegrityError + +from chrono.agendas.models import Agenda, Desk, MeetingType, Resource, ExpirableLock + +import pytest + + +@pytest.fixture +def lock(db): + agenda = Agenda.objects.create( + label=u'Foo bar Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=56 + ) + meeting_type = MeetingType(agenda=agenda, label='Blah', duration=30) + meeting_type.save() + desk1 = Desk.objects.create(agenda=agenda, label='Desk 1') + desk2 = Desk.objects.create(agenda=agenda, label='Desk 2') + resource = Resource.objects.create(label='re', description='re') + return Namespace(**locals()) + + +def test_lock_constraint_desk(lock): + ExpirableLock.objects.create( + agenda=lock.agenda, + desk=lock.desk1, + lock_code='1', + lock_expiration_datetime=now() + datetime.timedelta(minutes=5), + start_datetime=now(), + end_datetime=now() + datetime.timedelta(minutes=5), + ) + + ExpirableLock.objects.create( + agenda=lock.agenda, + desk=lock.desk2, + lock_code='2', + lock_expiration_datetime=now() + datetime.timedelta(minutes=5), + start_datetime=now(), + end_datetime=now() + datetime.timedelta(minutes=5), + ) + + ExpirableLock.objects.create( + resource=lock.resource, + lock_code='3', + lock_expiration_datetime=now() + datetime.timedelta(minutes=5), + start_datetime=now(), + end_datetime=now() + datetime.timedelta(minutes=5), + ) + + with pytest.raises(IntegrityError): + # prevent IntegrityError to break the current transaction + with transaction.atomic(): + ExpirableLock.objects.create( + agenda=lock.agenda, + desk=lock.desk1, + lock_code='4', + lock_expiration_datetime=now() + datetime.timedelta(minutes=5), + # interval overlaps interval of first lock + start_datetime=now() + datetime.timedelta(minutes=4), + end_datetime=now() + datetime.timedelta(minutes=6), + ) + + +def test_lock_constraint_resource(lock): + ExpirableLock.objects.create( + agenda=lock.agenda, + desk=lock.desk1, + lock_code='1', + lock_expiration_datetime=now() + datetime.timedelta(minutes=5), + start_datetime=now(), + end_datetime=now() + datetime.timedelta(minutes=5), + ) + + ExpirableLock.objects.create( + agenda=lock.agenda, + desk=lock.desk2, + lock_code='2', + lock_expiration_datetime=now() + datetime.timedelta(minutes=5), + start_datetime=now(), + end_datetime=now() + datetime.timedelta(minutes=5), + ) + + ExpirableLock.objects.create( + resource=lock.resource, + lock_code='3', + lock_expiration_datetime=now() + datetime.timedelta(minutes=5), + start_datetime=now(), + end_datetime=now() + datetime.timedelta(minutes=5), + ) + + with pytest.raises(IntegrityError): + # prevent IntegrityError to break the current transaction + with transaction.atomic(): + ExpirableLock.objects.create( + resource=lock.resource, + lock_code='4', + lock_expiration_datetime=now() + datetime.timedelta(minutes=5), + # interval overlaps interval of first lock + start_datetime=now() + datetime.timedelta(minutes=4), + end_datetime=now() + datetime.timedelta(minutes=6), + ) -- 2.30.1