0002-agendas-allow-exceptions-to-recurring-events-50561.patch
chrono/agendas/migrations/0076_auto_20210127_1746.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2021-01-27 16:46 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.db import migrations |
|
6 | ||
7 | ||
8 |
def create_exceptions_desk(apps, schema_editor): |
|
9 |
Agenda = apps.get_model('agendas', 'Agenda') |
|
10 |
Desk = apps.get_model('agendas', 'Desk') |
|
11 | ||
12 |
desks = [] |
|
13 |
for agenda in Agenda.objects.filter(kind='events'): |
|
14 |
desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder') |
|
15 |
desk.import_timeperiod_exceptions_from_settings() |
|
16 | ||
17 | ||
18 |
class Migration(migrations.Migration): |
|
19 | ||
20 |
dependencies = [ |
|
21 |
('agendas', '0075_event_recurrence_end_date'), |
|
22 |
] |
|
23 | ||
24 |
operations = [ |
|
25 |
migrations.RunPython(create_exceptions_desk, migrations.RunPython.noop), |
|
26 |
] |
chrono/agendas/models.py | ||
---|---|---|
581 | 581 |
recurring_events = self.prefetched_recurring_events |
582 | 582 |
else: |
583 | 583 |
recurring_events = self.event_set.filter(recurrence_rule__isnull=False) |
584 | ||
585 |
exceptions = self.get_recurrence_exceptions(min_start, max_start) |
|
584 | 586 |
for event in recurring_events: |
585 |
events.extend(event.get_recurrences(min_start, max_start, excluded_datetimes)) |
|
587 |
events.extend(event.get_recurrences(min_start, max_start, excluded_datetimes, exceptions))
|
|
586 | 588 | |
587 | 589 |
events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering]) |
588 | 590 |
return events |
... | ... | |
596 | 598 |
except (VariableDoesNotExist, TemplateSyntaxError): |
597 | 599 |
return |
598 | 600 | |
601 |
def get_recurrence_exceptions(self, min_start, max_start): |
|
602 |
desk, _ = Desk.objects.get_or_create(agenda=self, slug='_exceptions_holder') |
|
603 |
return TimePeriodException.objects.filter( |
|
604 |
Q(desk=desk) | Q(unavailability_calendar__desks=desk), |
|
605 |
start_datetime__lt=max_start, |
|
606 |
end_datetime__gt=min_start, |
|
607 |
) |
|
608 | ||
599 | 609 |
def prefetch_desks_and_exceptions(self): |
600 | 610 |
if self.kind == 'meetings': |
601 | 611 |
desks = self.desk_set.all() |
... | ... | |
1155 | 1165 |
event.save() |
1156 | 1166 |
return event |
1157 | 1167 | |
1158 |
def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None): |
|
1168 |
def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None, exceptions=None):
|
|
1159 | 1169 |
recurrences = [] |
1160 | 1170 |
rrule_set = rruleset() |
1161 | 1171 |
# do not generate recurrences for existing events |
1162 | 1172 |
rrule_set._exdate = excluded_datetimes or [] |
1163 | 1173 | |
1174 |
exceptions = exceptions or self.agenda.get_recurrence_exceptions(min_datetime, max_datetime) |
|
1175 |
for exception in exceptions: |
|
1176 |
dtstart = localtime(exception.start_datetime) |
|
1177 |
start_datetime = localtime(self.start_datetime) |
|
1178 |
if start_datetime.time() < dtstart.time(): |
|
1179 |
dtstart += datetime.timedelta(days=1) |
|
1180 |
dtstart = dtstart.replace( |
|
1181 |
hour=start_datetime.hour, minute=start_datetime.minute, second=0, microsecond=0 |
|
1182 |
) |
|
1183 |
rrule_set.exrule( |
|
1184 |
rrule( |
|
1185 |
freq=DAILY, |
|
1186 |
dtstart=make_naive(dtstart), |
|
1187 |
until=make_naive(exception.end_datetime), |
|
1188 |
) |
|
1189 |
) |
|
1190 | ||
1164 | 1191 |
event_base = Event( |
1165 | 1192 |
agenda=self.agenda, |
1166 | 1193 |
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 | ||
---|---|---|
623 | 623 |
default_desk = Desk(agenda=self.object, label=_('Desk 1')) |
624 | 624 |
default_desk.save() |
625 | 625 |
default_desk.import_timeperiod_exceptions_from_settings(enable=True) |
626 |
elif self.object.kind == 'events': |
|
627 |
desk = Desk.objects.create(agenda=self.object, slug='_exceptions_holder') |
|
628 |
desk.import_timeperiod_exceptions_from_settings() |
|
626 | 629 |
return model_form |
627 | 630 | |
628 | 631 |
def get_success_url(self): |
... | ... | |
1395 | 1398 |
if self.agenda.kind == 'meetings': |
1396 | 1399 |
context['has_resources'] = Resource.objects.exists() |
1397 | 1400 |
context['has_unavailability_calendars'] = UnavailabilityCalendar.objects.exists() |
1401 |
if self.agenda.kind == 'events': |
|
1402 |
context['has_recurring_events'] = self.agenda.event_set.filter( |
|
1403 |
recurrence_rule__isnull=False |
|
1404 |
).exists() |
|
1405 |
desk, _ = Desk.objects.get_or_create(agenda=self.agenda, slug='_exceptions_holder') |
|
1406 |
context['exceptions'] = TimePeriodException.objects.filter( |
|
1407 |
Q(desk=desk) | Q(unavailability_calendar__desks=desk), |
|
1408 |
end_datetime__gt=now(), |
|
1409 |
).select_related('source') |
|
1410 |
context['desk'] = desk |
|
1398 | 1411 |
return context |
1399 | 1412 | |
1400 | 1413 |
def get_events(self): |
tests/test_agendas.py | ||
---|---|---|
1793 | 1793 |
assert len(recurrences) == 5 |
1794 | 1794 |
assert recurrences[0].start_datetime == start_datetime |
1795 | 1795 |
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=4) |
1796 | ||
1797 | ||
1798 |
@override_settings( |
|
1799 |
EXCEPTIONS_SOURCES={ |
|
1800 |
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, |
|
1801 |
} |
|
1802 |
) |
|
1803 |
def test_recurring_events_exceptions(freezer): |
|
1804 |
freezer.move_to('2021-05-01 12:00') |
|
1805 |
agenda = Agenda.objects.create(label='Agenda', kind='events') |
|
1806 |
desk = Desk.objects.create(slug='_exceptions_holder', agenda=agenda) |
|
1807 |
event = Event.objects.create( |
|
1808 |
agenda=agenda, |
|
1809 |
start_datetime=now(), |
|
1810 |
repeat='daily', |
|
1811 |
places=5, |
|
1812 |
) |
|
1813 |
event.refresh_from_db() |
|
1814 |
start_datetime = localtime(event.start_datetime) |
|
1815 | ||
1816 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
1817 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-01' |
|
1818 |
first_of_may = recurrences[0] |
|
1819 | ||
1820 |
recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime) |
|
1821 |
recurrence.delete() |
|
1822 | ||
1823 |
desk.import_timeperiod_exceptions_from_settings(enable=True) |
|
1824 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
1825 |
# 05-01 is a holiday |
|
1826 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
1827 |
with pytest.raises(ValueError): |
|
1828 |
recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime) |
|
1829 |
first_event = recurrences[0] |
|
1830 | ||
1831 |
# exception before first_event start_datetime |
|
1832 |
time_period_exception = TimePeriodException.objects.create( |
|
1833 |
desk=desk, |
|
1834 |
start_datetime=first_event.start_datetime - datetime.timedelta(hours=1), |
|
1835 |
end_datetime=first_event.start_datetime - datetime.timedelta(minutes=30), |
|
1836 |
) |
|
1837 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
1838 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
1839 | ||
1840 |
# exception wraps around first_event start_datetime |
|
1841 |
time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(minutes=30) |
|
1842 |
time_period_exception.save() |
|
1843 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
1844 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-03' |
|
1845 | ||
1846 |
# exception starts after first_event start_datetime |
|
1847 |
time_period_exception.start_datetime = first_event.start_datetime + datetime.timedelta(minutes=15) |
|
1848 |
time_period_exception.save() |
|
1849 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
1850 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
1851 |
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03' |
|
1852 | ||
1853 |
# exception spans multiple days |
|
1854 |
time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(days=3) |
|
1855 |
time_period_exception.save() |
|
1856 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
1857 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
1858 |
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06' |
|
1859 | ||
1860 |
# move exception to unavailability calendar |
|
1861 |
unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar') |
|
1862 |
time_period_exception.desk = None |
|
1863 |
time_period_exception.unavailability_calendar = unavailability_calendar |
|
1864 |
time_period_exception.save() |
|
1865 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
1866 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
1867 |
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03' |
|
1868 | ||
1869 |
unavailability_calendar.desks.add(desk) |
|
1870 |
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) |
|
1871 |
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' |
|
1872 |
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06' |
tests/test_manager.py | ||
---|---|---|
5606 | 5606 |
assert resp.text.count('Swimming') == 2 # 1 booking + legend |
5607 | 5607 |
assert 'Booking colors:' in resp.text |
5608 | 5608 |
assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 2 |
5609 | ||
5610 | ||
5611 |
@override_settings( |
|
5612 |
EXCEPTIONS_SOURCES={ |
|
5613 |
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, |
|
5614 |
} |
|
5615 |
) |
|
5616 |
def test_recurring_events_manage_exceptions(settings, app, admin_user, freezer): |
|
5617 |
freezer.move_to('2021-07-01 12:10') |
|
5618 | ||
5619 |
app = login(app) |
|
5620 |
resp = app.get('/manage/') |
|
5621 |
resp = resp.click('New') |
|
5622 |
resp.form['label'] = 'Foo bar' |
|
5623 |
resp.form['kind'] = 'events' |
|
5624 |
resp = resp.form.submit().follow() |
|
5625 | ||
5626 |
agenda = Agenda.objects.get(label='Foo bar') |
|
5627 |
assert agenda.desk_set.count() == 1 |
|
5628 |
desk = agenda.desk_set.get(slug='_exceptions_holder') |
|
5629 | ||
5630 |
event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda) |
|
5631 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
5632 |
assert not 'Recurrence exceptions' in resp.text |
|
5633 | ||
5634 |
event.repeat = 'daily' |
|
5635 |
event.save() |
|
5636 | ||
5637 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) |
|
5638 |
assert len(resp.pyquery.find('.event-info')) == 31 |
|
5639 | ||
5640 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
5641 |
assert 'Recurrence exceptions' in resp.text |
|
5642 | ||
5643 |
resp = resp.click('Add a time period exception') |
|
5644 |
resp.form['start_datetime_0'] = now().strftime('%Y-%m-%d') |
|
5645 |
resp.form['start_datetime_1'] = now().strftime('%H:%M') |
|
5646 |
resp.form['end_datetime_0'] = (now() + datetime.timedelta(days=7)).strftime('%Y-%m-%d') |
|
5647 |
resp.form['end_datetime_1'] = (now() + datetime.timedelta(days=7)).strftime('%H:%M') |
|
5648 |
resp = resp.form.submit().follow() |
|
5649 |
assert desk.timeperiodexception_set.count() == 1 |
|
5650 | ||
5651 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) |
|
5652 |
assert len(resp.pyquery.find('.event-info')) == 24 |
|
5653 | ||
5654 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
5655 |
resp = resp.click('Configure', href='exceptions') |
|
5656 |
resp = resp.click('enable').follow() |
|
5657 |
assert TimePeriodException.objects.count() > 1 |
|
5658 |
assert 'Bastille Day' in resp.text |
|
5659 | ||
5660 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) |
|
5661 |
assert len(resp.pyquery.find('.event-info')) == 23 |
|
5662 | ||
5663 |
# add recurrence end date, which lead to recurrences creation |
|
5664 |
resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id)) |
|
5665 |
resp.form['recurrence_end_date'] = (now() + datetime.timedelta(days=31)).strftime('%Y-%m-%d') |
|
5666 |
resp = resp.form.submit() |
|
5667 | ||
5668 |
# recurrences corresponding to exceptions have not been created |
|
5669 |
assert Event.objects.count() == 24 |
|
5609 |
- |