Projet

Général

Profil

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

Valentin Deniaud, 27 avril 2021 18:01

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 +++++++++
 .../0080_recurrenceexceptionsreport.py        | 34 ++++++++++
 chrono/agendas/models.py                      | 50 ++++++++++++++-
 .../manager_events_agenda_settings.html       | 11 ++++
 debian/uwsgi.ini                              |  1 +
 tests/manager/test_all.py                     | 52 +++++++++++++++
 tests/test_agendas.py                         | 63 +++++++++++++++++++
 7 files changed, 237 insertions(+), 3 deletions(-)
 create mode 100644 chrono/agendas/management/commands/update_event_recurrences.py
 create mode 100644 chrono/agendas/migrations/0080_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/0080_recurrenceexceptionsreport.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.29 on 2021-04-01 13:57
3
from __future__ import unicode_literals
4

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

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    dependencies = [
12
        ('agendas', '0079_create_exceptions_desks'),
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
34 34
from django.core.exceptions import FieldDoesNotExist, ValidationError
35 35
from django.core.validators import MaxValueValidator, MinValueValidator
36 36
from django.db import connection, models, transaction
37
from django.db.models import Case, Count, Q, When
37
from django.db.models import Case, Count, Max, Q, When
38 38
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist, engines
39 39
from django.urls import reverse
40 40
from django.utils import functional
......
677 677
        )
678 678
        return events
679 679

  
680
    @transaction.atomic
681
    def update_event_recurrences(self):
682
        recurring_events = self.event_set.filter(recurrence_rule__isnull=False)
683
        recurrences = self.event_set.filter(primary_event__isnull=False)
684

  
685
        # remove recurrences
686
        datetimes = []
687
        min_start = localtime(now())
688
        max_start = recurrences.aggregate(dt=Max('start_datetime'))['dt']
689
        if not max_start:
690
            return
691

  
692
        exceptions = self.get_recurrence_exceptions(min_start, max_start)
693
        for event in recurring_events:
694
            events = event.get_recurrences(min_start, max_start, exceptions=exceptions)
695
            datetimes.extend([event.start_datetime for event in events])
696

  
697
        events = recurrences.filter(start_datetime__gt=min_start).exclude(start_datetime__in=datetimes)
698
        events.filter(Q(booking__isnull=True) | Q(booking__cancellation_datetime__isnull=False)).delete()
699
        # report events that weren't deleted because they have bookings
700
        report, _ = RecurrenceExceptionsReport.objects.get_or_create(agenda=self)
701
        report.events.set(events)
702

  
703
        # add recurrences
704
        excluded_datetimes = [event.datetime_slug for event in recurrences]
705
        Event.create_events_recurrences(
706
            recurring_events.filter(recurrence_end_date__isnull=False), excluded_datetimes
707
        )
708

  
680 709
    def get_booking_form_url(self):
681 710
        if not self.booking_form_url:
682 711
            return
......
1510 1539
        ).exists()
1511 1540

  
1512 1541
    def create_all_recurrences(self, excluded_datetimes=None):
1513
        max_datetime = datetime.datetime.combine(self.recurrence_end_date, datetime.time(0, 0))
1514
        recurrences = self.get_recurrences(localtime(now()), make_aware(max_datetime), excluded_datetimes)
1542
        Event.create_events_recurrences([self], excluded_datetimes)
1543

  
1544
    @classmethod
1545
    def create_events_recurrences(cls, events, excluded_datetimes=None):
1546
        recurrences = []
1547
        for event in events:
1548
            max_datetime = datetime.datetime.combine(event.recurrence_end_date, datetime.time(0, 0))
1549
            recurrences.extend(
1550
                event.get_recurrences(localtime(now()), make_aware(max_datetime), excluded_datetimes)
1551
            )
1515 1552
        Event.objects.bulk_create(recurrences)
1516 1553

  
1517 1554
    @property
......
2313 2350
        ordering = ['-timestamp']
2314 2351

  
2315 2352

  
2353
class RecurrenceExceptionsReport(models.Model):
2354
    agenda = models.OneToOneField(
2355
        Agenda, related_name='recurrence_exceptions_report', on_delete=models.CASCADE
2356
    )
2357
    events = models.ManyToManyField(Event)
2358

  
2359

  
2316 2360
class NotificationType:
2317 2361
    def __init__(self, name, related_field, settings):
2318 2362
        self.name = name
chrono/manager/templates/chrono/manager_events_agenda_settings.html
75 75
<a rel="popup" class="button" href="{% url 'chrono-manager-desk-add-import-time-period-exceptions' pk=desk.pk %}">{% trans 'Configure' %}</a>
76 76
</h3>
77 77
<div>
78
{% if object.recurrence_exceptions_report.events.exists %}
79
<div class="warningnotice">
80
<p>{% trans "The following events exist despite exceptions because they have active bookings:" %}</p>
81
<ul>
82
{% for event in object.recurrence_exceptions_report.events.all %}
83
<li><a href="{{ event.get_absolute_view_url }}">{{ event }}{% if event.label %} - {{ event.start_datetime|date:"DATETIME_FORMAT" }}{% endif %}</a></li>
84
{% endfor %}
85
</ul>
86
<p>{% trans "You can cancel them manually for this warning to go away, or wait until they are passed." %}</p>
87
</div>
88
{% endif %}
78 89
<ul class="objects-list single-links">
79 90
{% for exception in exceptions|slice:":5" %}
80 91
   <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/manager/test_all.py
4474 4474

  
4475 4475
    # recurrences corresponding to exceptions have not been created
4476 4476
    assert Event.objects.count() == 24
4477

  
4478

  
4479
def test_recurring_events_exceptions_report(settings, app, admin_user, freezer):
4480
    freezer.move_to('2021-07-01 12:10')
4481
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
4482
    event = Event.objects.create(
4483
        start_datetime=now(),
4484
        places=10,
4485
        repeat='daily',
4486
        recurrence_end_date=now() + datetime.timedelta(days=30),
4487
        agenda=agenda,
4488
    )
4489
    event.create_all_recurrences()
4490

  
4491
    app = login(app)
4492
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
4493
    assert len(resp.pyquery.find('.event-info')) == 30
4494

  
4495
    time_period_exception = TimePeriodException.objects.create(
4496
        desk=agenda.desk_set.get(),
4497
        start_datetime=datetime.date(year=2021, month=7, day=5),
4498
        end_datetime=datetime.date(year=2021, month=7, day=10),
4499
    )
4500
    call_command('update_event_recurrences')
4501

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

  
4505
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
4506
    assert not 'warningnotice' in resp.text
4507

  
4508
    event = Event.objects.get(start_datetime__day=11)
4509
    booking = Booking.objects.create(event=event)
4510
    time_period_exception.end_datetime = datetime.date(year=2021, month=7, day=12)
4511
    time_period_exception.save()
4512
    call_command('update_event_recurrences')
4513

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

  
4517
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
4518
    assert 'warningnotice' in resp.text
4519
    assert 'July 11, 2021, 2:10 p.m.' in resp.text
4520

  
4521
    booking.cancel()
4522
    call_command('update_event_recurrences')
4523

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

  
4527
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
4528
    assert not 'warningnotice' in resp.text
tests/test_agendas.py
2084 2084
    recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
2085 2085
    assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
2086 2086
    assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06'
2087

  
2088

  
2089
def test_recurring_events_exceptions_update_recurrences(freezer):
2090
    freezer.move_to('2021-05-01 12:00')
2091
    agenda = Agenda.objects.create(label='Agenda', kind='events')
2092
    desk = Desk.objects.get(slug='_exceptions_holder', agenda=agenda)
2093

  
2094
    daily_event = Event.objects.create(
2095
        agenda=agenda,
2096
        start_datetime=now(),
2097
        repeat='daily',
2098
        places=5,
2099
        recurrence_end_date=datetime.date(year=2021, month=5, day=8),
2100
    )
2101
    weekly_event = Event.objects.create(
2102
        agenda=agenda,
2103
        start_datetime=now(),
2104
        repeat='weekly',
2105
        places=5,
2106
        recurrence_end_date=datetime.date(year=2021, month=6, day=1),
2107
    )
2108
    Event.create_events_recurrences([daily_event, weekly_event])
2109

  
2110
    daily_event_no_end_date = Event.objects.create(
2111
        agenda=agenda,
2112
        start_datetime=now() + datetime.timedelta(hours=2),
2113
        repeat='daily',
2114
        places=5,
2115
    )
2116
    daily_event_no_end_date.refresh_from_db()
2117
    # create one recurrence on 07/05
2118
    daily_event_no_end_date.get_or_create_event_recurrence(now() + datetime.timedelta(days=6, hours=2))
2119

  
2120
    assert Event.objects.filter(primary_event=daily_event).count() == 7
2121
    assert Event.objects.filter(primary_event=weekly_event).count() == 5
2122
    assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 1
2123

  
2124
    time_period_exception = TimePeriodException.objects.create(
2125
        desk=desk,
2126
        start_datetime=datetime.date(year=2021, month=5, day=5),
2127
        end_datetime=datetime.date(year=2021, month=5, day=10),
2128
    )
2129
    agenda.update_event_recurrences()
2130
    assert Event.objects.filter(primary_event=daily_event).count() == 4
2131
    assert Event.objects.filter(primary_event=weekly_event).count() == 4
2132
    assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 0
2133

  
2134
    time_period_exception.delete()
2135
    agenda.update_event_recurrences()
2136
    assert Event.objects.filter(primary_event=daily_event).count() == 7
2137
    assert Event.objects.filter(primary_event=weekly_event).count() == 5
2138
    assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 0
2139

  
2140
    event = daily_event_no_end_date.get_or_create_event_recurrence(
2141
        now() + datetime.timedelta(days=6, hours=2)
2142
    )
2143
    booking = Booking.objects.create(event=event)
2144
    time_period_exception.save()
2145

  
2146
    agenda.update_event_recurrences()
2147
    assert Booking.objects.count() == 1
2148
    assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 1
2149
    assert agenda.recurrence_exceptions_report.events.get() == event
2087
-