agendas: move get_all_slots() and get_min/max_datetime() as Agenda's methods (#76335)

This commit is contained in:
Benjamin Dauvergne 2023-04-06 12:53:15 +02:00
parent ffb83732ba
commit 343e06552c
2 changed files with 287 additions and 292 deletions

View File

@ -164,6 +164,11 @@ def booking_template_validator(value):
pass
TimeSlot = collections.namedtuple(
'TimeSlot', ['start_datetime', 'end_datetime', 'full', 'desk', 'booked_for_external_user']
)
class Agenda(models.Model):
label = models.CharField(_('Label'), max_length=150)
slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
@ -1108,6 +1113,286 @@ class Agenda(models.Model):
return True
def get_min_datetime(self, start_datetime=None):
if self.minimal_booking_delay is None:
return start_datetime
if start_datetime is None:
return self.min_booking_datetime
return max(self.min_booking_datetime, start_datetime)
def get_max_datetime(self, end_datetime=None):
if self.maximal_booking_delay is None:
return end_datetime
if end_datetime is None:
return self.max_booking_datetime
return min(self.max_booking_datetime, end_datetime)
def get_all_slots(
self,
meeting_type,
resources=None,
unique=False,
start_datetime=None,
end_datetime=None,
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.
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 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, ignore it completely.
It if is booked, report the slot as full.
"""
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
# as the base meeting duration of each real agenda.
base_meeting_duration = self.get_base_meeting_duration()
max_meeting_duration_td = datetime.timedelta(minutes=self.get_max_meeting_duration())
base_min_datetime = self.get_min_datetime(start_datetime)
base_max_datetime = self.get_max_datetime(end_datetime)
meeting_duration = meeting_type.duration
meeting_duration_td = datetime.timedelta(minutes=meeting_duration)
now_datetime = now()
base_date = now_datetime.date()
agendas = self.get_real_agendas()
# regroup agendas by their opening period
agenda_ids_by_min_max_datetimes = collections.defaultdict(set)
agenda_id_min_max_datetime = {}
for agenda in agendas:
used_min_datetime = base_min_datetime
if self.minimal_booking_delay is None:
used_min_datetime = agenda.get_min_datetime(start_datetime)
used_max_datetime = base_max_datetime
if self.maximal_booking_delay is None:
used_max_datetime = agenda.get_max_datetime(end_datetime)
agenda_ids_by_min_max_datetimes[(used_min_datetime, used_max_datetime)].add(agenda.id)
agenda_id_min_max_datetime[agenda.id] = (used_min_datetime, used_max_datetime)
# aggregate time period exceptions by desk as IntervalSet for fast querying
# 1. sort exceptions by start_datetime
# 2. group them by desk
# 3. convert each desk's list of exception to intervals then IntervalSet
desks_exceptions = {
time_period_desk: IntervalSet.from_ordered(
map(TimePeriodException.as_interval, time_period_exceptions)
)
for time_period_desk, time_period_exceptions in itertools.groupby(
TimePeriodException.objects.filter(desk__agenda__in=agendas)
.select_related('desk')
.order_by('desk_id', 'start_datetime', 'end_datetime'),
key=lambda time_period: time_period.desk,
)
}
# add exceptions from unavailability calendar
time_period_exception_queryset = (
TimePeriodException.objects.all()
.select_related('unavailability_calendar')
.prefetch_related(
Prefetch(
'unavailability_calendar__desks',
queryset=Desk.objects.filter(agenda__in=agendas),
to_attr='prefetched_desks',
)
)
.filter(unavailability_calendar__desks__agenda__in=agendas)
.order_by('start_datetime', 'end_datetime')
)
for time_period_exception in time_period_exception_queryset:
# unavailability calendar can be used in all desks;
# ignore desks outside of current agenda(s)
for desk in time_period_exception.unavailability_calendar.prefetched_desks:
if desk not in desks_exceptions:
desks_exceptions[desk] = IntervalSet()
desks_exceptions[desk].add(
time_period_exception.start_datetime, time_period_exception.end_datetime
)
# 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]])
base = base - desk_exception
if not base:
# ignore this desk, exceptions cover all opening time
# use an empty interval (begin == end) for this
desk_min_max_datetime[desk] = (now_datetime, now_datetime)
continue
min_datetime = base.min().replace(hour=0, minute=0, second=0, microsecond=0)
if base_min_datetime:
min_datetime = max(min_datetime, base_min_datetime)
max_datetime = base.max()
if base_max_datetime:
max_datetime = min(max_datetime, base_max_datetime)
desk_min_max_datetime[desk] = (min_datetime, max_datetime)
# aggregate already booked time intervals by desk
bookings = {}
for (used_min_datetime, used_max_datetime), agenda_ids in agenda_ids_by_min_max_datetimes.items():
booked_events = (
Event.objects.filter(
agenda__in=agenda_ids,
start_datetime__gte=used_min_datetime - max_meeting_duration_td,
start_datetime__lte=used_max_datetime,
)
.exclude(booking__cancellation_datetime__isnull=False)
# ordering is important for the later groupby, it works like sort | uniq
.order_by('desk_id', 'start_datetime', 'meeting_type__duration')
.values_list('desk_id', 'start_datetime', 'meeting_type__duration')
)
# compute exclusion set by desk from all bookings, using
# itertools.groupby() to group them by desk_id
bookings.update(
(
desk_id,
IntervalSet.from_ordered(
(
event_start_datetime,
event_start_datetime + datetime.timedelta(minutes=event_duration),
)
for desk_id, event_start_datetime, event_duration in values
),
)
for desk_id, values in itertools.groupby(booked_events, lambda be: be[0])
)
# aggregate already booked time intervals for resources
resources_bookings = IntervalSet()
if self.kind == 'meetings' and resources:
used_min_datetime, used_max_datetime = agenda_id_min_max_datetime[self.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 - max_meeting_duration_td,
start_datetime__lte=used_max_datetime,
)
.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
)
# aggregate already booked time intervals by excluded_user_external_id
user_bookings = IntervalSet()
if 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=agendas,
start_datetime__gte=used_min_datetime - max_meeting_duration_td,
start_datetime__lte=used_max_datetime,
booking__user_external_id=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 self.get_effective_time_periods(base_min_datetime, base_max_datetime):
duration = (
datetime.datetime.combine(base_date, time_period.end_time)
- datetime.datetime.combine(base_date, time_period.start_time)
).seconds / 60
if duration < meeting_type.duration:
# skip time period that can't even hold a single meeting
continue
desks_by_min_max_datetime = collections.defaultdict(list)
for desk in time_period.desks:
min_max = desk_min_max_datetime.get(desk, agenda_id_min_max_datetime[desk.agenda_id])
desks_by_min_max_datetime[min_max].append(desk)
# aggregate agendas based on their real min/max_datetime :
# the get_time_slots() result is dependant upon these values, so even
# if we deduplicated a TimePeriod for some desks, if their respective
# agendas have different real min/max_datetime we must unduplicate them
# at time slot generation phase.
for (used_min_datetime, used_max_datetime), desks in desks_by_min_max_datetime.items():
for start_datetime in time_period.get_time_slots(
min_datetime=used_min_datetime,
max_datetime=used_max_datetime,
meeting_duration=meeting_duration,
base_duration=base_meeting_duration,
):
end_datetime = start_datetime + meeting_duration_td
timestamp = start_datetime.timestamp()
# skip generating datetimes if we already know that this
# datetime is available
if unique and unique_booked.get(timestamp) is False:
continue
for desk in sorted(desks, key=lambda desk: desk.label):
# ignore the slot for this desk, if it overlaps and exclusion period for this desk
excluded = desk in desks_exceptions and desks_exceptions[desk].overlaps(
start_datetime, end_datetime
)
if excluded:
continue
# 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
booked_for_external_user = user_bookings.overlaps(start_datetime, end_datetime)
booked = booked or booked_for_external_user
# then bookings if resources are free
if not booked:
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
yield TimeSlot(
start_datetime=start_datetime,
end_datetime=end_datetime,
desk=desk,
full=booked,
booked_for_external_user=booked_for_external_user,
)
if unique and not booked:
break
class VirtualMember(models.Model):
"""Trough model to link virtual agendas to their real agendas.

View File

@ -17,7 +17,6 @@
import collections
import copy
import datetime
import itertools
import json
import uuid
@ -52,11 +51,9 @@ from chrono.agendas.models import (
MeetingType,
SharedCustodyAgenda,
Subscription,
TimePeriodException,
)
from chrono.api import serializers
from chrono.api.utils import APIError, APIErrorBadRequest, Response
from chrono.interval import IntervalSet
from chrono.utils.publik_urls import translate_to_publik_url
from chrono.utils.timezone import localtime, make_aware, now
@ -69,291 +66,6 @@ def format_response_date(dt):
return localtime(dt).strftime('%Y-%m-%d')
def get_min_datetime(agenda, start_datetime=None):
if agenda.minimal_booking_delay is None:
return start_datetime
if start_datetime is None:
return agenda.min_booking_datetime
return max(agenda.min_booking_datetime, start_datetime)
def get_max_datetime(agenda, end_datetime=None):
if agenda.maximal_booking_delay is None:
return end_datetime
if end_datetime is None:
return agenda.max_booking_datetime
return min(agenda.max_booking_datetime, end_datetime)
TimeSlot = collections.namedtuple(
'TimeSlot', ['start_datetime', 'end_datetime', 'full', 'desk', 'booked_for_external_user']
)
def get_all_slots(
base_agenda,
meeting_type,
resources=None,
unique=False,
start_datetime=None,
end_datetime=None,
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.
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 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, ignore it completely.
It if is booked, report the slot as full.
"""
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
# as the base meeting duration of each real agenda.
base_meeting_duration = base_agenda.get_base_meeting_duration()
max_meeting_duration_td = datetime.timedelta(minutes=base_agenda.get_max_meeting_duration())
base_min_datetime = get_min_datetime(base_agenda, start_datetime)
base_max_datetime = get_max_datetime(base_agenda, end_datetime)
meeting_duration = meeting_type.duration
meeting_duration_td = datetime.timedelta(minutes=meeting_duration)
now_datetime = now()
base_date = now_datetime.date()
agendas = base_agenda.get_real_agendas()
# regroup agendas by their opening period
agenda_ids_by_min_max_datetimes = collections.defaultdict(set)
agenda_id_min_max_datetime = {}
for agenda in agendas:
used_min_datetime = base_min_datetime
if base_agenda.minimal_booking_delay is None:
used_min_datetime = get_min_datetime(agenda, start_datetime)
used_max_datetime = base_max_datetime
if base_agenda.maximal_booking_delay is None:
used_max_datetime = get_max_datetime(agenda, end_datetime)
agenda_ids_by_min_max_datetimes[(used_min_datetime, used_max_datetime)].add(agenda.id)
agenda_id_min_max_datetime[agenda.id] = (used_min_datetime, used_max_datetime)
# aggregate time period exceptions by desk as IntervalSet for fast querying
# 1. sort exceptions by start_datetime
# 2. group them by desk
# 3. convert each desk's list of exception to intervals then IntervalSet
desks_exceptions = {
time_period_desk: IntervalSet.from_ordered(
map(TimePeriodException.as_interval, time_period_exceptions)
)
for time_period_desk, time_period_exceptions in itertools.groupby(
TimePeriodException.objects.filter(desk__agenda__in=agendas)
.select_related('desk')
.order_by('desk_id', 'start_datetime', 'end_datetime'),
key=lambda time_period: time_period.desk,
)
}
# add exceptions from unavailability calendar
time_period_exception_queryset = (
TimePeriodException.objects.all()
.select_related('unavailability_calendar')
.prefetch_related(
Prefetch(
'unavailability_calendar__desks',
queryset=Desk.objects.filter(agenda__in=agendas),
to_attr='prefetched_desks',
)
)
.filter(unavailability_calendar__desks__agenda__in=agendas)
.order_by('start_datetime', 'end_datetime')
)
for time_period_exception in time_period_exception_queryset:
# unavailability calendar can be used in all desks;
# ignore desks outside of current agenda(s)
for desk in time_period_exception.unavailability_calendar.prefetched_desks:
if desk not in desks_exceptions:
desks_exceptions[desk] = IntervalSet()
desks_exceptions[desk].add(
time_period_exception.start_datetime, time_period_exception.end_datetime
)
# 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]])
base = base - desk_exception
if not base:
# ignore this desk, exceptions cover all opening time
# use an empty interval (begin == end) for this
desk_min_max_datetime[desk] = (now_datetime, now_datetime)
continue
min_datetime = base.min().replace(hour=0, minute=0, second=0, microsecond=0)
if base_min_datetime:
min_datetime = max(min_datetime, base_min_datetime)
max_datetime = base.max()
if base_max_datetime:
max_datetime = min(max_datetime, base_max_datetime)
desk_min_max_datetime[desk] = (min_datetime, max_datetime)
# aggregate already booked time intervals by desk
bookings = {}
for (used_min_datetime, used_max_datetime), agenda_ids in agenda_ids_by_min_max_datetimes.items():
booked_events = (
Event.objects.filter(
agenda__in=agenda_ids,
start_datetime__gte=used_min_datetime - max_meeting_duration_td,
start_datetime__lte=used_max_datetime,
)
.exclude(booking__cancellation_datetime__isnull=False)
# ordering is important for the later groupby, it works like sort | uniq
.order_by('desk_id', 'start_datetime', 'meeting_type__duration')
.values_list('desk_id', 'start_datetime', 'meeting_type__duration')
)
# compute exclusion set by desk from all bookings, using
# itertools.groupby() to group them by desk_id
bookings.update(
(
desk_id,
IntervalSet.from_ordered(
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
for desk_id, event_start_datetime, event_duration in values
),
)
for desk_id, values in itertools.groupby(booked_events, lambda be: be[0])
)
# aggregate already booked time intervals for resources
resources_bookings = IntervalSet()
if base_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 - max_meeting_duration_td,
start_datetime__lte=used_max_datetime,
)
.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
)
# aggregate already booked time intervals by excluded_user_external_id
user_bookings = IntervalSet()
if 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=agendas,
start_datetime__gte=used_min_datetime - max_meeting_duration_td,
start_datetime__lte=used_max_datetime,
booking__user_external_id=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(base_min_datetime, base_max_datetime):
duration = (
datetime.datetime.combine(base_date, time_period.end_time)
- datetime.datetime.combine(base_date, time_period.start_time)
).seconds / 60
if duration < meeting_type.duration:
# skip time period that can't even hold a single meeting
continue
desks_by_min_max_datetime = collections.defaultdict(list)
for desk in time_period.desks:
min_max = desk_min_max_datetime.get(desk, agenda_id_min_max_datetime[desk.agenda_id])
desks_by_min_max_datetime[min_max].append(desk)
# aggregate agendas based on their real min/max_datetime :
# the get_time_slots() result is dependant upon these values, so even
# if we deduplicated a TimePeriod for some desks, if their respective
# agendas have different real min/max_datetime we must unduplicate them
# at time slot generation phase.
for (used_min_datetime, used_max_datetime), desks in desks_by_min_max_datetime.items():
for start_datetime in time_period.get_time_slots(
min_datetime=used_min_datetime,
max_datetime=used_max_datetime,
meeting_duration=meeting_duration,
base_duration=base_meeting_duration,
):
end_datetime = start_datetime + meeting_duration_td
timestamp = start_datetime.timestamp()
# skip generating datetimes if we already know that this
# datetime is available
if unique and unique_booked.get(timestamp) is False:
continue
for desk in sorted(desks, key=lambda desk: desk.label):
# ignore the slot for this desk, if it overlaps and exclusion period for this desk
excluded = desk in desks_exceptions and desks_exceptions[desk].overlaps(
start_datetime, end_datetime
)
if excluded:
continue
# 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
booked_for_external_user = user_bookings.overlaps(start_datetime, end_datetime)
booked = booked or booked_for_external_user
# then bookings if resources are free
if not booked:
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
yield TimeSlot(
start_datetime=start_datetime,
end_datetime=end_datetime,
desk=desk,
full=booked,
booked_for_external_user=booked_for_external_user,
)
if unique and not booked:
break
def get_agenda_detail(request, agenda, check_events=False):
agenda_detail = {
'id': agenda.slug,
@ -1090,8 +802,7 @@ class MeetingDatetimes(APIView):
def unique_slots():
last_slot = None
all_slots = list(
get_all_slots(
agenda,
agenda.get_all_slots(
meeting_type,
resources=resources,
unique=True,
@ -1516,8 +1227,7 @@ class Fillslots(APIView):
except (MeetingType.DoesNotExist, ValueError):
raise APIErrorBadRequest(N_('invalid meeting type id: %s'), meeting_type_id)
all_slots = sorted(
get_all_slots(
agenda,
agenda.get_all_slots(
meeting_type,
resources=resources,
user_external_id=user_external_id if exclude_user else None,