0002-agendas-allow-exceptions-to-recurring-events-50561.patch
chrono/agendas/migrations/0077_auto_20210218_1533.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2021-01-27 16:46 |
|
3 | ||
4 |
from __future__ import unicode_literals |
|
5 | ||
6 |
from django.db import migrations |
|
7 | ||
8 | ||
9 |
def create_exceptions_desk(apps, schema_editor): |
|
10 |
Agenda = apps.get_model('agendas', 'Agenda') |
|
11 |
Desk = apps.get_model('agendas', 'Desk') |
|
12 |
desks = [] |
|
13 | ||
14 |
for agenda in Agenda.objects.filter(kind='events'): |
|
15 |
desks.append(Desk(agenda=agenda, slug='_exceptions_holder')) |
|
16 |
Desk.objects.bulk_create(desks) |
|
17 | ||
18 | ||
19 |
class Migration(migrations.Migration): |
|
20 | ||
21 |
dependencies = [ |
|
22 |
('agendas', '0076_event_recurrence_end_date'), |
|
23 |
] |
|
24 | ||
25 |
operations = [ |
|
26 |
migrations.RunPython(create_exceptions_desk, migrations.RunPython.noop), |
|
27 |
] |
chrono/agendas/models.py | ||
---|---|---|
207 | 207 |
return self.label |
208 | 208 | |
209 | 209 |
def save(self, *args, **kwargs): |
210 |
created = bool(not self.pk) |
|
210 | 211 |
if not self.slug: |
211 | 212 |
self.slug = generate_slug(self) |
212 | 213 |
if self.kind != 'virtual': |
... | ... | |
215 | 216 |
if self.maximal_booking_delay is None: |
216 | 217 |
self.maximal_booking_delay = 8 * 7 |
217 | 218 |
super(Agenda, self).save(*args, **kwargs) |
219 |
if created and self.kind == 'events': |
|
220 |
desk = Desk.objects.create(agenda=self, slug='_exceptions_holder') |
|
221 |
desk.import_timeperiod_exceptions_from_settings() |
|
218 | 222 | |
219 | 223 |
@property |
220 | 224 |
def base_slug(self): |
... | ... | |
324 | 328 |
agenda['events'] = [x.export_json() for x in self.event_set.filter(primary_event__isnull=True)] |
325 | 329 |
if hasattr(self, 'notifications_settings'): |
326 | 330 |
agenda['notifications_settings'] = self.notifications_settings.export_json() |
331 |
agenda['exceptions_desk'] = self.desk_set.get().export_json() |
|
327 | 332 |
elif self.kind == 'meetings': |
328 | 333 |
agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.all()] |
329 | 334 |
agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()] |
... | ... | |
341 | 346 |
if data['kind'] == 'events': |
342 | 347 |
events = data.pop('events') |
343 | 348 |
notifications_settings = data.pop('notifications_settings', None) |
349 |
exceptions_desk = data.pop('exceptions_desk', None) |
|
344 | 350 |
elif data['kind'] == 'meetings': |
345 | 351 |
meetingtypes = data.pop('meetingtypes') |
346 | 352 |
desks = data.pop('desks') |
... | ... | |
381 | 387 |
if notifications_settings: |
382 | 388 |
notifications_settings['agenda'] = agenda |
383 | 389 |
AgendaNotificationsSettings.import_json(notifications_settings) |
390 |
if exceptions_desk: |
|
391 |
exceptions_desk['agenda'] = agenda |
|
392 |
Desk.import_json(exceptions_desk) |
|
384 | 393 |
elif data['kind'] == 'meetings': |
385 | 394 |
if overwrite: |
386 | 395 |
MeetingType.objects.filter(agenda=agenda).delete() |
... | ... | |
589 | 598 |
recurring_events = self.prefetched_recurring_events |
590 | 599 |
else: |
591 | 600 |
recurring_events = self.event_set.filter(recurrence_rule__isnull=False) |
601 | ||
602 |
exceptions = self.get_recurrence_exceptions(min_start, max_start) |
|
592 | 603 |
for event in recurring_events: |
593 |
events.extend(event.get_recurrences(min_start, max_start, excluded_datetimes, slug_separator=':')) |
|
604 |
events.extend( |
|
605 |
event.get_recurrences( |
|
606 |
min_start, max_start, excluded_datetimes, exceptions, slug_separator=':' |
|
607 |
) |
|
608 |
) |
|
594 | 609 | |
595 | 610 |
events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering]) |
596 | 611 |
return events |
... | ... | |
604 | 619 |
except (VariableDoesNotExist, TemplateSyntaxError): |
605 | 620 |
return |
606 | 621 | |
622 |
def get_recurrence_exceptions(self, min_start, max_start): |
|
623 |
return TimePeriodException.objects.filter( |
|
624 |
Q(desk__slug='_exceptions_holder', desk__agenda=self) |
|
625 |
| Q( |
|
626 |
unavailability_calendar__desks__slug='_exceptions_holder', |
|
627 |
unavailability_calendar__desks__agenda=self, |
|
628 |
), |
|
629 |
start_datetime__lt=max_start, |
|
630 |
end_datetime__gt=min_start, |
|
631 |
) |
|
632 | ||
607 | 633 |
def prefetch_desks_and_exceptions(self, with_sources=False): |
608 | 634 |
if self.kind == 'meetings': |
609 | 635 |
desks = self.desk_set.all() |
... | ... | |
1263 | 1289 |
event.save() |
1264 | 1290 |
return event |
1265 | 1291 | |
1266 |
def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None, slug_separator='--'): |
|
1292 |
def get_recurrences( |
|
1293 |
self, min_datetime, max_datetime, excluded_datetimes=None, exceptions=None, slug_separator='--' |
|
1294 |
): |
|
1267 | 1295 |
recurrences = [] |
1268 | 1296 |
rrule_set = rruleset() |
1269 | 1297 |
# do not generate recurrences for existing events |
1270 | 1298 |
rrule_set._exdate = excluded_datetimes or [] |
1271 | 1299 | |
1300 |
if exceptions is None: |
|
1301 |
exceptions = self.agenda.get_recurrence_exceptions(min_datetime, max_datetime) |
|
1302 |
for exception in exceptions: |
|
1303 |
exception_start = localtime(exception.start_datetime) |
|
1304 |
event_start = localtime(self.start_datetime) |
|
1305 |
if event_start.time() < exception_start.time(): |
|
1306 |
exception_start += datetime.timedelta(days=1) |
|
1307 |
exception_start = exception_start.replace( |
|
1308 |
hour=event_start.hour, minute=event_start.minute, second=0, microsecond=0 |
|
1309 |
) |
|
1310 |
rrule_set.exrule( |
|
1311 |
rrule( |
|
1312 |
freq=DAILY, |
|
1313 |
dtstart=make_naive(exception_start), |
|
1314 |
until=make_naive(exception.end_datetime), |
|
1315 |
) |
|
1316 |
) |
|
1317 | ||
1272 | 1318 |
event_base = Event( |
1273 | 1319 |
agenda=self.agenda, |
1274 | 1320 |
primary_event=self, |
chrono/manager/templates/chrono/manager_events_agenda_settings.html | ||
---|---|---|
49 | 49 |
</div> |
50 | 50 |
</div> |
51 | 51 | |
52 |
{% if has_recurring_events %} |
|
53 |
<div class="section"> |
|
54 |
<h3>{% trans "Recurrence exceptions" %} |
|
55 |
<a rel="popup" class="button" href="{% url 'chrono-manager-desk-add-import-time-period-exceptions' pk=desk.pk %}">{% trans 'Configure' %}</a> |
|
56 |
</h3> |
|
57 |
<div> |
|
58 |
<ul class="objects-list single-links"> |
|
59 |
{% for exception in exceptions|slice:":5" %} |
|
60 |
<li><a rel="popup" {% if not exception.read_only %}href="{% url 'chrono-manager-time-period-exception-edit' pk=exception.pk %}"{% endif %}> |
|
61 |
{{ exception }} |
|
62 |
{% if not exception.read_only %} |
|
63 |
<a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-delete' pk=exception.id %}">{% trans "remove" %}</a> |
|
64 |
{% endif %} |
|
65 |
{% endfor %} |
|
66 |
{% if exceptions|length > 5 %} |
|
67 |
<li><a class="timeperiod-exception-all desk-{{ desk.pk }}" rel="popup" data-selector="div.timeperiod" href="{% url 'chrono-manager-time-period-exception-extract-list' pk=desk.id %}">({% trans 'see all exceptions' %})</a></li> |
|
68 |
{% endif %} |
|
69 |
<li><a class="add" rel="popup" href="{% url 'chrono-manager-agenda-add-time-period-exception' agenda_pk=object.pk pk=desk.pk %}">{% trans 'Add a time period exception' %}</a></li> |
|
70 |
</ul> |
|
71 |
</div> |
|
72 |
</div> |
|
73 |
{% endif %} |
|
74 | ||
52 | 75 |
{% endblock %} |
chrono/manager/views.py | ||
---|---|---|
1394 | 1394 |
if not self.object.desk_simple_management |
1395 | 1395 |
else False |
1396 | 1396 |
) |
1397 |
if self.agenda.kind == 'events': |
|
1398 |
context['has_recurring_events'] = self.agenda.event_set.filter( |
|
1399 |
recurrence_rule__isnull=False |
|
1400 |
).exists() |
|
1401 |
desk = Desk.objects.get(agenda=self.agenda, slug='_exceptions_holder') |
|
1402 |
context['exceptions'] = TimePeriodException.objects.filter( |
|
1403 |
Q(desk=desk) | Q(unavailability_calendar__desks=desk), |
|
1404 |
end_datetime__gt=now(), |
|
1405 |
) |
|
1406 |
context['desk'] = desk |
|
1397 | 1407 |
return context |
1398 | 1408 | |
1399 | 1409 |
def get_events(self): |
tests/test_agendas.py | ||
---|---|---|
1977 | 1977 |
assert len(recurrences) == 5 |
1978 | 1978 |
assert recurrences[0].start_datetime == start_datetime |
1979 | 1979 |
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=4) |
1980 | ||
1981 | ||
1982 |
@override_settings( |
|
1983 |
EXCEPTIONS_SOURCES={ |
|
1984 |
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, |
|
1985 |
} |
|
1986 |
) |
|
1987 |
def test_recurring_events_exceptions(freezer): |
|
1988 |
freezer.move_to('2021-05-01 12:00') |
|
1989 |
agenda = Agenda.objects.create(label='Agenda', kind='events') |
|
1990 |
desk = Desk.objects.get(slug='_exceptions_holder', agenda=agenda) |
|
1991 | ||
1992 |
event = Event.objects.create( |
|
1993 |
agenda=agenda, |
|
1994 |
start_datetime=now(), |
|
1995 |
repeat='daily', |
|
1996 |
places=5, |
|
1997 |
) |
|
1998 |
event.refresh_from_db() |
|
1999 |
start_datetime = localtime(event.start_datetime) |
|
2000 | ||
2001 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
2002 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-01' |
|
2003 |
first_of_may = recurrences[0] |
|
2004 | ||
2005 |
recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime) |
|
2006 |
recurrence.delete() |
|
2007 | ||
2008 |
desk.import_timeperiod_exceptions_from_settings(enable=True) |
|
2009 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
2010 |
# 05-01 is a holiday |
|
2011 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
2012 |
with pytest.raises(ValueError): |
|
2013 |
recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime) |
|
2014 |
first_event = recurrences[0] |
|
2015 | ||
2016 |
# exception before first_event start_datetime |
|
2017 |
time_period_exception = TimePeriodException.objects.create( |
|
2018 |
desk=desk, |
|
2019 |
start_datetime=first_event.start_datetime - datetime.timedelta(hours=1), |
|
2020 |
end_datetime=first_event.start_datetime - datetime.timedelta(minutes=30), |
|
2021 |
) |
|
2022 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
2023 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
2024 | ||
2025 |
# exception wraps around first_event start_datetime |
|
2026 |
time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(minutes=30) |
|
2027 |
time_period_exception.save() |
|
2028 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
2029 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-03' |
|
2030 | ||
2031 |
# exception starts after first_event start_datetime |
|
2032 |
time_period_exception.start_datetime = first_event.start_datetime + datetime.timedelta(minutes=15) |
|
2033 |
time_period_exception.save() |
|
2034 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
2035 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
2036 |
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03' |
|
2037 | ||
2038 |
# exception spans multiple days |
|
2039 |
time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(days=3) |
|
2040 |
time_period_exception.save() |
|
2041 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
2042 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
2043 |
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06' |
|
2044 | ||
2045 |
# move exception to unavailability calendar |
|
2046 |
unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar') |
|
2047 |
time_period_exception.desk = None |
|
2048 |
time_period_exception.unavailability_calendar = unavailability_calendar |
|
2049 |
time_period_exception.save() |
|
2050 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
2051 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
2052 |
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03' |
|
2053 | ||
2054 |
unavailability_calendar.desks.add(desk) |
|
2055 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
2056 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
2057 |
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06' |
tests/test_api.py | ||
---|---|---|
309 | 309 | |
310 | 310 |
with CaptureQueriesContext(connection) as ctx: |
311 | 311 |
resp = app.get('/api/agenda/', params={'with_open_events': '1'}) |
312 |
assert len(ctx.captured_queries) == 4
|
|
312 |
assert len(ctx.captured_queries) == 6
|
|
313 | 313 | |
314 | 314 | |
315 | 315 |
def test_agendas_meetingtypes_api(app, some_data, meetings_agenda): |
tests/test_import_export.py | ||
---|---|---|
58 | 58 |
agenda_meetings = Agenda.objects.create(label='Meetings Agenda', kind='meetings') |
59 | 59 |
MeetingType.objects.create(agenda=agenda_meetings, label='Meeting Type', duration=30) |
60 | 60 |
desk = Desk.objects.create(agenda=agenda_meetings, label='Desk') |
61 |
exceptions_desk = Desk.objects.get(agenda=agenda_events, slug='_exceptions_holder') |
|
61 | 62 | |
62 |
# add exception to meeting agenda |
|
63 | 63 |
tpx_start = make_aware(datetime.datetime(2017, 5, 22, 8, 0)) |
64 | 64 |
tpx_end = make_aware(datetime.datetime(2017, 5, 22, 12, 30)) |
65 | 65 |
TimePeriodException.objects.create(desk=desk, start_datetime=tpx_start, end_datetime=tpx_end) |
66 |
TimePeriodException.objects.create(desk=exceptions_desk, start_datetime=tpx_start, end_datetime=tpx_end) |
|
67 | ||
66 | 68 |
output = get_output_of_command('export_site') |
67 | 69 |
assert len(json.loads(output)['agendas']) == 2 |
68 | 70 |
import_site(data={}, clean=True) |
... | ... | |
87 | 89 |
assert Agenda.objects.count() == 2 |
88 | 90 |
first_imported_event = Agenda.objects.get(label='Events Agenda').event_set.first() |
89 | 91 |
assert first_imported_event.start_datetime == first_event.start_datetime |
90 |
assert TimePeriodException.objects.get().start_datetime == tpx_start |
|
91 |
assert TimePeriodException.objects.get().end_datetime == tpx_end |
|
92 |
assert TimePeriodException.objects.get(desk__agenda__kind='meetings').start_datetime == tpx_start |
|
93 |
assert TimePeriodException.objects.get(desk__agenda__kind='meetings').end_datetime == tpx_end |
|
94 |
assert TimePeriodException.objects.get(desk__agenda__kind='events').start_datetime == tpx_start |
|
95 |
assert TimePeriodException.objects.get(desk__agenda__kind='events').end_datetime == tpx_end |
|
92 | 96 | |
93 | 97 |
agenda1 = Agenda.objects.get(label='Events Agenda') |
94 | 98 |
agenda2 = Agenda.objects.get(label='Meetings Agenda') |
tests/test_manager.py | ||
---|---|---|
6333 | 6333 |
assert resp.text.count('Swimming') == 2 # 1 booking + legend |
6334 | 6334 |
assert 'Booking colors:' in resp.text |
6335 | 6335 |
assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 2 |
6336 | ||
6337 | ||
6338 |
@override_settings( |
|
6339 |
EXCEPTIONS_SOURCES={ |
|
6340 |
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, |
|
6341 |
} |
|
6342 |
) |
|
6343 |
def test_recurring_events_manage_exceptions(settings, app, admin_user, freezer): |
|
6344 |
freezer.move_to('2021-07-01 12:10') |
|
6345 | ||
6346 |
app = login(app) |
|
6347 |
resp = app.get('/manage/') |
|
6348 |
resp = resp.click('New') |
|
6349 |
resp.form['label'] = 'Foo bar' |
|
6350 |
resp.form['kind'] = 'events' |
|
6351 |
resp = resp.form.submit().follow() |
|
6352 | ||
6353 |
agenda = Agenda.objects.get(label='Foo bar') |
|
6354 |
assert agenda.desk_set.count() == 1 |
|
6355 |
desk = agenda.desk_set.get(slug='_exceptions_holder') |
|
6356 | ||
6357 |
event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda) |
|
6358 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
6359 |
assert not 'Recurrence exceptions' in resp.text |
|
6360 | ||
6361 |
event.repeat = 'daily' |
|
6362 |
event.save() |
|
6363 | ||
6364 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) |
|
6365 |
assert len(resp.pyquery.find('.event-info')) == 31 |
|
6366 | ||
6367 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
6368 |
assert 'Recurrence exceptions' in resp.text |
|
6369 | ||
6370 |
resp = resp.click('Add a time period exception') |
|
6371 |
resp.form['start_datetime_0'] = now().strftime('%Y-%m-%d') |
|
6372 |
resp.form['start_datetime_1'] = now().strftime('%H:%M') |
|
6373 |
resp.form['end_datetime_0'] = (now() + datetime.timedelta(days=7)).strftime('%Y-%m-%d') |
|
6374 |
resp.form['end_datetime_1'] = (now() + datetime.timedelta(days=7)).strftime('%H:%M') |
|
6375 |
resp = resp.form.submit().follow() |
|
6376 |
assert desk.timeperiodexception_set.count() == 1 |
|
6377 | ||
6378 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) |
|
6379 |
assert len(resp.pyquery.find('.event-info')) == 24 |
|
6380 | ||
6381 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
6382 |
resp = resp.click('Configure', href='exceptions') |
|
6383 |
resp = resp.click('enable').follow() |
|
6384 |
assert TimePeriodException.objects.count() > 1 |
|
6385 |
assert 'Bastille Day' in resp.text |
|
6386 | ||
6387 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) |
|
6388 |
assert len(resp.pyquery.find('.event-info')) == 23 |
|
6389 | ||
6390 |
# add recurrence end date, which lead to recurrences creation |
|
6391 |
resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id)) |
|
6392 |
resp.form['recurrence_end_date'] = (now() + datetime.timedelta(days=31)).strftime('%Y-%m-%d') |
|
6393 |
resp = resp.form.submit() |
|
6394 | ||
6395 |
# recurrences corresponding to exceptions have not been created |
|
6396 |
assert Event.objects.count() == 24 |
|
6336 |
- |