api: add date time period support in datetimes and fillslot (#70185)

This commit is contained in:
Valentin Deniaud 2022-10-13 16:20:17 +02:00
parent bb148dc008
commit f8e1888144
5 changed files with 212 additions and 27 deletions

View File

@ -542,43 +542,55 @@ class Agenda(models.Model):
self.reminder_settings.duplicate(agenda_target=new_agenda)
return new_agenda
def get_effective_time_periods(self):
def get_effective_time_periods(self, min_datetime=None, max_datetime=None):
"""Regroup timeperiods by desks.
List all timeperiods, timeperiods having the same begin_time and
end_time are regrouped in a SharedTimePeriod object, which has a
list of desks instead of only one desk.
"""
min_date = min_datetime.date() if min_datetime else None
max_date = max_datetime.date() if max_datetime else None
if self.kind == 'virtual':
return self.get_effective_time_periods_virtual()
return self.get_effective_time_periods_virtual(min_date, max_date)
elif self.kind == 'meetings':
return self.get_effective_time_periods_meetings()
return self.get_effective_time_periods_meetings(min_date, max_date)
else:
raise ValueError('does not work with kind %r' % self.kind)
def get_effective_time_periods_meetings(self):
def get_effective_time_periods_meetings(self, min_date, max_date):
"""List timeperiod instances for all desks of the agenda, convert them
into an Interval of WeekTime which can be compared and regrouped using
itertools.groupby.
"""
time_periods = TimePeriod.objects.filter(desk__agenda=self)
if min_date:
time_periods.filter(Q(date__isnull=True) | Q(date__gte=min_date))
if max_date:
time_periods.filter(Q(date__isnull=True) | Q(date__lte=max_date))
yield from (
SharedTimePeriod.from_weektime_interval(
weektime_interval,
desks=[time_period.desk for time_period in time_periods],
)
for weektime_interval, time_periods in itertools.groupby(
TimePeriod.objects.filter(desk__agenda=self)
.prefetch_related('desk')
.order_by('weekday', 'start_time', 'end_time'),
time_periods.prefetch_related('desk').order_by('weekday', 'start_time', 'end_time'),
key=TimePeriod.as_weektime_interval,
)
)
def get_effective_time_periods_virtual(self):
def get_effective_time_periods_virtual(self, min_date, max_date):
"""List timeperiod instances for all desks of all real agendas of this
virtual agenda, convert them into an Interval of WeekTime which can be
compared and regrouped using itertools.groupby.
"""
time_periods = TimePeriod.objects.filter(desk__agenda__virtual_agendas=self)
if min_date:
time_periods.filter(Q(date__isnull=True) | Q(date__gte=min_date))
if max_date:
time_periods.filter(Q(date__isnull=True) | Q(date__lte=max_date))
closed_hours_by_days = IntervalSet.from_ordered(
[
time_period.as_weektime_interval()
@ -586,9 +598,7 @@ class Agenda(models.Model):
]
)
for time_period_interval, time_periods in itertools.groupby(
TimePeriod.objects.filter(desk__agenda__virtual_agendas=self)
.order_by('weekday', 'start_time', 'end_time')
.prefetch_related('desk'),
time_periods.order_by('weekday', 'start_time', 'end_time').prefetch_related('desk'),
key=lambda tp: tp.as_weektime_interval(),
):
time_periods = list(time_periods)
@ -1091,14 +1101,17 @@ WEEKDAYS_LIST = sorted(WEEKDAYS.items(), key=lambda x: x[0])
class WeekTime(collections.namedtuple('WeekTime', ['weekday', 'time'])):
"""Representation of a time point in a weekday, ex.: Monday at 5 o'clock."""
def __new__(cls, weekday, weekday_indexes, time):
def __new__(cls, weekday, weekday_indexes, date, time):
if date:
weekday = date.weekday()
self = super().__new__(cls, weekday, time)
self.weekday_indexes = weekday_indexes
self.date = date
return self
def __repr__(self):
return '%s / %s' % (
force_str(WEEKDAYS[self.weekday]),
self.date or force_str(WEEKDAYS[self.weekday]),
date_format(self.time, 'TIME_FORMAT'),
)
@ -1139,12 +1152,15 @@ class TimePeriod(models.Model):
]
def __str__(self):
label = force_str(WEEKDAYS[self.weekday])
if self.weekday_indexes:
label = _('%(weekday)s (%(ordinals)s of the month)') % {
'weekday': label,
'ordinals': ', '.join(ordinal(i) for i in self.weekday_indexes),
}
if self.date:
label = date_format(self.date, 'l d F Y')
else:
label = force_str(WEEKDAYS[self.weekday])
if self.weekday_indexes:
label = _('%(weekday)s (%(ordinals)s of the month)') % {
'weekday': label,
'ordinals': ', '.join(ordinal(i) for i in self.weekday_indexes),
}
label = '%s / %s%s' % (
label,
@ -1191,8 +1207,8 @@ class TimePeriod(models.Model):
def as_weektime_interval(self):
return Interval(
WeekTime(self.weekday, self.weekday_indexes, self.start_time),
WeekTime(self.weekday, self.weekday_indexes, self.end_time),
WeekTime(self.weekday, self.weekday_indexes, self.date, self.start_time),
WeekTime(self.weekday, self.weekday_indexes, self.date, self.end_time),
)
def as_shared_timeperiods(self):
@ -1201,6 +1217,7 @@ class TimePeriod(models.Model):
weekday_indexes=self.weekday_indexes,
start_time=self.start_time,
end_time=self.end_time,
date=self.date,
desks=[self.desk],
)
@ -1226,13 +1243,14 @@ class SharedTimePeriod:
of get_all_slots() for details).
"""
__slots__ = ['weekday', 'weekday_indexes', 'start_time', 'end_time', 'desks']
__slots__ = ['weekday', 'weekday_indexes', 'start_time', 'end_time', 'date', 'desks']
def __init__(self, weekday, weekday_indexes, start_time, end_time, desks):
def __init__(self, weekday, weekday_indexes, start_time, end_time, date, desks):
self.weekday = weekday
self.weekday_indexes = weekday_indexes
self.start_time = start_time
self.end_time = end_time
self.date = date
self.desks = set(desks)
def __str__(self):
@ -1243,17 +1261,19 @@ class SharedTimePeriod:
)
def __eq__(self, other):
return (self.weekday, self.start_time, self.end_time) == (
return (self.weekday, self.start_time, self.end_time, self.date) == (
other.weekday,
other.start_time,
other.end_time,
other.date,
)
def __lt__(self, other):
return (self.weekday, self.start_time, self.end_time) < (
return (self.weekday, self.start_time, self.end_time, self.date) < (
other.weekday,
other.start_time,
other.end_time,
other.date,
)
def get_time_slots(self, min_datetime, max_datetime, meeting_duration, base_duration):
@ -1278,10 +1298,17 @@ class SharedTimePeriod:
Generated start_datetime MUST be in the local timezone, and the local
timezone must not change, as the API needs it to generate stable ids.
"""
if self.date and not (min_datetime.date() <= self.date <= max_datetime.date()):
return
meeting_duration = datetime.timedelta(minutes=meeting_duration)
duration = datetime.timedelta(minutes=base_duration)
real_min_datetime = min_datetime + datetime.timedelta(days=self.weekday - min_datetime.weekday())
real_min_datetime = (
min_datetime + datetime.timedelta(days=self.weekday - min_datetime.weekday())
if not self.date
else min_datetime
)
if real_min_datetime < min_datetime:
real_min_datetime += datetime.timedelta(days=7)
@ -1293,6 +1320,10 @@ class SharedTimePeriod:
event_datetime = make_aware(make_naive(real_min_datetime)).replace(
hour=self.start_time.hour, minute=self.start_time.minute, second=0, microsecond=0
)
if self.date:
event_datetime = event_datetime.replace(
day=self.date.day, month=self.date.month, year=self.date.year
)
# don't start before min_datetime
event_datetime = max(event_datetime, min_datetime)
@ -1305,6 +1336,10 @@ class SharedTimePeriod:
or event_datetime.date() != next_time.date()
or (self.weekday_indexes and get_weekday_index(event_datetime) not in self.weekday_indexes)
):
# if time slot is not repeating, end now
if self.date:
break
# switch to naive time for day/week changes
event_datetime = make_naive(event_datetime)
# back to morning
@ -1335,6 +1370,7 @@ class SharedTimePeriod:
weekday_indexes=begin.weekday_indexes or end.weekday_indexes,
start_time=begin.time,
end_time=end.time,
date=begin.date or end.date,
desks=desks,
)

View File

@ -285,7 +285,7 @@ def get_all_slots(
)
unique_booked = {}
for time_period in base_agenda.get_effective_time_periods():
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)

View File

@ -2426,3 +2426,91 @@ def test_datetimes_api_meetings_virtual_agenda_weekday_indexes(app):
'2022-03-07 11:30:00',
'2022-03-14 11:30:00',
]
@pytest.mark.freeze_time('2022-10-24 10:00')
def test_datetimes_api_meetings_agenda_date_time_period(app):
agenda = Agenda.objects.create(
label='Foo bar', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=8
)
meeting_type = MeetingType.objects.create(agenda=agenda, label='Plop', duration=30)
desk = Desk.objects.create(agenda=agenda, label='desk')
TimePeriod.objects.create(
date=datetime.date(2022, 10, 24),
start_time=datetime.time(12, 0),
end_time=datetime.time(14, 0),
desk=desk,
)
api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug)
resp = app.get(api_url)
assert [x['datetime'] for x in resp.json['data']] == [
'2022-10-24 12:00:00',
'2022-10-24 12:30:00',
'2022-10-24 13:00:00',
'2022-10-24 13:30:00',
]
resp = app.get(api_url, params={'date_start': '2022-10-25'})
assert resp.json['data'] == []
# mix with repeating period
TimePeriod.objects.create(
weekday=0,
start_time=datetime.time(13, 0),
end_time=datetime.time(15, 0),
desk=desk,
)
resp = app.get(api_url)
assert [x['datetime'] for x in resp.json['data']] == [
'2022-10-24 12:00:00',
'2022-10-24 12:30:00',
'2022-10-24 13:00:00',
'2022-10-24 13:30:00',
'2022-10-24 14:00:00',
'2022-10-24 14:30:00',
'2022-10-31 13:00:00',
'2022-10-31 13:30:00',
'2022-10-31 14:00:00',
'2022-10-31 14:30:00',
]
@pytest.mark.freeze_time('2022-10-24 10:00')
def test_datetimes_api_meetings_virtual_agenda_date_time_period(app):
agenda = Agenda.objects.create(
label='Foo bar', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=8
)
desk = Desk.objects.create(agenda=agenda, label='desk')
meeting_type = MeetingType.objects.create(agenda=agenda, label='Plop', duration=30)
virtual_agenda = Agenda.objects.create(label='Foo bar Meeting', kind='virtual')
virtual_agenda.real_agendas.add(agenda)
TimePeriod.objects.create(
date=datetime.date(2022, 10, 24),
start_time=datetime.time(12, 0),
end_time=datetime.time(14, 0),
desk=desk,
)
api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (virtual_agenda.slug, meeting_type.slug)
resp = app.get(api_url)
assert [x['datetime'] for x in resp.json['data']] == [
'2022-10-24 12:00:00',
'2022-10-24 12:30:00',
'2022-10-24 13:00:00',
'2022-10-24 13:30:00',
]
# add exclusion period on virtual agenda
TimePeriod.objects.create(
weekday=0, start_time=datetime.time(12, 00), end_time=datetime.time(13, 00), agenda=virtual_agenda
)
resp = app.get(api_url)
resp = app.get(api_url)
assert [x['datetime'] for x in resp.json['data']] == [
'2022-10-24 13:00:00',
'2022-10-24 13:30:00',
]

View File

@ -995,6 +995,42 @@ def test_booking_api_with_data(app, user):
assert Booking.objects.all()[0].extra_data == {'hello': 'world'}
@pytest.mark.freeze_time('2022-10-24 10:00')
def test_booking_api_meeting_date_time_period(app, user):
agenda = Agenda.objects.create(
label='Foo bar', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=8
)
meeting_type = MeetingType.objects.create(agenda=agenda, label='Plop', duration=30)
desk = Desk.objects.create(agenda=agenda, label='desk')
TimePeriod.objects.create(
date=datetime.date(2022, 10, 24),
start_time=datetime.time(12, 0),
end_time=datetime.time(14, 0),
desk=desk,
)
datetimes_resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug))
slot = datetimes_resp.json['data'][0]['id']
assert slot == 'plop:2022-10-24-1200'
app.authorization = ('Basic', ('john.doe', 'password'))
# single booking
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, slot))
assert Booking.objects.count() == 1
assert resp.json['duration'] == 30
# multiple slots
slots = [datetimes_resp.json['data'][1]['id'], datetimes_resp.json['data'][2]['id']]
assert slots == ['plop:2022-10-24-1230', 'plop:2022-10-24-1300']
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots})
assert Booking.objects.count() == 3
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, slot))
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'no more desk available'
def test_booking_api_available(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', minimal_booking_delay=0)
for i in range(0, 10):

View File

@ -491,3 +491,28 @@ def test_time_period_check_constraint():
start_time=datetime.time(hour=1, minute=0),
end_time=datetime.time(hour=2, minute=0),
)
def test_timeperiod_date_time_slots():
agenda = Agenda(label='Foo bar', slug='bar')
agenda.save()
desk = Desk.objects.create(label='Desk 1', agenda=agenda)
meeting_type = MeetingType(duration=60, agenda=agenda)
meeting_type.save()
timeperiod = TimePeriod(
desk=desk,
date=datetime.date(2022, 10, 24),
start_time=datetime.time(9, 0),
end_time=datetime.time(12, 0),
)
events = timeperiod.as_shared_timeperiods().get_time_slots(
min_datetime=make_aware(datetime.datetime(2022, 10, 1)),
max_datetime=make_aware(datetime.datetime(2022, 11, 1)),
meeting_duration=meeting_type.duration,
base_duration=agenda.get_base_meeting_duration(),
)
assert [x.timetuple()[:5] for x in sorted(events)] == [
(2022, 10, 24, 9, 0),
(2022, 10, 24, 10, 0),
(2022, 10, 24, 11, 0),
]