diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 013054a8..e8acb0a8 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -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. diff --git a/chrono/api/views.py b/chrono/api/views.py index eeba372b..b13bec83 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -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,