From 6d57af612e789b45b6a876b87d517cd2ca555050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= Date: Thu, 25 Feb 2021 09:57:26 +0100 Subject: [PATCH] api: exclude slots already booked by user - meetings (#51341) --- chrono/api/views.py | 42 +++++++++++++- tests/test_api.py | 138 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/chrono/api/views.py b/chrono/api/views.py index 3bd9606c..61a994e3 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -72,7 +72,13 @@ TimeSlot = collections.namedtuple('TimeSlot', ['start_datetime', 'end_datetime', def get_all_slots( - base_agenda, meeting_type, resources=None, unique=False, start_datetime=None, end_datetime=None + base_agenda, + meeting_type, + resources=None, + unique=False, + start_datetime=None, + end_datetime=None, + excluded_user_external_id=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. @@ -225,6 +231,32 @@ def get_all_slots( for event_start_datetime, event_duration in booked_events ) + # aggregate already booked time intervals by excluded_user_external_id + user_bookings = IntervalSet() + if excluded_user_external_id: + used_min_datetime, used_max_datetime = ( + min([v[0] for v in agenda_id_min_max_datetime.values()]), + max([v[1] for v in agenda_id_min_max_datetime.values()]), + ) + booked_events = ( + Event.objects.filter( + agenda__in=agenda_ids, + start_datetime__gte=used_min_datetime, + start_datetime__lte=used_max_datetime + meeting_duration_td, + booking__user_external_id=excluded_user_external_id, + ) + .exclude(booking__cancellation_datetime__isnull=False) + # ordering is important for the later groupby, it works like sort | uniq + .order_by('start_datetime', 'meeting_type__duration') + .values_list('start_datetime', 'meeting_type__duration') + ) + # compute exclusion set by desk from all bookings, using + # itertools.groupby() to group them by desk_id + user_bookings = IntervalSet.from_ordered( + (event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration)) + for event_start_datetime, event_duration in booked_events + ) + unique_booked = {} for time_period in base_agenda.get_effective_time_periods(): duration = ( @@ -272,8 +304,11 @@ def get_all_slots( # slot is full if an already booked event overlaps it # check resources first booked = resources_bookings.overlaps(start_datetime, end_datetime) + # then check user boookings + if not booked: + booked = user_bookings.overlaps(start_datetime, end_datetime) + # then bookings if resources are free if not booked: - # then bookings if resources are free booked = desk.id in bookings and bookings[desk.id].overlaps( start_datetime, end_datetime ) @@ -629,6 +664,8 @@ class MeetingDatetimes(APIView): http_status=status.HTTP_400_BAD_REQUEST, ) + user_external_id = request.GET.get('exclude_user_external_id') or None + # 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 @@ -650,6 +687,7 @@ class MeetingDatetimes(APIView): unique=True, start_datetime=start_datetime, end_datetime=end_datetime, + excluded_user_external_id=user_external_id, ) ) for slot in sorted(all_slots, key=lambda slot: slot[:3]): diff --git a/tests/test_api.py b/tests/test_api.py index a517f83a..5ee4541e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1007,6 +1007,66 @@ def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda, assert resp.json['err_desc'] == 'no more desk available' +@pytest.mark.freeze_time('2021-02-25') +def test_datetimes_api_meetings_agenda_exclude_slots(app): + tomorrow = now() + datetime.timedelta(days=1) + agenda = Agenda.objects.create( + label='Agenda', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=10 + ) + desk = Desk.objects.create(agenda=agenda, slug='desk') + meeting_type = MeetingType.objects.create(agenda=agenda, slug='foo-bar') + TimePeriod.objects.create( + weekday=tomorrow.date().weekday(), + start_time=datetime.time(9, 0), + end_time=datetime.time(17, 00), + desk=desk, + ) + desk.duplicate() + event = Event.objects.create( + agenda=agenda, + meeting_type=meeting_type, + places=1, + start_datetime=localtime(tomorrow).replace(hour=9, minute=0), + desk=desk, + ) + Booking.objects.create(event=event, user_external_id='42') + event2 = Event.objects.create( + agenda=agenda, + meeting_type=meeting_type, + places=1, + start_datetime=localtime(tomorrow).replace(hour=10, minute=0), + desk=desk, + ) + cancelled = Booking.objects.create(event=event2, user_external_id='35') + cancelled.cancel() + + resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug)) + assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900' + assert resp.json['data'][0]['disabled'] is False + assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000' + assert resp.json['data'][2]['disabled'] is False + + resp = app.get( + '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug), + params={'exclude_user_external_id': '35'}, + ) + assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900' + assert resp.json['data'][0]['disabled'] is False + assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000' + assert resp.json['data'][2]['disabled'] is False + + with CaptureQueriesContext(connection) as ctx: + resp = app.get( + '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug), + params={'exclude_user_external_id': '42'}, + ) + assert len(ctx.captured_queries) == 9 + assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900' + assert resp.json['data'][0]['disabled'] is True + assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000' + assert resp.json['data'][2]['disabled'] is False + + def test_booking_api(app, some_data, user): agenda = Agenda.objects.filter(label=u'Foo bar')[0] event = [x for x in Event.objects.filter(agenda=agenda) if x.in_bookable_period()][0] @@ -4831,6 +4891,84 @@ def test_virtual_agendas_meetings_datetimes_multiple_agendas(app, mock_now): assert len(resp.json['data']) == 20 +@pytest.mark.freeze_time('2021-02-25') +def test_virtual_agendas_meetings_datetimes_exclude_slots(app): + tomorrow = now() + datetime.timedelta(days=1) + agenda = Agenda.objects.create( + label='Agenda', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=10 + ) + desk = Desk.objects.create(agenda=agenda, slug='desk') + meeting_type = MeetingType.objects.create(agenda=agenda, slug='foo-bar') + TimePeriod.objects.create( + weekday=tomorrow.date().weekday(), + start_time=datetime.time(9, 0), + end_time=datetime.time(17, 00), + desk=desk, + ) + agenda2 = agenda.duplicate() + virt_agenda = Agenda.objects.create( + label='Virtual Agenda', kind='virtual', minimal_booking_delay=1, maximal_booking_delay=10 + ) + VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda) + VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda2) + + event = Event.objects.create( + agenda=agenda, + meeting_type=meeting_type, + places=1, + start_datetime=localtime(tomorrow).replace(hour=9, minute=0), + desk=desk, + ) + Booking.objects.create(event=event, user_external_id='42') + event2 = Event.objects.create( + agenda=agenda, + meeting_type=meeting_type, + places=1, + start_datetime=localtime(tomorrow).replace(hour=10, minute=0), + desk=desk, + ) + cancelled = Booking.objects.create(event=event2, user_external_id='35') + cancelled.cancel() + + resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug)) + assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900' + assert resp.json['data'][0]['disabled'] is False + assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000' + assert resp.json['data'][2]['disabled'] is False + + resp = app.get( + '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug), + params={'exclude_user_external_id': '35'}, + ) + assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900' + assert resp.json['data'][0]['disabled'] is False + assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000' + assert resp.json['data'][2]['disabled'] is False + + with CaptureQueriesContext(connection) as ctx: + resp = app.get( + '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug), + params={'exclude_user_external_id': '42'}, + ) + assert len(ctx.captured_queries) == 13 + assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900' + assert resp.json['data'][0]['disabled'] is True + assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000' + assert resp.json['data'][2]['disabled'] is False + + virt_agenda.minimal_booking_delay = None + virt_agenda.maximal_booking_delay = None + virt_agenda.save() + resp = app.get( + '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug), + params={'exclude_user_external_id': '42'}, + ) + assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900' + assert resp.json['data'][0]['disabled'] is True + assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000' + assert resp.json['data'][2]['disabled'] is False + + def test_virtual_agendas_meetings_booking(app, mock_now, user): foo_agenda = Agenda.objects.create( label='Foo Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5