0001-api-add-lock_code-parameter-to-fillslot-and-datetime.patch
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 |
- |