agendas: allow exceptions to recurring events (#50561)
This commit is contained in:
parent
a4622337eb
commit
80826930ed
|
@ -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),
|
||||
]
|
|
@ -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,
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue