From f8e188814454c299cdfd65ea31fbb7d51202e7d1 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 13 Oct 2022 16:20:17 +0200 Subject: [PATCH] api: add date time period support in datetimes and fillslot (#70185) --- chrono/agendas/models.py | 88 ++++++++++++++++++++-------- chrono/api/views.py | 2 +- tests/api/datetimes/test_meetings.py | 88 ++++++++++++++++++++++++++++ tests/api/fillslot/test_all.py | 36 ++++++++++++ tests/test_time_periods.py | 25 ++++++++ 5 files changed, 212 insertions(+), 27 deletions(-) diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index ae80e4aa..667ba3c6 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -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, ) diff --git a/chrono/api/views.py b/chrono/api/views.py index 32cada34..a81abe11 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -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) diff --git a/tests/api/datetimes/test_meetings.py b/tests/api/datetimes/test_meetings.py index 8f5fdf9a..4cd0d87e 100644 --- a/tests/api/datetimes/test_meetings.py +++ b/tests/api/datetimes/test_meetings.py @@ -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', + ] diff --git a/tests/api/fillslot/test_all.py b/tests/api/fillslot/test_all.py index 655e83df..4cd475f7 100644 --- a/tests/api/fillslot/test_all.py +++ b/tests/api/fillslot/test_all.py @@ -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): diff --git a/tests/test_time_periods.py b/tests/test_time_periods.py index b414ab99..92666d4a 100644 --- a/tests/test_time_periods.py +++ b/tests/test_time_periods.py @@ -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), + ]