implement locking for meeting's agendas (#17685)

* add a Lease model to associate a lock_code to a booking,
* add a new command "clean_leases" run by cron every 5 minutes to clean
  expired leases,
* add new parameter lock_code to get_all_slots() and exclude conflicting
  booking linked to this lock_code if provided,
* accept new lock_code query string parameter in the datetimes endpoints
  (to see available slot minus the locked ones, if the user want to
  change the chosen slot)
* add new parameters lock_code and confirm_after_lock to the fillslot
  endpoint:
  - when lock_code is used without confirm_after_lock:
    1. look for available slots excluding events/booking pairs associated with the given lock_code, by passing lock_code to get_all_slots
    2. before creating the new event/booking pair, clean existing pairs
       associated to the lock code,
    3. after creating the new pair, create a new Lease object with the
       lock code
  - when lock_code is used with confirm_after_lock do all previous steps
    but 3., making a normal meeting booking.
* add tests with lock_code on meeting's datetimes and fillslot use,
  checking exclusion by resources or user_id works with lock_code
This commit is contained in:
Benjamin Dauvergne 2021-03-16 14:29:41 +01:00
parent 2d8912c0a3
commit d6a5861876
8 changed files with 406 additions and 0 deletions

View File

@ -0,0 +1,26 @@
# chrono - agendas system
# Copyright (C) 2020 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand
from chrono.agendas.models import Lease
class Command(BaseCommand):
help = 'Clean expired leases and related bookings and events'
def handle(self, **options):
Lease.clean()

View File

@ -0,0 +1,41 @@
# Generated by Django 3.2.18 on 2023-08-22 08:19
import django.db.models.deletion
from django.db import migrations, models
from chrono.agendas.models import get_lease_expiration
class Migration(migrations.Migration):
dependencies = [
('agendas', '0165_booking_revert'),
]
operations = [
migrations.CreateModel(
name='Lease',
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')),
(
'expiration_datetime',
models.DateTimeField(verbose_name='Lease expiration time', default=get_lease_expiration),
),
(
'booking',
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to='agendas.booking',
verbose_name='Booking',
),
),
],
options={
'verbose_name': 'Lease',
'verbose_name_plural': 'Leases',
},
),
]

View File

@ -1208,6 +1208,7 @@ class Agenda(models.Model):
start_datetime=None,
end_datetime=None,
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.
@ -1226,6 +1227,7 @@ class Agenda(models.Model):
and bookings sets.
If it is excluded, ignore it completely.
It if 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 :
@ -1329,6 +1331,8 @@ class Agenda(models.Model):
.order_by('desk_id', 'start_datetime', 'meeting_type__duration')
.values_list('desk_id', 'start_datetime', 'meeting_type__duration')
)
if lock_code:
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
# compute exclusion set by desk from all bookings, using
# itertools.groupby() to group them by desk_id
bookings.update(
@ -1362,6 +1366,8 @@ class Agenda(models.Model):
.order_by('start_datetime', 'meeting_type__duration')
.values_list('start_datetime', 'meeting_type__duration')
)
if lock_code:
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
# compute exclusion set
resources_bookings = IntervalSet.from_ordered(
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
@ -1387,6 +1393,8 @@ class Agenda(models.Model):
.order_by('start_datetime', 'meeting_type__duration')
.values_list('start_datetime', 'meeting_type__duration')
)
if lock_code:
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
# compute exclusion set by desk from all bookings, using
# itertools.groupby() to group them by desk_id
user_bookings = IntervalSet.from_ordered(
@ -4633,3 +4641,26 @@ class SharedCustodySettings(models.Model):
return cls.objects.get()
except cls.DoesNotExist:
return cls()
def get_lease_expiration():
return now() + datetime.timedelta(seconds=settings.CHRONO_LOCK_DURATION)
class Lease(models.Model):
booking = models.OneToOneField(Booking, on_delete=models.CASCADE, verbose_name=_('Booking'))
lock_code = models.CharField(verbose_name=_('Lock code'), max_length=64, blank=False)
expiration_datetime = models.DateTimeField(
verbose_name=_('Lease expiration time'), default=get_lease_expiration
)
class Meta:
verbose_name = _('Lease')
verbose_name_plural = _('Leases')
@classmethod
def clean(cls):
'''Clean objects linked to leases.'''
# Delete expired meeting's events, bookings and leases.'''
Event.objects.filter(agenda__kind='meetings', booking__lease__expiration_datetime__lt=now()).delete()

View File

@ -107,6 +107,8 @@ class FillSlotSerializer(serializers.Serializer):
check_overlaps = serializers.BooleanField(default=False)
start_time = serializers.TimeField(required=False, allow_null=True)
end_time = serializers.TimeField(required=False, allow_null=True)
lock_code = serializers.CharField(max_length=64, required=False, allow_blank=False, trim_whitespace=True)
confirm_after_lock = serializers.BooleanField(default=False)
def validate(self, attrs):
super().validate(attrs)

View File

@ -50,6 +50,7 @@ from chrono.agendas.models import (
BookingColor,
Desk,
Event,
Lease,
MeetingType,
SharedCustodyAgenda,
Subscription,
@ -823,6 +824,12 @@ class MeetingDatetimes(APIView):
N_('user_external_id and exclude_user_external_id have different values')
)
lock_code = request.GET.get('lock_code', None)
if lock_code is not None:
lock_code = lock_code.strip()
if lock_code == '':
raise APIErrorBadRequest(_('lock_code must not be empty'))
# 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
@ -846,6 +853,7 @@ class MeetingDatetimes(APIView):
start_datetime=start_datetime,
end_datetime=end_datetime,
user_external_id=booked_user_external_id or excluded_user_external_id,
lock_code=lock_code,
)
)
for slot in sorted(all_slots, key=lambda slot: slot[:3]):
@ -867,6 +875,7 @@ class MeetingDatetimes(APIView):
start_datetime=start_datetime,
end_datetime=end_datetime,
user_external_id=booked_user_external_id or excluded_user_external_id,
lock_code=lock_code,
)
)
last_slot, slot_agendas = None, set()
@ -1368,6 +1377,11 @@ class MeetingsAgendaFillslot(APIView):
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
lock_code = payload.get('lock_code')
if lock_code:
# cannot apply a default value on the serializer bcause it is used with partial=True
confirm_after_lock = payload.get('confirm_after_lock') or False
to_cancel_booking = None
cancel_booking_id = None
if payload.get('cancel_booking_id'):
@ -1416,6 +1430,7 @@ class MeetingsAgendaFillslot(APIView):
meeting_type = agenda.get_meetingtype(id_=meeting_type_id)
except (MeetingType.DoesNotExist, ValueError):
raise APIErrorBadRequest(N_('invalid meeting type id: %s'), meeting_type_id)
all_slots = sorted(
agenda.get_all_slots(
meeting_type,
@ -1423,6 +1438,7 @@ class MeetingsAgendaFillslot(APIView):
user_external_id=user_external_id if exclude_user else None,
start_datetime=slot_datetime,
end_datetime=slot_datetime + datetime.timedelta(minutes=meeting_type.duration),
lock_code=lock_code,
),
key=lambda slot: slot.start_datetime,
)
@ -1499,6 +1515,12 @@ class MeetingsAgendaFillslot(APIView):
try:
with transaction.atomic():
if lock_code:
# delete events/bookings with the same lock code in the
# same agenda(s) to allow rebooking or confirming
Event.objects.filter(
agenda__in=agenda.get_real_agendas(), booking__lease__lock_code=lock_code
).delete()
if to_cancel_booking:
cancelled_booking_id = to_cancel_booking.pk
to_cancel_booking.cancel()
@ -1514,6 +1536,12 @@ class MeetingsAgendaFillslot(APIView):
color=color,
)
booking.save()
if lock_code and not confirm_after_lock:
Lease.objects.create(
booking=booking,
lock_code=lock_code,
)
except IntegrityError as e:
if 'tstzrange_constraint' in str(e):
# "optimistic concurrency control", between our availability

View File

@ -178,6 +178,9 @@ MELLON_IDENTITY_PROVIDERS = []
# (see http://docs.python-requests.org/en/master/user/advanced/#proxies)
REQUESTS_PROXIES = None
# default 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

1
debian/uwsgi.ini vendored
View File

@ -21,6 +21,7 @@ spooler-max-tasks = 20
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command cancel_events --all-tenants -v0
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command send_email_notifications --all-tenants -v0
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command update_event_recurrences --all-tenants -v0
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command clean_leases --all-tenants -v0
# every fifteen minutes
cron2 = minute=-15,unique=1 /usr/bin/chrono-manage tenant_command sync-ants-hub --all-tenants
# hourly

View File

@ -0,0 +1,274 @@
# chrono - agendas system
# Copyright (C) 2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
import pytest
from django.core.management import call_command
from chrono.agendas.models import Booking, Lease
from tests.utils import build_meetings_agenda, build_virtual_agenda
pytestmark = pytest.mark.django_db
def test_meetings_agenda(app, user):
'''Test fillslot on meetings agenda with lock_code'''
agenda = build_meetings_agenda(
'Agenda',
resources=['Re1'],
meeting_types=(30,),
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
)
datetimes_url = agenda._mt_30.get_datetimes_url()
# list free slots, with or without a lock
resp = app.get(datetimes_url + '?lock_code=MYLOCK')
free_slots = len(resp.json['data'])
resp = app.get(datetimes_url + '?lock_code=OTHERLOCK')
assert free_slots == len(resp.json['data'])
resp = app.get(datetimes_url)
assert free_slots == len(resp.json['data'])
# lock a slot
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# list free slots: one is locked ...
resp = app.get(datetimes_url)
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
resp = app.get(datetimes_url, params={'lock_code': 'OTHERLOCK'})
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
# ... unless it's MYLOCK
resp = app.get(datetimes_url, params={'lock_code': 'MYLOCK'})
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
# can't lock the same timeslot ...
resp_booking = app.post_json(fillslot_url, 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_json(fillslot_url, params={'lock_code': 'MYLOCK'})
assert resp_booking.json['err'] == 0
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# can't book the slot ...
resp_booking = app.post_json(fillslot_url)
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
resp_booking = app.post_json(fillslot_url, params={'confirm_after_lock': True})
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
resp_booking = app.post_json(fillslot_url, 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_json(fillslot_url, params={'lock_code': 'MYLOCK', 'confirm_after_lock': True})
assert resp_booking.json['err'] == 0
assert Booking.objects.count() == 1
assert Lease.objects.count() == 0
def test_meetings_agenda_expiration(app, user, freezer):
'''Test fillslot on meetings agenda with lock_code'''
agenda = build_meetings_agenda(
'Agenda',
resources=['Re1'],
meeting_types=(30,),
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
)
datetimes_url = agenda._mt_30.get_datetimes_url()
# list free slots
resp = app.get(datetimes_url)
# lock a slot
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# list free slots: one is locked ...
resp = app.get(datetimes_url)
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
# after 30 minutes it is not locked anymore
freezer.move_to(datetime.timedelta(minutes=30))
call_command('clean_leases')
resp = app.get(datetimes_url)
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
def test_meetings_agenda_with_resource_exclusion(app, user):
'''Test fillslot on meetings agenda with lock_code and ressources'''
agenda1 = build_meetings_agenda(
'Agenda 1',
resources=['Re1'],
meeting_types=(30,),
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
)
agenda2 = build_meetings_agenda(
'Agenda 2',
resources=['Re1'],
meeting_types=(30,),
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
)
resource = agenda2._re_re1
agenda1_datetimes_url = agenda1._mt_30.get_datetimes_url()
agenda2_datetimes_url = agenda2._mt_30.get_datetimes_url()
# list free slots, with or without a lock
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'OTHERLOCK', 'resources': resource.slug})
free_slots = len(resp.json['data'])
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'MYLOCK', 'resources': resource.slug})
assert free_slots == len(resp.json['data'])
resp = app.get(agenda1_datetimes_url, params={'resources': resource.slug})
assert free_slots == len(resp.json['data'])
resp = app.get(agenda1_datetimes_url)
assert free_slots == len(resp.json['data'])
# lock a slot
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK'})
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# list free slots: one is locked ...
resp = app.get(agenda1_datetimes_url, params={'resources': resource.slug})
assert free_slots == len(resp.json['data'])
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'OTHERLOCK', 'resources': resource.slug})
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
# ... unless it's MYLOCK
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'MYLOCK', 'resources': resource.slug})
assert free_slots == len(resp.json['data'])
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
# check slot is also disabled on another agenda with same resource
resp = app.get(agenda2_datetimes_url)
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
resp = app.get(agenda2_datetimes_url, params={'resources': resource.slug})
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
# can't lock the same timeslot ...
resp_booking = app.post_json(fillslot_url + '?resources=re1', 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_json(fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK'})
assert resp_booking.json['err'] == 0
assert Booking.objects.count() == 1
assert Lease.objects.get().lock_code == 'MYLOCK'
# can't book the slot ...
resp_booking = app.post_json(fillslot_url + '?resources=re1')
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'confirm_after_lock': True})
assert resp_booking.json['err'] == 1
assert resp_booking.json['reason'] == 'no more desk available'
resp_booking = app.post_json(
fillslot_url + '?resources=re1', 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_json(
fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK', 'confirm_after_lock': True}
)
assert resp_booking.json['err'] == 0
assert Booking.objects.count() == 1
assert Lease.objects.count() == 0
def test_virtual_agenda_with_external_user_id_exclusion(app, user):
'''Test lock_code use when excluding an external_user_id'''
agenda = build_virtual_agenda(
meeting_types=(30,),
agendas={
'Agenda 1': {
'desks': {
'desk': 'monday-friday 08:00-12:00 14:00-17:00',
},
},
'Agenda 2': {
'desks': {
'desk': 'monday-friday 09:00-12:00',
},
},
'Agenda 3': {
'desks': {
'desk': 'monday-friday 15:00-17:00',
},
},
},
)
datetimes_url = agenda._mt_30.get_datetimes_url()
resp = app.get(datetimes_url)
slots = resp.json['data']
# get first slot between 11 and 11:30
slot = [slot for slot in slots if ' 11:00:00' in slot['datetime']][0]
fillslot_url = slot['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'user_external_id': 'abcd'})
assert resp.json['err'] == 0
# check the lease was created
assert Booking.objects.filter(user_external_id='abcd', lease__lock_code='MYLOCK').count() == 1
# check 11:00 slot is still available
resp = app.get(datetimes_url)
slots = resp.json['data']
assert any(
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
), f"slot {slot['datetime']} should be available"
# check 11:00 slot is unavailable when tested with user_external_id
resp = app.get(datetimes_url, params={'user_external_id': 'abcd'})
slots = resp.json['data']
assert not any(
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
), f"slot {slot['datetime']} should not be available"
# check 11:00 slot is available if tested with user_external_id *AND* lock_code
resp = app.get(datetimes_url, params={'lock_code': 'MYLOCK', 'user_external_id': 'abcd'})
slots = resp.json['data']
assert any(
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
), f"slot {slot['datetime']} should be available"