0004-agendas-update-recurrences-asynchronously-50561.patch
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 |
- |