agendas: allow different reminder time between email and sms (#61368)

This commit is contained in:
Valentin Deniaud 2022-02-02 17:45:15 +01:00
parent 6358e4bda5
commit 9b3580973e
12 changed files with 239 additions and 84 deletions

View File

@ -40,13 +40,17 @@ class Command(BaseCommand):
def handle(self, **options):
translation.activate(settings.LANGUAGE_CODE)
for msg_type in ('email', 'sms'):
self.notify(msg_type)
def notify(self, msg_type):
# We want to send reminders x days before event starts, say x=2. For
# that we look at events that begin no earlier than 2 days minus 6
# hours from now, AND no later than 2 days. Hence an event that is in 2
# days will only be in this range from exactly 2 days before to 2 days
# before plus 6 hours. In case command is ran once every hour and a
# sending fails, this allows 6 retries before giving up.
reminder_delta = F('event__agenda__reminder_settings__days') * timedelta(1)
reminder_delta = F(f'event__agenda__reminder_settings__days_before_{msg_type}') * timedelta(1)
starts_before = timezone.now() + reminder_delta
starts_after = timezone.now() + reminder_delta - timedelta(hours=6)
@ -56,27 +60,29 @@ class Command(BaseCommand):
bookings = Booking.objects.filter(
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,
**{f'{msg_type}_reminder_datetime__isnull': True},
).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)
bookings.update(**{f'{msg_type}_reminder_datetime': SENDING_IN_PROGRESS})
try:
for booking in bookings_list:
self.send_reminder(booking)
self.send_reminder(booking, msg_type)
finally:
Booking.objects.filter(pk__in=bookings_pk, reminder_datetime=SENDING_IN_PROGRESS).update(
reminder_datetime=None
Booking.objects.filter(
pk__in=bookings_pk, **{f'{msg_type}_reminder_datetime': SENDING_IN_PROGRESS}
).update(
**{f'{msg_type}_reminder_datetime': None},
)
def send_reminder(self, booking):
def send_reminder(self, booking, msg_type):
agenda = booking.event.agenda
kind = agenda.kind
days = agenda.reminder_settings.days
days = getattr(agenda.reminder_settings, f'days_before_{msg_type}')
ctx = {
'booking': booking,
@ -87,15 +93,14 @@ class Command(BaseCommand):
}
ctx.update(settings.TEMPLATE_VARS)
if agenda.reminder_settings.send_email:
if msg_type == 'email':
emails = set(booking.extra_emails)
if booking.user_email:
emails.add(booking.user_email)
for email in emails:
self.send_email(email, booking, kind, ctx)
if agenda.reminder_settings.send_sms:
elif msg_type == 'sms':
phone_numbers = set(booking.extra_phone_numbers)
if booking.user_phone_number:
phone_numbers.add(booking.user_phone_number)
@ -111,7 +116,7 @@ class Command(BaseCommand):
try:
with atomic():
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [email], html_message=html_body)
booking.reminder_datetime = timezone.now()
booking.email_reminder_datetime = timezone.now()
booking.save()
except SMTPException:
pass
@ -134,7 +139,7 @@ class Command(BaseCommand):
settings.SMS_URL, json=payload, remote_service='auto', timeout=10, without_user=True
)
request.raise_for_status()
booking.reminder_datetime = timezone.now()
booking.sms_reminder_datetime = timezone.now()
booking.save()
except RequestException:
pass

View File

@ -29,7 +29,7 @@ class Migration(migrations.Migration):
(3, 'Three days before'),
],
null=True,
verbose_name='Send reminder',
verbose_name='Send email reminder',
help_text=(
'In order to prevent users from getting a reminder shortly after booking, '
'a reminder is sent less only if at least 12 hours have elapsed since booking time.'

View File

@ -0,0 +1,47 @@
# Generated by Django 2.2.19 on 2022-02-02 16:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0106_auto_20220202_1730'),
]
operations = [
migrations.RenameField(
model_name='agendaremindersettings',
old_name='days',
new_name='days_before_email',
),
migrations.AddField(
model_name='agendaremindersettings',
name='days_before_sms',
field=models.IntegerField(
blank=True,
choices=[
(None, 'Never'),
(1, 'One day before'),
(2, 'Two days before'),
(3, 'Three days before'),
],
help_text=(
'In order to prevent users from getting a reminder shortly after booking, a '
'reminder is sent less only if at least 12 hours have elapsed since booking time.'
),
null=True,
verbose_name='Send SMS reminder',
),
),
migrations.RenameField(
model_name='booking',
old_name='reminder_datetime',
new_name='email_reminder_datetime',
),
migrations.AddField(
model_name='booking',
name='sms_reminder_datetime',
field=models.DateTimeField(null=True),
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 2.2.19 on 2022-02-02 16:36
from django.db import migrations
from django.db.models import F
def migrate_reminder_fields(apps, schema_editor):
AgendaReminderSettings = apps.get_model('agendas', 'AgendaReminderSettings')
for settings in AgendaReminderSettings.objects.filter(days_before_email__isnull=False):
if settings.send_sms:
settings.days_before_sms = settings.days_before_email
if not settings.send_email:
settings.days_before_email = None
settings.save()
Booking = apps.get_model('agendas', 'Booking')
Booking.objects.update(sms_reminder_datetime=F('email_reminder_datetime'))
class Migration(migrations.Migration):
dependencies = [
('agendas', '0107_auto_20220202_1730'),
]
operations = [
migrations.RunPython(migrate_reminder_fields, reverse_code=migrations.RunPython.noop),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 2.2.19 on 2022-02-03 09:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('agendas', '0108_auto_20220202_1736'),
]
operations = [
migrations.RemoveField(
model_name='agendaremindersettings',
name='send_email',
),
migrations.RemoveField(
model_name='agendaremindersettings',
name='send_sms',
),
]

View File

@ -1847,7 +1847,8 @@ class Booking(models.Model):
extra_data = JSONField(null=True)
anonymization_datetime = models.DateTimeField(null=True)
cancellation_datetime = models.DateTimeField(null=True)
reminder_datetime = models.DateTimeField(null=True)
email_reminder_datetime = models.DateTimeField(null=True)
sms_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
@ -2816,23 +2817,31 @@ class AgendaReminderSettings(models.Model):
]
agenda = models.OneToOneField(Agenda, on_delete=models.CASCADE, related_name='reminder_settings')
days = models.IntegerField(
days_before_email = models.IntegerField(
null=True,
blank=True,
choices=CHOICES,
verbose_name=_('Send reminder'),
verbose_name=_('Send email reminder'),
help_text=_(
'In order to prevent users from getting a reminder shortly after booking, '
'a reminder is sent less only if at least 12 hours have elapsed since booking time.'
),
)
send_email = models.BooleanField(default=False, verbose_name=_('Notify by email'))
email_extra_info = models.TextField(
blank=True,
verbose_name=_('Additional text to include 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'))
days_before_sms = models.IntegerField(
null=True,
blank=True,
choices=CHOICES,
verbose_name=_('Send SMS reminder'),
help_text=_(
'In order to prevent users from getting a reminder shortly after booking, '
'a reminder is sent less only if at least 12 hours have elapsed since booking time.'
),
)
sms_extra_info = models.TextField(
blank=True,
verbose_name=_('Additional text to include in SMS'),
@ -2840,20 +2849,26 @@ class AgendaReminderSettings(models.Model):
)
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,
)
def get_message(days, by_email_or_sms):
return (
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.',
days,
)
% {'days': days, 'by_email_or_sms': by_email_or_sms}
)
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')
if self.days_before_email and self.days_before_email == self.days_before_sms:
return [get_message(self.days_before_email, _('both by email and by SMS'))]
return message % {'days': self.days, 'by_email_or_sms': by}
messages = []
if self.days_before_email:
messages.append(get_message(self.days_before_email, _('by email')))
if self.days_before_sms:
messages.append(get_message(self.days_before_sms, _('by SMS')))
return messages
@classmethod
def import_json(cls, data):
@ -2863,10 +2878,9 @@ class AgendaReminderSettings(models.Model):
def export_json(self):
return {
'days': self.days,
'send_email': self.send_email,
'days_before_email': self.days_before_email,
'days_before_sms': self.days_before_sms,
'email_extra_info': self.email_extra_info,
'send_sms': self.send_sms,
'sms_extra_info': self.sms_extra_info,
}

View File

@ -927,15 +927,9 @@ class AgendaReminderForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not settings.SMS_URL:
del self.fields['send_sms']
del self.fields['days_before_sms']
del self.fields['sms_extra_info']
def clean(self):
cleaned_data = super().clean()
if cleaned_data['days'] and not (cleaned_data['send_email'] or cleaned_data.get('send_sms')):
raise ValidationError(_('Select at least one notification medium.'))
return cleaned_data
class AgendasExportForm(forms.Form):
agendas = forms.BooleanField(label=_('Agendas'), required=False, initial=True)

View File

@ -44,18 +44,16 @@
<a rel="popup" class="button" href="{% url 'chrono-manager-agenda-reminder-settings' pk=object.id %}">{% trans "Configure" %}</a>
</h3>
<div>
{% for info in agenda.reminder_settings.display_info %}
<p>{{ info }}</p>
{% empty %}
<p>{% trans "Reminders are disabled for this agenda." %}</p>
{% endfor %}
<p>
{% 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 %}
</p>
<p>
{% if agenda.reminder_settings.send_email %}
{% if agenda.reminder_settings.days_before_email %}
<a rel="popup" data-selector="#message-preview" href="{% url 'chrono-manager-agenda-reminder-preview' pk=object.id type='email' %}">{% trans "Preview email" %}</a>
{% endif %}
{% if agenda.reminder_settings.send_sms %}
{% if agenda.reminder_settings.days_before_sms %}
<a rel="popup" data-selector="#message-preview" href="{% url 'chrono-manager-agenda-reminder-preview' pk=object.id type='sms' %}">{% trans "Preview SMS" %}</a>
{% endif %}
</p>

View File

@ -1908,7 +1908,7 @@ class AgendaReminderPreviewView(ManagedAgendaMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
kind = self.agenda.kind
days = self.agenda.reminder_settings.days
days = getattr(self.agenda.reminder_settings, 'days_before_%s' % self.type_)
paragraph = lorem_ipsum.paragraphs(1)[0][:232]
label = title(lorem_ipsum.words(2))

View File

@ -2863,19 +2863,33 @@ def test_manager_reminders(app, admin_user):
resp = resp.click('Configure', href='reminder')
assert not 'SMS' in resp.text
resp.form['days'] = 3
resp.form['send_email'] = True
resp.form['days_before_email'] = 3
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
assert 'reminded of their booking by SMS' not in resp.text
with override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO'):
resp = resp.click('Configure', href='reminder')
resp.form['send_sms'] = True
resp.form['days_before_sms'] = 3
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
with override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO'):
resp = resp.click('Configure', href='reminder')
resp.form['days_before_sms'] = 2
resp = resp.form.submit().follow()
assert 'Users will be reminded of their booking by email, 3 days in advance.' in resp.text
assert 'Users will be reminded of their booking by SMS, 2 days in advance.' in resp.text
with override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO'):
resp = resp.click('Configure', href='reminder')
resp.form['days_before_email'] = ''
resp = resp.form.submit().follow()
assert 'reminded of their booking by email' not in resp.text
assert 'Users will be reminded of their booking by SMS, 2 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
@ -2890,10 +2904,9 @@ def test_manager_reminders_preview(app, admin_user):
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
AgendaReminderSettings.objects.create(
agenda=agenda,
days=1,
send_email=True,
days_before_email=1,
email_extra_info='An ID will be required in order to process your form.',
send_sms=True,
days_before_sms=1,
sms_extra_info='Take ID card.',
)

View File

@ -1364,15 +1364,14 @@ def test_agenda_events_duplicate():
full_event=AgendaNotificationsSettings.EMAIL_FIELD,
full_event_emails=['hop@entrouvert.com', 'top@entrouvert.com'],
)
AgendaReminderSettings.objects.create(agenda=agenda, days=1, send_email=True, email_extra_info='top')
AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=1, email_extra_info='top')
new_agenda = agenda.duplicate()
assert new_agenda.pk != agenda.pk
assert new_agenda.kind == 'events'
assert new_agenda.notifications_settings.full_event == AgendaNotificationsSettings.EMAIL_FIELD
assert new_agenda.notifications_settings.full_event_emails == ['hop@entrouvert.com', 'top@entrouvert.com']
assert new_agenda.reminder_settings.days == 1
assert new_agenda.reminder_settings.send_email is True
assert new_agenda.reminder_settings.days_before_email == 1
assert new_agenda.reminder_settings.email_extra_info == 'top'
new_desk = new_agenda.desk_set.get()
@ -1626,7 +1625,7 @@ def test_agenda_reminders(mailoutbox, freezer):
# 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)
AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=1)
# 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')
@ -1662,7 +1661,7 @@ def test_agenda_reminders(mailoutbox, freezer):
@pytest.mark.freeze_time('2020-01-01 14:00')
def test_agenda_reminders_extra_emails(mailoutbox, freezer):
agenda = Agenda.objects.create(label='Events', kind='events')
AgendaReminderSettings.objects.create(agenda=agenda, days=1, send_email=True)
AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=1)
start_datetime = now() + datetime.timedelta(days=2)
event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event')
@ -1688,7 +1687,7 @@ def test_agenda_reminders_extra_emails(mailoutbox, freezer):
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)
AgendaReminderSettings.objects.create(agenda=agenda, days_before_sms=1)
start_datetime = now() + datetime.timedelta(days=2)
event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event')
@ -1726,11 +1725,11 @@ def test_agenda_reminders_sms(freezer):
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)
settings = AgendaReminderSettings.objects.create(agenda=agenda)
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.days_before_email = 1
settings.save()
booking = Booking.objects.create(event=event, user_email='t@test.org')
freezer.move_to('2020-01-02 15:00')
@ -1744,16 +1743,18 @@ def test_agenda_reminders_retry(freezer):
call_command('send_booking_reminders')
assert mock_send.call_count == 1
booking.refresh_from_db()
assert not booking.reminder_datetime
assert not booking.email_reminder_datetime
assert not booking.sms_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
assert booking.email_reminder_datetime
assert not booking.sms_reminder_datetime
settings.send_email = False
settings.send_sms = True
settings.days_before_email = None
settings.days_before_sms = 1
settings.save()
freezer.move_to('2020-01-01 14:00')
booking = Booking.objects.create(event=event, user_phone_number='+336123456789')
@ -1769,16 +1770,17 @@ def test_agenda_reminders_retry(freezer):
call_command('send_booking_reminders')
assert mock_send.call_count == 1
booking.refresh_from_db()
assert not booking.reminder_datetime
assert not booking.sms_reminder_datetime
assert not booking.email_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
assert booking.sms_reminder_datetime
assert not booking.email_reminder_datetime
# when both sms and email are to be sent, only one is necessary to consider reminder successful
settings.send_email = True
settings.days_before_email = 1
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')
@ -1787,7 +1789,6 @@ def test_agenda_reminders_retry(freezer):
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
@ -1796,19 +1797,53 @@ def test_agenda_reminders_retry(freezer):
assert mock_send.call_count == 1
assert mock_send_mail.call_count == 1
booking.refresh_from_db()
assert booking.reminder_datetime
assert booking.sms_reminder_datetime
assert booking.email_reminder_datetime
call_command('send_booking_reminders')
assert mock_send.call_count == 1
assert mock_send_mail.call_count == 1
@override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO')
def test_agenda_reminders_different_days_before(freezer):
freezer.move_to('2020-01-01 14:00')
agenda = Agenda.objects.create(label='Events', kind='events')
AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=1, days_before_sms=2)
start_datetime = now() + datetime.timedelta(days=3)
event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event')
Booking.objects.create(event=event, user_email='t@test.org', user_phone_number='+336123456789')
with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send_sms, mock.patch(
'chrono.agendas.management.commands.send_booking_reminders.send_mail'
) as mock_send_mail:
mock_response = mock.Mock(status_code=200)
mock_send_sms.return_value = mock_response
mock_send_mail.return_value = None
call_command('send_booking_reminders')
assert mock_send_sms.call_count == 0
assert mock_send_mail.call_count == 0
# two days before
freezer.move_to('2020-01-02 15:00')
call_command('send_booking_reminders')
assert mock_send_sms.call_count == 1
assert mock_send_mail.call_count == 0
# one day before
freezer.move_to('2020-01-03 15:00')
call_command('send_booking_reminders')
assert mock_send_sms.call_count == 1
assert mock_send_mail.call_count == 1
@override_settings(TIME_ZONE='UTC')
def test_agenda_reminders_email_content(mailoutbox, 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_email=True, email_extra_info='Do no forget ID card.'
agenda=agenda, days_before_email=1, email_extra_info='Do no forget ID card.'
)
start_datetime = now() + datetime.timedelta(days=2)
event = Event.objects.create(
@ -1870,7 +1905,7 @@ 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.'
agenda=agenda, days_before_sms=1, 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')
@ -1899,7 +1934,7 @@ def test_agenda_reminders_meetings(mailoutbox, freezer):
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)
AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=2)
event = Event.objects.create(
agenda=agenda,

View File

@ -675,9 +675,8 @@ def test_import_export_reminder_settings():
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
AgendaReminderSettings.objects.create(
agenda=agenda,
days=2,
send_email=True,
send_sms=False,
days_before_email=2,
days_before_sms=1,
email_extra_info='test',
)
output = get_output_of_command('export_site')
@ -690,9 +689,8 @@ def test_import_export_reminder_settings():
agenda = Agenda.objects.first()
AgendaReminderSettings.objects.get(
agenda=agenda,
days=2,
send_email=True,
send_sms=False,
days_before_email=2,
days_before_sms=1,
email_extra_info='test',
)