0002-agendas-add-booking-reminder-mechanisms-45293.patch
chrono/agendas/management/commands/send_booking_reminders.py | ||
---|---|---|
1 |
# chrono - agendas system |
|
2 |
# Copyright (C) 2020 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 datetime import datetime, timedelta |
|
18 |
from urllib.parse import urljoin |
|
19 |
from requests import RequestException |
|
20 |
from smtplib import SMTPException |
|
21 | ||
22 |
from django.conf import settings |
|
23 |
from django.core.mail import send_mail |
|
24 |
from django.core.management.base import BaseCommand |
|
25 |
from django.db.models import F |
|
26 |
from django.db.transaction import atomic |
|
27 |
from django.template.loader import render_to_string |
|
28 |
from django.utils import timezone, translation |
|
29 |
from django.utils.translation import ugettext_lazy as _ |
|
30 | ||
31 |
from chrono.agendas.models import Agenda, Booking |
|
32 |
from chrono.utils.requests_wrapper import requests |
|
33 | ||
34 |
SENDING_IN_PROGRESS = datetime(year=2, month=1, day=1) |
|
35 | ||
36 | ||
37 |
class Command(BaseCommand): |
|
38 |
help = 'Send booking reminders' |
|
39 | ||
40 |
def handle(self, **options): |
|
41 |
translation.activate(settings.LANGUAGE_CODE) |
|
42 | ||
43 |
reminder_delta = F('event__agenda__reminder_settings__days') * timedelta(1) |
|
44 |
starts_before = timezone.now() + reminder_delta |
|
45 |
# 12 hours time window to run the command and send reminder, thus excluding old events |
|
46 |
starts_after = timezone.now() + reminder_delta - timedelta(hours=12) |
|
47 |
# prevent user who just booked from getting a reminder |
|
48 |
created_before = timezone.now() - timedelta(hours=12) |
|
49 | ||
50 |
bookings = Booking.objects.filter( |
|
51 |
event__agenda__reminder_settings__days__isnull=False, # useless ? |
|
52 |
cancellation_datetime__isnull=True, |
|
53 |
creation_datetime__lte=created_before, |
|
54 |
reminder_datetime__isnull=True, |
|
55 |
event__start_datetime__lte=starts_before, |
|
56 |
event__start_datetime__gte=starts_after, |
|
57 |
).select_related('event', 'event__agenda', 'event__agenda__reminder_settings') |
|
58 | ||
59 |
bookings_list = list(bookings) |
|
60 |
bookings_pk = list(bookings.values_list('pk', flat=True)) |
|
61 |
bookings.update(reminder_datetime=SENDING_IN_PROGRESS) |
|
62 | ||
63 |
try: |
|
64 |
for booking in bookings_list: |
|
65 |
self.send_reminder(booking) |
|
66 |
finally: |
|
67 |
Booking.objects.filter(pk__in=bookings_pk, reminder_datetime__lte=SENDING_IN_PROGRESS).update( |
|
68 |
reminder_datetime=None |
|
69 |
) |
|
70 | ||
71 |
def send_reminder(self, booking): |
|
72 |
agenda = booking.event.agenda |
|
73 |
kind = agenda.kind |
|
74 |
days = agenda.reminder_settings.days |
|
75 | ||
76 |
ctx = { |
|
77 |
'event': booking.event, |
|
78 |
'meeting': booking.user_display_label, |
|
79 |
'form_url': booking.form_url, |
|
80 |
'in_x_days': _('tomorrow') if days == 1 else _('in %s days') % days, |
|
81 |
'time': booking.event.start_datetime.strftime('%H:%M'), |
|
82 |
'date': booking.event.start_datetime.date().strftime('%A %d %B'), |
|
83 |
'date_short': booking.event.start_datetime.date().strftime('%d/%m'), |
|
84 |
'email_extra_info': agenda.reminder_settings.email_extra_info, |
|
85 |
'sms_extra_info': agenda.reminder_settings.sms_extra_info, |
|
86 |
} |
|
87 |
ctx.update(getattr(settings, 'TEMPLATE_VARS', {})) |
|
88 |
if booking.form_url: |
|
89 |
ctx['form_url'] = urljoin(settings.SITE_BASE_URL, booking.form_url) |
|
90 | ||
91 |
if agenda.reminder_settings.send_email: |
|
92 |
self.send_email(booking, kind, ctx) |
|
93 |
if agenda.reminder_settings.send_sms: |
|
94 |
self.send_sms(booking, kind, ctx) |
|
95 | ||
96 |
@staticmethod |
|
97 |
def send_email(booking, kind, ctx): |
|
98 |
if not booking.user_email: |
|
99 |
return |
|
100 | ||
101 |
subject = render_to_string('agendas/%s_reminder_subject.txt' % kind, ctx).strip() |
|
102 |
body = render_to_string('agendas/%s_reminder_body.txt' % kind, ctx) |
|
103 |
html_body = render_to_string('agendas/%s_reminder_body.html' % kind, ctx) |
|
104 |
try: |
|
105 |
with atomic(): |
|
106 |
send_mail( |
|
107 |
subject, body, settings.DEFAULT_FROM_EMAIL, [booking.user_email], html_message=html_body |
|
108 |
) |
|
109 |
booking.reminder_datetime = timezone.now() |
|
110 |
booking.save() |
|
111 |
except SMTPException: |
|
112 |
pass |
|
113 | ||
114 |
@staticmethod |
|
115 |
def send_sms(booking, kind, ctx): |
|
116 |
if not booking.user_phone_number: |
|
117 |
return |
|
118 | ||
119 |
sms_url = getattr(settings, 'SMS_URL', '') |
|
120 |
if not sms_url: |
|
121 |
return |
|
122 |
sms_from = settings.SMS_FROM |
|
123 | ||
124 |
message = render_to_string('agendas/%s_reminder_message.txt' % kind, ctx).strip() |
|
125 |
payload = { |
|
126 |
'message': message, |
|
127 |
'from': settings.SMS_FROM, |
|
128 |
'to': [booking.user_phone_number], |
|
129 |
} |
|
130 | ||
131 |
try: |
|
132 |
with atomic(): |
|
133 |
request = requests.post(sms_url, json=payload, remote_service='auto', timeout=10) |
|
134 |
request.raise_for_status() |
|
135 |
booking.reminder_datetime = timezone.now() |
|
136 |
booking.save() |
|
137 |
except RequestException: |
|
138 |
pass |
chrono/agendas/migrations/0062_auto_20200915_1401.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2020-09-15 12:01 |
|
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', '0061_auto_20200909_1752'), |
|
13 |
] |
|
14 | ||
15 |
operations = [ |
|
16 |
migrations.CreateModel( |
|
17 |
name='AgendaReminderSettings', |
|
18 |
fields=[ |
|
19 |
( |
|
20 |
'id', |
|
21 |
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), |
|
22 |
), |
|
23 |
( |
|
24 |
'days', |
|
25 |
models.IntegerField( |
|
26 |
blank=True, |
|
27 |
choices=[ |
|
28 |
(None, 'Never'), |
|
29 |
(1, 'One day before'), |
|
30 |
(2, 'Two days before'), |
|
31 |
(3, 'Three days before'), |
|
32 |
], |
|
33 |
null=True, |
|
34 |
verbose_name='Send reminder', |
|
35 |
), |
|
36 |
), |
|
37 |
('send_email', models.BooleanField(default=False, verbose_name='Notify by email')), |
|
38 |
( |
|
39 |
'email_extra_info', |
|
40 |
models.TextField( |
|
41 |
blank=True, |
|
42 |
help_text='Basic information such as event name, time and date are already included', |
|
43 |
verbose_name='Additional text to incude in emails', |
|
44 |
), |
|
45 |
), |
|
46 |
('send_sms', models.BooleanField(default=False, verbose_name='Notify by SMS')), |
|
47 |
( |
|
48 |
'sms_extra_info', |
|
49 |
models.TextField( |
|
50 |
blank=True, |
|
51 |
help_text='Basic information such as event name, time and date are already included', |
|
52 |
verbose_name='Additional text to incude in SMS', |
|
53 |
), |
|
54 |
), |
|
55 |
( |
|
56 |
'agenda', |
|
57 |
models.OneToOneField( |
|
58 |
on_delete=django.db.models.deletion.CASCADE, |
|
59 |
related_name='reminder_settings', |
|
60 |
to='agendas.Agenda', |
|
61 |
), |
|
62 |
), |
|
63 |
], |
|
64 |
), |
|
65 |
migrations.AddField( |
|
66 |
model_name='booking', name='reminder_datetime', field=models.DateTimeField(null=True), |
|
67 |
), |
|
68 |
] |
chrono/agendas/models.py | ||
---|---|---|
43 | 43 |
from django.utils.module_loading import import_string |
44 | 44 |
from django.utils.text import slugify |
45 | 45 |
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware |
46 |
from django.utils.translation import ugettext_lazy as _, ugettext |
|
46 |
from django.utils.translation import ugettext_lazy as _, ugettext, ungettext
|
|
47 | 47 | |
48 | 48 |
from jsonfield import JSONField |
49 | 49 | |
... | ... | |
286 | 286 |
}, |
287 | 287 |
'resources': [x.slug for x in self.resources.all()], |
288 | 288 |
} |
289 |
if hasattr(self, 'reminder_settings'): |
|
290 |
agenda['reminder_settings'] = self.reminder_settings.export_json() |
|
289 | 291 |
if self.kind == 'events': |
290 | 292 |
agenda['events'] = [x.export_json() for x in self.event_set.all()] |
291 | 293 |
if hasattr(self, 'notifications_settings'): |
... | ... | |
302 | 304 |
def import_json(cls, data, overwrite=False): |
303 | 305 |
data = data.copy() |
304 | 306 |
permissions = data.pop('permissions') or {} |
307 |
reminder_settings = data.pop('reminder_settings', None) |
|
305 | 308 |
if data['kind'] == 'events': |
306 | 309 |
events = data.pop('events') |
307 | 310 |
notifications_settings = data.pop('notifications_settings', None) |
... | ... | |
329 | 332 |
if not created: |
330 | 333 |
for k, v in data.items(): |
331 | 334 |
setattr(agenda, k, v) |
335 |
if overwrite: |
|
336 |
AgendaReminderSettings.objects.filter(agenda=agenda).delete() |
|
337 |
if reminder_settings: |
|
338 |
reminder_settings['agenda'] = agenda |
|
339 |
AgendaReminderSettings.import_json(reminder_settings).save() |
|
332 | 340 |
if data['kind'] == 'events': |
333 | 341 |
if overwrite: |
334 | 342 |
Event.objects.filter(agenda=agenda).delete() |
... | ... | |
993 | 1001 |
event = models.ForeignKey(Event, on_delete=models.CASCADE) |
994 | 1002 |
extra_data = JSONField(null=True) |
995 | 1003 |
cancellation_datetime = models.DateTimeField(null=True) |
1004 |
reminder_datetime = models.DateTimeField(null=True) |
|
996 | 1005 |
in_waiting_list = models.BooleanField(default=False) |
997 | 1006 |
creation_datetime = models.DateTimeField(auto_now_add=True) |
998 | 1007 |
# primary booking is used to group multiple bookings together |
... | ... | |
1676 | 1685 |
'cancelled_event': self.cancelled_event, |
1677 | 1686 |
'cancelled_event_emails': self.cancelled_event_emails, |
1678 | 1687 |
} |
1688 | ||
1689 | ||
1690 |
class AgendaReminderSettings(models.Model): |
|
1691 |
ONE_DAY_BEFORE = 1 |
|
1692 |
TWO_DAYS_BEFORE = 2 |
|
1693 |
THREE_DAYS_BEFORE = 3 |
|
1694 | ||
1695 |
CHOICES = [ |
|
1696 |
(None, _('Never')), |
|
1697 |
(ONE_DAY_BEFORE, _('One day before')), |
|
1698 |
(TWO_DAYS_BEFORE, _('Two days before')), |
|
1699 |
(THREE_DAYS_BEFORE, _('Three days before')), |
|
1700 |
] |
|
1701 | ||
1702 |
agenda = models.OneToOneField(Agenda, on_delete=models.CASCADE, related_name='reminder_settings') |
|
1703 |
days = models.IntegerField(null=True, blank=True, choices=CHOICES, verbose_name=_('Send reminder')) |
|
1704 |
send_email = models.BooleanField(default=False, verbose_name=_('Notify by email')) |
|
1705 |
email_extra_info = models.TextField( |
|
1706 |
blank=True, |
|
1707 |
verbose_name=_('Additional text to incude in emails'), |
|
1708 |
help_text=_('Basic information such as event name, time and date are already included'), |
|
1709 |
) |
|
1710 |
send_sms = models.BooleanField(default=False, verbose_name=_('Notify by SMS')) |
|
1711 |
sms_extra_info = models.TextField( |
|
1712 |
blank=True, |
|
1713 |
verbose_name=_('Additional text to incude in SMS'), |
|
1714 |
help_text=_('Basic information such as event name, time and date are already included'), |
|
1715 |
) |
|
1716 | ||
1717 |
def display_info(self): |
|
1718 |
message = ungettext( |
|
1719 |
'Users will be reminded of their booking %(by_email_or_sms)s, one day in advance.', |
|
1720 |
'Users will be reminded of their booking %(by_email_or_sms)s, %(days)s days in advance.', |
|
1721 |
self.days, |
|
1722 |
) |
|
1723 | ||
1724 |
if self.send_sms and self.send_email: |
|
1725 |
by = _('both by email and by SMS') |
|
1726 |
elif self.send_sms: |
|
1727 |
by = _('by SMS') |
|
1728 |
elif self.send_email: |
|
1729 |
by = _('by email') |
|
1730 | ||
1731 |
return message % {'days': self.days, 'by_email_or_sms': by} |
|
1732 | ||
1733 |
@classmethod |
|
1734 |
def import_json(cls, data): |
|
1735 |
data = clean_import_data(cls, data) |
|
1736 |
return cls(**data) |
|
1737 | ||
1738 |
def export_json(self): |
|
1739 |
return { |
|
1740 |
'days': self.days, |
|
1741 |
'send_email': self.send_email, |
|
1742 |
'email_extra_info': self.email_extra_info, |
|
1743 |
'send_sms': self.send_sms, |
|
1744 |
'sms_extra_info': self.sms_extra_info, |
|
1745 |
} |
chrono/agendas/templates/agendas/events_reminder_body.html | ||
---|---|---|
1 |
{% extends "emails/body_base.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block content %} |
|
5 |
<p>{% trans "Hi," %}</p> |
|
6 | ||
7 |
<p> |
|
8 |
{% blocktrans trimmed %} |
|
9 |
You have a booking for event "{{ event }}", on {{ date }} at {{ time }}. |
|
10 |
{% endblocktrans %} |
|
11 |
</p> |
|
12 | ||
13 |
{% if email_extra_info %} |
|
14 |
<p>{{ email_extra_info }}</p> |
|
15 |
{% endif %} |
|
16 | ||
17 |
{% if form_url %} |
|
18 |
{% with _("Edit or cancel booking") as button_label %} |
|
19 |
{% include "emails/button-link.html" with url=form_url label=button_label %} |
|
20 |
{% endwith %} |
|
21 |
{% endif %} |
|
22 |
{% endblock %} |
chrono/agendas/templates/agendas/events_reminder_body.txt | ||
---|---|---|
1 |
{% extends "emails/body_base.txt" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block content %}{% autoescape off %}{% blocktrans %}Hi, |
|
5 | ||
6 |
You have a booking for event "{{ event }}", on {{ date }} at {{ time }}.{% endblocktrans %} |
|
7 |
{% if email_extra_info %} |
|
8 |
{{ email_extra_info }} |
|
9 |
{% endif %} |
|
10 |
{% if form_url %} |
|
11 |
{% trans "If in need to cancel it, you can do so here:" %} {{ form_url }} |
|
12 |
{% endif %} |
|
13 |
{% endautoescape %} |
|
14 |
{% endblock %} |
chrono/agendas/templates/agendas/events_reminder_message.txt | ||
---|---|---|
1 |
{% load i18n %} |
|
2 | ||
3 |
{% blocktrans %}Reminder: you have a booking for event "{{ event }}", on {{ date_short }} at {{ time }}.{% endblocktrans %}{% if sms_extra_info %} {{ sms_extra_info }}{% endif %} |
chrono/agendas/templates/agendas/events_reminder_subject.txt | ||
---|---|---|
1 |
{% extends "emails/subject.txt" %} |
|
2 |
{% block email-subject %}{% autoescape off %}Reminder for your booking {{ in_x_days }} at {{ time }}{% endautoescape %}{% endblock %} |
|
3 |
chrono/agendas/templates/agendas/meetings_reminder_body.html | ||
---|---|---|
1 |
{% extends "emails/body_base.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block content %} |
|
5 |
<p>{% trans "Hi," %}</p> |
|
6 | ||
7 |
<p> |
|
8 |
{% if meeting %} |
|
9 |
{% blocktrans %}Your meeting "{{ meeting }}" is scheduled {{ in_x_days }} at {{ time }}.{% endblocktrans %} |
|
10 |
{% else %} |
|
11 |
{% blocktrans %}You have a meeting scheduled on {{ date }} at {{ time }}.{% endblocktrans %} |
|
12 |
{% endif %} |
|
13 |
</p> |
|
14 | ||
15 |
{% if email_extra_info %} |
|
16 |
<p>{{ email_extra_info }}</p> |
|
17 |
{% endif %} |
|
18 | ||
19 |
{% if form_url %} |
|
20 |
{% with _("Edit or cancel meeting") as button_label %} |
|
21 |
{% include "emails/button-link.html" with url=form_url label=button_label %} |
|
22 |
{% endwith %} |
|
23 |
{% endif %} |
|
24 |
{% endblock %} |
chrono/agendas/templates/agendas/meetings_reminder_body.txt | ||
---|---|---|
1 |
{% extends "emails/body_base.txt" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block content %}{% autoescape off %}{% trans "Hi," %} |
|
5 | ||
6 |
{% if meeting %} |
|
7 |
{% blocktrans %}Your meeting "{{ meeting }}" is scheduled on {{ date }} at {{ time }}.{% endblocktrans %} |
|
8 |
{% else %} |
|
9 |
{% blocktrans %}You have a meeting scheduled on {{ date }} at {{ time }}.{% endblocktrans %} |
|
10 |
{% endif %} |
|
11 | ||
12 |
{% if email_extra_info %}{{ email_extra_info }}{% endif %} |
|
13 | ||
14 |
{% if form_url %} |
|
15 |
{% trans "If in need to cancel it, you can do so here:" %} {{ form_url }} |
|
16 |
{% endif %} |
|
17 |
{% endautoescape %} |
|
18 |
{% endblock %} |
chrono/agendas/templates/agendas/meetings_reminder_message.txt | ||
---|---|---|
1 |
{% load i18n %} |
|
2 | ||
3 |
{% if label %}{% blocktrans %}Reminder: your meeting "{{ meeting }}" is scheduled on {{ date_short }} at {{ time }}.{% endblocktrans %}{% else %}{% blocktrans %}Reminder: you have a meeting scheduled on {{ date_short }} at {{ time }}.{% endblocktrans %}{% endif %}{% if sms_extra_info %} {{ sms_extra_info }}{% endif %} |
chrono/agendas/templates/agendas/meetings_reminder_subject.txt | ||
---|---|---|
1 |
{% extends "emails/subject.txt" %} |
|
2 |
{% block email-subject %}{% autoescape off %}Reminder for your meeting {{ in_x_days }} at {{ time }}{% endautoescape %}{% endblock %} |
|
3 |
chrono/manager/forms.py | ||
---|---|---|
20 | 20 |
import datetime |
21 | 21 | |
22 | 22 |
from django import forms |
23 |
from django.conf import settings |
|
23 | 24 |
from django.contrib.auth.models import Group |
24 | 25 |
from django.core.exceptions import FieldDoesNotExist |
25 | 26 |
from django.forms import ValidationError |
... | ... | |
41 | 42 |
Resource, |
42 | 43 |
Category, |
43 | 44 |
AgendaNotificationsSettings, |
45 |
AgendaReminderSettings, |
|
44 | 46 |
WEEKDAYS_LIST, |
45 | 47 |
) |
46 | 48 | |
... | ... | |
526 | 528 |
self.fields[email_field].widget.attrs['size'] = 80 |
527 | 529 |
self.fields[email_field].label = '' |
528 | 530 |
self.fields[email_field].help_text = _('Enter a comma separated list of email addresses.') |
531 | ||
532 | ||
533 |
class AgendaReminderForm(forms.ModelForm): |
|
534 |
class Meta: |
|
535 |
model = AgendaReminderSettings |
|
536 |
exclude = ['agenda'] |
|
537 | ||
538 |
def clean(self): |
|
539 |
cleaned_data = super().clean() |
|
540 |
if cleaned_data['days'] and not (cleaned_data['send_sms'] or cleaned_data['send_email']): |
|
541 |
raise ValidationError(_('Select at least one notification medium.')) |
|
542 | ||
543 |
if cleaned_data['send_sms'] and not hasattr(settings, 'SMS_URL'): |
|
544 |
raise ValidationError(_('SMS are unavailable on this instance.')) |
|
545 |
return cleaned_data |
chrono/manager/templates/chrono/manager_agenda_reminder_form.html | ||
---|---|---|
1 |
{% extends "chrono/manager_agenda_view.html" %} |
|
2 |
{% load i18n %} |
|
3 | ||
4 |
{% block breadcrumb %} |
|
5 |
{{ block.super }} |
|
6 |
<a href="">{% trans "Reminder settings" %}</a> |
|
7 |
{% endblock %} |
|
8 | ||
9 |
{% block appbar %} |
|
10 |
<h2>{% trans "Reminder settings" %}</h2> |
|
11 |
{% endblock %} |
|
12 | ||
13 |
{% block content %} |
|
14 |
<form method="post" enctype="multipart/form-data"> |
|
15 |
{% csrf_token %} |
|
16 |
{{ form.as_p }} |
|
17 |
<div class="buttons"> |
|
18 |
<button class="submit-button">{% trans "Save" %}</button> |
|
19 |
<a class="cancel" href="{% url 'chrono-manager-agenda-settings' pk=agenda.id %}">{% trans 'Cancel' %}</a> |
|
20 |
</div> |
|
21 |
</form> |
|
22 |
{% endblock %} |
chrono/manager/templates/chrono/manager_agenda_settings.html | ||
---|---|---|
34 | 34 |
{% block agenda-settings %} |
35 | 35 |
{% endblock %} |
36 | 36 | |
37 |
{% block agenda-reminder %} |
|
38 |
<div class="section"> |
|
39 |
<h3>{% trans "Booking reminders" %}</h3> |
|
40 |
<div> |
|
41 |
<p> |
|
42 |
{% if not agenda.reminder_settings or not agenda.reminder_settings.days %} |
|
43 |
{% trans "Reminders are disabled for this agenda." %} |
|
44 |
{% else %} |
|
45 |
{{ agenda.reminder_settings.display_info }} |
|
46 |
{% endif %} |
|
47 |
</p> |
|
48 |
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-reminder-settings' pk=object.id %}">{% trans "Configure" %}</a> |
|
49 |
</div> |
|
50 |
</div> |
|
51 |
{% endblock %} |
|
52 | ||
37 | 53 |
{% block agenda-permissions %} |
38 | 54 |
<div class="section"> |
39 | 55 |
<h3>{% trans "Permissions" %}</h3> |
chrono/manager/templates/chrono/manager_virtual_agenda_settings.html | ||
---|---|---|
76 | 76 |
</div> |
77 | 77 |
{% endif %} |
78 | 78 | |
79 |
{% block agenda-reminder %} |
|
80 |
{% endblock %} |
|
79 | 81 |
{% endblock %} |
chrono/manager/urls.py | ||
---|---|---|
78 | 78 |
views.agenda_notifications_settings, |
79 | 79 |
name='chrono-manager-agenda-notifications-settings', |
80 | 80 |
), |
81 |
url( |
|
82 |
r'^agendas/(?P<pk>\d+)/reminder$', |
|
83 |
views.agenda_reminder_settings, |
|
84 |
name='chrono-manager-agenda-reminder-settings', |
|
85 |
), |
|
81 | 86 |
url( |
82 | 87 |
r'^agendas/(?P<pk>\d+)/events/(?P<event_pk>\d+)/$', |
83 | 88 |
views.event_view, |
chrono/manager/views.py | ||
---|---|---|
64 | 64 |
Category, |
65 | 65 |
EventCancellationReport, |
66 | 66 |
AgendaNotificationsSettings, |
67 |
AgendaReminderSettings, |
|
67 | 68 |
) |
68 | 69 | |
69 | 70 |
from .forms import ( |
... | ... | |
92 | 93 |
BookingCancelForm, |
93 | 94 |
EventCancelForm, |
94 | 95 |
AgendaNotificationsForm, |
96 |
AgendaReminderForm, |
|
95 | 97 |
) |
96 | 98 |
from .utils import import_site |
97 | 99 | |
... | ... | |
1371 | 1373 |
agenda_notifications_settings = AgendaNotificationsSettingsView.as_view() |
1372 | 1374 | |
1373 | 1375 | |
1376 |
class AgendaReminderSettingsView(ManagedAgendaMixin, UpdateView): |
|
1377 |
template_name = 'chrono/manager_agenda_reminder_form.html' |
|
1378 |
model = AgendaReminderSettings |
|
1379 |
form_class = AgendaReminderForm |
|
1380 | ||
1381 |
def get_object(self): |
|
1382 |
try: |
|
1383 |
return self.agenda.reminder_settings |
|
1384 |
except AgendaReminderSettings.DoesNotExist: |
|
1385 |
return AgendaReminderSettings.objects.create(agenda=self.agenda) |
|
1386 | ||
1387 | ||
1388 |
agenda_reminder_settings = AgendaReminderSettingsView.as_view() |
|
1389 | ||
1390 | ||
1374 | 1391 |
class EventDetailView(ViewableAgendaMixin, DetailView): |
1375 | 1392 |
model = Event |
1376 | 1393 |
pk_url_kwarg = 'event_pk' |
debian/chrono.cron.hourly | ||
---|---|---|
2 | 2 | |
3 | 3 |
/sbin/runuser -u chrono /usr/bin/chrono-manage -- tenant_command clearsessions --all-tenants |
4 | 4 |
/sbin/runuser -u chrono /usr/bin/chrono-manage -- tenant_command sync_desks_timeperiod_exceptions --all-tenants |
5 |
/sbin/runuser -u chrono /usr/bin/chrono-manage -- tenant_command send_booking_reminders --all-tenants |
tests/test_agendas.py | ||
---|---|---|
1 | 1 |
import pytest |
2 | 2 |
import datetime |
3 |
import json |
|
3 | 4 |
import mock |
4 | 5 |
import requests |
6 |
import smtplib |
|
5 | 7 | |
6 | 8 | |
7 | 9 |
from django.contrib.auth.models import Group, User |
... | ... | |
25 | 27 |
VirtualMember, |
26 | 28 |
EventCancellationReport, |
27 | 29 |
AgendaNotificationsSettings, |
30 |
AgendaReminderSettings, |
|
28 | 31 |
) |
29 | 32 | |
30 | 33 |
pytestmark = pytest.mark.django_db |
... | ... | |
1238 | 1241 |
# no new email on subsequent run |
1239 | 1242 |
call_command('send_email_notifications') |
1240 | 1243 |
assert len(mailoutbox) == 1 |
1244 | ||
1245 | ||
1246 |
def test_agenda_reminders(mailoutbox, freezer): |
|
1247 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
1248 | ||
1249 |
# add some old event with booking |
|
1250 |
freezer.move_to('2019-01-01') |
|
1251 |
old_event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event') |
|
1252 |
Booking.objects.create(event=old_event, user_email='old@test.org') |
|
1253 | ||
1254 |
# no reminder configured |
|
1255 |
call_command('send_booking_reminders') |
|
1256 |
assert len(mailoutbox) == 0 |
|
1257 | ||
1258 |
# move to present day |
|
1259 |
freezer.move_to('2020-01-01 14:00') |
|
1260 |
# configure reminder the day before |
|
1261 |
AgendaReminderSettings.objects.create(agenda=agenda, days=1, send_email=True) |
|
1262 |
# event starts in 2 days |
|
1263 |
start_datetime = now() + datetime.timedelta(days=2) |
|
1264 |
event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') |
|
1265 | ||
1266 |
for i in range(5): |
|
1267 |
booking = Booking.objects.create(event=event, user_email='t@test.org') |
|
1268 |
# extra booking with no email, should be ignored |
|
1269 |
booking = Booking.objects.create(event=event) |
|
1270 | ||
1271 |
freezer.move_to('2020-01-02 10:00') |
|
1272 |
# not time to send reminders yet |
|
1273 |
call_command('send_booking_reminders') |
|
1274 |
assert len(mailoutbox) == 0 |
|
1275 | ||
1276 |
# one of the booking is cancelled |
|
1277 |
Booking.objects.filter(user_email='t@test.org').first().cancel() |
|
1278 | ||
1279 |
freezer.move_to('2020-01-02 15:00') |
|
1280 |
call_command('send_booking_reminders') |
|
1281 |
assert len(mailoutbox) == 4 |
|
1282 |
mailoutbox.clear() |
|
1283 | ||
1284 |
call_command('send_booking_reminders') |
|
1285 |
assert len(mailoutbox) == 0 |
|
1286 | ||
1287 |
# booking is placed the day of the event, notfication should no be sent |
|
1288 |
freezer.move_to('2020-01-03 08:00') |
|
1289 |
booking = Booking.objects.create(event=event, user_email='t@test.org') |
|
1290 |
call_command('send_booking_reminders') |
|
1291 |
assert len(mailoutbox) == 0 |
|
1292 | ||
1293 | ||
1294 |
@override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_FROM='EO') |
|
1295 |
def test_agenda_reminders_sms(freezer): |
|
1296 |
freezer.move_to('2020-01-01 14:00') |
|
1297 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
1298 |
AgendaReminderSettings.objects.create(agenda=agenda, days=1, send_sms=True) |
|
1299 |
start_datetime = now() + datetime.timedelta(days=2) |
|
1300 |
event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') |
|
1301 | ||
1302 |
for i in range(5): |
|
1303 |
booking = Booking.objects.create(event=event, user_phone_number='+336123456789') |
|
1304 |
booking = Booking.objects.create(event=event) |
|
1305 | ||
1306 |
freezer.move_to('2020-01-02 15:00') |
|
1307 |
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: |
|
1308 |
mock_response = mock.Mock(status_code=200) |
|
1309 |
mock_send.return_value = mock_response |
|
1310 |
call_command('send_booking_reminders') |
|
1311 | ||
1312 |
assert mock_send.call_count == 5 |
|
1313 |
body = json.loads(mock_send.call_args[0][0].body) |
|
1314 |
assert body['from'] == 'EO' |
|
1315 |
assert body['to'] == ['+336123456789'] |
|
1316 | ||
1317 | ||
1318 |
@override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_FROM='EO') |
|
1319 |
def test_agenda_reminders_retry(freezer): |
|
1320 |
freezer.move_to('2020-01-01 14:00') |
|
1321 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
1322 |
settings = AgendaReminderSettings.objects.create(agenda=agenda, days=1) |
|
1323 |
start_datetime = now() + datetime.timedelta(days=2) |
|
1324 |
event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') |
|
1325 | ||
1326 |
settings.send_email = True |
|
1327 |
settings.save() |
|
1328 |
booking = Booking.objects.create(event=event, user_email='t@test.org') |
|
1329 |
freezer.move_to('2020-01-02 15:00') |
|
1330 | ||
1331 |
def send_mail_error(*args, **kwargs): |
|
1332 |
raise smtplib.SMTPException |
|
1333 | ||
1334 |
with mock.patch('chrono.agendas.management.commands.send_booking_reminders.send_mail') as mock_send: |
|
1335 |
mock_send.return_value = None |
|
1336 |
mock_send.side_effect = send_mail_error |
|
1337 |
call_command('send_booking_reminders') |
|
1338 |
assert mock_send.call_count == 1 |
|
1339 |
booking.refresh_from_db() |
|
1340 |
assert not booking.reminder_datetime |
|
1341 | ||
1342 |
mock_send.side_effect = None |
|
1343 |
call_command('send_booking_reminders') |
|
1344 |
assert mock_send.call_count == 2 |
|
1345 |
booking.refresh_from_db() |
|
1346 |
assert booking.reminder_datetime |
|
1347 | ||
1348 |
settings.send_email = False |
|
1349 |
settings.send_sms = True |
|
1350 |
settings.save() |
|
1351 |
freezer.move_to('2020-01-01 14:00') |
|
1352 |
booking = Booking.objects.create(event=event, user_phone_number='+336123456789') |
|
1353 |
freezer.move_to('2020-01-02 15:00') |
|
1354 | ||
1355 |
def mocked_requests_connection_error(*args, **kwargs): |
|
1356 |
raise requests.ConnectionError('unreachable') |
|
1357 | ||
1358 |
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: |
|
1359 |
mock_send.side_effect = mocked_requests_connection_error |
|
1360 |
mock_response = mock.Mock(status_code=200) |
|
1361 |
mock_send.return_value = mock_response |
|
1362 |
call_command('send_booking_reminders') |
|
1363 |
assert mock_send.call_count == 1 |
|
1364 |
booking.refresh_from_db() |
|
1365 |
assert not booking.reminder_datetime |
|
1366 | ||
1367 |
mock_send.side_effect = None |
|
1368 |
call_command('send_booking_reminders') |
|
1369 |
assert mock_send.call_count == 2 |
|
1370 |
booking.refresh_from_db() |
|
1371 |
assert booking.reminder_datetime |
|
1372 | ||
1373 |
# when both sms and email are to be sent, only one is necessary to consider reminder successful |
|
1374 |
settings.send_email = True |
|
1375 |
settings.save() |
|
1376 |
freezer.move_to('2020-01-01 14:00') |
|
1377 |
booking = Booking.objects.create(event=event, user_phone_number='+336123456789', user_email='t@test.org') |
|
1378 |
freezer.move_to('2020-01-02 15:00') |
|
1379 | ||
1380 |
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send, mock.patch( |
|
1381 |
'chrono.agendas.management.commands.send_booking_reminders.send_mail' |
|
1382 |
) as mock_send_mail: |
|
1383 |
mock_send.side_effect = mocked_requests_connection_error |
|
1384 |
mock_response = mock.Mock(status_code=200) |
|
1385 |
mock_send.return_value = mock_response |
|
1386 |
mock_send_mail.return_value = None |
|
1387 |
call_command('send_booking_reminders') |
|
1388 | ||
1389 |
assert mock_send.call_count == 1 |
|
1390 |
assert mock_send_mail.call_count == 1 |
|
1391 |
booking.refresh_from_db() |
|
1392 |
assert booking.reminder_datetime |
|
1393 | ||
1394 |
call_command('send_booking_reminders') |
|
1395 |
assert mock_send.call_count == 1 |
|
1396 |
assert mock_send_mail.call_count == 1 |
|
1397 | ||
1398 | ||
1399 |
def test_agenda_reminders_email_content(mailoutbox, freezer): |
|
1400 |
freezer.move_to('2020-01-01 14:00') |
|
1401 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
1402 |
settings = AgendaReminderSettings.objects.create( |
|
1403 |
agenda=agenda, days=1, send_email=True, email_extra_info='Do no forget ID card.' |
|
1404 |
) |
|
1405 |
start_datetime = now() + datetime.timedelta(days=2) |
|
1406 |
event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Pool party') |
|
1407 | ||
1408 |
booking = Booking.objects.create(event=event, user_email='t@test.org') |
|
1409 | ||
1410 |
freezer.move_to('2020-01-02 15:00') |
|
1411 |
call_command('send_booking_reminders') |
|
1412 | ||
1413 |
mail = mailoutbox[0] |
|
1414 |
assert mail.subject == 'Reminder for your booking tomorrow at 14:00' |
|
1415 |
mail_bodies = (mail.body, mail.alternatives[0][0]) |
|
1416 |
for body in mail_bodies: |
|
1417 |
assert 'Hi,' in body |
|
1418 |
assert 'You have a booking for event "Pool party", on Friday 03 January at 14:00.' in body |
|
1419 |
assert 'Do no forget ID card.' in body |
|
1420 |
assert not 'cancel' in body |
|
1421 |
mailoutbox.clear() |
|
1422 | ||
1423 |
freezer.move_to('2020-01-01 14:00') |
|
1424 |
booking = Booking.objects.create(event=event, user_email='t@test.org', form_url='https://example.org/') |
|
1425 |
freezer.move_to('2020-01-02 15:00') |
|
1426 |
call_command('send_booking_reminders') |
|
1427 | ||
1428 |
mail = mailoutbox[0] |
|
1429 |
assert 'If in need to cancel it, you can do so here: https://example.org/' in mail.body |
|
1430 |
assert 'Edit or cancel booking' in mail.alternatives[0][0] |
|
1431 |
assert 'href="https://example.org/"' in mail.alternatives[0][0] |
|
1432 | ||
1433 | ||
1434 |
@override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_FROM='EO') |
|
1435 |
def test_agenda_reminders_sms_content(freezer): |
|
1436 |
freezer.move_to('2020-01-01 14:00') |
|
1437 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
1438 |
AgendaReminderSettings.objects.create( |
|
1439 |
agenda=agenda, days=1, send_sms=True, sms_extra_info='Do no forget ID card.' |
|
1440 |
) |
|
1441 |
start_datetime = now() + datetime.timedelta(days=2) |
|
1442 |
event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Pool party') |
|
1443 | ||
1444 |
booking = Booking.objects.create(event=event, user_phone_number='+336123456789') |
|
1445 | ||
1446 |
freezer.move_to('2020-01-02 15:00') |
|
1447 |
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: |
|
1448 |
mock_response = mock.Mock(status_code=200) |
|
1449 |
mock_send.return_value = mock_response |
|
1450 |
call_command('send_booking_reminders') |
|
1451 | ||
1452 |
body = json.loads(mock_send.call_args[0][0].body) |
|
1453 |
assert ( |
|
1454 |
body['message'] |
|
1455 |
== 'Reminder: you have a booking for event "Pool party", on 03/01 at 14:00. Do no forget ID card.' |
|
1456 |
) |
|
1457 | ||
1458 | ||
1459 |
def test_agenda_reminders_meetings(mailoutbox, freezer): |
|
1460 |
freezer.move_to('2020-01-01 11:00') |
|
1461 |
agenda = Agenda.objects.create(label='Events', kind='meetings') |
|
1462 |
desk = Desk.objects.create(agenda=agenda, label='Desk') |
|
1463 |
meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=30) |
|
1464 |
timeperiod = TimePeriod.objects.create( |
|
1465 |
desk=desk, weekday=now().weekday(), start_time=datetime.time(10, 0), end_time=datetime.time(18, 0) |
|
1466 |
) |
|
1467 |
AgendaReminderSettings.objects.create(agenda=agenda, days=2, send_email=True) |
|
1468 | ||
1469 |
event = Event.objects.create( |
|
1470 |
agenda=agenda, |
|
1471 |
places=1, |
|
1472 |
desk=desk, |
|
1473 |
meeting_type=meetingtype, |
|
1474 |
start_datetime=now() + datetime.timedelta(days=5), # 06/01 |
|
1475 |
) |
|
1476 |
Booking.objects.create(event=event, user_email='t@test.org', user_display_label='Birth certificate') |
|
1477 | ||
1478 |
freezer.move_to('2020-01-04 15:00') |
|
1479 |
call_command('send_booking_reminders') |
|
1480 |
assert len(mailoutbox) == 1 |
|
1481 | ||
1482 |
mail = mailoutbox[0] |
|
1483 |
assert mail.subject == 'Reminder for your meeting in 2 days at 11:00' |
|
1484 |
assert 'Your meeting "Birth certificate" is scheduled on Monday 06 January at 11:00.' in mail.body |
tests/test_import_export.py | ||
---|---|---|
29 | 29 |
MeetingType, |
30 | 30 |
VirtualMember, |
31 | 31 |
AgendaNotificationsSettings, |
32 |
AgendaReminderSettings, |
|
32 | 33 |
) |
33 | 34 |
from chrono.manager.utils import import_site |
34 | 35 | |
... | ... | |
483 | 484 |
cancelled_event=AgendaNotificationsSettings.EMAIL_FIELD, |
484 | 485 |
cancelled_event_emails=['hop@entrouvert.com', 'top@entrouvert.com'], |
485 | 486 |
) |
487 | ||
488 | ||
489 |
def test_import_export_reminder_settings(): |
|
490 |
agenda = Agenda.objects.create(label='Foo bar', kind='events') |
|
491 |
settings = AgendaReminderSettings.objects.create( |
|
492 |
agenda=agenda, days=2, send_email=True, send_sms=False, email_extra_info='test', |
|
493 |
) |
|
494 |
output = get_output_of_command('export_site') |
|
495 |
payload = json.loads(output) |
|
496 | ||
497 |
agenda.delete() |
|
498 |
assert not AgendaReminderSettings.objects.exists() |
|
499 | ||
500 |
import_site(payload) |
|
501 |
agenda = Agenda.objects.first() |
|
502 |
AgendaReminderSettings.objects.get( |
|
503 |
agenda=agenda, days=2, send_email=True, send_sms=False, email_extra_info='test', |
|
504 |
) |
tests/test_manager.py | ||
---|---|---|
3910 | 3910 |
assert 'Notifications' in resp.text |
3911 | 3911 |
assert 'Notifications are disabled' in resp.text |
3912 | 3912 | |
3913 |
resp = resp.click('Configure') |
|
3913 |
resp = resp.click('Configure', href='notifications')
|
|
3914 | 3914 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
3915 | 3915 |
assert 'Notifications are disabled' in resp.text |
3916 | 3916 | |
3917 |
resp = resp.click('Configure') |
|
3917 |
resp = resp.click('Configure', href='notifications')
|
|
3918 | 3918 |
resp.form['cancelled_event'] = 'use-email-field' |
3919 | 3919 |
resp = resp.form.submit().follow() |
3920 | 3920 |
assert 'Notifications are disabled' in resp.text |
3921 | 3921 | |
3922 |
resp = resp.click('Configure') |
|
3922 |
resp = resp.click('Configure', href='notifications')
|
|
3923 | 3923 |
resp.form['cancelled_event_emails'] = 'hop@entrouvert.com, top@entrouvert.com' |
3924 | 3924 |
resp.form['almost_full_event'] = 'edit-role' |
3925 | 3925 |
resp.form['full_event'] = 'view-role' |
... | ... | |
3953 | 3953 |
login(app) |
3954 | 3954 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
3955 | 3955 | |
3956 |
resp = resp.click('Configure') |
|
3956 |
resp = resp.click('Configure', href='notifications')
|
|
3957 | 3957 |
resp.form['cancelled_event'] = 'use-email-field' |
3958 | 3958 |
resp.form['cancelled_event_emails'] = 'hop@entrouvert.com' |
3959 | 3959 |
resp.form.submit() |
... | ... | |
3966 | 3966 |
# no notification is sent for old event |
3967 | 3967 |
assert len(mailoutbox) == 1 |
3968 | 3968 |
assert 'New event' in mailoutbox[0].subject |
3969 | ||
3970 | ||
3971 |
def test_manager_reminders(app, admin_user): |
|
3972 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
3973 | ||
3974 |
login(app) |
|
3975 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
3976 | ||
3977 |
assert 'Booking reminders' in resp.text |
|
3978 |
assert 'Reminders are disabled' in resp.text |
|
3979 | ||
3980 |
resp = resp.click('Configure', href='reminder') |
|
3981 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
3982 |
assert 'Reminders are disabled' in resp.text |
|
3983 | ||
3984 |
resp = resp.click('Configure', href='reminder') |
|
3985 |
resp.form['days'] = 3 |
|
3986 |
resp.form['send_email'] = True |
|
3987 |
resp.form['email_extra_info'] = 'test' |
|
3988 |
resp = resp.form.submit().follow() |
|
3989 | ||
3990 |
assert 'Users will be reminded of their booking by email, 3 days in advance.' in resp.text |
|
3991 | ||
3992 |
resp = resp.click('Configure', href='reminder') |
|
3993 |
resp.form['send_sms'] = True |
|
3994 |
resp = resp.form.submit() |
|
3995 |
assert 'SMS are unavailable on this instance.' in resp.text |
|
3996 | ||
3997 |
with override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_FROM='EO'): |
|
3998 |
resp = resp.form.submit().follow() |
|
3999 |
assert 'Users will be reminded of their booking both by email and by SMS, 3 days in advance.' in resp.text |
|
4000 | ||
4001 |
agenda = Agenda.objects.create(label='Meetings', kind='meetings') |
|
4002 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
4003 |
assert 'Booking reminders' in resp.text |
|
4004 | ||
4005 |
agenda = Agenda.objects.create(label='Virtual', kind='virtual') |
|
4006 |
resp = app.get('/manage/agendas/%s/settings' % agenda.id) |
|
4007 |
assert not 'Booking reminders' in resp.text |
|
3969 |
- |