0007-api-add-date-time-period-support-in-datetimes-and-fi.patch
chrono/agendas/models.py | ||
---|---|---|
541 | 541 |
self.reminder_settings.duplicate(agenda_target=new_agenda) |
542 | 542 |
return new_agenda |
543 | 543 | |
544 |
def get_effective_time_periods(self): |
|
544 |
def get_effective_time_periods(self, min_datetime=None, max_datetime=None):
|
|
545 | 545 |
"""Regroup timeperiods by desks. |
546 | 546 | |
547 | 547 |
List all timeperiods, timeperiods having the same begin_time and |
548 | 548 |
end_time are regrouped in a SharedTimePeriod object, which has a |
549 | 549 |
list of desks instead of only one desk. |
550 | 550 |
""" |
551 |
min_date = min_datetime.date() if min_datetime else None |
|
552 |
max_date = max_datetime.date() if max_datetime else None |
|
551 | 553 |
if self.kind == 'virtual': |
552 |
return self.get_effective_time_periods_virtual() |
|
554 |
return self.get_effective_time_periods_virtual(min_date, max_date)
|
|
553 | 555 |
elif self.kind == 'meetings': |
554 |
return self.get_effective_time_periods_meetings() |
|
556 |
return self.get_effective_time_periods_meetings(min_date, max_date)
|
|
555 | 557 |
else: |
556 | 558 |
raise ValueError('does not work with kind %r' % self.kind) |
557 | 559 | |
558 |
def get_effective_time_periods_meetings(self): |
|
560 |
def get_effective_time_periods_meetings(self, min_date, max_date):
|
|
559 | 561 |
"""List timeperiod instances for all desks of the agenda, convert them |
560 | 562 |
into an Interval of WeekTime which can be compared and regrouped using |
561 | 563 |
itertools.groupby. |
562 | 564 |
""" |
565 |
time_periods = TimePeriod.objects.filter(desk__agenda=self) |
|
566 |
if min_date: |
|
567 |
time_periods.filter(Q(date__isnull=True) | Q(date__gte=min_date)) |
|
568 |
if max_date: |
|
569 |
time_periods.filter(Q(date__isnull=True) | Q(date__lte=max_date)) |
|
570 | ||
563 | 571 |
yield from ( |
564 | 572 |
SharedTimePeriod.from_weektime_interval( |
565 | 573 |
weektime_interval, |
566 | 574 |
desks=[time_period.desk for time_period in time_periods], |
567 | 575 |
) |
568 | 576 |
for weektime_interval, time_periods in itertools.groupby( |
569 |
TimePeriod.objects.filter(desk__agenda=self) |
|
570 |
.prefetch_related('desk') |
|
571 |
.order_by('weekday', 'start_time', 'end_time'), |
|
577 |
time_periods.prefetch_related('desk').order_by('weekday', 'start_time', 'end_time'), |
|
572 | 578 |
key=TimePeriod.as_weektime_interval, |
573 | 579 |
) |
574 | 580 |
) |
575 | 581 | |
576 |
def get_effective_time_periods_virtual(self): |
|
582 |
def get_effective_time_periods_virtual(self, min_date, max_date):
|
|
577 | 583 |
"""List timeperiod instances for all desks of all real agendas of this |
578 | 584 |
virtual agenda, convert them into an Interval of WeekTime which can be |
579 | 585 |
compared and regrouped using itertools.groupby. |
580 | 586 |
""" |
587 |
time_periods = TimePeriod.objects.filter(desk__agenda__virtual_agendas=self) |
|
588 |
if min_date: |
|
589 |
time_periods.filter(Q(date__isnull=True) | Q(date__gte=min_date)) |
|
590 |
if max_date: |
|
591 |
time_periods.filter(Q(date__isnull=True) | Q(date__lte=max_date)) |
|
592 | ||
581 | 593 |
closed_hours_by_days = IntervalSet.from_ordered( |
582 | 594 |
[ |
583 | 595 |
time_period.as_weektime_interval() |
... | ... | |
585 | 597 |
] |
586 | 598 |
) |
587 | 599 |
for time_period_interval, time_periods in itertools.groupby( |
588 |
TimePeriod.objects.filter(desk__agenda__virtual_agendas=self) |
|
589 |
.order_by('weekday', 'start_time', 'end_time') |
|
590 |
.prefetch_related('desk'), |
|
600 |
time_periods.order_by('weekday', 'start_time', 'end_time').prefetch_related('desk'), |
|
591 | 601 |
key=lambda tp: tp.as_weektime_interval(), |
592 | 602 |
): |
593 | 603 |
time_periods = list(time_periods) |
... | ... | |
1090 | 1100 |
class WeekTime(collections.namedtuple('WeekTime', ['weekday', 'time'])): |
1091 | 1101 |
"""Representation of a time point in a weekday, ex.: Monday at 5 o'clock.""" |
1092 | 1102 | |
1093 |
def __new__(cls, weekday, weekday_indexes, time): |
|
1103 |
def __new__(cls, weekday, weekday_indexes, date, time): |
|
1104 |
if date: |
|
1105 |
weekday = date.weekday() |
|
1094 | 1106 |
self = super().__new__(cls, weekday, time) |
1095 | 1107 |
self.weekday_indexes = weekday_indexes |
1108 |
self.date = date |
|
1096 | 1109 |
return self |
1097 | 1110 | |
1098 | 1111 |
def __repr__(self): |
1099 | 1112 |
return '%s / %s' % ( |
1100 |
force_str(WEEKDAYS[self.weekday]), |
|
1113 |
self.date or force_str(WEEKDAYS[self.weekday]),
|
|
1101 | 1114 |
date_format(self.time, 'TIME_FORMAT'), |
1102 | 1115 |
) |
1103 | 1116 | |
... | ... | |
1138 | 1151 |
] |
1139 | 1152 | |
1140 | 1153 |
def __str__(self): |
1141 |
label = force_str(WEEKDAYS[self.weekday]) |
|
1142 |
if self.weekday_indexes: |
|
1143 |
label = _('%(weekday)s (%(ordinals)s of the month)') % { |
|
1144 |
'weekday': label, |
|
1145 |
'ordinals': ', '.join(ordinal(i) for i in self.weekday_indexes), |
|
1146 |
} |
|
1154 |
if self.date: |
|
1155 |
label = date_format(self.date, 'l d F Y') |
|
1156 |
else: |
|
1157 |
label = force_str(WEEKDAYS[self.weekday]) |
|
1158 |
if self.weekday_indexes: |
|
1159 |
label = _('%(weekday)s (%(ordinals)s of the month)') % { |
|
1160 |
'weekday': label, |
|
1161 |
'ordinals': ', '.join(ordinal(i) for i in self.weekday_indexes), |
|
1162 |
} |
|
1147 | 1163 | |
1148 | 1164 |
label = '%s / %s → %s' % ( |
1149 | 1165 |
label, |
... | ... | |
1189 | 1205 | |
1190 | 1206 |
def as_weektime_interval(self): |
1191 | 1207 |
return Interval( |
1192 |
WeekTime(self.weekday, self.weekday_indexes, self.start_time), |
|
1193 |
WeekTime(self.weekday, self.weekday_indexes, self.end_time), |
|
1208 |
WeekTime(self.weekday, self.weekday_indexes, self.date, self.start_time),
|
|
1209 |
WeekTime(self.weekday, self.weekday_indexes, self.date, self.end_time),
|
|
1194 | 1210 |
) |
1195 | 1211 | |
1196 | 1212 |
def as_shared_timeperiods(self): |
... | ... | |
1199 | 1215 |
weekday_indexes=self.weekday_indexes, |
1200 | 1216 |
start_time=self.start_time, |
1201 | 1217 |
end_time=self.end_time, |
1218 |
date=self.date, |
|
1202 | 1219 |
desks=[self.desk], |
1203 | 1220 |
) |
1204 | 1221 | |
... | ... | |
1224 | 1241 |
of get_all_slots() for details). |
1225 | 1242 |
""" |
1226 | 1243 | |
1227 |
__slots__ = ['weekday', 'weekday_indexes', 'start_time', 'end_time', 'desks'] |
|
1244 |
__slots__ = ['weekday', 'weekday_indexes', 'start_time', 'end_time', 'date', 'desks']
|
|
1228 | 1245 | |
1229 |
def __init__(self, weekday, weekday_indexes, start_time, end_time, desks): |
|
1246 |
def __init__(self, weekday, weekday_indexes, start_time, end_time, date, desks):
|
|
1230 | 1247 |
self.weekday = weekday |
1231 | 1248 |
self.weekday_indexes = weekday_indexes |
1232 | 1249 |
self.start_time = start_time |
1233 | 1250 |
self.end_time = end_time |
1251 |
self.date = date |
|
1234 | 1252 |
self.desks = set(desks) |
1235 | 1253 | |
1236 | 1254 |
def __str__(self): |
... | ... | |
1241 | 1259 |
) |
1242 | 1260 | |
1243 | 1261 |
def __eq__(self, other): |
1244 |
return (self.weekday, self.start_time, self.end_time) == ( |
|
1262 |
return (self.weekday, self.start_time, self.end_time, self.date) == (
|
|
1245 | 1263 |
other.weekday, |
1246 | 1264 |
other.start_time, |
1247 | 1265 |
other.end_time, |
1266 |
other.date, |
|
1248 | 1267 |
) |
1249 | 1268 | |
1250 | 1269 |
def __lt__(self, other): |
1251 |
return (self.weekday, self.start_time, self.end_time) < ( |
|
1270 |
return (self.weekday, self.start_time, self.end_time, self.date) < (
|
|
1252 | 1271 |
other.weekday, |
1253 | 1272 |
other.start_time, |
1254 | 1273 |
other.end_time, |
1274 |
other.date, |
|
1255 | 1275 |
) |
1256 | 1276 | |
1257 | 1277 |
def get_time_slots(self, min_datetime, max_datetime, meeting_duration, base_duration): |
... | ... | |
1276 | 1296 |
Generated start_datetime MUST be in the local timezone, and the local |
1277 | 1297 |
timezone must not change, as the API needs it to generate stable ids. |
1278 | 1298 |
""" |
1299 |
if self.date and not (min_datetime.date() <= self.date <= max_datetime.date()): |
|
1300 |
return |
|
1301 | ||
1279 | 1302 |
meeting_duration = datetime.timedelta(minutes=meeting_duration) |
1280 | 1303 |
duration = datetime.timedelta(minutes=base_duration) |
1281 | 1304 | |
1282 |
real_min_datetime = min_datetime + datetime.timedelta(days=self.weekday - min_datetime.weekday()) |
|
1305 |
real_min_datetime = ( |
|
1306 |
min_datetime + datetime.timedelta(days=self.weekday - min_datetime.weekday()) |
|
1307 |
if not self.date |
|
1308 |
else min_datetime |
|
1309 |
) |
|
1283 | 1310 |
if real_min_datetime < min_datetime: |
1284 | 1311 |
real_min_datetime += datetime.timedelta(days=7) |
1285 | 1312 | |
... | ... | |
1291 | 1318 |
event_datetime = make_aware(make_naive(real_min_datetime)).replace( |
1292 | 1319 |
hour=self.start_time.hour, minute=self.start_time.minute, second=0, microsecond=0 |
1293 | 1320 |
) |
1321 |
if self.date: |
|
1322 |
event_datetime = event_datetime.replace( |
|
1323 |
day=self.date.day, month=self.date.month, year=self.date.year |
|
1324 |
) |
|
1294 | 1325 |
# don't start before min_datetime |
1295 | 1326 |
event_datetime = max(event_datetime, min_datetime) |
1296 | 1327 | |
... | ... | |
1303 | 1334 |
or event_datetime.date() != next_time.date() |
1304 | 1335 |
or (self.weekday_indexes and get_weekday_index(event_datetime) not in self.weekday_indexes) |
1305 | 1336 |
): |
1337 |
# if time slot is not repeating, end now |
|
1338 |
if self.date: |
|
1339 |
break |
|
1340 | ||
1306 | 1341 |
# switch to naive time for day/week changes |
1307 | 1342 |
event_datetime = make_naive(event_datetime) |
1308 | 1343 |
# back to morning |
... | ... | |
1333 | 1368 |
weekday_indexes=begin.weekday_indexes or end.weekday_indexes, |
1334 | 1369 |
start_time=begin.time, |
1335 | 1370 |
end_time=end.time, |
1371 |
date=begin.date or end.date, |
|
1336 | 1372 |
desks=desks, |
1337 | 1373 |
) |
1338 | 1374 |
chrono/api/views.py | ||
---|---|---|
285 | 285 |
) |
286 | 286 | |
287 | 287 |
unique_booked = {} |
288 |
for time_period in base_agenda.get_effective_time_periods(): |
|
288 |
for time_period in base_agenda.get_effective_time_periods(base_min_datetime, base_max_datetime):
|
|
289 | 289 |
duration = ( |
290 | 290 |
datetime.datetime.combine(base_date, time_period.end_time) |
291 | 291 |
- datetime.datetime.combine(base_date, time_period.start_time) |
tests/api/datetimes/test_meetings.py | ||
---|---|---|
2426 | 2426 |
'2022-03-07 11:30:00', |
2427 | 2427 |
'2022-03-14 11:30:00', |
2428 | 2428 |
] |
2429 | ||
2430 | ||
2431 |
@pytest.mark.freeze_time('2022-10-24 10:00') |
|
2432 |
def test_datetimes_api_meetings_agenda_date_time_period(app): |
|
2433 |
agenda = Agenda.objects.create( |
|
2434 |
label='Foo bar', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=8 |
|
2435 |
) |
|
2436 |
meeting_type = MeetingType.objects.create(agenda=agenda, label='Plop', duration=30) |
|
2437 |
desk = Desk.objects.create(agenda=agenda, label='desk') |
|
2438 | ||
2439 |
TimePeriod.objects.create( |
|
2440 |
date=datetime.date(2022, 10, 24), |
|
2441 |
start_time=datetime.time(12, 0), |
|
2442 |
end_time=datetime.time(14, 0), |
|
2443 |
desk=desk, |
|
2444 |
) |
|
2445 |
api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug) |
|
2446 | ||
2447 |
resp = app.get(api_url) |
|
2448 |
assert [x['datetime'] for x in resp.json['data']] == [ |
|
2449 |
'2022-10-24 12:00:00', |
|
2450 |
'2022-10-24 12:30:00', |
|
2451 |
'2022-10-24 13:00:00', |
|
2452 |
'2022-10-24 13:30:00', |
|
2453 |
] |
|
2454 | ||
2455 |
resp = app.get(api_url, params={'date_start': '2022-10-25'}) |
|
2456 |
assert resp.json['data'] == [] |
|
2457 | ||
2458 |
# mix with repeating period |
|
2459 |
TimePeriod.objects.create( |
|
2460 |
weekday=0, |
|
2461 |
start_time=datetime.time(13, 0), |
|
2462 |
end_time=datetime.time(15, 0), |
|
2463 |
desk=desk, |
|
2464 |
) |
|
2465 | ||
2466 |
resp = app.get(api_url) |
|
2467 |
assert [x['datetime'] for x in resp.json['data']] == [ |
|
2468 |
'2022-10-24 12:00:00', |
|
2469 |
'2022-10-24 12:30:00', |
|
2470 |
'2022-10-24 13:00:00', |
|
2471 |
'2022-10-24 13:30:00', |
|
2472 |
'2022-10-24 14:00:00', |
|
2473 |
'2022-10-24 14:30:00', |
|
2474 |
'2022-10-31 13:00:00', |
|
2475 |
'2022-10-31 13:30:00', |
|
2476 |
'2022-10-31 14:00:00', |
|
2477 |
'2022-10-31 14:30:00', |
|
2478 |
] |
|
2479 | ||
2480 | ||
2481 |
@pytest.mark.freeze_time('2022-10-24 10:00') |
|
2482 |
def test_datetimes_api_meetings_virtual_agenda_date_time_period(app): |
|
2483 |
agenda = Agenda.objects.create( |
|
2484 |
label='Foo bar', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=8 |
|
2485 |
) |
|
2486 |
desk = Desk.objects.create(agenda=agenda, label='desk') |
|
2487 |
meeting_type = MeetingType.objects.create(agenda=agenda, label='Plop', duration=30) |
|
2488 |
virtual_agenda = Agenda.objects.create(label='Foo bar Meeting', kind='virtual') |
|
2489 |
virtual_agenda.real_agendas.add(agenda) |
|
2490 | ||
2491 |
TimePeriod.objects.create( |
|
2492 |
date=datetime.date(2022, 10, 24), |
|
2493 |
start_time=datetime.time(12, 0), |
|
2494 |
end_time=datetime.time(14, 0), |
|
2495 |
desk=desk, |
|
2496 |
) |
|
2497 | ||
2498 |
api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (virtual_agenda.slug, meeting_type.slug) |
|
2499 |
resp = app.get(api_url) |
|
2500 |
assert [x['datetime'] for x in resp.json['data']] == [ |
|
2501 |
'2022-10-24 12:00:00', |
|
2502 |
'2022-10-24 12:30:00', |
|
2503 |
'2022-10-24 13:00:00', |
|
2504 |
'2022-10-24 13:30:00', |
|
2505 |
] |
|
2506 | ||
2507 |
# add exclusion period on virtual agenda |
|
2508 |
TimePeriod.objects.create( |
|
2509 |
weekday=0, start_time=datetime.time(12, 00), end_time=datetime.time(13, 00), agenda=virtual_agenda |
|
2510 |
) |
|
2511 |
resp = app.get(api_url) |
|
2512 |
resp = app.get(api_url) |
|
2513 |
assert [x['datetime'] for x in resp.json['data']] == [ |
|
2514 |
'2022-10-24 13:00:00', |
|
2515 |
'2022-10-24 13:30:00', |
|
2516 |
] |
tests/api/fillslot/test_all.py | ||
---|---|---|
995 | 995 |
assert Booking.objects.all()[0].extra_data == {'hello': 'world'} |
996 | 996 | |
997 | 997 | |
998 |
@pytest.mark.freeze_time('2022-10-24 10:00') |
|
999 |
def test_booking_api_meeting_date_time_period(app, user): |
|
1000 |
agenda = Agenda.objects.create( |
|
1001 |
label='Foo bar', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=8 |
|
1002 |
) |
|
1003 |
meeting_type = MeetingType.objects.create(agenda=agenda, label='Plop', duration=30) |
|
1004 |
desk = Desk.objects.create(agenda=agenda, label='desk') |
|
1005 | ||
1006 |
TimePeriod.objects.create( |
|
1007 |
date=datetime.date(2022, 10, 24), |
|
1008 |
start_time=datetime.time(12, 0), |
|
1009 |
end_time=datetime.time(14, 0), |
|
1010 |
desk=desk, |
|
1011 |
) |
|
1012 |
datetimes_resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug)) |
|
1013 |
slot = datetimes_resp.json['data'][0]['id'] |
|
1014 |
assert slot == 'plop:2022-10-24-1200' |
|
1015 | ||
1016 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
1017 | ||
1018 |
# single booking |
|
1019 |
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, slot)) |
|
1020 |
assert Booking.objects.count() == 1 |
|
1021 |
assert resp.json['duration'] == 30 |
|
1022 | ||
1023 |
# multiple slots |
|
1024 |
slots = [datetimes_resp.json['data'][1]['id'], datetimes_resp.json['data'][2]['id']] |
|
1025 |
assert slots == ['plop:2022-10-24-1230', 'plop:2022-10-24-1300'] |
|
1026 |
resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': slots}) |
|
1027 |
assert Booking.objects.count() == 3 |
|
1028 | ||
1029 |
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, slot)) |
|
1030 |
assert resp.json['err'] == 1 |
|
1031 |
assert resp.json['err_desc'] == 'no more desk available' |
|
1032 | ||
1033 | ||
998 | 1034 |
def test_booking_api_available(app, user): |
999 | 1035 |
agenda = Agenda.objects.create(label='Foo bar', kind='events', minimal_booking_delay=0) |
1000 | 1036 |
for i in range(0, 10): |
tests/test_time_periods.py | ||
---|---|---|
491 | 491 |
start_time=datetime.time(hour=1, minute=0), |
492 | 492 |
end_time=datetime.time(hour=2, minute=0), |
493 | 493 |
) |
494 | ||
495 | ||
496 |
def test_timeperiod_date_time_slots(): |
|
497 |
agenda = Agenda(label='Foo bar', slug='bar') |
|
498 |
agenda.save() |
|
499 |
desk = Desk.objects.create(label='Desk 1', agenda=agenda) |
|
500 |
meeting_type = MeetingType(duration=60, agenda=agenda) |
|
501 |
meeting_type.save() |
|
502 |
timeperiod = TimePeriod( |
|
503 |
desk=desk, |
|
504 |
date=datetime.date(2022, 10, 24), |
|
505 |
start_time=datetime.time(9, 0), |
|
506 |
end_time=datetime.time(12, 0), |
|
507 |
) |
|
508 |
events = timeperiod.as_shared_timeperiods().get_time_slots( |
|
509 |
min_datetime=make_aware(datetime.datetime(2022, 10, 1)), |
|
510 |
max_datetime=make_aware(datetime.datetime(2022, 11, 1)), |
|
511 |
meeting_duration=meeting_type.duration, |
|
512 |
base_duration=agenda.get_base_meeting_duration(), |
|
513 |
) |
|
514 |
assert [x.timetuple()[:5] for x in sorted(events)] == [ |
|
515 |
(2022, 10, 24, 9, 0), |
|
516 |
(2022, 10, 24, 10, 0), |
|
517 |
(2022, 10, 24, 11, 0), |
|
518 |
] |
|
494 |
- |