Projet

Général

Profil

0002-agendas-allow-exceptions-to-recurring-events-50561.patch

Valentin Deniaud, 22 février 2021 15:25

Télécharger (18,6 ko)

Voir les différences:

Subject: [PATCH 2/4] agendas: allow exceptions to recurring events (#50561)

 .../migrations/0077_auto_20210218_1533.py     | 27 +++++++
 chrono/agendas/models.py                      | 50 +++++++++++-
 .../manager_events_agenda_settings.html       | 23 ++++++
 chrono/manager/views.py                       | 10 +++
 tests/test_agendas.py                         | 78 +++++++++++++++++++
 tests/test_api.py                             |  2 +-
 tests/test_import_export.py                   | 10 ++-
 tests/test_manager.py                         | 61 +++++++++++++++
 8 files changed, 255 insertions(+), 6 deletions(-)
 create mode 100644 chrono/agendas/migrations/0077_auto_20210218_1533.py
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
-