agendas: implements free time calculation (#76335)
gitea/chrono/pipeline/head This commit looks good
Details
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:
parent
8278e6dca1
commit
df6721d580
|
@ -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)
|
||||
|
|
|
@ -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'),
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue