Projet

Général

Profil

0004-agendas-update-recurrences-asynchronously-50561.patch

Valentin Deniaud, 22 février 2021 15:25

Télécharger (14,4 ko)

Voir les différences:

Subject: [PATCH 4/4] agendas: update recurrences asynchronously (#50561)

 .../commands/update_event_recurrences.py      | 29 +++++++++
 .../0078_recurrenceexceptionsreport.py        | 34 ++++++++++
 chrono/agendas/models.py                      | 50 ++++++++++++++-
 .../manager_events_agenda_settings.html       | 11 ++++
 debian/uwsgi.ini                              |  1 +
 tests/test_agendas.py                         | 63 +++++++++++++++++++
 tests/test_manager.py                         | 52 +++++++++++++++
 7 files changed, 237 insertions(+), 3 deletions(-)
 create mode 100644 chrono/agendas/management/commands/update_event_recurrences.py
 create mode 100644 chrono/agendas/migrations/0078_recurrenceexceptionsreport.py
chrono/agendas/management/commands/update_event_recurrences.py
1
# chrono - agendas system
2
# Copyright (C) 2021  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.core.management.base import BaseCommand
18
from django.utils import timezone
19

  
20
from chrono.agendas.models import Agenda
21

  
22

  
23
class Command(BaseCommand):
24
    help = 'Update event recurrences to reflect exceptions'
25

  
26
    def handle(self, **options):
27
        agendas = Agenda.objects.filter(kind='events', event__recurrence_rule__isnull=False).distinct()
28
        for agenda in agendas:
29
            agenda.update_event_recurrences()
chrono/agendas/migrations/0078_recurrenceexceptionsreport.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2021-02-18 14:41
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6
import django.db.models.deletion
7

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    dependencies = [
12
        ('agendas', '0077_auto_20210218_1533'),
13
    ]
14

  
15
    operations = [
16
        migrations.CreateModel(
17
            name='RecurrenceExceptionsReport',
18
            fields=[
19
                (
20
                    'id',
21
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
22
                ),
23
                (
24
                    'agenda',
25
                    models.OneToOneField(
26
                        on_delete=django.db.models.deletion.CASCADE,
27
                        related_name='recurrence_exceptions_report',
28
                        to='agendas.Agenda',
29
                    ),
30
                ),
31
                ('events', models.ManyToManyField(to='agendas.Event')),
32
            ],
33
        ),
34
    ]
chrono/agendas/models.py
37 37
from django.core.exceptions import ValidationError
38 38
from django.core.validators import MaxValueValidator, MinValueValidator
39 39
from django.db import models, transaction, connection
40
from django.db.models import Count, Q, Case, When
40
from django.db.models import Count, Q, Case, When, Max
41 41
from django.template import engines, Context, Template, TemplateSyntaxError, VariableDoesNotExist
42 42
from django.urls import reverse
43 43
from django.utils import functional
......
611 611
        events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering])
612 612
        return events
613 613

  
614
    @transaction.atomic
615
    def update_event_recurrences(self):
616
        recurring_events = self.event_set.filter(recurrence_rule__isnull=False)
617
        recurrences = self.event_set.filter(primary_event__isnull=False)
618

  
619
        # remove recurrences
620
        datetimes = []
621
        min_start = localtime(now())
622
        max_start = recurrences.aggregate(dt=Max('start_datetime'))['dt']
623
        if not max_start:
624
            return
625

  
626
        exceptions = self.get_recurrence_exceptions(min_start, max_start)
627
        for event in recurring_events:
628
            events = event.get_recurrences(min_start, max_start, exceptions=exceptions)
629
            datetimes.extend([event.start_datetime for event in events])
630

  
631
        events = recurrences.filter(start_datetime__gt=min_start).exclude(start_datetime__in=datetimes)
632
        events.filter(Q(booking__isnull=True) | Q(booking__cancellation_datetime__isnull=False)).delete()
633
        # report events that weren't deleted because they have bookings
634
        report, _ = RecurrenceExceptionsReport.objects.get_or_create(agenda=self)
635
        report.events.set(events)
636

  
637
        # add recurrences
638
        excluded_datetimes = [make_naive(dt) for dt in recurrences.values_list('start_datetime', flat=True)]
639
        Event.create_events_recurrences(
640
            recurring_events.filter(recurrence_end_date__isnull=False), excluded_datetimes
641
        )
642

  
614 643
    def get_booking_form_url(self):
615 644
        if not self.booking_form_url:
616 645
            return
......
1395 1424
        ).exists()
1396 1425

  
1397 1426
    def create_all_recurrences(self, excluded_datetimes=None):
1398
        max_datetime = datetime.datetime.combine(self.recurrence_end_date, datetime.time(0, 0))
1399
        recurrences = self.get_recurrences(localtime(now()), make_aware(max_datetime), excluded_datetimes)
1427
        Event.create_events_recurrences([self], excluded_datetimes)
1428

  
1429
    @classmethod
1430
    def create_events_recurrences(cls, events, excluded_datetimes=None):
1431
        recurrences = []
1432
        for event in events:
1433
            max_datetime = datetime.datetime.combine(event.recurrence_end_date, datetime.time(0, 0))
1434
            recurrences.extend(
1435
                event.get_recurrences(localtime(now()), make_aware(max_datetime), excluded_datetimes)
1436
            )
1400 1437
        Event.objects.bulk_create(recurrences)
1401 1438

  
1402 1439

  
......
2191 2228
        ordering = ['-timestamp']
2192 2229

  
2193 2230

  
2231
class RecurrenceExceptionsReport(models.Model):
2232
    agenda = models.OneToOneField(
2233
        Agenda, related_name='recurrence_exceptions_report', on_delete=models.CASCADE
2234
    )
2235
    events = models.ManyToManyField(Event)
2236

  
2237

  
2194 2238
class NotificationType:
2195 2239
    def __init__(self, name, related_field, settings):
2196 2240
        self.name = name
chrono/manager/templates/chrono/manager_events_agenda_settings.html
55 55
<a rel="popup" class="button" href="{% url 'chrono-manager-desk-add-import-time-period-exceptions' pk=desk.pk %}">{% trans 'Configure' %}</a>
56 56
</h3>
57 57
<div>
58
{% if object.recurrence_exceptions_report.events.exists %}
59
<div class="warningnotice">
60
<p>{% trans "The following events exist despite exceptions because they have active bookings:" %}</p>
61
<ul>
62
{% for event in object.recurrence_exceptions_report.events.all %}
63
<li><a href="{{ event.get_absolute_view_url }}">{{ event }}{% if event.label %} - {{ event.start_datetime|date:"DATETIME_FORMAT" }}{% endif %}</a></li>
64
{% endfor %}
65
</ul>
66
<p>{% trans "You can cancel them manually for this warning to go away, or wait until they are passed." %}</p>
67
</div>
68
{% endif %}
58 69
<ul class="objects-list single-links">
59 70
{% for exception in exceptions|slice:":5" %}
60 71
   <li><a rel="popup" {% if not exception.read_only %}href="{% url 'chrono-manager-time-period-exception-edit' pk=exception.pk %}"{% endif %}>
debian/uwsgi.ini
17 17
# every five minutes
18 18
cron = -5 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command cancel_events --all-tenants -v0
19 19
cron = -5 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command send_email_notifications --all-tenants -v0
20
cron = -5 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command update_event_recurrences --all-tenants -v0
20 21
# hourly
21 22
cron = 1 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command clearsessions --all-tenants
22 23
cron = 1 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command send_booking_reminders --all-tenants
tests/test_agendas.py
2055 2055
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2056 2056
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
2057 2057
    assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06'
2058

  
2059

  
2060
def test_recurring_events_exceptions_update_recurrences(freezer):
2061
    freezer.move_to('2021-05-01 12:00')
2062
    agenda = Agenda.objects.create(label='Agenda', kind='events')
2063
    desk = Desk.objects.get(slug='_exceptions_holder', agenda=agenda)
2064

  
2065
    daily_event = Event.objects.create(
2066
        agenda=agenda,
2067
        start_datetime=now(),
2068
        repeat='daily',
2069
        places=5,
2070
        recurrence_end_date=datetime.date(year=2021, month=5, day=8),
2071
    )
2072
    weekly_event = Event.objects.create(
2073
        agenda=agenda,
2074
        start_datetime=now(),
2075
        repeat='weekly',
2076
        places=5,
2077
        recurrence_end_date=datetime.date(year=2021, month=6, day=1),
2078
    )
2079
    Event.create_events_recurrences([daily_event, weekly_event])
2080

  
2081
    daily_event_no_end_date = Event.objects.create(
2082
        agenda=agenda,
2083
        start_datetime=now() + datetime.timedelta(hours=2),
2084
        repeat='daily',
2085
        places=5,
2086
    )
2087
    daily_event_no_end_date.refresh_from_db()
2088
    # create one recurrence on 07/05
2089
    daily_event_no_end_date.get_or_create_event_recurrence(now() + datetime.timedelta(days=6, hours=2))
2090

  
2091
    assert Event.objects.filter(primary_event=daily_event).count() == 7
2092
    assert Event.objects.filter(primary_event=weekly_event).count() == 5
2093
    assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 1
2094

  
2095
    time_period_exception = TimePeriodException.objects.create(
2096
        desk=desk,
2097
        start_datetime=datetime.date(year=2021, month=5, day=5),
2098
        end_datetime=datetime.date(year=2021, month=5, day=10),
2099
    )
2100
    agenda.update_event_recurrences()
2101
    assert Event.objects.filter(primary_event=daily_event).count() == 4
2102
    assert Event.objects.filter(primary_event=weekly_event).count() == 4
2103
    assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 0
2104

  
2105
    time_period_exception.delete()
2106
    agenda.update_event_recurrences()
2107
    assert Event.objects.filter(primary_event=daily_event).count() == 7
2108
    assert Event.objects.filter(primary_event=weekly_event).count() == 5
2109
    assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 0
2110

  
2111
    event = daily_event_no_end_date.get_or_create_event_recurrence(
2112
        now() + datetime.timedelta(days=6, hours=2)
2113
    )
2114
    booking = Booking.objects.create(event=event)
2115
    time_period_exception.save()
2116

  
2117
    agenda.update_event_recurrences()
2118
    assert Booking.objects.count() == 1
2119
    assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 1
2120
    assert agenda.recurrence_exceptions_report.events.get() == event
tests/test_manager.py
6394 6394

  
6395 6395
    # recurrences corresponding to exceptions have not been created
6396 6396
    assert Event.objects.count() == 24
6397

  
6398

  
6399
def test_recurring_events_exceptions_report(settings, app, admin_user, freezer):
6400
    freezer.move_to('2021-07-01 12:10')
6401
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
6402
    event = Event.objects.create(
6403
        start_datetime=now(),
6404
        places=10,
6405
        repeat='daily',
6406
        recurrence_end_date=now() + datetime.timedelta(days=30),
6407
        agenda=agenda,
6408
    )
6409
    event.create_all_recurrences()
6410

  
6411
    app = login(app)
6412
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
6413
    assert len(resp.pyquery.find('.event-info')) == 30
6414

  
6415
    time_period_exception = TimePeriodException.objects.create(
6416
        desk=agenda.desk_set.get(),
6417
        start_datetime=datetime.date(year=2021, month=7, day=5),
6418
        end_datetime=datetime.date(year=2021, month=7, day=10),
6419
    )
6420
    call_command('update_event_recurrences')
6421

  
6422
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
6423
    assert len(resp.pyquery.find('.event-info')) == 25
6424

  
6425
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
6426
    assert not 'warningnotice' in resp.text
6427

  
6428
    event = Event.objects.get(start_datetime__day=11)
6429
    booking = Booking.objects.create(event=event)
6430
    time_period_exception.end_datetime = datetime.date(year=2021, month=7, day=12)
6431
    time_period_exception.save()
6432
    call_command('update_event_recurrences')
6433

  
6434
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
6435
    assert len(resp.pyquery.find('.event-info')) == 24
6436

  
6437
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
6438
    assert 'warningnotice' in resp.text
6439
    assert 'July 11, 2021, 2:10 p.m.' in resp.text
6440

  
6441
    booking.cancel()
6442
    call_command('update_event_recurrences')
6443

  
6444
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
6445
    assert len(resp.pyquery.find('.event-info')) == 23
6446

  
6447
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
6448
    assert not 'warningnotice' in resp.text
6397
-