agendas: update recurrences asynchronously (#50561)

This commit is contained in:
Valentin Deniaud 2021-02-09 14:09:41 +01:00
parent 6aa49605cc
commit c68902b967
7 changed files with 235 additions and 3 deletions

View File

@ -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 <http://www.gnu.org/licenses/>.
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()

View File

@ -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')),
],
),
]

View File

@ -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

View File

@ -75,6 +75,17 @@
<a rel="popup" class="button" href="{% url 'chrono-manager-desk-add-import-time-period-exceptions' pk=desk.pk %}">{% trans 'Configure' %}</a>
</h3>
<div>
{% if object.recurrence_exceptions_report.events.exists %}
<div class="warningnotice">
<p>{% trans "The following events exist despite exceptions because they have active bookings:" %}</p>
<ul>
{% for event in object.recurrence_exceptions_report.events.all %}
<li><a href="{{ event.get_absolute_view_url }}">{{ event }}{% if event.label %} - {{ event.start_datetime|date:"DATETIME_FORMAT" }}{% endif %}</a></li>
{% endfor %}
</ul>
<p>{% trans "You can cancel them manually for this warning to go away, or wait until they are passed." %}</p>
</div>
{% endif %}
<ul class="objects-list single-links">
{% for exception in exceptions|slice:":5" %}
<li><a rel="popup" {% if not exception.read_only %}href="{% url 'chrono-manager-time-period-exception-edit' pk=exception.pk %}"{% endif %}>

1
debian/uwsgi.ini vendored
View File

@ -17,6 +17,7 @@ spooler-python-import = chrono.utils.spooler
# every five minutes
cron = -5 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command cancel_events --all-tenants -v0
cron = -5 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command send_email_notifications --all-tenants -v0
cron = -5 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command update_event_recurrences --all-tenants -v0
# hourly
cron = 1 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command clearsessions --all-tenants
cron = 1 -1 -1 -1 -1 /usr/bin/chrono-manage tenant_command send_booking_reminders --all-tenants

View File

@ -4474,3 +4474,55 @@ def test_recurring_events_manage_exceptions(settings, app, admin_user, freezer):
# recurrences corresponding to exceptions have not been created
assert Event.objects.count() == 24
def test_recurring_events_exceptions_report(settings, app, admin_user, freezer):
freezer.move_to('2021-07-01 12:10')
agenda = Agenda.objects.create(label='Foo bar', kind='events')
event = Event.objects.create(
start_datetime=now(),
places=10,
repeat='daily',
recurrence_end_date=now() + datetime.timedelta(days=30),
agenda=agenda,
)
event.create_all_recurrences()
app = login(app)
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
assert len(resp.pyquery.find('.event-info')) == 30
time_period_exception = TimePeriodException.objects.create(
desk=agenda.desk_set.get(),
start_datetime=datetime.date(year=2021, month=7, day=5),
end_datetime=datetime.date(year=2021, month=7, day=10),
)
call_command('update_event_recurrences')
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
assert len(resp.pyquery.find('.event-info')) == 25
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert not 'warningnotice' in resp.text
event = Event.objects.get(start_datetime__day=11)
booking = Booking.objects.create(event=event)
time_period_exception.end_datetime = datetime.date(year=2021, month=7, day=12)
time_period_exception.save()
call_command('update_event_recurrences')
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
assert len(resp.pyquery.find('.event-info')) == 24
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert 'warningnotice' in resp.text
assert 'July 11, 2021, 2:10 p.m.' in resp.text
booking.cancel()
call_command('update_event_recurrences')
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
assert len(resp.pyquery.find('.event-info')) == 23
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert not 'warningnotice' in resp.text

View File

@ -2084,3 +2084,66 @@ def test_recurring_events_exceptions(freezer):
recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7))
assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02'
assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06'
def test_recurring_events_exceptions_update_recurrences(freezer):
freezer.move_to('2021-05-01 12:00')
agenda = Agenda.objects.create(label='Agenda', kind='events')
desk = Desk.objects.get(slug='_exceptions_holder', agenda=agenda)
daily_event = Event.objects.create(
agenda=agenda,
start_datetime=now(),
repeat='daily',
places=5,
recurrence_end_date=datetime.date(year=2021, month=5, day=8),
)
weekly_event = Event.objects.create(
agenda=agenda,
start_datetime=now(),
repeat='weekly',
places=5,
recurrence_end_date=datetime.date(year=2021, month=6, day=1),
)
Event.create_events_recurrences([daily_event, weekly_event])
daily_event_no_end_date = Event.objects.create(
agenda=agenda,
start_datetime=now() + datetime.timedelta(hours=2),
repeat='daily',
places=5,
)
daily_event_no_end_date.refresh_from_db()
# create one recurrence on 07/05
daily_event_no_end_date.get_or_create_event_recurrence(now() + datetime.timedelta(days=6, hours=2))
assert Event.objects.filter(primary_event=daily_event).count() == 7
assert Event.objects.filter(primary_event=weekly_event).count() == 5
assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 1
time_period_exception = TimePeriodException.objects.create(
desk=desk,
start_datetime=datetime.date(year=2021, month=5, day=5),
end_datetime=datetime.date(year=2021, month=5, day=10),
)
agenda.update_event_recurrences()
assert Event.objects.filter(primary_event=daily_event).count() == 4
assert Event.objects.filter(primary_event=weekly_event).count() == 4
assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 0
time_period_exception.delete()
agenda.update_event_recurrences()
assert Event.objects.filter(primary_event=daily_event).count() == 7
assert Event.objects.filter(primary_event=weekly_event).count() == 5
assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 0
event = daily_event_no_end_date.get_or_create_event_recurrence(
now() + datetime.timedelta(days=6, hours=2)
)
booking = Booking.objects.create(event=event)
time_period_exception.save()
agenda.update_event_recurrences()
assert Booking.objects.count() == 1
assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 1
assert agenda.recurrence_exceptions_report.events.get() == event