api: check resources in datetimes endpoint (#38942)

This commit is contained in:
Lauréline Guérin 2020-05-26 11:51:40 +02:00
parent 4cc1bf6c45
commit a1d38b0a88
No known key found for this signature in database
GPG Key ID: 1FAB9B9B4F93D473
2 changed files with 268 additions and 10 deletions

View File

@ -62,24 +62,26 @@ def get_max_datetime(agenda):
TimeSlot = collections.namedtuple('TimeSlot', ['start_datetime', 'end_datetime', 'full', 'desk'])
def get_all_slots(agenda, meeting_type, unique=False):
def get_all_slots(base_agenda, meeting_type, resources=None, unique=False):
'''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.
The process is done in three phases:
The process is done in four phases:
- first phase: aggregate time intervals, during which a meeting is impossible
due to TimePeriodException models, by desk in IntervalSet (compressed
and ordered list of intervals).
- second phase: aggregate time intervals by desk for already booked slots, again
to make IntervalSet,
- third and las phase: generate time slots from each time period based
- third phase: for a meetings agenda, if resources has to be booked,
aggregate time intervals for already booked resources, to make IntervalSet.
- fourth and last phase: generate time slots from each time period based
on the time period definition and on the desk's respective agenda real
min/max_datetime; for each time slot check its status in the exclusion
and bookings sets.
If it is excluded, ingore it completely.
It if is booked, reports the slot as full.
If it is excluded, ignore it completely.
It if is booked, report the slot as full.
'''
base_agenda = agenda
resources = resources or []
# virtual agendas have one constraint :
# all the real agendas MUST have the same meetingstypes, the consequence is
# that the base_meeting_duration for the virtual agenda is always the same
@ -93,7 +95,7 @@ def get_all_slots(agenda, meeting_type, unique=False):
now_datetime = now()
base_date = now_datetime.date()
agendas = agenda.get_real_agendas()
agendas = base_agenda.get_real_agendas()
# regroup agendas by their opening period
agenda_ids_by_min_max_datetimes = collections.defaultdict(set)
@ -119,7 +121,7 @@ def get_all_slots(agenda, meeting_type, unique=False):
key=lambda time_period: time_period.desk,
)
}
# compute reduced min/max_datetime windows by desks based on exclusions
# compute reduced min/max_datetime windows by desks based on exceptions
desk_min_max_datetime = {}
for desk, desk_exception in desks_exceptions.items():
base = IntervalSet([agenda_id_min_max_datetime[desk.agenda_id]])
@ -164,6 +166,29 @@ def get_all_slots(agenda, meeting_type, unique=False):
for desk_id, values in itertools.groupby(booked_events, lambda be: be[0])
)
# aggregate already booked time intervals for resources
resources_bookings = IntervalSet()
if agenda.kind == 'meetings' and resources:
used_min_datetime, used_max_datetime = agenda_id_min_max_datetime[base_agenda.pk]
event_ids_queryset = Event.resources.through.objects.filter(
resource__in=[r.pk for r in resources]
).values('event')
booked_events = (
Event.objects.filter(
pk__in=event_ids_queryset,
start_datetime__gte=used_min_datetime,
start_datetime__lte=used_max_datetime + meeting_duration_td,
)
.exclude(booking__cancellation_datetime__isnull=False)
.order_by('start_datetime', 'meeting_type__duration')
.values_list('start_datetime', 'meeting_type__duration')
)
# compute exclusion set
resources_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 = (
@ -212,7 +237,13 @@ def get_all_slots(agenda, meeting_type, unique=False):
continue
# slot is full if an already booked event overlaps it
booked = desk.id in bookings and bookings[desk.id].overlaps(start_datetime, end_datetime)
# check resources first
booked = resources_bookings.overlaps(start_datetime, end_datetime)
if not booked:
# then bookings if resources are free
booked = desk.id in bookings and bookings[desk.id].overlaps(
start_datetime, end_datetime
)
if unique and unique_booked.get(timestamp) is booked:
continue
unique_booked[timestamp] = booked
@ -416,6 +447,22 @@ class MeetingDatetimes(APIView):
now_datetime = now()
resources = None
if agenda.kind == 'meetings' and 'resources' in request.GET:
resources_slugs = [s for s in request.GET['resources'].split(',') if s]
resources = list(agenda.resources.filter(slug__in=resources_slugs))
if len(resources) != len(resources_slugs):
unknown_slugs = set(resources_slugs) - set([r.slug for r in resources])
unknown_slugs = sorted(list(unknown_slugs))
return Response(
{
'err': 1,
'err_class': 'invalid resource: %s' % ', '.join(unknown_slugs),
'err_desc': _('invalid resource: %s') % ', '.join(unknown_slugs),
},
status=status.HTTP_400_BAD_REQUEST,
)
# 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
@ -429,7 +476,7 @@ class MeetingDatetimes(APIView):
# The generator also remove slots starting before the current time.
def unique_slots():
last_slot = None
all_slots = list(get_all_slots(agenda, meeting_type, unique=True))
all_slots = list(get_all_slots(agenda, meeting_type, resources=resources, unique=True))
for slot in sorted(all_slots, key=lambda slot: slot[:3]):
if slot.start_datetime < now_datetime:
continue

View File

@ -426,6 +426,217 @@ def test_datetimes_api_meetings_agenda(app, meetings_agenda):
assert len(resp.json['data']) == 3
def test_datetimes_api_meetings_agenda_with_resources(app):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
tomorrow_str = tomorrow.isoformat()
agenda = Agenda.objects.create(
label='Agenda', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=10
)
other_agenda = Agenda.objects.create(label='Other', kind='meetings')
other_desk = Desk.objects.create(agenda=other_agenda, slug='desk-1')
other_meeting_type = MeetingType.objects.create(agenda=other_agenda, slug='foo-bar', duration=90)
resource1 = Resource.objects.create(label='Resource 1')
resource2 = Resource.objects.create(label='Resource 2')
resource3 = Resource.objects.create(label='Resource 3')
agenda.resources.add(resource1, resource2, resource3)
desk = Desk.objects.create(agenda=agenda, slug='desk-1')
desk2 = Desk.objects.create(agenda=agenda, slug='desk-2')
meeting_type = MeetingType.objects.create(agenda=agenda, slug='foo-bar')
TimePeriod.objects.create(
weekday=tomorrow.weekday(), start_time=datetime.time(9, 0), end_time=datetime.time(17, 00), desk=desk,
)
api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
# all resources are free
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s,%s' % (
agenda.slug,
meeting_type.slug,
resource1.slug,
resource2.slug,
)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
# resource 1 is not available from 10h to 11h30 in another agenda
dt = make_aware(datetime.datetime.combine(tomorrow, datetime.time(10, 0)))
event1 = Event.objects.create(
agenda=other_agenda,
meeting_type=other_meeting_type,
places=1,
full=False,
start_datetime=dt,
desk=other_desk,
)
event1.resources.add(resource1)
booking_r1 = Booking.objects.create(event=event1)
# resource 3 is not available from 9h to 10h in this agenda (but desk-1 is free)
dt = make_aware(datetime.datetime.combine(tomorrow, datetime.time(9, 0)))
event2 = Event.objects.create(
agenda=agenda, meeting_type=meeting_type, places=1, full=False, start_datetime=dt, desk=desk2,
)
event2.resources.add(resource3)
Booking.objects.create(event=event2)
dt = make_aware(datetime.datetime.combine(tomorrow, datetime.time(9, 30)))
event3 = Event.objects.create(
agenda=agenda, meeting_type=meeting_type, places=1, full=False, start_datetime=dt, desk=desk2,
)
event3.resources.add(resource3)
Booking.objects.create(event=event3)
# check for resource 1 and resource 2: not available from 10H to 11H30
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s,%s' % (
agenda.slug,
meeting_type.slug,
resource1.slug,
resource2.slug,
)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
'%s 10:00:00' % tomorrow_str,
'%s 10:30:00' % tomorrow_str,
'%s 11:00:00' % tomorrow_str,
]
# check for resource 2 only ? it's free
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
agenda.slug,
meeting_type.slug,
resource2.slug,
)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
# check for resource 3: not available from 9H to 10H
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
agenda.slug,
meeting_type.slug,
resource3.slug,
)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
'%s 09:00:00' % tomorrow_str,
'%s 09:30:00' % tomorrow_str,
]
# check for resource 1 and resource 3
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s,%s' % (
agenda.slug,
meeting_type.slug,
resource1.slug,
resource3.slug,
)
with CaptureQueriesContext(connection) as ctx:
resp = app.get(api_url)
assert len(ctx.captured_queries) == 9
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
'%s 09:00:00' % tomorrow_str,
'%s 09:30:00' % tomorrow_str,
'%s 10:00:00' % tomorrow_str,
'%s 10:30:00' % tomorrow_str,
'%s 11:00:00' % tomorrow_str,
]
# no resources to book
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=' % (agenda.slug, meeting_type.slug,)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug,)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
# event 3 is booked without resource, only one desk
event3.desk = desk
event3.save()
event3.resources.clear()
desk2.delete()
api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug,)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
'%s 09:30:00' % tomorrow_str
]
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
agenda.slug,
meeting_type.slug,
resource1.slug,
)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
'%s 09:30:00' % tomorrow_str,
'%s 10:00:00' % tomorrow_str,
'%s 10:30:00' % tomorrow_str,
'%s 11:00:00' % tomorrow_str,
]
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
agenda.slug,
meeting_type.slug,
resource3.slug,
)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
'%s 09:30:00' % tomorrow_str
]
# resource is unknown or not valid for this agenda
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=foobarbaz' % (agenda.slug, meeting_type.slug,)
resp = app.get(api_url, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid resource: foobarbaz' # legacy
assert resp.json['err_class'] == 'invalid resource: foobarbaz'
assert resp.json['err_desc'] == 'invalid resource: foobarbaz'
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s,foobarbaz' % (
agenda.slug,
meeting_type.slug,
resource3.slug,
)
resp = app.get(api_url, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid resource: foobarbaz' # legacy
assert resp.json['err_class'] == 'invalid resource: foobarbaz'
assert resp.json['err_desc'] == 'invalid resource: foobarbaz'
agenda.resources.remove(resource3)
resp = app.get(api_url, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid resource: foobarbaz, resource-3' # legacy
assert resp.json['err_class'] == 'invalid resource: foobarbaz, resource-3'
assert resp.json['err_desc'] == 'invalid resource: foobarbaz, resource-3'
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
agenda.slug,
meeting_type.slug,
resource3.slug,
)
resp = app.get(api_url, status=400)
assert resp.json['err'] == 1
assert resp.json['reason'] == 'invalid resource: resource-3' # legacy
assert resp.json['err_class'] == 'invalid resource: resource-3'
assert resp.json['err_desc'] == 'invalid resource: resource-3'
# if booking is canceled the resource is free
booking_r1.cancel()
api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
agenda.slug,
meeting_type.slug,
resource1.slug,
)
resp = app.get(api_url)
assert len(resp.json['data']) == 32
assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
'%s 09:30:00' % tomorrow_str
]
def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda, user):
meetings_agenda.minimal_booking_delay = 0
meetings_agenda.maximal_booking_delay = 10