From df6721d5809e3ee48e5d2a320302e0224755981a Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Thu, 6 Apr 2023 16:19:51 +0200 Subject: [PATCH] agendas: implements free time calculation (#76335) SharedTimePeriod gets a get_intervals(mintime, maxtime) method returning the list of intervals of open time between mintime and maxtime. Agenda gets a get_free_time(mintime, maxtime) method returning the list of intervals of open time between mintime and maxtim. --- chrono/agendas/models.py | 169 ++++++++++++++++++++++++++++++++++++++- tests/test_agendas.py | 146 ++++++++++++++++++++++++++++++++- 2 files changed, 311 insertions(+), 4 deletions(-) diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index c5d4cb22..4d7217ef 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -1393,6 +1393,145 @@ class Agenda(models.Model): if unique and not booked: break + def get_free_time( + self, + start_datetime=None, + end_datetime=None, + ): + """Get open time on this agenda in the future. + + The process is done in three phases: + + 1. aggregate exceptions by desk, + 2. aggregate booked events by desks, + 3. for each desk compute the normal opening time based on timeperiods, + then remove expceptions and booked events, and aggregate all that as + the result. + """ + assert self.kind != 'events', 'get_all_slots() does not work on events agendas' + + max_meeting_duration_td = datetime.timedelta(minutes=self.get_max_meeting_duration()) + base_min_datetime = self.get_min_datetime(start_datetime) or now() + base_max_datetime = self.get_max_datetime(end_datetime) + + 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.id: 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.id not in desks_exceptions: + desks_exceptions[desk.id] = IntervalSet() + desks_exceptions[desk.id].add( + time_period_exception.start_datetime, time_period_exception.end_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 open times by desks + free_time_by_desk = {} + for time_period in self.get_effective_time_periods(base_min_datetime, base_max_datetime): + base_interval_set = IntervalSet.from_ordered( + time_period.get_intervals(base_min_datetime, base_max_datetime) + ) + for desk in time_period.desks: + if desk in free_time_by_desk: + free_time_by_desk[desk] += base_interval_set + else: + free_time_by_desk[desk] = base_interval_set + + # reduce desks' open time by agenda effective min/max datetime + free_time = [] + for desk, value in free_time_by_desk.items(): + min_max = agenda_id_min_max_datetime[desk.agenda_id] + desk_free_time = value + if not desk_free_time: + continue + if desk_free_time.min() < min_max[0]: + desk_free_time -= IntervalSet([(free_time.min(), min_max[0])]) + if min_max[1] < desk_free_time.max(): + desk_free_time -= IntervalSet([(min_max[1], free_time.max())]) + if desk.id in desks_exceptions: + desk_free_time -= desks_exceptions[desk.id] + if desk.id in bookings: + desk_free_time -= bookings[desk.id] + if free_time is None: + free_time = desk_free_time + else: + free_time += desk_free_time + return free_time + class VirtualMember(models.Model): """Trough model to link virtual agendas to their real agendas. @@ -1594,9 +1733,9 @@ class SharedTimePeriod: self.date = date self.desks = set(desks) - def __str__(self): + def __repr__(self): return '%s / %s → %s' % ( - force_str(WEEKDAYS[self.weekday]), + WEEKDAYS[self.weekday], date_format(self.start_time, 'TIME_FORMAT'), date_format(self.end_time, 'TIME_FORMAT'), ) @@ -1713,6 +1852,32 @@ class SharedTimePeriod: desks=desks, ) + def get_intervals(self, min_datetime, max_datetime): + """Generate all possible intervals of time between min_datetime and + max_datetime, corresponding to the this timeperiod. + """ + min_datetime = localtime(min_datetime) + max_datetime = localtime(max_datetime) + + if self.date: + # if self.date if out of the current period, returns early + if not (min_datetime.date() <= self.date <= max_datetime.date()): + return + start_datetime = make_aware(datetime.datetime.combine(self.date, self.start_time)) + else: + start_datetime = make_aware(datetime.datetime.combine(min_datetime.date(), self.start_time)) + start_datetime += datetime.timedelta(days=self.weekday - min_datetime.weekday()) + if start_datetime < min_datetime: + start_datetime += datetime.timedelta(days=7) + + while start_datetime < max_datetime: + if not self.weekday_indexes or get_weekday_index(start_datetime) in self.weekday_indexes: + end_datetime = make_aware(datetime.datetime.combine(start_datetime.date(), self.end_time)) + yield (max(start_datetime, min_datetime), min(end_datetime, max_datetime)) + if self.date: + break + start_datetime += datetime.timedelta(days=7) + class MeetingType(models.Model): agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE) diff --git a/tests/test_agendas.py b/tests/test_agendas.py index dfb69f29..f5e01f6f 100644 --- a/tests/test_agendas.py +++ b/tests/test_agendas.py @@ -36,9 +36,10 @@ from chrono.agendas.models import ( UnavailabilityCalendar, VirtualMember, ) +from chrono.utils.interval import IntervalSet from chrono.utils.timezone import localtime, make_aware, make_naive, now -from .utils import add_meeting, build_agendas, utc +from .utils import add_day_timeperiod, add_exception, add_meeting, build_agendas, paris, utc pytestmark = pytest.mark.django_db @@ -118,7 +119,6 @@ END:VCALENDAR""" INVALID_ICS_SAMPLE = """content """ - with open('tests/data/atreal.ics') as f: ICS_ATREAL = f.read() @@ -3638,3 +3638,145 @@ def test_shared_custody_agenda_unique_child_no_date_end(): SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=datetime.date(2023, 1, 1) ) + + +def test_meetings_agenda_get_free_time(db, freezer): + freezer.move_to(paris('2023-04-02 19:10')) + agenda = build_agendas( + ''' +meetings Agenda 30 + desk "Desk 1" monday-friday 08:00-12:00 14:00-17:00 + unavailability-calendar Congés + exception Avril paris('2023-04-05 00:00:00 24h') + desk "Desk 2" monday-friday 09:00-12:00 + unavailability-calendar Congés + desk "Desk 3" monday-friday 15:00-17:00 + unavailability-calendar Congés +''' + ) + + full_free_time = IntervalSet( + [ + paris('2023-04-03 08:00 4h'), + paris('2023-04-03 14:00 3h'), + paris('2023-04-04 08:00 4h'), + paris('2023-04-04 14:00 3h'), + paris('2023-04-06 08:00 4h'), + paris('2023-04-06 14:00 3h'), + paris('2023-04-07 08:00 4h'), + paris('2023-04-07 14:00 3h'), + paris('2023-04-08 08:00 2h'), + ] + ) + + def closed(): + return full_free_time - agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) + + add_day_timeperiod(agenda._desk_1, paris('2023-04-08 08:00 2h')) + + assert agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) == full_free_time + assert full_free_time - agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) == [] + assert closed() == [] + assert ( + agenda.get_free_time(start_datetime=paris('2023-04-03 13:00'), end_datetime=paris('2023-04-09 19:10')) + == list(full_free_time)[1:] + ) + + # desk1 is closed on monday 3th april form 8' to 12' but + # desk2 is still open from 9' to 12', so free time is only + # diminished of the 8' to 9' hour. + add_exception(agenda._desk_1, paris('2023-04-03 08:00 4h')) + assert closed() == [paris('2023-04-03 08:00 1h')] + + # now close desk2 from 11' to 12' on monday 3th + add_exception(agenda._desk_2, paris('2023-04-03 11:00 1h')) + assert closed() == [paris('2023-04-03 08:00 1h'), paris('2023-04-03 11:00 1h')] + + # add a meeting on tuesday 4th april on desk1 at 14'30 + add_meeting(agenda._desk_1, paris('2023-04-04 14:30')) + # an event on 5th at 15' but slot should still be open on desk3 + add_meeting(agenda._desk_1, paris('2023-04-05 15:00')) + # two events on 6th at 15', slot is closed + add_meeting(agenda._desk_1, paris('2023-04-06 15:00')) + add_meeting(agenda._desk_3, paris('2023-04-06 15:00')) + assert closed() == [ + paris('2023-04-03 08:00 1h'), + paris('2023-04-03 11:00 1h'), + paris('2023-04-04 14:30 30m'), + paris('2023-04-06 15:00 30m'), + ] + + +def test_virtual_agenda_get_free_time(db, freezer): + freezer.move_to(paris('2023-04-02 19:10')) + # context: + # * a virtual agenda, containing: + # * three real agendas, with timperiods of monday to friday and a 30 minutes meeting type + # - agenda 1. 8-12h/14-17h + # - agenda 2. 9-12h + # - agenda 3. 15-17h + # * looking at week from monday 3th april 2023 to friday 7th april 2023 + agenda = build_agendas( + ''' +virtual Agenda 30 + meetings Agenda-1 + desk Desk-1 monday-friday 08:00-12:00 14:00-17:00 + unavailability-calendar Congés + exception Avril paris('2023-04-05 00:00:00 24h') + meetings Agenda-2 + desk Desk-1 monday-friday 09:00-12:00 + unavailability-calendar Congés + meetings Agenda-3 + desk Desk-1 monday-friday 15:00-17:00 + unavailability-calendar Congés +''' + ) + + full_free_time = IntervalSet( + [ + paris('2023-04-03 08:00 4h'), + paris('2023-04-03 14:00 3h'), + paris('2023-04-04 08:00 4h'), + paris('2023-04-04 14:00 3h'), + paris('2023-04-06 08:00 4h'), + paris('2023-04-06 14:00 3h'), + paris('2023-04-07 08:00 4h'), + paris('2023-04-07 14:00 3h'), + paris('2023-04-08 08:00 2h'), + ] + ) + + def closed(): + return full_free_time - agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) + + add_day_timeperiod(agenda._agenda_1, paris('2023-04-08 08:00 2h')) + + assert agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) == full_free_time + assert ( + agenda.get_free_time(start_datetime=paris('2023-04-03 13:00'), end_datetime=paris('2023-04-09 19:10')) + == list(full_free_time)[1:] + ) + + # agenda1.desk is closed on monday 3th april form 8' to 12' but + # agenda2.desk is still open from 9' to 12', so free time is only + # diminished of the 8' to 9' hour. + add_exception(agenda._agenda_1, paris('2023-04-03 08:00 4h')) + assert closed() == [paris('2023-04-03 08:00 1h')] + + # now close agenda2.desk from 11' to 12' on monday 3th + add_exception(agenda._agenda_2, paris('2023-04-03 11:00 1h')) + assert closed() == [paris('2023-04-03 08:00 1h'), paris('2023-04-03 11:00 1h')] + + # add a meeting on tuesday 4th april on agenda1.desk at 14'30 + add_meeting(agenda._agenda_1, paris('2023-04-04 14:30')) + # an event on 5th at 15' but slot should still be open on agenda3 + add_meeting(agenda._agenda_1, paris('2023-04-05 15:00')) + # two events on 6th at 15', slot is closed + add_meeting(agenda._agenda_1, paris('2023-04-06 15:00')) + add_meeting(agenda._agenda_3, paris('2023-04-06 15:00')) + assert closed() == [ + paris('2023-04-03 08:00 1h'), + paris('2023-04-03 11:00 1h'), + paris('2023-04-04 14:30 30m'), + paris('2023-04-06 15:00 30m'), + ]