Projet

Général

Profil

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

Valentin Deniaud, 27 avril 2021 18:01

Télécharger (21,2 ko)

Voir les différences:

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

 .../0079_create_exceptions_desks.py           | 27 +++++++
 chrono/agendas/models.py                      | 46 ++++++++++-
 .../manager_events_agenda_settings.html       | 24 ++++++
 chrono/manager/views.py                       |  9 +++
 tests/manager/test_all.py                     | 65 +++++++++++++++-
 tests/test_agendas.py                         | 78 +++++++++++++++++++
 tests/test_api.py                             | 41 +++++++++-
 tests/test_import_export.py                   | 10 ++-
 8 files changed, 292 insertions(+), 8 deletions(-)
 create mode 100644 chrono/agendas/migrations/0079_create_exceptions_desks.py
chrono/agendas/migrations/0079_create_exceptions_desks.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', '0078_absence_reasons'),
23
    ]
24

  
25
    operations = [
26
        migrations.RunPython(create_exceptions_desk, migrations.RunPython.noop),
27
    ]
chrono/agendas/models.py
213 213
        return self.label
214 214

  
215 215
    def save(self, *args, **kwargs):
216
        created = bool(not self.pk)
216 217
        if not self.slug:
217 218
            self.slug = generate_slug(self)
218 219
        if self.kind != 'virtual':
......
223 224
        if self.kind != 'events' and self.pk is None:
224 225
            self.default_view = 'day'
225 226
        super(Agenda, self).save(*args, **kwargs)
227
        if created and self.kind == 'events':
228
            desk = Desk.objects.create(agenda=self, slug='_exceptions_holder')
229
            desk.import_timeperiod_exceptions_from_settings()
226 230

  
227 231
    @property
228 232
    def base_slug(self):
......
342 346
            agenda['absence_reasons_group'] = (
343 347
                self.absence_reasons_group.slug if self.absence_reasons_group else None
344 348
            )
349
            agenda['exceptions_desk'] = self.desk_set.get().export_json()
345 350
        elif self.kind == 'meetings':
346 351
            agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.filter(deleted=False)]
347 352
            agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()]
......
359 364
        if data['kind'] == 'events':
360 365
            events = data.pop('events')
361 366
            notifications_settings = data.pop('notifications_settings', None)
367
            exceptions_desk = data.pop('exceptions_desk', None)
362 368
        elif data['kind'] == 'meetings':
363 369
            meetingtypes = data.pop('meetingtypes')
364 370
            desks = data.pop('desks')
......
408 414
            if notifications_settings:
409 415
                notifications_settings['agenda'] = agenda
410 416
                AgendaNotificationsSettings.import_json(notifications_settings)
417
            if exceptions_desk:
418
                exceptions_desk['agenda'] = agenda
419
                Desk.import_json(exceptions_desk)
411 420
        elif data['kind'] == 'meetings':
412 421
            if overwrite:
413 422
                MeetingType.objects.filter(agenda=agenda).delete()
......
653 662
            recurring_events = self.prefetched_recurring_events
654 663
        else:
655 664
            recurring_events = self.event_set.filter(recurrence_rule__isnull=False)
665

  
666
        exceptions = self.get_recurrence_exceptions(min_start, max_start)
656 667
        for event in recurring_events:
657 668
            events.extend(
658 669
                event.get_recurrences(
659
                    min_start, max_start, excluded_datetimes.get(event.pk), slug_separator=':'
670
                    min_start, max_start, excluded_datetimes.get(event.pk), exceptions, slug_separator=':'
660 671
                )
661 672
            )
662 673

  
......
674 685
        except (VariableDoesNotExist, TemplateSyntaxError):
675 686
            return
676 687

  
688
    def get_recurrence_exceptions(self, min_start, max_start):
689
        return TimePeriodException.objects.filter(
690
            Q(desk__slug='_exceptions_holder', desk__agenda=self)
691
            | Q(
692
                unavailability_calendar__desks__slug='_exceptions_holder',
693
                unavailability_calendar__desks__agenda=self,
694
            ),
695
            start_datetime__lt=max_start,
696
            end_datetime__gt=min_start,
697
        )
698

  
677 699
    def prefetch_desks_and_exceptions(self, with_sources=False):
678 700
        if self.kind == 'meetings':
679 701
            desks = self.desk_set.all()
......
1382 1404
                event.save()
1383 1405
                return event
1384 1406

  
1385
    def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None, slug_separator='--'):
1407
    def get_recurrences(
1408
        self, min_datetime, max_datetime, excluded_datetimes=None, exceptions=None, slug_separator='--'
1409
    ):
1386 1410
        recurrences = []
1387 1411
        rrule_set = rruleset()
1388 1412
        # do not generate recurrences for existing events
1389 1413
        rrule_set._exdate = excluded_datetimes or []
1390 1414

  
1415
        if exceptions is None:
1416
            exceptions = self.agenda.get_recurrence_exceptions(min_datetime, max_datetime)
1417
        for exception in exceptions:
1418
            exception_start = localtime(exception.start_datetime)
1419
            event_start = localtime(self.start_datetime)
1420
            if event_start.time() < exception_start.time():
1421
                exception_start += datetime.timedelta(days=1)
1422
            exception_start = exception_start.replace(
1423
                hour=event_start.hour, minute=event_start.minute, second=0, microsecond=0
1424
            )
1425
            rrule_set.exrule(
1426
                rrule(
1427
                    freq=DAILY,
1428
                    dtstart=make_naive(exception_start),
1429
                    until=make_naive(exception.end_datetime),
1430
                )
1431
            )
1432

  
1391 1433
        event_base = Event(
1392 1434
            agenda=self.agenda,
1393 1435
            primary_event=self,
chrono/manager/templates/chrono/manager_events_agenda_settings.html
68 68
{% endfor %}
69 69
</div>
70 70
</div>
71

  
72
{% if has_recurring_events %}
73
<div class="section">
74
<h3>{% trans "Recurrence exceptions" %}
75
<a rel="popup" class="button" href="{% url 'chrono-manager-desk-add-import-time-period-exceptions' pk=desk.pk %}">{% trans 'Configure' %}</a>
76
</h3>
77
<div>
78
<ul class="objects-list single-links">
79
{% for exception in exceptions|slice:":5" %}
80
   <li><a rel="popup" {% if not exception.read_only %}href="{% url 'chrono-manager-time-period-exception-edit' pk=exception.pk %}"{% endif %}>
81
  {{ exception }}
82
  {% if not exception.read_only %}
83
  <a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-delete' pk=exception.id %}">{% trans "remove" %}</a>
84
  {% endif %}
85
{% endfor %}
86
{% if exceptions|length > 5 %}
87
<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>
88
{% endif %}
89
<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>
90
</ul>
91
</div>
92
</div>
93
{% endif %}
94

  
71 95
{% endblock %}
chrono/manager/views.py
1573 1573
            )
1574 1574
        if self.agenda.kind == 'events':
1575 1575
            context['has_absence_reasons'] = AbsenceReasonGroup.objects.exists()
1576
            context['has_recurring_events'] = self.agenda.event_set.filter(
1577
                recurrence_rule__isnull=False
1578
            ).exists()
1579
            desk = Desk.objects.get(agenda=self.agenda, slug='_exceptions_holder')
1580
            context['exceptions'] = TimePeriodException.objects.filter(
1581
                Q(desk=desk) | Q(unavailability_calendar__desks=desk),
1582
                end_datetime__gt=now(),
1583
            )
1584
            context['desk'] = desk
1576 1585
        return context
1577 1586

  
1578 1587
    def get_events(self):
tests/manager/test_all.py
2617 2617

  
2618 2618
    with CaptureQueriesContext(connection) as ctx:
2619 2619
        resp = app.get('/manage/agendas/%s/2020/11/11/' % agenda.pk)
2620
        assert len(ctx.captured_queries) == 5
2620
        assert len(ctx.captured_queries) == 6
2621 2621

  
2622 2622
    assert len(resp.pyquery.find('.event-info')) == 2
2623 2623
    assert 'abc' in resp.pyquery.find('.event-info')[0].text
......
2680 2680

  
2681 2681
    with CaptureQueriesContext(connection) as ctx:
2682 2682
        resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 11))
2683
        assert len(ctx.captured_queries) == 7
2683
        assert len(ctx.captured_queries) == 8
2684 2684
    assert len(resp.pyquery.find('.event-info')) == 5
2685 2685
    assert 'abc' in resp.pyquery.find('.event-info')[0].text
2686 2686
    assert 'abc' in resp.pyquery.find('.event-info')[1].text
......
4413 4413
    assert resp.text.count('Swimming') == 2  # 1 booking + legend
4414 4414
    assert 'Booking colors:' in resp.text
4415 4415
    assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 2
4416

  
4417

  
4418
@override_settings(
4419
    EXCEPTIONS_SOURCES={
4420
        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
4421
    }
4422
)
4423
def test_recurring_events_manage_exceptions(settings, app, admin_user, freezer):
4424
    freezer.move_to('2021-07-01 12:10')
4425

  
4426
    app = login(app)
4427
    resp = app.get('/manage/')
4428
    resp = resp.click('New')
4429
    resp.form['label'] = 'Foo bar'
4430
    resp.form['kind'] = 'events'
4431
    resp = resp.form.submit().follow()
4432

  
4433
    agenda = Agenda.objects.get(label='Foo bar')
4434
    assert agenda.desk_set.count() == 1
4435
    desk = agenda.desk_set.get(slug='_exceptions_holder')
4436

  
4437
    event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda)
4438
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
4439
    assert not 'Recurrence exceptions' in resp.text
4440

  
4441
    event.repeat = 'daily'
4442
    event.save()
4443

  
4444
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
4445
    assert len(resp.pyquery.find('.event-info')) == 31
4446

  
4447
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
4448
    assert 'Recurrence exceptions' in resp.text
4449

  
4450
    resp = resp.click('Add a time period exception')
4451
    resp.form['start_datetime_0'] = now().strftime('%Y-%m-%d')
4452
    resp.form['start_datetime_1'] = now().strftime('%H:%M')
4453
    resp.form['end_datetime_0'] = (now() + datetime.timedelta(days=7)).strftime('%Y-%m-%d')
4454
    resp.form['end_datetime_1'] = (now() + datetime.timedelta(days=7)).strftime('%H:%M')
4455
    resp = resp.form.submit().follow()
4456
    assert desk.timeperiodexception_set.count() == 1
4457

  
4458
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
4459
    assert len(resp.pyquery.find('.event-info')) == 24
4460

  
4461
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
4462
    resp = resp.click('Configure', href='exceptions')
4463
    resp = resp.click('enable').follow()
4464
    assert TimePeriodException.objects.count() > 1
4465
    assert 'Bastille Day' in resp.text
4466

  
4467
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
4468
    assert len(resp.pyquery.find('.event-info')) == 23
4469

  
4470
    # add recurrence end date, which lead to recurrences creation
4471
    resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
4472
    resp.form['recurrence_end_date'] = (now() + datetime.timedelta(days=31)).strftime('%Y-%m-%d')
4473
    resp = resp.form.submit()
4474

  
4475
    # recurrences corresponding to exceptions have not been created
4476
    assert Event.objects.count() == 24
tests/test_agendas.py
2006 2006

  
2007 2007
    events = agenda.get_open_events()[:8]
2008 2008
    assert [e.primary_event.slug for e in events] == ['c', 'b', 'a', 'd', 'c', 'b', 'a', 'd']
2009

  
2010

  
2011
@override_settings(
2012
    EXCEPTIONS_SOURCES={
2013
        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
2014
    }
2015
)
2016
def test_recurring_events_exceptions(freezer):
2017
    freezer.move_to('2021-05-01 12:00')
2018
    agenda = Agenda.objects.create(label='Agenda', kind='events')
2019
    desk = Desk.objects.get(slug='_exceptions_holder', agenda=agenda)
2020

  
2021
    event = Event.objects.create(
2022
        agenda=agenda,
2023
        start_datetime=now(),
2024
        repeat='daily',
2025
        places=5,
2026
    )
2027
    event.refresh_from_db()
2028
    start_datetime = localtime(event.start_datetime)
2029

  
2030
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2031
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-01'
2032
    first_of_may = recurrences[0]
2033

  
2034
    recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime)
2035
    recurrence.delete()
2036

  
2037
    desk.import_timeperiod_exceptions_from_settings(enable=True)
2038
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2039
    # 05-01 is a holiday
2040
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
2041
    with pytest.raises(ValueError):
2042
        recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime)
2043
    first_event = recurrences[0]
2044

  
2045
    # exception before first_event start_datetime
2046
    time_period_exception = TimePeriodException.objects.create(
2047
        desk=desk,
2048
        start_datetime=first_event.start_datetime - datetime.timedelta(hours=1),
2049
        end_datetime=first_event.start_datetime - datetime.timedelta(minutes=30),
2050
    )
2051
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2052
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
2053

  
2054
    # exception wraps around first_event start_datetime
2055
    time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(minutes=30)
2056
    time_period_exception.save()
2057
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2058
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-03'
2059

  
2060
    # exception starts after first_event start_datetime
2061
    time_period_exception.start_datetime = first_event.start_datetime + datetime.timedelta(minutes=15)
2062
    time_period_exception.save()
2063
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2064
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
2065
    assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03'
2066

  
2067
    # exception spans multiple days
2068
    time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(days=3)
2069
    time_period_exception.save()
2070
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2071
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
2072
    assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06'
2073

  
2074
    # move exception to unavailability calendar
2075
    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
2076
    time_period_exception.desk = None
2077
    time_period_exception.unavailability_calendar = unavailability_calendar
2078
    time_period_exception.save()
2079
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2080
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
2081
    assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03'
2082

  
2083
    unavailability_calendar.desks.add(desk)
2084
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2085
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
2086
    assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06'
tests/test_api.py
306 306
    resp = app.get('/api/agenda/', params={'with_open_events': '1'})
307 307
    assert len(resp.json['data']) == 1
308 308

  
309
    for i in range(10):
310
        event_agenda = Agenda.objects.create(label='Foo bar', category=category_a)
311
        event = Event.objects.create(start_datetime=now(), places=10, agenda=event_agenda, repeat='daily')
312
        TimePeriodException.objects.create(
313
            desk=event_agenda.desk_set.get(),
314
            start_datetime=now(),
315
            end_datetime=now() + datetime.timedelta(hours=1),
316
        )
317

  
309 318
    with CaptureQueriesContext(connection) as ctx:
310 319
        resp = app.get('/api/agenda/', params={'with_open_events': '1'})
311
        assert len(ctx.captured_queries) == 4
320
        assert len(ctx.captured_queries) == 15
312 321

  
313 322

  
314 323
def test_agendas_meetingtypes_api(app, some_data, meetings_agenda):
......
6322 6331

  
6323 6332
    resp = app.get('/api/agenda/foo/meetings/mt5/datetimes/', params=make_date_filters(10, 0, 10, 30))
6324 6333
    assert len(resp.json['data']) == 3
6334

  
6335

  
6336
def test_recurring_events_api_exceptions(app, user, freezer):
6337
    freezer.move_to('2021-01-12 12:05')  # Tuesday
6338
    agenda = Agenda.objects.create(
6339
        label='Foo bar', kind='events', minimal_booking_delay=1, maximal_booking_delay=30
6340
    )
6341
    event = Event.objects.create(
6342
        slug='abc', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda
6343
    )
6344

  
6345
    resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
6346
    data = resp.json['data']
6347
    assert len(data) == 4
6348
    assert data[0]['datetime'] == '2021-01-19 13:05:00'
6349

  
6350
    time_period_exception = TimePeriodException.objects.create(
6351
        desk=agenda.desk_set.get(),
6352
        start_datetime=datetime.date(year=2021, month=1, day=18),
6353
        end_datetime=datetime.date(year=2021, month=1, day=20),
6354
    )
6355
    resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
6356
    assert len(resp.json['data']) == 3
6357
    assert resp.json['data'][0]['datetime'] == '2021-01-26 13:05:00'
6358

  
6359
    # try to book excluded event
6360
    fillslot_url = data[0]['api']['fillslot_url']
6361
    app.authorization = ('Basic', ('john.doe', 'password'))
6362
    resp = app.post(fillslot_url, status=400)
6363
    assert resp.json['err'] == 1
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')
95
-