From 04da5092807e9c15f62adb7068d2f17b974cf4f3 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 9 Feb 2021 14:09:41 +0100 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 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 0000000..1e8606c --- /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/0078_recurrenceexceptionsreport.py b/chrono/agendas/migrations/0078_recurrenceexceptionsreport.py new file mode 100644 index 0000000..0be30b2 --- /dev/null +++ b/chrono/agendas/migrations/0078_recurrenceexceptionsreport.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2021-02-18 14:41 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0077_auto_20210218_1533'), + ] + + 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 fc95f1b..8a76325 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -37,7 +37,7 @@ from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models, transaction, connection -from django.db.models import Count, Q, Case, When +from django.db.models import Count, Q, Case, When, Max from django.template import engines, Context, Template, TemplateSyntaxError, VariableDoesNotExist from django.urls import reverse from django.utils import functional @@ -611,6 +611,35 @@ class Agenda(models.Model): events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering]) 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 = [make_naive(dt) for dt in recurrences.values_list('start_datetime', flat=True)] + 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 @@ -1395,8 +1424,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) @@ -2191,6 +2228,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 d5f9037..dc12a9c 100644 --- a/chrono/manager/templates/chrono/manager_events_agenda_settings.html +++ b/chrono/manager/templates/chrono/manager_events_agenda_settings.html @@ -55,6 +55,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 %}