Projet

Général

Profil

0001-api-add-lock_code-parameter-to-fillslot-and-datetime.patch

Benjamin Dauvergne, 16 mars 2021 19:28

Télécharger (31,5 ko)

Voir les différences:

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
chrono/agendas/migrations/0077_expirablelock.py
1
# Generated by Django 2.2.19 on 2021-03-16 13:44
2

  
3
from django.db import migrations, models
4
import django.db.models.deletion
5

  
6
sql_forwards = """
7
ALTER TABLE agendas_expirablelock
8
ADD CONSTRAINT lock_desk_constraint
9
EXCLUDE USING GIST(desk_id WITH =, tstzrange(start_datetime, end_datetime) WITH &&)
10
    WHERE (desk_id IS NOT NULL);
11
ALTER TABLE agendas_expirablelock
12
ADD CONSTRAINT lock_resource_constraint
13
EXCLUDE USING GIST(resource_id WITH =, tstzrange(start_datetime, end_datetime) WITH &&)
14
    WHERE (resource_id IS NOT NULL);
15
"""
16

  
17
sql_backwards = """
18
ALTER TABLE agendas_expirablelock DROP CONSTRAINT lock_desk_constraint;
19
ALTER TABLE agendas_expirablelock DROP CONSTRAINT lock_resource_constraint;
20
"""
21

  
22

  
23
class Migration(migrations.Migration):
24
    dependencies = [
25
        ('agendas', '0076_event_recurrence_end_date'),
26
    ]
27

  
28
    operations = [
29
        migrations.CreateModel(
30
            name='ExpirableLock',
31
            fields=[
32
                (
33
                    'id',
34
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
35
                ),
36
                ('lock_code', models.CharField(max_length=64, verbose_name='Lock code')),
37
                ('lock_expiration_datetime', models.DateTimeField(verbose_name='Lock expiration time')),
38
                ('start_datetime', models.DateTimeField(verbose_name='Start')),
39
                ('end_datetime', models.DateTimeField(verbose_name='End')),
40
                (
41
                    'agenda',
42
                    models.ForeignKey(
43
                        null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.Agenda'
44
                    ),
45
                ),
46
                (
47
                    'desk',
48
                    models.ForeignKey(
49
                        null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.Desk'
50
                    ),
51
                ),
52
                (
53
                    'resource',
54
                    models.ForeignKey(
55
                        null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.Resource'
56
                    ),
57
                ),
58
            ],
59
            options={
60
                'index_together': {('start_datetime', 'end_datetime')},
61
            },
62
        ),
63
        migrations.RunSQL(sql=sql_forwards, reverse_sql=sql_backwards),
64
    ]
chrono/agendas/models.py
2373 2373
            'send_sms': self.send_sms,
2374 2374
            'sms_extra_info': self.sms_extra_info,
2375 2375
        }
2376

  
2377

  
2378
class ExpirableLock(models.Model):
2379
    desk = models.ForeignKey(Desk, null=True, on_delete=models.CASCADE)
2380
    resource = models.ForeignKey(Resource, null=True, on_delete=models.CASCADE)
2381
    agenda = models.ForeignKey(Agenda, null=True, on_delete=models.CASCADE)
2382
    lock_code = models.CharField(_('Lock code'), max_length=64, blank=False)
2383
    lock_expiration_datetime = models.DateTimeField(_('Lock expiration time'))
2384
    start_datetime = models.DateTimeField(_('Start'))
2385
    end_datetime = models.DateTimeField(_('End'))
2386

  
2387
    class Meta:
2388
        index_together = (('start_datetime', 'end_datetime'),)
2389

  
2390
    def as_interval(self):
2391
        return Interval(self.start_datetime, self.end_datetime)
chrono/api/views.py
19 19
import itertools
20 20
import uuid
21 21

  
22
from django.db import transaction
22
from django.conf import settings
23
from django.db import transaction, IntegrityError
23 24
from django.db.models import Prefetch, Q
24 25
from django.http import Http404, HttpResponse
25 26
from django.shortcuts import get_object_or_404
......
38 39
from rest_framework.views import APIView
39 40

  
40 41
from chrono.api.utils import Response, APIError
41
from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriodException, Desk, BookingColor
42
from ..agendas.models import (
43
    Agenda,
44
    Event,
45
    Booking,
46
    MeetingType,
47
    TimePeriodException,
48
    Desk,
49
    BookingColor,
50
    ExpirableLock,
51
)
42 52
from ..interval import IntervalSet
43 53

  
44 54

  
......
79 89
    start_datetime=None,
80 90
    end_datetime=None,
81 91
    excluded_user_external_id=None,
92
    lock_code=None,
82 93
):
83 94
    """Get all occupation state of all possible slots for the given agenda (of
84 95
    its real agendas for a virtual agenda) and the given meeting_type.
......
96 107
      min/max_datetime; for each time slot check its status in the exclusion
97 108
      and bookings sets.
98 109
      If it is excluded, ignore it completely.
99
      It if is booked, report the slot as full.
110
      If it is booked, report the slot as full.
111
      If it is booked but match the lock code, report the slot as open.
112

  
100 113
    """
101 114
    resources = resources or []
102 115
    # virtual agendas have one constraint :
......
257 270
            for event_start_datetime, event_duration in booked_events
258 271
        )
259 272

  
273
    # delete old locks
274
    ExpirableLock.objects.filter(lock_expiration_datetime__lt=now()).delete()
275
    # aggregate non-expired locked time slots
276
    desk_locked_intervals = collections.defaultdict(lambda: IntervalSet())
277
    resource_locked_intervals = IntervalSet()
278
    q = Q(agenda__in=agendas)
279
    if resources:
280
        q |= Q(resource__in=resources)
281
    for lock in (
282
        ExpirableLock.objects
283
        # only lock related to on of the agenda or the resource
284
        .filter(q)
285
        .exclude(lock_code=lock_code)
286
        .order_by('start_datetime', 'end_datetime')
287
    ):
288
        if lock.desk:
289
            desk_locked_intervals[lock.desk_id].add(lock.start_datetime, lock.end_datetime)
290
        if resources and lock.resource:
291
            resource_locked_intervals.add(lock.start_datetime, lock.end_datetime)
292

  
260 293
    unique_booked = {}
261 294
    for time_period in base_agenda.get_effective_time_periods():
262 295
        duration = (
......
303 336

  
304 337
                    # slot is full if an already booked event overlaps it
305 338
                    # check resources first
306
                    booked = resources_bookings.overlaps(start_datetime, end_datetime)
339
                    booked = False
340
                    if resources:
341
                        if not booked:
342
                            booked = resources_bookings.overlaps(start_datetime, end_datetime)
343
                        if not booked:
344
                            booked = resource_locked_intervals.overlaps(start_datetime, end_datetime)
307 345
                    # then check user boookings
308 346
                    if not booked:
309 347
                        booked = user_bookings.overlaps(start_datetime, end_datetime)
......
312 350
                        booked = desk.id in bookings and bookings[desk.id].overlaps(
313 351
                            start_datetime, end_datetime
314 352
                        )
353
                    # then locks
354
                    if not booked and desk.id in desk_locked_intervals:
355
                        booked = desk_locked_intervals[desk.id].overlaps(start_datetime, end_datetime)
315 356
                    if unique and unique_booked.get(timestamp) is booked:
316 357
                        continue
317 358
                    unique_booked[timestamp] = booked
......
673 714

  
674 715
        user_external_id = request.GET.get('exclude_user_external_id') or None
675 716

  
717
        lock_code = request.GET.get('lock_code', None)
718
        if lock_code == '':
719
            raise APIError(
720
                _('lock_code must not be empty'),
721
                err_class='lock_code must not be empty',
722
                http_status=status.HTTP_400_BAD_REQUEST,
723
            )
724

  
676 725
        # Generate an unique slot for each possible meeting [start_datetime,
677 726
        # end_datetime] range.
678 727
        # First use get_all_slots() to get each possible meeting by desk and
......
695 744
                    start_datetime=start_datetime,
696 745
                    end_datetime=end_datetime,
697 746
                    excluded_user_external_id=user_external_id,
747
                    lock_code=lock_code,
698 748
                )
699 749
            )
700 750
            for slot in sorted(all_slots, key=lambda slot: slot[:3]):
......
872 922
    force_waiting_list = serializers.BooleanField(default=False)
873 923
    use_color_for = serializers.CharField(max_length=250, allow_blank=True)
874 924

  
925
    lock_code = serializers.CharField(max_length=64, required=False, allow_blank=True)
926
    lock_duration = serializers.IntegerField(
927
        min_value=0, default=lambda: settings.CHRONO_LOCK_DURATION
928
    )  # in seconds
929
    confirm_after_lock = serializers.BooleanField(default=False)
930

  
875 931

  
876 932
class StringOrListField(serializers.ListField):
877 933
    def to_internal_value(self, data):
......
917 973
            )
918 974
        payload = serializer.validated_data
919 975

  
976
        lock_code = payload.get('lock_code')
977
        if lock_code == '':  # lock_code should be absent or a non-empty string
978
            raise APIError(
979
                _('lock_code cannot be empty'),
980
                err_class='invalid payload',
981
                errors=serializer.errors,
982
                http_status=status.HTTP_400_BAD_REQUEST,
983
            )
984

  
920 985
        if 'slots' in payload:
921 986
            slots = payload['slots']
922 987
        if not slots:
......
1039 1104
                    meeting_type,
1040 1105
                    resources=resources,
1041 1106
                    excluded_user_external_id=user_external_id if exclude_user else None,
1107
                    lock_code=lock_code,
1042 1108
                ),
1043 1109
                key=lambda slot: slot.start_datetime,
1044 1110
            )
......
1116 1182
            # booking requires real Event objects (not lazy Timeslots);
1117 1183
            # create them now, with data from the slots and the desk we found.
1118 1184
            events = []
1119
            for start_datetime in datetimes:
1120
                event = Event.objects.create(
1121
                    agenda=available_desk.agenda,
1122
                    slug=str(uuid.uuid4()),  # set slug to avoid queries during slug generation
1123
                    meeting_type=meeting_type,
1124
                    start_datetime=start_datetime,
1125
                    full=False,
1126
                    places=1,
1127
                    desk=available_desk,
1128
                )
1129
                if resources:
1130
                    event.resources.add(*resources)
1131
                events.append(event)
1185
            if not lock_code or payload.get('confirm_after_lock'):
1186
                for start_datetime in datetimes:
1187
                    event = Event.objects.create(
1188
                        agenda=available_desk.agenda,
1189
                        slug=str(uuid.uuid4()),  # set slug to avoid queries during slug generation
1190
                        meeting_type=meeting_type,
1191
                        start_datetime=start_datetime,
1192
                        full=False,
1193
                        places=1,
1194
                        desk=available_desk,
1195
                    )
1196
                    if resources:
1197
                        event.resources.add(*resources)
1198
                    events.append(event)
1199
            else:
1200
                # remove existing locks
1201
                ExpirableLock.objects.filter(lock_code=lock_code).delete()
1202

  
1203
                # create new locks
1204
                lock_duration = payload.get('lock_duration')
1205
                if not lock_duration or lock_duration < settings.CHRONO_LOCK_DURATION:
1206
                    lock_duration = settings.CHRONO_LOCK_DURATION
1207
                lock_expiration_datetime = now() + datetime.timedelta(seconds=lock_duration)
1208
                meeting_duration = datetime.timedelta(minutes=meeting_type.duration)
1209
                locks = []
1210
                for start_datetime in datetimes:
1211
                    locks.append(
1212
                        ExpirableLock(
1213
                            desk=available_desk,
1214
                            agenda=available_desk.agenda,
1215
                            lock_code=lock_code,
1216
                            lock_expiration_datetime=lock_expiration_datetime,
1217
                            start_datetime=start_datetime,
1218
                            end_datetime=start_datetime + meeting_duration,
1219
                        )
1220
                    )
1221
                    for resource in resources:
1222
                        locks.append(
1223
                            ExpirableLock(
1224
                                resource=resource,
1225
                                lock_code=lock_code,
1226
                                lock_expiration_datetime=lock_expiration_datetime,
1227
                                start_datetime=start_datetime,
1228
                                end_datetime=start_datetime + meeting_duration,
1229
                            )
1230
                        )
1231
                try:
1232
                    with transaction.atomic():
1233
                        ExpirableLock.objects.bulk_create(locks)
1234
                except IntegrityError:
1235
                    raise APIError(
1236
                        _('no more desk available'),
1237
                        err_class='no more desk available',
1238
                    )
1239
                else:
1240
                    return Response({'err': 0})
1132 1241
        else:
1242
            if lock_code:
1243
                raise APIError(
1244
                    _('lock_code does not work with events'),
1245
                    err_class='lock_code does not work with events',
1246
                    http_status=status.HTTP_400_BAD_REQUEST,
1247
                )
1133 1248
            # convert event recurrence identifiers to real event slugs
1134 1249
            for i, slot in enumerate(slots.copy()):
1135 1250
                if ':' not in slot:
......
1195 1310
                cancelled_booking_id = to_cancel_booking.pk
1196 1311
                to_cancel_booking.cancel()
1197 1312

  
1313
            if lock_code:
1314
                ExpirableLock.objects.filter(lock_code=lock_code).delete()
1315

  
1198 1316
            # now we have a list of events, book them.
1199 1317
            primary_booking = None
1200 1318
            for event in events:
chrono/settings.py
168 168
# (see http://docs.python-requests.org/en/master/user/advanced/#proxies)
169 169
REQUESTS_PROXIES = None
170 170

  
171
# default lock duration, in seconds
172
CHRONO_LOCK_DURATION = 10 * 60
173

  
171 174
# timeout used in python-requests call, in seconds
172 175
# we use 28s by default: timeout just before web server, which is usually 30s
173 176
REQUESTS_TIMEOUT = 28
tests/test_api.py
25 25
    UnavailabilityCalendar,
26 26
    VirtualMember,
27 27
    BookingColor,
28
    ExpirableLock,
28 29
)
29 30
import chrono.api.views
30 31

  
......
849 850
    )
850 851
    with CaptureQueriesContext(connection) as ctx:
851 852
        resp = app.get(api_url)
852
        assert len(ctx.captured_queries) == 10
853
        assert len(ctx.captured_queries) == 12
853 854
    assert len(resp.json['data']) == 32
854 855
    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
855 856
        '%s 09:00:00' % tomorrow_str,
......
1062 1063
            '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug),
1063 1064
            params={'exclude_user_external_id': '42'},
1064 1065
        )
1065
        assert len(ctx.captured_queries) == 9
1066
        assert len(ctx.captured_queries) == 11
1066 1067
    assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900'
1067 1068
    assert resp.json['data'][0]['disabled'] is True
1068 1069
    assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000'
......
4921 4922
    with CaptureQueriesContext(connection) as ctx:
4922 4923
        resp = app.get(api_url)
4923 4924
        assert len(resp.json['data']) == 12
4924
        assert len(ctx.captured_queries) == 12
4925
        assert len(ctx.captured_queries) == 14
4925 4926

  
4926 4927
    # simulate booking
4927 4928
    dt = datetime.datetime.strptime(resp.json['data'][2]['id'].split(':')[1], '%Y-%m-%d-%H%M')
......
5050 5051
            '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug),
5051 5052
            params={'exclude_user_external_id': '42'},
5052 5053
        )
5053
        assert len(ctx.captured_queries) == 13
5054
        assert len(ctx.captured_queries) == 15
5054 5055
    assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900'
5055 5056
    assert resp.json['data'][0]['disabled'] is True
5056 5057
    assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000'
......
5450 5451
    # 2 slots are gone
5451 5452
    with CaptureQueriesContext(connection) as ctx:
5452 5453
        resp2 = app.get(datetimes_url)
5453
        assert len(ctx.captured_queries) == 10
5454
        assert len(ctx.captured_queries) == 12
5454 5455
    assert len(resp.json['data']) == len(resp2.json['data']) + 2
5455 5456

  
5456 5457
    # add a standard desk exception
......
6045 6046

  
6046 6047
    new_event = Booking.objects.get(pk=resp.json['booking_id']).event
6047 6048
    assert event.start_datetime == new_event.start_datetime
6049

  
6050

  
6051
def test_booking_api_meeting_lock_and_confirm(app, meetings_agenda, user):
6052
    agenda_id = meetings_agenda.slug
6053
    meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
6054

  
6055
    # list free slots, with or without a lock
6056
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
6057
    free_slots = len(resp.json['data'])
6058
    resp = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=OTHERLOCK' % meeting_type.id)
6059
    assert free_slots == len(resp.json['data'])
6060
    resp = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=MYLOCK' % meeting_type.id)
6061
    assert free_slots == len(resp.json['data'])
6062

  
6063
    # lock a slot
6064
    event_id = resp.json['data'][2]['id']
6065
    assert urlparse.urlparse(
6066
        resp.json['data'][2]['api']['fillslot_url']
6067
    ).path == '/api/agenda/%s/fillslot/%s/' % (meetings_agenda.slug, event_id)
6068
    app.authorization = ('Basic', ('john.doe', 'password'))
6069
    app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), params={'lock_code': 'MYLOCK'})
6070
    assert Booking.objects.count() == 0
6071
    assert ExpirableLock.objects.count() == 1
6072
    assert (
6073
        ExpirableLock.objects.filter(
6074
            agenda=meetings_agenda,
6075
            desk=meetings_agenda.desk_set.get(),
6076
            lock_code='MYLOCK',
6077
            lock_expiration_datetime__isnull=False,
6078
        ).count()
6079
        == 1
6080
    )
6081

  
6082
    # list free slots: one is locked ...
6083
    resp2 = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
6084
    assert free_slots == len([x for x in resp2.json['data']])
6085
    assert len([x for x in resp2.json['data'] if x.get('disabled')]) == 1
6086

  
6087
    resp2 = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=OTHERLOCK' % meeting_type.id)
6088
    assert free_slots == len([x for x in resp2.json['data']])
6089
    assert len([x for x in resp2.json['data'] if x.get('disabled')]) == 1
6090

  
6091
    # ... unless it's MYLOCK
6092
    resp2 = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=MYLOCK' % meeting_type.id)
6093
    assert free_slots == len([x for x in resp2.json['data']])
6094
    assert len([x for x in resp2.json['data'] if x.get('disabled')]) == 0
6095

  
6096
    # can't lock the same timeslot ...
6097
    resp_booking = app.post(
6098
        '/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), params={'lock_code': 'OTHERLOCK'}
6099
    )
6100
    assert resp_booking.json['err'] == 1
6101
    assert resp_booking.json['reason'] == 'no more desk available'
6102

  
6103
    # ... unless with MYLOCK (aka "relock")
6104
    resp_booking = app.post(
6105
        '/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), params={'lock_code': 'MYLOCK'}
6106
    )
6107
    assert resp_booking.json['err'] == 0
6108
    assert Booking.objects.count() == 0
6109
    assert ExpirableLock.objects.count() == 1
6110
    assert (
6111
        ExpirableLock.objects.filter(
6112
            agenda=meetings_agenda,
6113
            desk=meetings_agenda.desk_set.get(),
6114
            lock_code='MYLOCK',
6115
            lock_expiration_datetime__isnull=False,
6116
        ).count()
6117
        == 1
6118
    )
6119

  
6120
    # can't book the slot ...
6121
    resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
6122
    assert resp_booking.json['err'] == 1
6123
    assert resp_booking.json['reason'] == 'no more desk available'
6124

  
6125
    resp_booking = app.post(
6126
        '/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), params={'confirm_after_lock': True}
6127
    )
6128
    assert resp_booking.json['err'] == 1
6129
    assert resp_booking.json['reason'] == 'no more desk available'
6130

  
6131
    resp_booking = app.post(
6132
        '/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id),
6133
        params={'lock_code': 'OTHERLOCK', 'confirm_after_lock': True},
6134
    )
6135
    assert resp_booking.json['err'] == 1
6136
    assert resp_booking.json['reason'] == 'no more desk available'
6137

  
6138
    # ... unless with MYLOCK (aka "confirm")
6139
    resp_booking = app.post(
6140
        '/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id),
6141
        params={'lock_code': 'MYLOCK', 'confirm_after_lock': True},
6142
    )
6143
    assert resp_booking.json['err'] == 0
6144
    assert Booking.objects.count() == 1
6145
    assert ExpirableLock.objects.count() == 0
6146

  
6147

  
6148
def test_booking_api_meeting_lock_and_confirm_with_resource(app, meetings_agenda, user):
6149
    resource1 = Resource.objects.create(label='Resource 1', slug='re1')
6150
    resource2 = Resource.objects.create(label='Resource 2', slug='re2')
6151
    meetings_agenda.resources.add(resource1, resource2)
6152
    agenda_id = meetings_agenda.slug
6153
    meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
6154

  
6155
    # list free slots, with or without a lock
6156
    resp = app.get('/api/agenda/meetings/%s/datetimes/?resources=re1' % meeting_type.id)
6157
    free_slots = len(resp.json['data'])
6158
    resp = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=OTHERLOCK&resources=re1' % meeting_type.id)
6159
    assert free_slots == len(resp.json['data'])
6160
    resp = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=MYLOCK&resources=re1' % meeting_type.id)
6161
    assert free_slots == len(resp.json['data'])
6162

  
6163
    # lock a slot
6164
    event_id = resp.json['data'][2]['id']
6165
    assert urlparse.urlparse(
6166
        resp.json['data'][2]['api']['fillslot_url']
6167
    ).path == '/api/agenda/%s/fillslot/%s/' % (meetings_agenda.slug, event_id)
6168
    app.authorization = ('Basic', ('john.doe', 'password'))
6169
    app.post(
6170
        '/api/agenda/%s/fillslot/%s/?resources=re1' % (agenda_id, event_id), params={'lock_code': 'MYLOCK'}
6171
    )
6172
    assert Booking.objects.count() == 0
6173
    assert ExpirableLock.objects.count() == 2
6174
    assert (
6175
        ExpirableLock.objects.filter(
6176
            agenda=meetings_agenda,
6177
            desk=meetings_agenda.desk_set.get(),
6178
            resource__isnull=True,
6179
            lock_code='MYLOCK',
6180
            lock_expiration_datetime__isnull=False,
6181
        ).count()
6182
        == 1
6183
    )
6184
    assert (
6185
        ExpirableLock.objects.filter(
6186
            agenda__isnull=True,
6187
            desk__isnull=True,
6188
            resource=resource1,
6189
            lock_code='MYLOCK',
6190
            lock_expiration_datetime__isnull=False,
6191
        ).count()
6192
        == 1
6193
    )
6194
    old_lock_ids = set(ExpirableLock.objects.values_list('id', flat=True))
6195

  
6196
    # list free slots: one is locked ...
6197
    resp2 = app.get('/api/agenda/meetings/%s/datetimes/?resources=re1' % meeting_type.id)
6198
    assert free_slots == len([x for x in resp2.json['data']])
6199
    assert len([x for x in resp2.json['data'] if x.get('disabled')]) == 1
6200

  
6201
    resp2 = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=OTHERLOCK&resources=re1' % meeting_type.id)
6202
    assert free_slots == len([x for x in resp2.json['data']])
6203
    assert len([x for x in resp2.json['data'] if x.get('disabled')]) == 1
6204

  
6205
    # ... unless it's MYLOCK
6206
    resp2 = app.get('/api/agenda/meetings/%s/datetimes/?lock_code=MYLOCK&resources=re1' % meeting_type.id)
6207
    assert free_slots == len([x for x in resp2.json['data']])
6208
    assert len([x for x in resp2.json['data'] if x.get('disabled')]) == 0
6209

  
6210
    # can't lock the same timeslot ...
6211
    resp_booking = app.post(
6212
        '/api/agenda/%s/fillslot/%s/?resources=re1' % (agenda_id, event_id), params={'lock_code': 'OTHERLOCK'}
6213
    )
6214
    assert resp_booking.json['err'] == 1
6215
    assert resp_booking.json['reason'] == 'no more desk available'
6216

  
6217
    # ... unless with MYLOCK (aka "relock")
6218
    resp_booking = app.post(
6219
        '/api/agenda/%s/fillslot/%s/?resources=re1' % (agenda_id, event_id), params={'lock_code': 'MYLOCK'}
6220
    )
6221
    assert resp_booking.json['err'] == 0
6222
    assert Booking.objects.count() == 0
6223
    assert ExpirableLock.objects.count() == 2
6224
    assert (
6225
        ExpirableLock.objects.filter(
6226
            agenda=meetings_agenda,
6227
            desk=meetings_agenda.desk_set.get(),
6228
            lock_code='MYLOCK',
6229
            lock_expiration_datetime__isnull=False,
6230
        ).count()
6231
        == 1
6232
    )
6233
    assert (
6234
        ExpirableLock.objects.filter(
6235
            agenda__isnull=True,
6236
            desk__isnull=True,
6237
            resource=resource1,
6238
            lock_code='MYLOCK',
6239
            lock_expiration_datetime__isnull=False,
6240
        ).count()
6241
        == 1
6242
    )
6243
    new_lock_ids = set(ExpirableLock.objects.values_list('id', flat=True))
6244
    assert not (old_lock_ids & new_lock_ids)
6245

  
6246
    # can't book the slot ...
6247
    resp_booking = app.post('/api/agenda/%s/fillslot/%s/?resources=re1' % (agenda_id, event_id))
6248
    assert resp_booking.json['err'] == 1
6249
    assert resp_booking.json['reason'] == 'no more desk available'
6250

  
6251
    resp_booking = app.post(
6252
        '/api/agenda/%s/fillslot/%s/?resources=re1' % (agenda_id, event_id),
6253
        params={'confirm_after_lock': True},
6254
    )
6255
    assert resp_booking.json['err'] == 1
6256
    assert resp_booking.json['reason'] == 'no more desk available'
6257

  
6258
    resp_booking = app.post(
6259
        '/api/agenda/%s/fillslot/%s/?resources=re1' % (agenda_id, event_id),
6260
        params={'lock_code': 'OTHERLOCK', 'confirm_after_lock': True},
6261
    )
6262
    assert resp_booking.json['err'] == 1
6263
    assert resp_booking.json['reason'] == 'no more desk available'
6264

  
6265
    # ... unless with MYLOCK (aka "confirm")
6266
    resp_booking = app.post(
6267
        '/api/agenda/%s/fillslot/%s/?resources=re1' % (agenda_id, event_id),
6268
        params={'lock_code': 'MYLOCK', 'confirm_after_lock': True},
6269
    )
6270
    assert resp_booking.json['err'] == 0
6271
    assert Booking.objects.count() == 1
6272
    assert ExpirableLock.objects.count() == 0
tests/test_locks.py
1
from argparse import Namespace
2
import datetime
3

  
4
from django.utils.timezone import now
5
from django.db import transaction, IntegrityError
6

  
7
from chrono.agendas.models import Agenda, Desk, MeetingType, Resource, ExpirableLock
8

  
9
import pytest
10

  
11

  
12
@pytest.fixture
13
def lock(db):
14
    agenda = Agenda.objects.create(
15
        label=u'Foo bar Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=56
16
    )
17
    meeting_type = MeetingType(agenda=agenda, label='Blah', duration=30)
18
    meeting_type.save()
19
    desk1 = Desk.objects.create(agenda=agenda, label='Desk 1')
20
    desk2 = Desk.objects.create(agenda=agenda, label='Desk 2')
21
    resource = Resource.objects.create(label='re', description='re')
22
    return Namespace(**locals())
23

  
24

  
25
def test_lock_constraint_desk(lock):
26
    ExpirableLock.objects.create(
27
        agenda=lock.agenda,
28
        desk=lock.desk1,
29
        lock_code='1',
30
        lock_expiration_datetime=now() + datetime.timedelta(minutes=5),
31
        start_datetime=now(),
32
        end_datetime=now() + datetime.timedelta(minutes=5),
33
    )
34

  
35
    ExpirableLock.objects.create(
36
        agenda=lock.agenda,
37
        desk=lock.desk2,
38
        lock_code='2',
39
        lock_expiration_datetime=now() + datetime.timedelta(minutes=5),
40
        start_datetime=now(),
41
        end_datetime=now() + datetime.timedelta(minutes=5),
42
    )
43

  
44
    ExpirableLock.objects.create(
45
        resource=lock.resource,
46
        lock_code='3',
47
        lock_expiration_datetime=now() + datetime.timedelta(minutes=5),
48
        start_datetime=now(),
49
        end_datetime=now() + datetime.timedelta(minutes=5),
50
    )
51

  
52
    with pytest.raises(IntegrityError):
53
        # prevent IntegrityError to break the current transaction
54
        with transaction.atomic():
55
            ExpirableLock.objects.create(
56
                agenda=lock.agenda,
57
                desk=lock.desk1,
58
                lock_code='4',
59
                lock_expiration_datetime=now() + datetime.timedelta(minutes=5),
60
                # interval overlaps interval of first lock
61
                start_datetime=now() + datetime.timedelta(minutes=4),
62
                end_datetime=now() + datetime.timedelta(minutes=6),
63
            )
64

  
65

  
66
def test_lock_constraint_resource(lock):
67
    ExpirableLock.objects.create(
68
        agenda=lock.agenda,
69
        desk=lock.desk1,
70
        lock_code='1',
71
        lock_expiration_datetime=now() + datetime.timedelta(minutes=5),
72
        start_datetime=now(),
73
        end_datetime=now() + datetime.timedelta(minutes=5),
74
    )
75

  
76
    ExpirableLock.objects.create(
77
        agenda=lock.agenda,
78
        desk=lock.desk2,
79
        lock_code='2',
80
        lock_expiration_datetime=now() + datetime.timedelta(minutes=5),
81
        start_datetime=now(),
82
        end_datetime=now() + datetime.timedelta(minutes=5),
83
    )
84

  
85
    ExpirableLock.objects.create(
86
        resource=lock.resource,
87
        lock_code='3',
88
        lock_expiration_datetime=now() + datetime.timedelta(minutes=5),
89
        start_datetime=now(),
90
        end_datetime=now() + datetime.timedelta(minutes=5),
91
    )
92

  
93
    with pytest.raises(IntegrityError):
94
        # prevent IntegrityError to break the current transaction
95
        with transaction.atomic():
96
            ExpirableLock.objects.create(
97
                resource=lock.resource,
98
                lock_code='4',
99
                lock_expiration_datetime=now() + datetime.timedelta(minutes=5),
100
                # interval overlaps interval of first lock
101
                start_datetime=now() + datetime.timedelta(minutes=4),
102
                end_datetime=now() + datetime.timedelta(minutes=6),
103
            )
0
-