From c68902b967f2a9160eafc2576adb476284c59030 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 9 Feb 2021 14:09:41 +0100 Subject: [PATCH] agendas: update recurrences asynchronously (#50561) --- .../commands/update_event_recurrences.py | 29 +++++++++ .../0081_recurrenceexceptionsreport.py | 32 ++++++++++ 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, 235 insertions(+), 3 deletions(-) create mode 100644 chrono/agendas/management/commands/update_event_recurrences.py create mode 100644 chrono/agendas/migrations/0081_recurrenceexceptionsreport.py diff --git a/chrono/agendas/management/commands/update_event_recurrences.py b/chrono/agendas/management/commands/update_event_recurrences.py new file mode 100644 index 00000000..1e8606cc --- /dev/null +++ b/chrono/agendas/management/commands/update_event_recurrences.py @@ -0,0 +1,29 @@ +# chrono - agendas system +# Copyright (C) 2021 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from chrono.agendas.models import Agenda + + +class Command(BaseCommand): + help = 'Update event recurrences to reflect exceptions' + + def handle(self, **options): + agendas = Agenda.objects.filter(kind='events', event__recurrence_rule__isnull=False).distinct() + for agenda in agendas: + agenda.update_event_recurrences() diff --git a/chrono/agendas/migrations/0081_recurrenceexceptionsreport.py b/chrono/agendas/migrations/0081_recurrenceexceptionsreport.py new file mode 100644 index 00000000..9fa640a1 --- /dev/null +++ b/chrono/agendas/migrations/0081_recurrenceexceptionsreport.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.19 on 2021-04-28 15:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0080_create_exceptions_desks'), + ] + + operations = [ + migrations.CreateModel( + name='RecurrenceExceptionsReport', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'agenda', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='recurrence_exceptions_report', + to='agendas.Agenda', + ), + ), + ('events', models.ManyToManyField(to='agendas.Event')), + ], + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 147f0629..5670a358 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -34,7 +34,7 @@ from django.contrib.postgres.fields import ArrayField from django.core.exceptions import FieldDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import connection, models, transaction -from django.db.models import Case, Count, Q, When +from django.db.models import Case, Count, Max, Q, When from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist, engines from django.urls import reverse from django.utils import functional @@ -677,6 +677,35 @@ class Agenda(models.Model): ) return events + @transaction.atomic + def update_event_recurrences(self): + recurring_events = self.event_set.filter(recurrence_rule__isnull=False) + recurrences = self.event_set.filter(primary_event__isnull=False) + + # remove recurrences + datetimes = [] + min_start = localtime(now()) + max_start = recurrences.aggregate(dt=Max('start_datetime'))['dt'] + if not max_start: + return + + exceptions = self.get_recurrence_exceptions(min_start, max_start) + for event in recurring_events: + events = event.get_recurrences(min_start, max_start, exceptions=exceptions) + datetimes.extend([event.start_datetime for event in events]) + + events = recurrences.filter(start_datetime__gt=min_start).exclude(start_datetime__in=datetimes) + events.filter(Q(booking__isnull=True) | Q(booking__cancellation_datetime__isnull=False)).delete() + # report events that weren't deleted because they have bookings + report, _ = RecurrenceExceptionsReport.objects.get_or_create(agenda=self) + report.events.set(events) + + # add recurrences + excluded_datetimes = [event.datetime_slug for event in recurrences] + Event.create_events_recurrences( + recurring_events.filter(recurrence_end_date__isnull=False), excluded_datetimes + ) + def get_booking_form_url(self): if not self.booking_form_url: return @@ -1510,8 +1539,16 @@ class Event(models.Model): ).exists() def create_all_recurrences(self, excluded_datetimes=None): - max_datetime = datetime.datetime.combine(self.recurrence_end_date, datetime.time(0, 0)) - recurrences = self.get_recurrences(localtime(now()), make_aware(max_datetime), excluded_datetimes) + Event.create_events_recurrences([self], excluded_datetimes) + + @classmethod + def create_events_recurrences(cls, events, excluded_datetimes=None): + recurrences = [] + for event in events: + max_datetime = datetime.datetime.combine(event.recurrence_end_date, datetime.time(0, 0)) + recurrences.extend( + event.get_recurrences(localtime(now()), make_aware(max_datetime), excluded_datetimes) + ) Event.objects.bulk_create(recurrences) @property @@ -2313,6 +2350,13 @@ class EventCancellationReport(models.Model): ordering = ['-timestamp'] +class RecurrenceExceptionsReport(models.Model): + agenda = models.OneToOneField( + Agenda, related_name='recurrence_exceptions_report', on_delete=models.CASCADE + ) + events = models.ManyToManyField(Event) + + class NotificationType: def __init__(self, name, related_field, settings): self.name = name diff --git a/chrono/manager/templates/chrono/manager_events_agenda_settings.html b/chrono/manager/templates/chrono/manager_events_agenda_settings.html index da862b96..205c8e96 100644 --- a/chrono/manager/templates/chrono/manager_events_agenda_settings.html +++ b/chrono/manager/templates/chrono/manager_events_agenda_settings.html @@ -75,6 +75,17 @@ {% trans 'Configure' %}
+{% if object.recurrence_exceptions_report.events.exists %} +
+

{% trans "The following events exist despite exceptions because they have active bookings:" %}

+ +

{% trans "You can cancel them manually for this warning to go away, or wait until they are passed." %}

+
+{% endif %}