From 1565c1859c9c5196f2be824c975526c8f514da40 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 15 Sep 2020 14:05:38 +0200 Subject: [PATCH 2/3] agendas: add booking reminder mechanisms (#45293) --- .../commands/send_booking_reminders.py | 138 ++++++++++ .../migrations/0062_auto_20200915_1401.py | 68 +++++ chrono/agendas/models.py | 69 ++++- .../agendas/events_reminder_body.html | 22 ++ .../agendas/events_reminder_body.txt | 14 + .../agendas/events_reminder_message.txt | 3 + .../agendas/events_reminder_subject.txt | 3 + .../agendas/meetings_reminder_body.html | 24 ++ .../agendas/meetings_reminder_body.txt | 18 ++ .../agendas/meetings_reminder_message.txt | 3 + .../agendas/meetings_reminder_subject.txt | 3 + chrono/manager/forms.py | 17 ++ .../chrono/manager_agenda_reminder_form.html | 22 ++ .../chrono/manager_agenda_settings.html | 16 ++ .../manager_virtual_agenda_settings.html | 2 + chrono/manager/urls.py | 5 + chrono/manager/views.py | 17 ++ debian/chrono.cron.hourly | 1 + tests/test_agendas.py | 244 ++++++++++++++++++ tests/test_import_export.py | 19 ++ tests/test_manager.py | 47 +++- 21 files changed, 750 insertions(+), 5 deletions(-) create mode 100644 chrono/agendas/management/commands/send_booking_reminders.py create mode 100644 chrono/agendas/migrations/0062_auto_20200915_1401.py create mode 100644 chrono/agendas/templates/agendas/events_reminder_body.html create mode 100644 chrono/agendas/templates/agendas/events_reminder_body.txt create mode 100644 chrono/agendas/templates/agendas/events_reminder_message.txt create mode 100644 chrono/agendas/templates/agendas/events_reminder_subject.txt create mode 100644 chrono/agendas/templates/agendas/meetings_reminder_body.html create mode 100644 chrono/agendas/templates/agendas/meetings_reminder_body.txt create mode 100644 chrono/agendas/templates/agendas/meetings_reminder_message.txt create mode 100644 chrono/agendas/templates/agendas/meetings_reminder_subject.txt create mode 100644 chrono/manager/templates/chrono/manager_agenda_reminder_form.html diff --git a/chrono/agendas/management/commands/send_booking_reminders.py b/chrono/agendas/management/commands/send_booking_reminders.py new file mode 100644 index 0000000..e2a5103 --- /dev/null +++ b/chrono/agendas/management/commands/send_booking_reminders.py @@ -0,0 +1,138 @@ +# chrono - agendas system +# Copyright (C) 2020 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 datetime import datetime, timedelta +from urllib.parse import urljoin +from requests import RequestException +from smtplib import SMTPException + +from django.conf import settings +from django.core.mail import send_mail +from django.core.management.base import BaseCommand +from django.db.models import F +from django.db.transaction import atomic +from django.template.loader import render_to_string +from django.utils import timezone, translation +from django.utils.translation import ugettext_lazy as _ + +from chrono.agendas.models import Agenda, Booking +from chrono.utils.requests_wrapper import requests + +SENDING_IN_PROGRESS = datetime(year=2, month=1, day=1) + + +class Command(BaseCommand): + help = 'Send booking reminders' + + def handle(self, **options): + translation.activate(settings.LANGUAGE_CODE) + + reminder_delta = F('event__agenda__reminder_settings__days') * timedelta(1) + starts_before = timezone.now() + reminder_delta + # 12 hours time window to run the command and send reminder, thus excluding old events + starts_after = timezone.now() + reminder_delta - timedelta(hours=12) + # prevent user who just booked from getting a reminder + created_before = timezone.now() - timedelta(hours=12) + + bookings = Booking.objects.filter( + event__agenda__reminder_settings__days__isnull=False, # useless ? + cancellation_datetime__isnull=True, + creation_datetime__lte=created_before, + reminder_datetime__isnull=True, + event__start_datetime__lte=starts_before, + event__start_datetime__gte=starts_after, + ).select_related('event', 'event__agenda', 'event__agenda__reminder_settings') + + bookings_list = list(bookings) + bookings_pk = list(bookings.values_list('pk', flat=True)) + bookings.update(reminder_datetime=SENDING_IN_PROGRESS) + + try: + for booking in bookings_list: + self.send_reminder(booking) + finally: + Booking.objects.filter(pk__in=bookings_pk, reminder_datetime__lte=SENDING_IN_PROGRESS).update( + reminder_datetime=None + ) + + def send_reminder(self, booking): + agenda = booking.event.agenda + kind = agenda.kind + days = agenda.reminder_settings.days + + ctx = { + 'event': booking.event, + 'meeting': booking.user_display_label, + 'form_url': booking.form_url, + 'in_x_days': _('tomorrow') if days == 1 else _('in %s days') % days, + 'time': booking.event.start_datetime.strftime('%H:%M'), + 'date': booking.event.start_datetime.date().strftime('%A %d %B'), + 'date_short': booking.event.start_datetime.date().strftime('%d/%m'), + 'email_extra_info': agenda.reminder_settings.email_extra_info, + 'sms_extra_info': agenda.reminder_settings.sms_extra_info, + } + ctx.update(getattr(settings, 'TEMPLATE_VARS', {})) + if booking.form_url: + ctx['form_url'] = urljoin(settings.SITE_BASE_URL, booking.form_url) + + if agenda.reminder_settings.send_email: + self.send_email(booking, kind, ctx) + if agenda.reminder_settings.send_sms: + self.send_sms(booking, kind, ctx) + + @staticmethod + def send_email(booking, kind, ctx): + if not booking.user_email: + return + + subject = render_to_string('agendas/%s_reminder_subject.txt' % kind, ctx).strip() + body = render_to_string('agendas/%s_reminder_body.txt' % kind, ctx) + html_body = render_to_string('agendas/%s_reminder_body.html' % kind, ctx) + try: + with atomic(): + send_mail( + subject, body, settings.DEFAULT_FROM_EMAIL, [booking.user_email], html_message=html_body + ) + booking.reminder_datetime = timezone.now() + booking.save() + except SMTPException: + pass + + @staticmethod + def send_sms(booking, kind, ctx): + if not booking.user_phone_number: + return + + sms_url = getattr(settings, 'SMS_URL', '') + if not sms_url: + return + sms_from = settings.SMS_FROM + + message = render_to_string('agendas/%s_reminder_message.txt' % kind, ctx).strip() + payload = { + 'message': message, + 'from': settings.SMS_FROM, + 'to': [booking.user_phone_number], + } + + try: + with atomic(): + request = requests.post(sms_url, json=payload, remote_service='auto', timeout=10) + request.raise_for_status() + booking.reminder_datetime = timezone.now() + booking.save() + except RequestException: + pass diff --git a/chrono/agendas/migrations/0062_auto_20200915_1401.py b/chrono/agendas/migrations/0062_auto_20200915_1401.py new file mode 100644 index 0000000..32f00ae --- /dev/null +++ b/chrono/agendas/migrations/0062_auto_20200915_1401.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-09-15 12:01 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0061_auto_20200909_1752'), + ] + + operations = [ + migrations.CreateModel( + name='AgendaReminderSettings', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'days', + models.IntegerField( + blank=True, + choices=[ + (None, 'Never'), + (1, 'One day before'), + (2, 'Two days before'), + (3, 'Three days before'), + ], + null=True, + verbose_name='Send reminder', + ), + ), + ('send_email', models.BooleanField(default=False, verbose_name='Notify by email')), + ( + 'email_extra_info', + models.TextField( + blank=True, + help_text='Basic information such as event name, time and date are already included', + verbose_name='Additional text to incude in emails', + ), + ), + ('send_sms', models.BooleanField(default=False, verbose_name='Notify by SMS')), + ( + 'sms_extra_info', + models.TextField( + blank=True, + help_text='Basic information such as event name, time and date are already included', + verbose_name='Additional text to incude in SMS', + ), + ), + ( + 'agenda', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='reminder_settings', + to='agendas.Agenda', + ), + ), + ], + ), + migrations.AddField( + model_name='booking', name='reminder_datetime', field=models.DateTimeField(null=True), + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 07af7e3..824dcdf 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -43,7 +43,7 @@ from django.utils.formats import date_format from django.utils.module_loading import import_string from django.utils.text import slugify from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware -from django.utils.translation import ugettext_lazy as _, ugettext +from django.utils.translation import ugettext_lazy as _, ugettext, ungettext from jsonfield import JSONField @@ -286,6 +286,8 @@ class Agenda(models.Model): }, 'resources': [x.slug for x in self.resources.all()], } + if hasattr(self, 'reminder_settings'): + agenda['reminder_settings'] = self.reminder_settings.export_json() if self.kind == 'events': agenda['events'] = [x.export_json() for x in self.event_set.all()] if hasattr(self, 'notifications_settings'): @@ -302,6 +304,7 @@ class Agenda(models.Model): def import_json(cls, data, overwrite=False): data = data.copy() permissions = data.pop('permissions') or {} + reminder_settings = data.pop('reminder_settings', None) if data['kind'] == 'events': events = data.pop('events') notifications_settings = data.pop('notifications_settings', None) @@ -329,6 +332,11 @@ class Agenda(models.Model): if not created: for k, v in data.items(): setattr(agenda, k, v) + if overwrite: + AgendaReminderSettings.objects.filter(agenda=agenda).delete() + if reminder_settings: + reminder_settings['agenda'] = agenda + AgendaReminderSettings.import_json(reminder_settings).save() if data['kind'] == 'events': if overwrite: Event.objects.filter(agenda=agenda).delete() @@ -993,6 +1001,7 @@ class Booking(models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE) extra_data = JSONField(null=True) cancellation_datetime = models.DateTimeField(null=True) + reminder_datetime = models.DateTimeField(null=True) in_waiting_list = models.BooleanField(default=False) creation_datetime = models.DateTimeField(auto_now_add=True) # primary booking is used to group multiple bookings together @@ -1676,3 +1685,61 @@ class AgendaNotificationsSettings(models.Model): 'cancelled_event': self.cancelled_event, 'cancelled_event_emails': self.cancelled_event_emails, } + + +class AgendaReminderSettings(models.Model): + ONE_DAY_BEFORE = 1 + TWO_DAYS_BEFORE = 2 + THREE_DAYS_BEFORE = 3 + + CHOICES = [ + (None, _('Never')), + (ONE_DAY_BEFORE, _('One day before')), + (TWO_DAYS_BEFORE, _('Two days before')), + (THREE_DAYS_BEFORE, _('Three days before')), + ] + + agenda = models.OneToOneField(Agenda, on_delete=models.CASCADE, related_name='reminder_settings') + days = models.IntegerField(null=True, blank=True, choices=CHOICES, verbose_name=_('Send reminder')) + send_email = models.BooleanField(default=False, verbose_name=_('Notify by email')) + email_extra_info = models.TextField( + blank=True, + verbose_name=_('Additional text to incude in emails'), + help_text=_('Basic information such as event name, time and date are already included'), + ) + send_sms = models.BooleanField(default=False, verbose_name=_('Notify by SMS')) + sms_extra_info = models.TextField( + blank=True, + verbose_name=_('Additional text to incude in SMS'), + help_text=_('Basic information such as event name, time and date are already included'), + ) + + def display_info(self): + message = ungettext( + 'Users will be reminded of their booking %(by_email_or_sms)s, one day in advance.', + 'Users will be reminded of their booking %(by_email_or_sms)s, %(days)s days in advance.', + self.days, + ) + + if self.send_sms and self.send_email: + by = _('both by email and by SMS') + elif self.send_sms: + by = _('by SMS') + elif self.send_email: + by = _('by email') + + return message % {'days': self.days, 'by_email_or_sms': by} + + @classmethod + def import_json(cls, data): + data = clean_import_data(cls, data) + return cls(**data) + + def export_json(self): + return { + 'days': self.days, + 'send_email': self.send_email, + 'email_extra_info': self.email_extra_info, + 'send_sms': self.send_sms, + 'sms_extra_info': self.sms_extra_info, + } diff --git a/chrono/agendas/templates/agendas/events_reminder_body.html b/chrono/agendas/templates/agendas/events_reminder_body.html new file mode 100644 index 0000000..8863daa --- /dev/null +++ b/chrono/agendas/templates/agendas/events_reminder_body.html @@ -0,0 +1,22 @@ +{% extends "emails/body_base.html" %} +{% load i18n %} + +{% block content %} +

{% trans "Hi," %}

+ +

+{% blocktrans trimmed %} +You have a booking for event "{{ event }}", on {{ date }} at {{ time }}. +{% endblocktrans %} +

+ +{% if email_extra_info %} +

{{ email_extra_info }}

+{% endif %} + +{% if form_url %} +{% with _("Edit or cancel booking") as button_label %} +{% include "emails/button-link.html" with url=form_url label=button_label %} +{% endwith %} +{% endif %} +{% endblock %} diff --git a/chrono/agendas/templates/agendas/events_reminder_body.txt b/chrono/agendas/templates/agendas/events_reminder_body.txt new file mode 100644 index 0000000..9dfe3d0 --- /dev/null +++ b/chrono/agendas/templates/agendas/events_reminder_body.txt @@ -0,0 +1,14 @@ +{% extends "emails/body_base.txt" %} +{% load i18n %} + +{% block content %}{% autoescape off %}{% blocktrans %}Hi, + +You have a booking for event "{{ event }}", on {{ date }} at {{ time }}.{% endblocktrans %} +{% if email_extra_info %} +{{ email_extra_info }} +{% endif %} +{% if form_url %} +{% trans "If in need to cancel it, you can do so here:" %} {{ form_url }} +{% endif %} +{% endautoescape %} +{% endblock %} diff --git a/chrono/agendas/templates/agendas/events_reminder_message.txt b/chrono/agendas/templates/agendas/events_reminder_message.txt new file mode 100644 index 0000000..11a34f9 --- /dev/null +++ b/chrono/agendas/templates/agendas/events_reminder_message.txt @@ -0,0 +1,3 @@ +{% load i18n %} + +{% blocktrans %}Reminder: you have a booking for event "{{ event }}", on {{ date_short }} at {{ time }}.{% endblocktrans %}{% if sms_extra_info %} {{ sms_extra_info }}{% endif %} diff --git a/chrono/agendas/templates/agendas/events_reminder_subject.txt b/chrono/agendas/templates/agendas/events_reminder_subject.txt new file mode 100644 index 0000000..88094c8 --- /dev/null +++ b/chrono/agendas/templates/agendas/events_reminder_subject.txt @@ -0,0 +1,3 @@ +{% extends "emails/subject.txt" %} +{% block email-subject %}{% autoescape off %}Reminder for your booking {{ in_x_days }} at {{ time }}{% endautoescape %}{% endblock %} + diff --git a/chrono/agendas/templates/agendas/meetings_reminder_body.html b/chrono/agendas/templates/agendas/meetings_reminder_body.html new file mode 100644 index 0000000..250c0ec --- /dev/null +++ b/chrono/agendas/templates/agendas/meetings_reminder_body.html @@ -0,0 +1,24 @@ +{% extends "emails/body_base.html" %} +{% load i18n %} + +{% block content %} +

{% trans "Hi," %}

+ +

+{% if meeting %} +{% blocktrans %}Your meeting "{{ meeting }}" is scheduled {{ in_x_days }} at {{ time }}.{% endblocktrans %} +{% else %} +{% blocktrans %}You have a meeting scheduled on {{ date }} at {{ time }}.{% endblocktrans %} +{% endif %} +

+ +{% if email_extra_info %} +

{{ email_extra_info }}

+{% endif %} + +{% if form_url %} +{% with _("Edit or cancel meeting") as button_label %} +{% include "emails/button-link.html" with url=form_url label=button_label %} +{% endwith %} +{% endif %} +{% endblock %} diff --git a/chrono/agendas/templates/agendas/meetings_reminder_body.txt b/chrono/agendas/templates/agendas/meetings_reminder_body.txt new file mode 100644 index 0000000..7071532 --- /dev/null +++ b/chrono/agendas/templates/agendas/meetings_reminder_body.txt @@ -0,0 +1,18 @@ +{% extends "emails/body_base.txt" %} +{% load i18n %} + +{% block content %}{% autoescape off %}{% trans "Hi," %} + +{% if meeting %} +{% blocktrans %}Your meeting "{{ meeting }}" is scheduled on {{ date }} at {{ time }}.{% endblocktrans %} +{% else %} +{% blocktrans %}You have a meeting scheduled on {{ date }} at {{ time }}.{% endblocktrans %} +{% endif %} + +{% if email_extra_info %}{{ email_extra_info }}{% endif %} + +{% if form_url %} +{% trans "If in need to cancel it, you can do so here:" %} {{ form_url }} +{% endif %} +{% endautoescape %} +{% endblock %} diff --git a/chrono/agendas/templates/agendas/meetings_reminder_message.txt b/chrono/agendas/templates/agendas/meetings_reminder_message.txt new file mode 100644 index 0000000..85308f7 --- /dev/null +++ b/chrono/agendas/templates/agendas/meetings_reminder_message.txt @@ -0,0 +1,3 @@ +{% load i18n %} + +{% 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 %} diff --git a/chrono/agendas/templates/agendas/meetings_reminder_subject.txt b/chrono/agendas/templates/agendas/meetings_reminder_subject.txt new file mode 100644 index 0000000..afb901a --- /dev/null +++ b/chrono/agendas/templates/agendas/meetings_reminder_subject.txt @@ -0,0 +1,3 @@ +{% extends "emails/subject.txt" %} +{% block email-subject %}{% autoescape off %}Reminder for your meeting {{ in_x_days }} at {{ time }}{% endautoescape %}{% endblock %} + diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py index 9eadba6..0fe6092 100644 --- a/chrono/manager/forms.py +++ b/chrono/manager/forms.py @@ -20,6 +20,7 @@ import csv import datetime from django import forms +from django.conf import settings from django.contrib.auth.models import Group from django.core.exceptions import FieldDoesNotExist from django.forms import ValidationError @@ -41,6 +42,7 @@ from chrono.agendas.models import ( Resource, Category, AgendaNotificationsSettings, + AgendaReminderSettings, WEEKDAYS_LIST, ) @@ -526,3 +528,18 @@ class AgendaNotificationsForm(forms.ModelForm): self.fields[email_field].widget.attrs['size'] = 80 self.fields[email_field].label = '' self.fields[email_field].help_text = _('Enter a comma separated list of email addresses.') + + +class AgendaReminderForm(forms.ModelForm): + class Meta: + model = AgendaReminderSettings + exclude = ['agenda'] + + def clean(self): + cleaned_data = super().clean() + if cleaned_data['days'] and not (cleaned_data['send_sms'] or cleaned_data['send_email']): + raise ValidationError(_('Select at least one notification medium.')) + + if cleaned_data['send_sms'] and not hasattr(settings, 'SMS_URL'): + raise ValidationError(_('SMS are unavailable on this instance.')) + return cleaned_data diff --git a/chrono/manager/templates/chrono/manager_agenda_reminder_form.html b/chrono/manager/templates/chrono/manager_agenda_reminder_form.html new file mode 100644 index 0000000..4501fb2 --- /dev/null +++ b/chrono/manager/templates/chrono/manager_agenda_reminder_form.html @@ -0,0 +1,22 @@ +{% extends "chrono/manager_agenda_view.html" %} +{% load i18n %} + +{% block breadcrumb %} +{{ block.super }} +{% trans "Reminder settings" %} +{% endblock %} + +{% block appbar %} +

{% trans "Reminder settings" %}

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} +
+ + {% trans 'Cancel' %} +
+
+{% endblock %} diff --git a/chrono/manager/templates/chrono/manager_agenda_settings.html b/chrono/manager/templates/chrono/manager_agenda_settings.html index 9b7c77d..a348ec5 100644 --- a/chrono/manager/templates/chrono/manager_agenda_settings.html +++ b/chrono/manager/templates/chrono/manager_agenda_settings.html @@ -34,6 +34,22 @@ {% block agenda-settings %} {% endblock %} +{% block agenda-reminder %} +
+

{% trans "Booking reminders" %}

+
+

+{% if not agenda.reminder_settings or not agenda.reminder_settings.days %} +{% trans "Reminders are disabled for this agenda." %} +{% else %} +{{ agenda.reminder_settings.display_info }} +{% endif %} +

+{% trans "Configure" %} +
+
+{% endblock %} + {% block agenda-permissions %}

{% trans "Permissions" %}

diff --git a/chrono/manager/templates/chrono/manager_virtual_agenda_settings.html b/chrono/manager/templates/chrono/manager_virtual_agenda_settings.html index 8cd930a..ba34d09 100644 --- a/chrono/manager/templates/chrono/manager_virtual_agenda_settings.html +++ b/chrono/manager/templates/chrono/manager_virtual_agenda_settings.html @@ -76,4 +76,6 @@
{% endif %} +{% block agenda-reminder %} +{% endblock %} {% endblock %} diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py index 30d3e47..c6e886f 100644 --- a/chrono/manager/urls.py +++ b/chrono/manager/urls.py @@ -78,6 +78,11 @@ urlpatterns = [ views.agenda_notifications_settings, name='chrono-manager-agenda-notifications-settings', ), + url( + r'^agendas/(?P\d+)/reminder$', + views.agenda_reminder_settings, + name='chrono-manager-agenda-reminder-settings', + ), url( r'^agendas/(?P\d+)/events/(?P\d+)/$', views.event_view, diff --git a/chrono/manager/views.py b/chrono/manager/views.py index cb42e34..63a0b1d 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -64,6 +64,7 @@ from chrono.agendas.models import ( Category, EventCancellationReport, AgendaNotificationsSettings, + AgendaReminderSettings, ) from .forms import ( @@ -92,6 +93,7 @@ from .forms import ( BookingCancelForm, EventCancelForm, AgendaNotificationsForm, + AgendaReminderForm, ) from .utils import import_site @@ -1371,6 +1373,21 @@ class AgendaNotificationsSettingsView(ManagedAgendaMixin, UpdateView): agenda_notifications_settings = AgendaNotificationsSettingsView.as_view() +class AgendaReminderSettingsView(ManagedAgendaMixin, UpdateView): + template_name = 'chrono/manager_agenda_reminder_form.html' + model = AgendaReminderSettings + form_class = AgendaReminderForm + + def get_object(self): + try: + return self.agenda.reminder_settings + except AgendaReminderSettings.DoesNotExist: + return AgendaReminderSettings.objects.create(agenda=self.agenda) + + +agenda_reminder_settings = AgendaReminderSettingsView.as_view() + + class EventDetailView(ViewableAgendaMixin, DetailView): model = Event pk_url_kwarg = 'event_pk' diff --git a/debian/chrono.cron.hourly b/debian/chrono.cron.hourly index 57026e7..e50b4ed 100644 --- a/debian/chrono.cron.hourly +++ b/debian/chrono.cron.hourly @@ -2,3 +2,4 @@ /sbin/runuser -u chrono /usr/bin/chrono-manage -- tenant_command clearsessions --all-tenants /sbin/runuser -u chrono /usr/bin/chrono-manage -- tenant_command sync_desks_timeperiod_exceptions --all-tenants +/sbin/runuser -u chrono /usr/bin/chrono-manage -- tenant_command send_booking_reminders --all-tenants diff --git a/tests/test_agendas.py b/tests/test_agendas.py index b64eaa2..a0735a8 100644 --- a/tests/test_agendas.py +++ b/tests/test_agendas.py @@ -1,7 +1,9 @@ import pytest import datetime +import json import mock import requests +import smtplib from django.contrib.auth.models import Group, User @@ -25,6 +27,7 @@ from chrono.agendas.models import ( VirtualMember, EventCancellationReport, AgendaNotificationsSettings, + AgendaReminderSettings, ) pytestmark = pytest.mark.django_db @@ -1238,3 +1241,244 @@ def test_agenda_notifications_cancelled(mailoutbox): # no new email on subsequent run call_command('send_email_notifications') assert len(mailoutbox) == 1 + + +def test_agenda_reminders(mailoutbox, freezer): + agenda = Agenda.objects.create(label='Events', kind='events') + + # add some old event with booking + freezer.move_to('2019-01-01') + old_event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event') + Booking.objects.create(event=old_event, user_email='old@test.org') + + # no reminder configured + call_command('send_booking_reminders') + assert len(mailoutbox) == 0 + + # move to present day + freezer.move_to('2020-01-01 14:00') + # configure reminder the day before + AgendaReminderSettings.objects.create(agenda=agenda, days=1, send_email=True) + # event starts in 2 days + start_datetime = now() + datetime.timedelta(days=2) + event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') + + for i in range(5): + booking = Booking.objects.create(event=event, user_email='t@test.org') + # extra booking with no email, should be ignored + booking = Booking.objects.create(event=event) + + freezer.move_to('2020-01-02 10:00') + # not time to send reminders yet + call_command('send_booking_reminders') + assert len(mailoutbox) == 0 + + # one of the booking is cancelled + Booking.objects.filter(user_email='t@test.org').first().cancel() + + freezer.move_to('2020-01-02 15:00') + call_command('send_booking_reminders') + assert len(mailoutbox) == 4 + mailoutbox.clear() + + call_command('send_booking_reminders') + assert len(mailoutbox) == 0 + + # booking is placed the day of the event, notfication should no be sent + freezer.move_to('2020-01-03 08:00') + booking = Booking.objects.create(event=event, user_email='t@test.org') + call_command('send_booking_reminders') + assert len(mailoutbox) == 0 + + +@override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_FROM='EO') +def test_agenda_reminders_sms(freezer): + freezer.move_to('2020-01-01 14:00') + agenda = Agenda.objects.create(label='Events', kind='events') + AgendaReminderSettings.objects.create(agenda=agenda, days=1, send_sms=True) + start_datetime = now() + datetime.timedelta(days=2) + event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') + + for i in range(5): + booking = Booking.objects.create(event=event, user_phone_number='+336123456789') + booking = Booking.objects.create(event=event) + + freezer.move_to('2020-01-02 15:00') + with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: + mock_response = mock.Mock(status_code=200) + mock_send.return_value = mock_response + call_command('send_booking_reminders') + + assert mock_send.call_count == 5 + body = json.loads(mock_send.call_args[0][0].body) + assert body['from'] == 'EO' + assert body['to'] == ['+336123456789'] + + +@override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_FROM='EO') +def test_agenda_reminders_retry(freezer): + freezer.move_to('2020-01-01 14:00') + agenda = Agenda.objects.create(label='Events', kind='events') + settings = AgendaReminderSettings.objects.create(agenda=agenda, days=1) + start_datetime = now() + datetime.timedelta(days=2) + event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') + + settings.send_email = True + settings.save() + booking = Booking.objects.create(event=event, user_email='t@test.org') + freezer.move_to('2020-01-02 15:00') + + def send_mail_error(*args, **kwargs): + raise smtplib.SMTPException + + with mock.patch('chrono.agendas.management.commands.send_booking_reminders.send_mail') as mock_send: + mock_send.return_value = None + mock_send.side_effect = send_mail_error + call_command('send_booking_reminders') + assert mock_send.call_count == 1 + booking.refresh_from_db() + assert not booking.reminder_datetime + + mock_send.side_effect = None + call_command('send_booking_reminders') + assert mock_send.call_count == 2 + booking.refresh_from_db() + assert booking.reminder_datetime + + settings.send_email = False + settings.send_sms = True + settings.save() + freezer.move_to('2020-01-01 14:00') + booking = Booking.objects.create(event=event, user_phone_number='+336123456789') + freezer.move_to('2020-01-02 15:00') + + def mocked_requests_connection_error(*args, **kwargs): + raise requests.ConnectionError('unreachable') + + with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: + mock_send.side_effect = mocked_requests_connection_error + mock_response = mock.Mock(status_code=200) + mock_send.return_value = mock_response + call_command('send_booking_reminders') + assert mock_send.call_count == 1 + booking.refresh_from_db() + assert not booking.reminder_datetime + + mock_send.side_effect = None + call_command('send_booking_reminders') + assert mock_send.call_count == 2 + booking.refresh_from_db() + assert booking.reminder_datetime + + # when both sms and email are to be sent, only one is necessary to consider reminder successful + settings.send_email = True + settings.save() + freezer.move_to('2020-01-01 14:00') + booking = Booking.objects.create(event=event, user_phone_number='+336123456789', user_email='t@test.org') + freezer.move_to('2020-01-02 15:00') + + with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send, mock.patch( + 'chrono.agendas.management.commands.send_booking_reminders.send_mail' + ) as mock_send_mail: + mock_send.side_effect = mocked_requests_connection_error + mock_response = mock.Mock(status_code=200) + mock_send.return_value = mock_response + mock_send_mail.return_value = None + call_command('send_booking_reminders') + + assert mock_send.call_count == 1 + assert mock_send_mail.call_count == 1 + booking.refresh_from_db() + assert booking.reminder_datetime + + call_command('send_booking_reminders') + assert mock_send.call_count == 1 + assert mock_send_mail.call_count == 1 + + +def test_agenda_reminders_email_content(mailoutbox, freezer): + freezer.move_to('2020-01-01 14:00') + agenda = Agenda.objects.create(label='Events', kind='events') + settings = AgendaReminderSettings.objects.create( + agenda=agenda, days=1, send_email=True, email_extra_info='Do no forget ID card.' + ) + start_datetime = now() + datetime.timedelta(days=2) + event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Pool party') + + booking = Booking.objects.create(event=event, user_email='t@test.org') + + freezer.move_to('2020-01-02 15:00') + call_command('send_booking_reminders') + + mail = mailoutbox[0] + assert mail.subject == 'Reminder for your booking tomorrow at 14:00' + mail_bodies = (mail.body, mail.alternatives[0][0]) + for body in mail_bodies: + assert 'Hi,' in body + assert 'You have a booking for event "Pool party", on Friday 03 January at 14:00.' in body + assert 'Do no forget ID card.' in body + assert not 'cancel' in body + mailoutbox.clear() + + freezer.move_to('2020-01-01 14:00') + booking = Booking.objects.create(event=event, user_email='t@test.org', form_url='https://example.org/') + freezer.move_to('2020-01-02 15:00') + call_command('send_booking_reminders') + + mail = mailoutbox[0] + assert 'If in need to cancel it, you can do so here: https://example.org/' in mail.body + assert 'Edit or cancel booking' in mail.alternatives[0][0] + assert 'href="https://example.org/"' in mail.alternatives[0][0] + + +@override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_FROM='EO') +def test_agenda_reminders_sms_content(freezer): + freezer.move_to('2020-01-01 14:00') + agenda = Agenda.objects.create(label='Events', kind='events') + AgendaReminderSettings.objects.create( + agenda=agenda, days=1, send_sms=True, sms_extra_info='Do no forget ID card.' + ) + start_datetime = now() + datetime.timedelta(days=2) + event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Pool party') + + booking = Booking.objects.create(event=event, user_phone_number='+336123456789') + + freezer.move_to('2020-01-02 15:00') + with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: + mock_response = mock.Mock(status_code=200) + mock_send.return_value = mock_response + call_command('send_booking_reminders') + + body = json.loads(mock_send.call_args[0][0].body) + assert ( + body['message'] + == 'Reminder: you have a booking for event "Pool party", on 03/01 at 14:00. Do no forget ID card.' + ) + + +def test_agenda_reminders_meetings(mailoutbox, freezer): + freezer.move_to('2020-01-01 11:00') + agenda = Agenda.objects.create(label='Events', kind='meetings') + desk = Desk.objects.create(agenda=agenda, label='Desk') + meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=30) + timeperiod = TimePeriod.objects.create( + desk=desk, weekday=now().weekday(), start_time=datetime.time(10, 0), end_time=datetime.time(18, 0) + ) + AgendaReminderSettings.objects.create(agenda=agenda, days=2, send_email=True) + + event = Event.objects.create( + agenda=agenda, + places=1, + desk=desk, + meeting_type=meetingtype, + start_datetime=now() + datetime.timedelta(days=5), # 06/01 + ) + Booking.objects.create(event=event, user_email='t@test.org', user_display_label='Birth certificate') + + freezer.move_to('2020-01-04 15:00') + call_command('send_booking_reminders') + assert len(mailoutbox) == 1 + + mail = mailoutbox[0] + assert mail.subject == 'Reminder for your meeting in 2 days at 11:00' + assert 'Your meeting "Birth certificate" is scheduled on Monday 06 January at 11:00.' in mail.body diff --git a/tests/test_import_export.py b/tests/test_import_export.py index 4eb4a54..1487680 100644 --- a/tests/test_import_export.py +++ b/tests/test_import_export.py @@ -29,6 +29,7 @@ from chrono.agendas.models import ( MeetingType, VirtualMember, AgendaNotificationsSettings, + AgendaReminderSettings, ) from chrono.manager.utils import import_site @@ -483,3 +484,21 @@ def test_import_export_notification_settings(): cancelled_event=AgendaNotificationsSettings.EMAIL_FIELD, cancelled_event_emails=['hop@entrouvert.com', 'top@entrouvert.com'], ) + + +def test_import_export_reminder_settings(): + agenda = Agenda.objects.create(label='Foo bar', kind='events') + settings = AgendaReminderSettings.objects.create( + agenda=agenda, days=2, send_email=True, send_sms=False, email_extra_info='test', + ) + output = get_output_of_command('export_site') + payload = json.loads(output) + + agenda.delete() + assert not AgendaReminderSettings.objects.exists() + + import_site(payload) + agenda = Agenda.objects.first() + AgendaReminderSettings.objects.get( + agenda=agenda, days=2, send_email=True, send_sms=False, email_extra_info='test', + ) diff --git a/tests/test_manager.py b/tests/test_manager.py index 6b31a40..4886577 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -3910,16 +3910,16 @@ def test_agenda_notifications(app, admin_user, managers_group): assert 'Notifications' in resp.text assert 'Notifications are disabled' in resp.text - resp = resp.click('Configure') + resp = resp.click('Configure', href='notifications') resp = app.get('/manage/agendas/%s/settings' % agenda.id) assert 'Notifications are disabled' in resp.text - resp = resp.click('Configure') + resp = resp.click('Configure', href='notifications') resp.form['cancelled_event'] = 'use-email-field' resp = resp.form.submit().follow() assert 'Notifications are disabled' in resp.text - resp = resp.click('Configure') + resp = resp.click('Configure', href='notifications') resp.form['cancelled_event_emails'] = 'hop@entrouvert.com, top@entrouvert.com' resp.form['almost_full_event'] = 'edit-role' resp.form['full_event'] = 'view-role' @@ -3953,7 +3953,7 @@ def test_agenda_notifications_no_old_events(app, admin_user, mailoutbox): login(app) resp = app.get('/manage/agendas/%s/settings' % agenda.id) - resp = resp.click('Configure') + resp = resp.click('Configure', href='notifications') resp.form['cancelled_event'] = 'use-email-field' resp.form['cancelled_event_emails'] = 'hop@entrouvert.com' resp.form.submit() @@ -3966,3 +3966,42 @@ def test_agenda_notifications_no_old_events(app, admin_user, mailoutbox): # no notification is sent for old event assert len(mailoutbox) == 1 assert 'New event' in mailoutbox[0].subject + + +def test_manager_reminders(app, admin_user): + agenda = Agenda.objects.create(label='Events', kind='events') + + login(app) + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + + assert 'Booking reminders' in resp.text + assert 'Reminders are disabled' in resp.text + + resp = resp.click('Configure', href='reminder') + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + assert 'Reminders are disabled' in resp.text + + resp = resp.click('Configure', href='reminder') + resp.form['days'] = 3 + resp.form['send_email'] = True + resp.form['email_extra_info'] = 'test' + resp = resp.form.submit().follow() + + assert 'Users will be reminded of their booking by email, 3 days in advance.' in resp.text + + resp = resp.click('Configure', href='reminder') + resp.form['send_sms'] = True + resp = resp.form.submit() + assert 'SMS are unavailable on this instance.' in resp.text + + with override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_FROM='EO'): + resp = resp.form.submit().follow() + assert 'Users will be reminded of their booking both by email and by SMS, 3 days in advance.' in resp.text + + agenda = Agenda.objects.create(label='Meetings', kind='meetings') + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + assert 'Booking reminders' in resp.text + + agenda = Agenda.objects.create(label='Virtual', kind='virtual') + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + assert not 'Booking reminders' in resp.text -- 2.20.1