api: forbid overlapping events booking (#64383)
This commit is contained in:
parent
5bc6828342
commit
b359c3f1ff
|
@ -36,8 +36,8 @@ from django.contrib.postgres.fields import ArrayField, JSONField
|
|||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import connection, models, transaction
|
||||
from django.db.models import Count, Exists, F, Func, Max, OuterRef, Prefetch, Q
|
||||
from django.db.models.functions import Cast, Coalesce, ExtractWeek
|
||||
from django.db.models import Count, Exists, ExpressionWrapper, F, Func, Max, OuterRef, Prefetch, Q, Value
|
||||
from django.db.models.functions import Cast, Coalesce, Concat, ExtractWeek
|
||||
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist, engines
|
||||
from django.urls import reverse
|
||||
from django.utils import functional
|
||||
|
@ -55,7 +55,7 @@ from django.utils.translation import ungettext
|
|||
|
||||
from chrono.interval import Interval, IntervalSet
|
||||
from chrono.utils.date import get_weekday_index
|
||||
from chrono.utils.db import SumCardinality
|
||||
from chrono.utils.db import ArraySubquery, SumCardinality
|
||||
from chrono.utils.publik_urls import translate_from_publik_url
|
||||
from chrono.utils.requests_wrapper import requests as requests_wrapper
|
||||
|
||||
|
@ -1562,6 +1562,29 @@ class Event(models.Model):
|
|||
)
|
||||
return qs
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset_with_overlaps(qs):
|
||||
qs = qs.annotate(
|
||||
computed_end_datetime=ExpressionWrapper(
|
||||
F('start_datetime') + datetime.timedelta(minutes=1) * F('duration'),
|
||||
output_field=models.DateTimeField(),
|
||||
),
|
||||
computed_slug=Concat('agenda__slug', Value('@'), 'slug'),
|
||||
)
|
||||
|
||||
overlapping_events = qs.filter(
|
||||
start_datetime__lt=OuterRef('computed_end_datetime'),
|
||||
computed_end_datetime__gt=OuterRef('start_datetime'),
|
||||
).exclude(pk=OuterRef('pk'))
|
||||
|
||||
return qs.annotate(
|
||||
overlaps=ArraySubquery(
|
||||
overlapping_events.values('computed_slug'),
|
||||
output_field=ArrayField(models.CharField()),
|
||||
),
|
||||
has_overlap=Exists(overlapping_events),
|
||||
)
|
||||
|
||||
@property
|
||||
def remaining_places(self):
|
||||
return max(0, self.places - self.booked_places)
|
||||
|
|
|
@ -75,6 +75,7 @@ class SlotSerializer(serializers.Serializer):
|
|||
extra_phone_numbers = StringOrListField(
|
||||
required=False, child=serializers.CharField(max_length=16, allow_blank=False)
|
||||
)
|
||||
check_overlaps = serializers.BooleanField(default=False)
|
||||
|
||||
|
||||
class SlotsSerializer(SlotSerializer):
|
||||
|
@ -325,6 +326,7 @@ class MultipleAgendasDatetimesSerializer(AgendaOrSubscribedSlugsMixin, Datetimes
|
|||
guardian_external_id = serializers.CharField(max_length=250, required=False)
|
||||
with_status = serializers.BooleanField(default=False)
|
||||
guardian_external_id = serializers.CharField(max_length=250, required=False)
|
||||
check_overlaps = serializers.BooleanField(default=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
super().validate(attrs)
|
||||
|
|
|
@ -596,6 +596,8 @@ def get_event_detail(
|
|||
details['status'] = 'cancelled'
|
||||
else:
|
||||
details['status'] = 'free'
|
||||
if hasattr(event, 'overlaps'):
|
||||
details['overlaps'] = event.overlaps
|
||||
|
||||
return details
|
||||
|
||||
|
@ -911,6 +913,7 @@ class MultipleAgendasDatetimes(APIView):
|
|||
show_past_events = bool(payload.get('show_past_events'))
|
||||
show_only_subscribed = bool('subscribed' in payload)
|
||||
with_status = bool(payload.get('with_status'))
|
||||
check_overlaps = bool(payload.get('check_overlaps'))
|
||||
|
||||
entries = Event.objects.none()
|
||||
for agenda in agendas:
|
||||
|
@ -926,6 +929,8 @@ class MultipleAgendasDatetimes(APIView):
|
|||
show_out_of_minimal_delay=show_past_events,
|
||||
)
|
||||
entries = Event.annotate_queryset_for_user(entries, user_external_id, with_status=with_status)
|
||||
if check_overlaps:
|
||||
entries = Event.annotate_queryset_with_overlaps(entries)
|
||||
if show_only_subscribed:
|
||||
entries = entries.filter(
|
||||
agenda__subscriptions__user_external_id=user_external_id,
|
||||
|
@ -1843,9 +1848,18 @@ class EventsFillslots(APIView):
|
|||
payload = serializer.validated_data
|
||||
user_external_id = payload['user_external_id']
|
||||
bypass_delays = payload.get('bypass_delays')
|
||||
check_overlaps = payload.get('check_overlaps')
|
||||
|
||||
events = self.get_events(request, payload)
|
||||
|
||||
if check_overlaps:
|
||||
overlapping_events = Event.annotate_queryset_with_overlaps(events).filter(has_overlap=True)
|
||||
if overlapping_events:
|
||||
raise APIError(
|
||||
N_('Some events occur at the same time: %s'),
|
||||
', '.join(sorted(str(x) for x in overlapping_events)),
|
||||
)
|
||||
|
||||
already_booked_events = self.get_already_booked_events(user_external_id)
|
||||
already_booked_events = already_booked_events.filter(start_datetime__gt=now())
|
||||
if start_datetime:
|
||||
|
@ -1877,6 +1891,7 @@ class EventsFillslots(APIView):
|
|||
booking__user_external_id=user_external_id,
|
||||
booking__cancellation_datetime__isnull=False,
|
||||
)
|
||||
|
||||
# book only events without active booking for the user
|
||||
events = events.exclude(
|
||||
pk__in=Booking.objects.filter(
|
||||
|
|
|
@ -15,13 +15,22 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.db.migrations.operations.base import Operation
|
||||
from django.db.models import Aggregate
|
||||
from django.db.models import Aggregate, Subquery
|
||||
|
||||
|
||||
class SumCardinality(Aggregate):
|
||||
template = 'SUM(CARDINALITY(%(expressions)s))'
|
||||
|
||||
|
||||
class ArraySubquery(Subquery):
|
||||
'''
|
||||
Available in Django 4.0
|
||||
https://docs.djangoproject.com/en/4.0/ref/contrib/postgres/expressions/#arraysubquery-expressions
|
||||
'''
|
||||
|
||||
template = 'ARRAY(%(subquery)s)'
|
||||
|
||||
|
||||
class EnsureJsonbType(Operation):
|
||||
|
||||
reversible = True
|
||||
|
|
|
@ -382,7 +382,11 @@ def test_datetimes_multiple_agendas_queries(app):
|
|||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.get(
|
||||
'/api/agendas/datetimes/',
|
||||
params={'agendas': ','.join(str(i) for i in range(10)), 'show_past_events': True},
|
||||
params={
|
||||
'agendas': ','.join(str(i) for i in range(10)),
|
||||
'show_past_events': True,
|
||||
'check_overlaps': True,
|
||||
},
|
||||
)
|
||||
assert len(resp.json['data']) == 30
|
||||
assert len(ctx.captured_queries) == 2
|
||||
|
@ -390,7 +394,12 @@ def test_datetimes_multiple_agendas_queries(app):
|
|||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.get(
|
||||
'/api/agendas/datetimes/',
|
||||
params={'subscribed': 'all', 'user_external_id': 'xxx', 'show_past_events': True},
|
||||
params={
|
||||
'subscribed': 'all',
|
||||
'user_external_id': 'xxx',
|
||||
'show_past_events': True,
|
||||
'check_overlaps': True,
|
||||
},
|
||||
)
|
||||
assert len(resp.json['data']) == 30
|
||||
assert len(ctx.captured_queries) == 2
|
||||
|
@ -398,7 +407,12 @@ def test_datetimes_multiple_agendas_queries(app):
|
|||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.get(
|
||||
'/api/agendas/datetimes/',
|
||||
params={'subscribed': 'category-a', 'user_external_id': 'xxx', 'show_past_events': True},
|
||||
params={
|
||||
'subscribed': 'category-a',
|
||||
'user_external_id': 'xxx',
|
||||
'show_past_events': True,
|
||||
'check_overlaps': True,
|
||||
},
|
||||
)
|
||||
assert len(resp.json['data']) == 30
|
||||
assert len(ctx.captured_queries) == 2
|
||||
|
@ -420,6 +434,7 @@ def test_datetimes_multiple_agendas_queries(app):
|
|||
'user_external_id': 'xxx',
|
||||
'guardian_external_id': 'mother_id',
|
||||
'show_past_events': True,
|
||||
'check_overlaps': True,
|
||||
},
|
||||
)
|
||||
assert len(resp.json['data']) == 30
|
||||
|
@ -1352,3 +1367,68 @@ def test_datetimes_multiple_agendas_with_status(app):
|
|||
status=400,
|
||||
)
|
||||
assert 'required' in resp.json['errors']['user_external_id'][0]
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2021-09-06 12:00')
|
||||
def test_datetimes_multiple_agendas_overlapping_events(app):
|
||||
agenda = Agenda.objects.create(label='Foo bar', kind='events')
|
||||
Event.objects.create(
|
||||
label='Event 12-14',
|
||||
start_datetime=now() + datetime.timedelta(days=5),
|
||||
duration=120,
|
||||
places=5,
|
||||
agenda=agenda,
|
||||
)
|
||||
Event.objects.create(
|
||||
label='Event containing all events',
|
||||
start_datetime=now() + datetime.timedelta(days=4, hours=23),
|
||||
duration=440,
|
||||
places=5,
|
||||
agenda=agenda,
|
||||
)
|
||||
second_agenda = Agenda.objects.create(label='Foo bar 2', kind='events')
|
||||
Event.objects.create(
|
||||
label='Event 13-15',
|
||||
start_datetime=now() + datetime.timedelta(days=5, hours=1),
|
||||
duration=120,
|
||||
places=5,
|
||||
agenda=second_agenda,
|
||||
)
|
||||
Event.objects.create(
|
||||
label='Event 14-16',
|
||||
start_datetime=now() + datetime.timedelta(days=5, hours=2),
|
||||
duration=120,
|
||||
places=5,
|
||||
agenda=second_agenda,
|
||||
)
|
||||
Event.objects.create(
|
||||
label='Event no duration',
|
||||
start_datetime=now() + datetime.timedelta(days=5, hours=1),
|
||||
places=5,
|
||||
agenda=second_agenda,
|
||||
)
|
||||
Event.objects.create(
|
||||
label='Event other day',
|
||||
start_datetime=now() + datetime.timedelta(days=6),
|
||||
places=5,
|
||||
agenda=second_agenda,
|
||||
)
|
||||
|
||||
resp = app.get('/api/agendas/datetimes/', params={'agendas': 'foo-bar,foo-bar-2', 'check_overlaps': True})
|
||||
assert [(x['id'], x['overlaps']) for x in resp.json['data']] == [
|
||||
(
|
||||
'foo-bar@event-containing-all-events',
|
||||
['foo-bar@event-12-14', 'foo-bar-2@event-13-15', 'foo-bar-2@event-14-16'],
|
||||
),
|
||||
('foo-bar@event-12-14', ['foo-bar@event-containing-all-events', 'foo-bar-2@event-13-15']),
|
||||
(
|
||||
'foo-bar-2@event-13-15',
|
||||
['foo-bar@event-containing-all-events', 'foo-bar@event-12-14', 'foo-bar-2@event-14-16'],
|
||||
),
|
||||
('foo-bar-2@event-no-duration', []),
|
||||
('foo-bar-2@event-14-16', ['foo-bar@event-containing-all-events', 'foo-bar-2@event-13-15']),
|
||||
('foo-bar-2@event-other-day', []),
|
||||
]
|
||||
|
||||
resp = app.get('/api/agendas/datetimes/', params={'agendas': 'foo-bar,foo-bar-2'})
|
||||
assert ['overlaps' not in x for x in resp.json['data']]
|
||||
|
|
|
@ -30,10 +30,10 @@ def test_api_events_fillslots(app, user):
|
|||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
fillslots_url = '/api/agenda/%s/events/fillslots/' % agenda.slug
|
||||
params = {'user_external_id': 'user_id', 'slots': 'event,event-2'}
|
||||
params = {'user_external_id': 'user_id', 'check_overlaps': True, 'slots': 'event,event-2'}
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert len(ctx.captured_queries) == 11
|
||||
assert len(ctx.captured_queries) == 12
|
||||
assert resp.json['booking_count'] == 2
|
||||
assert len(resp.json['booked_events']) == 2
|
||||
assert resp.json['booked_events'][0]['id'] == 'event'
|
||||
|
@ -402,3 +402,117 @@ def test_api_events_fillslots_preserve_out_of_delays_bookings(app, user, freezer
|
|||
assert resp.json['cancelled_booking_count'] == 0
|
||||
assert event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
|
||||
assert second_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2021-09-06 12:00')
|
||||
def test_api_events_fillslots_overlapping_events(app, user, freezer):
|
||||
agenda = Agenda.objects.create(label='Foo bar', kind='events')
|
||||
first_event = Event.objects.create(
|
||||
label='Event 12-14',
|
||||
start_datetime=now() + datetime.timedelta(days=5),
|
||||
duration=120,
|
||||
places=5,
|
||||
agenda=agenda,
|
||||
)
|
||||
second_event = Event.objects.create(
|
||||
label='Event 13-15',
|
||||
start_datetime=now() + datetime.timedelta(days=5, hours=1),
|
||||
duration=120,
|
||||
places=5,
|
||||
agenda=agenda,
|
||||
)
|
||||
Event.objects.create(
|
||||
label='Event 14-16',
|
||||
start_datetime=now() + datetime.timedelta(days=5, hours=2),
|
||||
duration=120,
|
||||
places=5,
|
||||
agenda=agenda,
|
||||
)
|
||||
Event.objects.create(
|
||||
label='Event no duration',
|
||||
start_datetime=now() + datetime.timedelta(days=5, hours=1),
|
||||
places=5,
|
||||
agenda=agenda,
|
||||
)
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
fillslots_url = '/api/agenda/foo-bar/events/fillslots/'
|
||||
params = {'user_external_id': 'user_id', 'check_overlaps': True}
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14'})
|
||||
assert resp.json['booking_count'] == 1
|
||||
|
||||
# booking the same event is still allowed
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14'})
|
||||
assert resp.json['err'] == 0
|
||||
assert resp.json['booking_count'] == 0
|
||||
|
||||
# changing booking to second event is allowed
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-13-15'})
|
||||
assert resp.json['err'] == 0
|
||||
assert resp.json['booking_count'] == 1
|
||||
assert resp.json['cancelled_booking_count'] == 1
|
||||
|
||||
# events are not overlapping if one ends when the other starts
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-14-16'})
|
||||
assert resp.json['booking_count'] == 2
|
||||
assert resp.json['cancelled_booking_count'] == 1
|
||||
|
||||
# booking overlapping events is allowed if one has no duration
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-no-duration'})
|
||||
assert resp.json['err'] == 0
|
||||
assert resp.json['booking_count'] == 1
|
||||
assert resp.json['cancelled_booking_count'] == 1
|
||||
|
||||
# default behavior does not check for overlaps
|
||||
resp = app.post_json(
|
||||
fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event-12-14,event-13-15'}
|
||||
)
|
||||
assert resp.json['err'] == 0
|
||||
assert resp.json['booking_count'] == 1
|
||||
assert resp.json['cancelled_booking_count'] == 1
|
||||
|
||||
# clearing overlapping bookings is allowed
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': ''})
|
||||
assert resp.json['err'] == 0
|
||||
assert resp.json['booking_count'] == 0
|
||||
assert resp.json['cancelled_booking_count'] == 2
|
||||
|
||||
# booking overlapping events with durations is forbidden
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'})
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == 'Some events occur at the same time: Event 12-14, Event 13-15'
|
||||
|
||||
# still overlaps but start before
|
||||
second_event.start_datetime -= datetime.timedelta(hours=2)
|
||||
second_event.save()
|
||||
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'})
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == 'Some events occur at the same time: Event 12-14, Event 13-15'
|
||||
|
||||
# still overlaps but contains first event
|
||||
second_event.start_datetime = first_event.start_datetime - datetime.timedelta(minutes=10)
|
||||
second_event.save()
|
||||
second_event.duration = first_event.duration + 10
|
||||
second_event.save()
|
||||
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'})
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == 'Some events occur at the same time: Event 12-14, Event 13-15'
|
||||
|
||||
# still overlaps but contained by first event
|
||||
second_event.start_datetime = first_event.start_datetime + datetime.timedelta(minutes=10)
|
||||
second_event.save()
|
||||
second_event.duration = first_event.duration - 10
|
||||
second_event.save()
|
||||
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'})
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == 'Some events occur at the same time: Event 12-14, Event 13-15'
|
||||
|
||||
# no more overlap
|
||||
second_event.start_datetime -= datetime.timedelta(hours=5)
|
||||
second_event.save()
|
||||
|
||||
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'})
|
||||
assert resp.json['booking_count'] == 2
|
||||
|
|
|
@ -160,10 +160,10 @@ def test_api_events_fillslots_multiple_agendas(app, user):
|
|||
assert event_slugs == 'first-agenda@event,second-agenda@event'
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
params = {'user_external_id': 'user_id', 'slots': event_slugs}
|
||||
params = {'user_external_id': 'user_id', 'check_overlaps': True, 'slots': event_slugs}
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post_json('/api/agendas/events/fillslots/?agendas=%s' % agenda_slugs, params=params)
|
||||
assert len(ctx.captured_queries) == 16
|
||||
assert len(ctx.captured_queries) == 17
|
||||
assert resp.json['booking_count'] == 2
|
||||
assert len(resp.json['booked_events']) == 2
|
||||
assert resp.json['booked_events'][0]['id'] == 'first-agenda@event'
|
||||
|
@ -648,3 +648,49 @@ def test_api_events_fillslots_multiple_agendas_shared_custody(app, user):
|
|||
)
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == 'Some events are outside guardian custody: first-agenda@event-thursday'
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2021-09-06 12:00')
|
||||
def test_api_events_fillslots_multiple_agendas_overlapping_events(app, user, freezer):
|
||||
agenda = Agenda.objects.create(label='Foo bar', kind='events')
|
||||
Event.objects.create(
|
||||
label='Event',
|
||||
start_datetime=now() + datetime.timedelta(days=5),
|
||||
duration=120,
|
||||
places=5,
|
||||
agenda=agenda,
|
||||
)
|
||||
second_agenda = Agenda.objects.create(label='Foo bar 2', kind='events')
|
||||
Event.objects.create(
|
||||
label='Event 2',
|
||||
start_datetime=now() + datetime.timedelta(days=5, hours=1),
|
||||
duration=120,
|
||||
places=5,
|
||||
agenda=second_agenda,
|
||||
)
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
fillslots_url = '/api/agendas/events/fillslots/?agendas=%s'
|
||||
resp = app.post_json(
|
||||
fillslots_url % ','.join((agenda.slug, second_agenda.slug)),
|
||||
params={
|
||||
'user_external_id': 'user_id',
|
||||
'check_overlaps': True,
|
||||
'slots': 'foo-bar@event,foo-bar-2@event-2',
|
||||
},
|
||||
)
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == 'Some events occur at the same time: Event, Event 2'
|
||||
|
||||
# events can be booked separately
|
||||
resp = app.post_json(
|
||||
fillslots_url % agenda.slug,
|
||||
params={'user_external_id': 'user_id', 'check_overlaps': True, 'slots': 'foo-bar@event'},
|
||||
)
|
||||
assert resp.json['booking_count'] == 1
|
||||
|
||||
resp = app.post_json(
|
||||
fillslots_url % second_agenda.slug,
|
||||
params={'user_external_id': 'user_id', 'check_overlaps': True, 'slots': 'foo-bar-2@event-2'},
|
||||
)
|
||||
assert resp.json['booking_count'] == 1
|
||||
|
|
Loading…
Reference in New Issue