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