agendas: implements free time calculation (#76335)
gitea/chrono/pipeline/head This commit looks good Details

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.
This commit is contained in:
Benjamin Dauvergne 2023-04-06 16:19:51 +02:00
parent 8278e6dca1
commit df6721d580
2 changed files with 311 additions and 4 deletions

View File

@ -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)

View File

@ -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'),
]