agendas: allow exceptions to recurring events (#50561)

This commit is contained in:
Valentin Deniaud 2021-01-28 15:40:57 +01:00
parent a4622337eb
commit 80826930ed
8 changed files with 292 additions and 8 deletions

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2021-01-27 16:46
from __future__ import unicode_literals
from django.db import migrations
def create_exceptions_desk(apps, schema_editor):
Agenda = apps.get_model('agendas', 'Agenda')
Desk = apps.get_model('agendas', 'Desk')
desks = []
for agenda in Agenda.objects.filter(kind='events'):
desks.append(Desk(agenda=agenda, slug='_exceptions_holder'))
Desk.objects.bulk_create(desks)
class Migration(migrations.Migration):
dependencies = [
('agendas', '0079_auto_20210428_1533'),
]
operations = [
migrations.RunPython(create_exceptions_desk, migrations.RunPython.noop),
]

View File

@ -213,6 +213,7 @@ class Agenda(models.Model):
return self.label
def save(self, *args, **kwargs):
created = bool(not self.pk)
if not self.slug:
self.slug = generate_slug(self)
if self.kind != 'virtual':
@ -223,6 +224,9 @@ class Agenda(models.Model):
if self.kind != 'events' and self.pk is None:
self.default_view = 'day'
super(Agenda, self).save(*args, **kwargs)
if created and self.kind == 'events':
desk = Desk.objects.create(agenda=self, slug='_exceptions_holder')
desk.import_timeperiod_exceptions_from_settings()
@property
def base_slug(self):
@ -342,6 +346,7 @@ class Agenda(models.Model):
agenda['absence_reasons_group'] = (
self.absence_reasons_group.slug if self.absence_reasons_group else None
)
agenda['exceptions_desk'] = self.desk_set.get().export_json()
elif self.kind == 'meetings':
agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.filter(deleted=False)]
agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()]
@ -359,6 +364,7 @@ class Agenda(models.Model):
if data['kind'] == 'events':
events = data.pop('events')
notifications_settings = data.pop('notifications_settings', None)
exceptions_desk = data.pop('exceptions_desk', None)
elif data['kind'] == 'meetings':
meetingtypes = data.pop('meetingtypes')
desks = data.pop('desks')
@ -408,6 +414,9 @@ class Agenda(models.Model):
if notifications_settings:
notifications_settings['agenda'] = agenda
AgendaNotificationsSettings.import_json(notifications_settings)
if exceptions_desk:
exceptions_desk['agenda'] = agenda
Desk.import_json(exceptions_desk)
elif data['kind'] == 'meetings':
if overwrite:
MeetingType.objects.filter(agenda=agenda).delete()
@ -653,10 +662,12 @@ class Agenda(models.Model):
recurring_events = self.prefetched_recurring_events
else:
recurring_events = self.event_set.filter(recurrence_rule__isnull=False)
exceptions = self.get_recurrence_exceptions(min_start, max_start)
for event in recurring_events:
events.extend(
event.get_recurrences(
min_start, max_start, excluded_datetimes.get(event.pk), slug_separator=':'
min_start, max_start, excluded_datetimes.get(event.pk), exceptions, slug_separator=':'
)
)
@ -674,6 +685,17 @@ class Agenda(models.Model):
except (VariableDoesNotExist, TemplateSyntaxError):
return
def get_recurrence_exceptions(self, min_start, max_start):
return TimePeriodException.objects.filter(
Q(desk__slug='_exceptions_holder', desk__agenda=self)
| Q(
unavailability_calendar__desks__slug='_exceptions_holder',
unavailability_calendar__desks__agenda=self,
),
start_datetime__lt=max_start,
end_datetime__gt=min_start,
)
def prefetch_desks_and_exceptions(self, with_sources=False):
if self.kind == 'meetings':
desks = self.desk_set.all()
@ -1382,12 +1404,32 @@ class Event(models.Model):
event.save()
return event
def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None, slug_separator='--'):
def get_recurrences(
self, min_datetime, max_datetime, excluded_datetimes=None, exceptions=None, slug_separator='--'
):
recurrences = []
rrule_set = rruleset()
# do not generate recurrences for existing events
rrule_set._exdate = excluded_datetimes or []
if exceptions is None:
exceptions = self.agenda.get_recurrence_exceptions(min_datetime, max_datetime)
for exception in exceptions:
exception_start = localtime(exception.start_datetime)
event_start = localtime(self.start_datetime)
if event_start.time() < exception_start.time():
exception_start += datetime.timedelta(days=1)
exception_start = exception_start.replace(
hour=event_start.hour, minute=event_start.minute, second=0, microsecond=0
)
rrule_set.exrule(
rrule(
freq=DAILY,
dtstart=make_naive(exception_start),
until=make_naive(exception.end_datetime),
)
)
event_base = Event(
agenda=self.agenda,
primary_event=self,

View File

@ -68,4 +68,28 @@
{% endfor %}
</div>
</div>
{% if has_recurring_events %}
<div class="section">
<h3>{% trans "Recurrence exceptions" %}
<a rel="popup" class="button" href="{% url 'chrono-manager-desk-add-import-time-period-exceptions' pk=desk.pk %}">{% trans 'Configure' %}</a>
</h3>
<div>
<ul class="objects-list single-links">
{% for exception in exceptions|slice:":5" %}
<li><a rel="popup" {% if not exception.read_only %}href="{% url 'chrono-manager-time-period-exception-edit' pk=exception.pk %}"{% endif %}>
{{ exception }}
{% if not exception.read_only %}
<a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-delete' pk=exception.id %}">{% trans "remove" %}</a>
{% endif %}
{% endfor %}
{% if exceptions|length > 5 %}
<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>
{% endif %}
<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>
</ul>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -1573,6 +1573,15 @@ class AgendaSettings(ManagedAgendaMixin, DetailView):
)
if self.agenda.kind == 'events':
context['has_absence_reasons'] = AbsenceReasonGroup.objects.exists()
context['has_recurring_events'] = self.agenda.event_set.filter(
recurrence_rule__isnull=False
).exists()
desk = Desk.objects.get(agenda=self.agenda, slug='_exceptions_holder')
context['exceptions'] = TimePeriodException.objects.filter(
Q(desk=desk) | Q(unavailability_calendar__desks=desk),
end_datetime__gt=now(),
)
context['desk'] = desk
return context
def get_events(self):

View File

@ -2617,7 +2617,7 @@ def test_agenda_events_day_view(app, admin_user):
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/manage/agendas/%s/2020/11/11/' % agenda.pk)
assert len(ctx.captured_queries) == 5
assert len(ctx.captured_queries) == 6
assert len(resp.pyquery.find('.event-info')) == 2
assert 'abc' in resp.pyquery.find('.event-info')[0].text
@ -2680,7 +2680,7 @@ def test_agenda_events_month_view(app, admin_user):
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 11))
assert len(ctx.captured_queries) == 7
assert len(ctx.captured_queries) == 8
assert len(resp.pyquery.find('.event-info')) == 5
assert 'abc' in resp.pyquery.find('.event-info')[0].text
assert 'abc' in resp.pyquery.find('.event-info')[1].text
@ -4413,3 +4413,64 @@ def test_agenda_booking_colors(app, admin_user, api_user, view):
assert resp.text.count('Swimming') == 2 # 1 booking + legend
assert 'Booking colors:' in resp.text
assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 2
@override_settings(
EXCEPTIONS_SOURCES={
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
}
)
def test_recurring_events_manage_exceptions(settings, app, admin_user, freezer):
freezer.move_to('2021-07-01 12:10')
app = login(app)
resp = app.get('/manage/')
resp = resp.click('New')
resp.form['label'] = 'Foo bar'
resp.form['kind'] = 'events'
resp = resp.form.submit().follow()
agenda = Agenda.objects.get(label='Foo bar')
assert agenda.desk_set.count() == 1
desk = agenda.desk_set.get(slug='_exceptions_holder')
event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda)
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert not 'Recurrence exceptions' in resp.text
event.repeat = 'daily'
event.save()
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
assert len(resp.pyquery.find('.event-info')) == 31
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert 'Recurrence exceptions' in resp.text
resp = resp.click('Add a time period exception')
resp.form['start_datetime_0'] = now().strftime('%Y-%m-%d')
resp.form['start_datetime_1'] = now().strftime('%H:%M')
resp.form['end_datetime_0'] = (now() + datetime.timedelta(days=7)).strftime('%Y-%m-%d')
resp.form['end_datetime_1'] = (now() + datetime.timedelta(days=7)).strftime('%H:%M')
resp = resp.form.submit().follow()
assert desk.timeperiodexception_set.count() == 1
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
assert len(resp.pyquery.find('.event-info')) == 24
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
resp = resp.click('Configure', href='exceptions')
resp = resp.click('enable').follow()
assert TimePeriodException.objects.count() > 1
assert 'Bastille Day' in resp.text
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
assert len(resp.pyquery.find('.event-info')) == 23
# add recurrence end date, which lead to recurrences creation
resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
resp.form['recurrence_end_date'] = (now() + datetime.timedelta(days=31)).strftime('%Y-%m-%d')
resp = resp.form.submit()
# recurrences corresponding to exceptions have not been created
assert Event.objects.count() == 24

View File

@ -2006,3 +2006,81 @@ def test_recurring_events_sort(freezer):
events = agenda.get_open_events()[:8]
assert [e.primary_event.slug for e in events] == ['c', 'b', 'a', 'd', 'c', 'b', 'a', 'd']
@override_settings(
EXCEPTIONS_SOURCES={
'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
}
)
def test_recurring_events_exceptions(freezer):
freezer.move_to('2021-05-01 12:00')
agenda = Agenda.objects.create(label='Agenda', kind='events')
desk = Desk.objects.get(slug='_exceptions_holder', agenda=agenda)
event = Event.objects.create(
agenda=agenda,
start_datetime=now(),
repeat='daily',
places=5,
)
event.refresh_from_db()
start_datetime = localtime(event.start_datetime)
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-01'
first_of_may = recurrences[0]
recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime)
recurrence.delete()
desk.import_timeperiod_exceptions_from_settings(enable=True)
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
# 05-01 is a holiday
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
with pytest.raises(ValueError):
recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime)
first_event = recurrences[0]
# exception before first_event start_datetime
time_period_exception = TimePeriodException.objects.create(
desk=desk,
start_datetime=first_event.start_datetime - datetime.timedelta(hours=1),
end_datetime=first_event.start_datetime - datetime.timedelta(minutes=30),
)
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
# exception wraps around first_event start_datetime
time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(minutes=30)
time_period_exception.save()
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-03'
# exception starts after first_event start_datetime
time_period_exception.start_datetime = first_event.start_datetime + datetime.timedelta(minutes=15)
time_period_exception.save()
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03'
# exception spans multiple days
time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(days=3)
time_period_exception.save()
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06'
# move exception to unavailability calendar
unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
time_period_exception.desk = None
time_period_exception.unavailability_calendar = unavailability_calendar
time_period_exception.save()
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03'
unavailability_calendar.desks.add(desk)
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06'

View File

@ -306,9 +306,18 @@ def test_agendas_api(app):
resp = app.get('/api/agenda/', params={'with_open_events': '1'})
assert len(resp.json['data']) == 1
for i in range(10):
event_agenda = Agenda.objects.create(label='Foo bar', category=category_a)
event = Event.objects.create(start_datetime=now(), places=10, agenda=event_agenda, repeat='daily')
TimePeriodException.objects.create(
desk=event_agenda.desk_set.get(),
start_datetime=now(),
end_datetime=now() + datetime.timedelta(hours=1),
)
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/api/agenda/', params={'with_open_events': '1'})
assert len(ctx.captured_queries) == 4
assert len(ctx.captured_queries) == 15
def test_agendas_meetingtypes_api(app, some_data, meetings_agenda):
@ -6322,3 +6331,33 @@ def test_date_filter_overlapping_events(app):
resp = app.get('/api/agenda/foo/meetings/mt5/datetimes/', params=make_date_filters(10, 0, 10, 30))
assert len(resp.json['data']) == 3
def test_recurring_events_api_exceptions(app, user, freezer):
freezer.move_to('2021-01-12 12:05') # Tuesday
agenda = Agenda.objects.create(
label='Foo bar', kind='events', minimal_booking_delay=1, maximal_booking_delay=30
)
event = Event.objects.create(
slug='abc', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda
)
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
data = resp.json['data']
assert len(data) == 4
assert data[0]['datetime'] == '2021-01-19 13:05:00'
time_period_exception = TimePeriodException.objects.create(
desk=agenda.desk_set.get(),
start_datetime=datetime.date(year=2021, month=1, day=18),
end_datetime=datetime.date(year=2021, month=1, day=20),
)
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
assert len(resp.json['data']) == 3
assert resp.json['data'][0]['datetime'] == '2021-01-26 13:05:00'
# try to book excluded event
fillslot_url = data[0]['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post(fillslot_url, status=400)
assert resp.json['err'] == 1

View File

@ -58,11 +58,13 @@ def test_import_export(app):
agenda_meetings = Agenda.objects.create(label='Meetings Agenda', kind='meetings')
MeetingType.objects.create(agenda=agenda_meetings, label='Meeting Type', duration=30)
desk = Desk.objects.create(agenda=agenda_meetings, label='Desk')
exceptions_desk = Desk.objects.get(agenda=agenda_events, slug='_exceptions_holder')
# add exception to meeting agenda
tpx_start = make_aware(datetime.datetime(2017, 5, 22, 8, 0))
tpx_end = make_aware(datetime.datetime(2017, 5, 22, 12, 30))
TimePeriodException.objects.create(desk=desk, start_datetime=tpx_start, end_datetime=tpx_end)
TimePeriodException.objects.create(desk=exceptions_desk, start_datetime=tpx_start, end_datetime=tpx_end)
output = get_output_of_command('export_site')
assert len(json.loads(output)['agendas']) == 2
import_site(data={}, clean=True)
@ -87,8 +89,10 @@ def test_import_export(app):
assert Agenda.objects.count() == 2
first_imported_event = Agenda.objects.get(label='Events Agenda').event_set.first()
assert first_imported_event.start_datetime == first_event.start_datetime
assert TimePeriodException.objects.get().start_datetime == tpx_start
assert TimePeriodException.objects.get().end_datetime == tpx_end
assert TimePeriodException.objects.get(desk__agenda__kind='meetings').start_datetime == tpx_start
assert TimePeriodException.objects.get(desk__agenda__kind='meetings').end_datetime == tpx_end
assert TimePeriodException.objects.get(desk__agenda__kind='events').start_datetime == tpx_start
assert TimePeriodException.objects.get(desk__agenda__kind='events').end_datetime == tpx_end
agenda1 = Agenda.objects.get(label='Events Agenda')
agenda2 = Agenda.objects.get(label='Meetings Agenda')