From eacd8dbb6aea02b88f3a83a247b1e92e6db45bbe Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 16 Jul 2020 15:12:47 +0200 Subject: [PATCH 2/2] agendas: add email notifications for events (#44158) --- .../commands/send_email_notifications.py | 58 ++++++++++ .../migrations/0058_auto_20200729_1150.py | 98 ++++++++++++++++ chrono/agendas/models.py | 108 ++++++++++++++++++ .../agendas/event_notification_body.html | 1 + .../agendas/event_notification_body.txt | 1 + chrono/manager/forms.py | 18 +++ .../manager_agenda_notifications_form.html | 34 ++++++ .../manager_events_agenda_settings.html | 18 +++ chrono/manager/urls.py | 5 + chrono/manager/views.py | 23 +++- tests/settings.py | 2 + tests/test_agendas.py | 93 ++++++++++++++- tests/test_import_export.py | 27 +++++ tests/test_manager.py | 73 +++++++++++- 14 files changed, 552 insertions(+), 7 deletions(-) create mode 100644 chrono/agendas/management/commands/send_email_notifications.py create mode 100644 chrono/agendas/migrations/0058_auto_20200729_1150.py create mode 100644 chrono/agendas/templates/agendas/event_notification_body.html create mode 100644 chrono/agendas/templates/agendas/event_notification_body.txt create mode 100644 chrono/manager/templates/chrono/manager_agenda_notifications_form.html diff --git a/chrono/agendas/management/commands/send_email_notifications.py b/chrono/agendas/management/commands/send_email_notifications.py new file mode 100644 index 0000000..a21810b --- /dev/null +++ b/chrono/agendas/management/commands/send_email_notifications.py @@ -0,0 +1,58 @@ +# 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 urllib.parse import urljoin + +from django.conf import settings as django_settings +from django.core.mail import send_mail +from django.core.management.base import BaseCommand +from django.db.transaction import atomic +from django.template.loader import render_to_string +from django.utils.translation import ugettext_lazy as _ + +from chrono.agendas.models import AgendaNotificationsSettings + + +class Command(BaseCommand): + EMAIL_SUBJECTS = { + 'almost_full': _('Alert: event "%s" is almost full (90%%)'), + 'full': _('Alert: event "%s" is full'), + 'cancelled': _('Alert: event "%s" is cancelled'), + } + help = 'Send email notifications' + + def handle(self, **options): + notifications_settings = AgendaNotificationsSettings.objects.all() + for settings in notifications_settings.select_related('agenda'): + for setting, recipients in settings: + if not recipients: + continue + + status = setting.replace('_event', '') + events = settings.agenda.event_set.filter(**{status: True, 'was_' + status: False}) + for event in events: + self.send_notification(event, status, recipients) + + def send_notification(self, event, status, recipients): + subject = self.EMAIL_SUBJECTS[status] % event + ctx = {'event_url': urljoin(django_settings.SITE_BASE_URL, event.get_absolute_view_url())} + body = render_to_string('agendas/event_notification_body.txt', ctx) + html_body = render_to_string('agendas/event_notification_body.html', ctx) + + with atomic(): + setattr(event, 'was_' + status, True) + event.save() + send_mail(subject, body, django_settings.DEFAULT_FROM_EMAIL, recipients, html_message=html_body) diff --git a/chrono/agendas/migrations/0058_auto_20200729_1150.py b/chrono/agendas/migrations/0058_auto_20200729_1150.py new file mode 100644 index 0000000..610110b --- /dev/null +++ b/chrono/agendas/migrations/0058_auto_20200729_1150.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-07-29 09:50 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0057_event_almost_full'), + ] + + operations = [ + migrations.CreateModel( + name='AgendaNotificationsSettings', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'almost_full_event', + models.CharField( + blank=True, + choices=[ + ('edit-role', 'Edit Role'), + ('view-role', 'View Role'), + ('use-email-field', 'Specify email addresses manually'), + ], + max_length=16, + verbose_name='Almost full event (90%)', + ), + ), + ( + 'almost_full_event_emails', + django.contrib.postgres.fields.ArrayField( + base_field=models.EmailField(max_length=254), blank=True, null=True, size=None + ), + ), + ( + 'full_event', + models.CharField( + blank=True, + choices=[ + ('edit-role', 'Edit Role'), + ('view-role', 'View Role'), + ('use-email-field', 'Specify email addresses manually'), + ], + max_length=16, + verbose_name='Full event', + ), + ), + ( + 'full_event_emails', + django.contrib.postgres.fields.ArrayField( + base_field=models.EmailField(max_length=254), blank=True, null=True, size=None + ), + ), + ( + 'cancelled_event', + models.CharField( + blank=True, + choices=[ + ('edit-role', 'Edit Role'), + ('view-role', 'View Role'), + ('use-email-field', 'Specify email addresses manually'), + ], + max_length=16, + verbose_name='Cancelled event', + ), + ), + ( + 'cancelled_event_emails', + django.contrib.postgres.fields.ArrayField( + base_field=models.EmailField(max_length=254), blank=True, null=True, size=None + ), + ), + ( + 'agenda', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='notifications_settings', + to='agendas.Agenda', + ), + ), + ], + ), + migrations.AddField( + model_name='event', name='was_almost_full', field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='event', name='was_cancelled', field=models.BooleanField(default=False), + ), + migrations.AddField(model_name='event', name='was_full', field=models.BooleanField(default=False),), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index a2cb121..01d9d94 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -29,6 +29,7 @@ import vobject import django from django.conf import settings from django.contrib.auth.models import Group +from django.contrib.postgres.fields import ArrayField from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator @@ -269,6 +270,8 @@ class Agenda(models.Model): } if self.kind == 'events': agenda['events'] = [x.export_json() for x in self.event_set.all()] + if hasattr(self, 'notifications_settings'): + agenda['notifications_settings'] = self.notifications_settings.export_json() elif self.kind == 'meetings': agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.all()] agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()] @@ -283,6 +286,7 @@ class Agenda(models.Model): permissions = data.pop('permissions') or {} if data['kind'] == 'events': events = data.pop('events') + notifications_settings = data.pop('notifications_settings', None) elif data['kind'] == 'meetings': meetingtypes = data.pop('meetingtypes') desks = data.pop('desks') @@ -310,9 +314,13 @@ class Agenda(models.Model): if data['kind'] == 'events': if overwrite: Event.objects.filter(agenda=agenda).delete() + AgendaNotificationsSettings.objects.filter(agenda=agenda).delete() for event_data in events: event_data['agenda'] = agenda Event.import_json(event_data).save() + if notifications_settings: + notifications_settings['agenda'] = agenda + AgendaNotificationsSettings.import_json(notifications_settings).save() elif data['kind'] == 'meetings': if overwrite: MeetingType.objects.filter(agenda=agenda).delete() @@ -780,6 +788,11 @@ class Event(models.Model): desk = models.ForeignKey('Desk', null=True, on_delete=models.CASCADE) resources = models.ManyToManyField('Resource') + # flags for detecting changes + was_almost_full = models.BooleanField(default=False) + was_full = models.BooleanField(default=False) + was_cancelled = models.BooleanField(default=False) + class Meta: ordering = ['agenda', 'start_datetime', 'duration', 'label'] unique_together = ('agenda', 'slug') @@ -898,6 +911,9 @@ class Event(models.Model): def get_absolute_url(self): return reverse('chrono-manager-event-edit', kwargs={'pk': self.agenda.id, 'event_pk': self.id}) + def get_absolute_view_url(self): + return reverse('chrono-manager-event-view', kwargs={'pk': self.agenda.id, 'event_pk': self.id}) + @classmethod def import_json(cls, data): data['start_datetime'] = make_aware( @@ -1432,3 +1448,95 @@ class TimePeriodException(models.Model): def as_interval(self): '''Simplify insertion into IntervalSet''' return Interval(self.start_datetime, self.end_datetime) + + +class AgendaNotificationsSettings(models.Model): + EMAIL_FIELD = 'use-email-field' + VIEW_ROLE = 'view-role' + EDIT_ROLE = 'edit-role' + + CHOICES = [ + (EDIT_ROLE, _('Edit Role')), + (VIEW_ROLE, _('View Role')), + (EMAIL_FIELD, _('Specify email addresses manually')), + ] + + agenda = models.OneToOneField(Agenda, on_delete=models.CASCADE, related_name='notifications_settings') + + almost_full_event = models.CharField( + max_length=16, blank=True, choices=CHOICES, verbose_name=_('Almost full event (90%)') + ) + almost_full_event_emails = ArrayField(models.EmailField(), blank=True, null=True) + + full_event = models.CharField(max_length=16, blank=True, choices=CHOICES, verbose_name=_('Full event')) + full_event_emails = ArrayField(models.EmailField(), blank=True, null=True) + + cancelled_event = models.CharField( + max_length=16, blank=True, choices=CHOICES, verbose_name=_('Cancelled event') + ) + cancelled_event_emails = ArrayField(models.EmailField(), blank=True, null=True) + + def __iter__(self): + for field in self.get_setting_fields(): + yield (field.name, self.get_recipients(field.name)) + + @classmethod + def get_setting_fields(cls): + return [field for field in cls._meta.get_fields() if isinstance(field, models.CharField)] + + @classmethod + def get_email_field_names(cls): + return [field.name for field in cls._meta.get_fields() if isinstance(field, ArrayField)] + + def get_recipients(self, setting): + value = getattr(self, setting) + if not value: + return [] + + if value == self.EMAIL_FIELD: + return getattr(self, setting + '_emails') + + role = self.get_role_from_choice(value) + if not role or not hasattr(role, 'role'): + return [] + emails = role.role.emails + if role.role.emails_to_members: + emails.extend(role.user_set.values_list('email', flat=True)) + return emails + + @property + def display_info(self): + for field in self.get_setting_fields(): + choice = getattr(self, field.name) + if not choice: + continue + + if choice == self.EMAIL_FIELD: + emails = getattr(self, field.name + '_emails') + yield (field.verbose_name, ', '.join(emails)) + else: + role = self.get_role_from_choice(choice) + if role: + display_name = getattr(self, 'get_' + field.name + '_display')() + yield (field.verbose_name, '%s (%s)' % (display_name, role)) + + def get_role_from_choice(self, choice): + if choice == self.EDIT_ROLE: + return self.agenda.edit_role + elif choice == self.VIEW_ROLE: + return self.agenda.view_role + + @classmethod + def import_json(cls, data): + data = clean_import_data(cls, data) + return cls(**data) + + def export_json(self): + return { + 'almost_full_event': self.almost_full_event, + 'almost_full_event_emails': self.almost_full_event_emails, + 'full_event': self.full_event, + 'full_event_emails': self.full_event_emails, + 'cancelled_event': self.cancelled_event, + 'cancelled_event_emails': self.cancelled_event_emails, + } diff --git a/chrono/agendas/templates/agendas/event_notification_body.html b/chrono/agendas/templates/agendas/event_notification_body.html new file mode 100644 index 0000000..b085844 --- /dev/null +++ b/chrono/agendas/templates/agendas/event_notification_body.html @@ -0,0 +1 @@ +You can view it here. diff --git a/chrono/agendas/templates/agendas/event_notification_body.txt b/chrono/agendas/templates/agendas/event_notification_body.txt new file mode 100644 index 0000000..8656a64 --- /dev/null +++ b/chrono/agendas/templates/agendas/event_notification_body.txt @@ -0,0 +1 @@ +You can view it here: {{ event_url }}. diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py index 45c73f9..7adb765 100644 --- a/chrono/manager/forms.py +++ b/chrono/manager/forms.py @@ -40,6 +40,7 @@ from chrono.agendas.models import ( VirtualMember, Resource, Category, + AgendaNotificationsSettings, WEEKDAYS_LIST, ) @@ -473,3 +474,20 @@ class BookingCancelForm(forms.ModelForm): class Meta: model = Booking fields = [] + + +class AgendaNotificationsForm(forms.ModelForm): + class Meta: + model = AgendaNotificationsSettings + fields = '__all__' + widgets = { + 'agenda': forms.HiddenInput(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + for email_field in AgendaNotificationsSettings.get_email_field_names(): + 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.') diff --git a/chrono/manager/templates/chrono/manager_agenda_notifications_form.html b/chrono/manager/templates/chrono/manager_agenda_notifications_form.html new file mode 100644 index 0000000..7716520 --- /dev/null +++ b/chrono/manager/templates/chrono/manager_agenda_notifications_form.html @@ -0,0 +1,34 @@ +{% extends "chrono/manager_agenda_view.html" %} +{% load i18n %} + +{% block breadcrumb %} +{{ block.super }} +{% trans "Notification settings" %} +{% endblock %} + +{% block appbar %} +

{% trans "Notification settings" %}

+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} +
+ + {% trans 'Cancel' %} +
+ + +
+{% endblock %} diff --git a/chrono/manager/templates/chrono/manager_events_agenda_settings.html b/chrono/manager/templates/chrono/manager_events_agenda_settings.html index 0a81ea8..bc9fb62 100644 --- a/chrono/manager/templates/chrono/manager_events_agenda_settings.html +++ b/chrono/manager/templates/chrono/manager_events_agenda_settings.html @@ -30,4 +30,22 @@ +
+

{% trans "Notifications" %}

+
+
    +{% for setting, value in object.notifications_settings.display_info %} +
  • + {% blocktrans %} + {{ setting }}: {{ value }} will be notified. + {% endblocktrans %} +
  • +{% empty %} +{% trans "Notifications are disabled for this agenda." %} +{% endfor %} +
+{% trans 'Configure' %} +
+
+ {% endblock %} diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py index c36062f..0f9d790 100644 --- a/chrono/manager/urls.py +++ b/chrono/manager/urls.py @@ -73,6 +73,11 @@ urlpatterns = [ views.agenda_import_events, name='chrono-manager-agenda-import-events', ), + url( + r'^agendas/(?P\d+)/notifications$', + views.agenda_notifications_settings, + name='chrono-manager-agenda-notifications-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 4fa0ba8..c00b827 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -23,7 +23,7 @@ import uuid from django.contrib import messages from django.core.exceptions import PermissionDenied -from django.db.models import Q +from django.db.models import Q, F from django.db.models import Min, Max from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 @@ -62,6 +62,7 @@ from chrono.agendas.models import ( VirtualMember, Resource, Category, + AgendaNotificationsSettings, ) from .forms import ( @@ -88,6 +89,7 @@ from .forms import ( CategoryAddForm, CategoryEditForm, BookingCancelForm, + AgendaNotificationsForm, ) from .utils import import_site @@ -1339,6 +1341,25 @@ class AgendaImportEventsView(ManagedAgendaMixin, FormView): agenda_import_events = AgendaImportEventsView.as_view() +class AgendaNotificationsSettingsView(ManagedAgendaMixin, UpdateView): + template_name = 'chrono/manager_agenda_notifications_form.html' + model = AgendaNotificationsSettings + form_class = AgendaNotificationsForm + + def get_object(self): + try: + return self.agenda.notifications_settings + except AgendaNotificationsSettings.DoesNotExist: + # prevent old events from sending notifications + statuses = ('almost_full', 'full', 'cancelled') + kwargs = {'was_' + status: F(status) for status in statuses} + self.agenda.event_set.update(**kwargs) + return AgendaNotificationsSettings.objects.create(agenda=self.agenda) + + +agenda_notifications_settings = AgendaNotificationsSettingsView.as_view() + + class EventDetailView(ViewableAgendaMixin, DetailView): model = Event pk_url_kwarg = 'event_pk' diff --git a/tests/settings.py b/tests/settings.py index d1ace55..cb105be 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -25,3 +25,5 @@ KNOWN_SERVICES = { } }, } + +SITE_BASE_URL = 'https://example.com' diff --git a/tests/test_agendas.py b/tests/test_agendas.py index d971e2a..de42970 100644 --- a/tests/test_agendas.py +++ b/tests/test_agendas.py @@ -4,7 +4,7 @@ import mock import requests -from django.contrib.auth.models import Group +from django.contrib.auth.models import Group, User from django.core.files.base import ContentFile from django.core.management import call_command from django.utils.timezone import localtime, make_aware, now @@ -22,6 +22,7 @@ from chrono.agendas.models import ( TimePeriodException, TimePeriodExceptionSource, VirtualMember, + AgendaNotificationsSettings, ) pytestmark = pytest.mark.django_db @@ -1008,3 +1009,93 @@ def test_agenda_virtual_duplicate(): assert VirtualMember.objects.filter(virtual_agenda=new_agenda, real_agenda=agenda1).exists() assert VirtualMember.objects.filter(virtual_agenda=new_agenda, real_agenda=agenda2).exists() + + +@mock.patch('django.contrib.auth.models.Group.role', create=True) +@pytest.mark.parametrize( + 'emails_to_members,emails', + [(False, []), (False, ['test@entrouvert.com']), (True, []), (True, ['test@entrouvert.com']),], +) +def test_agenda_notifications_role_email(mocked_role, emails_to_members, emails, mailoutbox): + group = Group.objects.create(name='group') + user = User.objects.create(username='user', email='user@entrouvert.com') + user.groups.add(group) + mocked_role.emails_to_members = emails_to_members + mocked_role.emails = emails + expected_recipients = emails + if emails_to_members: + expected_recipients.append(user.email) + expected_email_count = 1 if emails else 0 + + agenda = Agenda.objects.create(label='Foo bar', kind='event', edit_role=group) + + event = Event.objects.create(agenda=agenda, places=10, start_datetime=now(), label='Hop') + settings = AgendaNotificationsSettings.objects.create(agenda=agenda) + settings.almost_full_event = AgendaNotificationsSettings.EDIT_ROLE + settings.save() + + # book 9/10 places to reach almost full state + for i in range(9): + Booking.objects.create(event=event) + event.refresh_from_db() + assert event.almost_full + + call_command('send_email_notifications') + assert len(mailoutbox) == expected_email_count + if mailoutbox: + assert mailoutbox[0].recipients() == expected_recipients + assert mailoutbox[0].subject == 'Alert: event "Hop" is almost full (90%)' + + # no new email on subsequent run + call_command('send_email_notifications') + assert len(mailoutbox) == expected_email_count + + +def test_agenda_notifications_email_list(mailoutbox): + agenda = Agenda.objects.create(label='Foo bar', kind='event') + + event = Event.objects.create(agenda=agenda, places=10, start_datetime=now(), label='Hop') + settings = AgendaNotificationsSettings.objects.create(agenda=agenda) + settings.full_event = AgendaNotificationsSettings.EMAIL_FIELD + settings.full_event_emails = recipients = ['hop@entrouvert.com', 'top@entrouvert.com'] + settings.save() + + for i in range(10): + Booking.objects.create(event=event) + event.refresh_from_db() + assert event.full + + call_command('send_email_notifications') + assert len(mailoutbox) == 1 + assert mailoutbox[0].recipients() == recipients + assert mailoutbox[0].subject == 'Alert: event "Hop" is full' + assert ( + 'view it here: https://example.com/manage/agendas/%s/events/%s/' % (agenda.pk, event.pk,) + in mailoutbox[0].body + ) + + # no new email on subsequent run + call_command('send_email_notifications') + assert len(mailoutbox) == 1 + + +def test_agenda_notifications_cancelled(mailoutbox): + agenda = Agenda.objects.create(label='Foo bar', kind='event') + + event = Event.objects.create(agenda=agenda, places=10, start_datetime=now(), label='Hop') + settings = AgendaNotificationsSettings.objects.create(agenda=agenda) + settings.cancelled_event = AgendaNotificationsSettings.EMAIL_FIELD + settings.cancelled_event_emails = recipients = ['hop@entrouvert.com', 'top@entrouvert.com'] + settings.save() + + event.cancelled = True + event.save() + + call_command('send_email_notifications') + assert len(mailoutbox) == 1 + assert mailoutbox[0].recipients() == recipients + assert mailoutbox[0].subject == 'Alert: event "Hop" is cancelled' + + # no new email on subsequent run + call_command('send_email_notifications') + assert len(mailoutbox) == 1 diff --git a/tests/test_import_export.py b/tests/test_import_export.py index 52f35ce..4eb4a54 100644 --- a/tests/test_import_export.py +++ b/tests/test_import_export.py @@ -28,6 +28,7 @@ from chrono.agendas.models import ( AgendaImportError, MeetingType, VirtualMember, + AgendaNotificationsSettings, ) from chrono.manager.utils import import_site @@ -456,3 +457,29 @@ def test_import_export_slug_fields(app): with pytest.raises(AgendaImportError) as excinfo: import_site(payload) assert str(excinfo.value) == 'Bad slug format "meeting-type&"' + + +def test_import_export_notification_settings(): + agenda = Agenda.objects.create(label='Foo bar', kind='events') + settings = AgendaNotificationsSettings.objects.create( + agenda=agenda, + almost_full_event=AgendaNotificationsSettings.EDIT_ROLE, + full_event=AgendaNotificationsSettings.VIEW_ROLE, + cancelled_event=AgendaNotificationsSettings.EMAIL_FIELD, + cancelled_event_emails=['hop@entrouvert.com', 'top@entrouvert.com'], + ) + output = get_output_of_command('export_site') + payload = json.loads(output) + + agenda.delete() + assert not AgendaNotificationsSettings.objects.exists() + + import_site(payload) + agenda = Agenda.objects.first() + AgendaNotificationsSettings.objects.get( + agenda=agenda, + almost_full_event=AgendaNotificationsSettings.EDIT_ROLE, + full_event=AgendaNotificationsSettings.VIEW_ROLE, + cancelled_event=AgendaNotificationsSettings.EMAIL_FIELD, + cancelled_event_emails=['hop@entrouvert.com', 'top@entrouvert.com'], + ) diff --git a/tests/test_manager.py b/tests/test_manager.py index c1ddcb5..47f0033 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -9,6 +9,7 @@ import mock import os from django.contrib.auth.models import User, Group +from django.core.management import call_command from django.db import connection from django.test.utils import CaptureQueriesContext from django.utils.encoding import force_text @@ -48,15 +49,18 @@ def simple_user(): @pytest.fixture -def manager_user(): +def managers_group(): + group, _ = Group.objects.get_or_create(name='Managers') + return group + + +@pytest.fixture +def manager_user(managers_group): try: user = User.objects.get(username='manager') except User.DoesNotExist: user = User.objects.create_user('manager', password='manager') - group, created = Group.objects.get_or_create(name='Managers') - if created: - group.save() - user.groups.set([group]) + user.groups.set([managers_group]) return user @@ -3737,3 +3741,62 @@ def test_event_cancellation(app, admin_user): assert 'Cancelled' in resp.text assert '0/10 bookings' in resp.text assert Booking.objects.filter(event=event, cancellation_datetime__isnull=False).count() == 2 + + +def test_agenda_notifications(app, admin_user, managers_group): + agenda = Agenda.objects.create(label='Events', kind='events') + + login(app) + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + + assert 'Notifications' in resp.text + assert 'Notifications are disabled' in resp.text + + resp = resp.click('Configure') + resp.form['almost_full_event'] = 'edit-role' + resp.form['full_event'] = 'view-role' + resp.form['cancelled_event'] = 'use-email-field' + resp.form['cancelled_event_emails'] = 'hop@entrouvert.com, top@entrouvert.com' + resp = resp.form.submit().follow() + + settings = agenda.notifications_settings + assert settings.almost_full_event == 'edit-role' + assert settings.full_event == 'view-role' + assert settings.cancelled_event == 'use-email-field' + assert settings.cancelled_event_emails == ['hop@entrouvert.com', 'top@entrouvert.com'] + + assert 'Cancelled event: hop@entrouvert.com, top@entrouvert.com will be notified' in resp.text + assert not 'Full event:' in resp.text + assert not 'Almost full event (90%):' in resp.text + + agenda.view_role = managers_group + agenda.edit_role = Group.objects.create(name='hop') + agenda.save() + + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + assert 'Almost full event (90%): Edit Role (hop) will be notified' in resp.text + assert 'Full event: View Role (Managers) will be notified' in resp.text + + +def test_agenda_notifications_no_old_events(app, admin_user, mailoutbox): + agenda = Agenda.objects.create(label='Events', kind='events') + event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Old event') + event.cancelled = True + event.save() + + login(app) + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + + resp = resp.click('Configure') + resp.form['cancelled_event'] = 'use-email-field' + resp.form['cancelled_event_emails'] = 'hop@entrouvert.com' + resp.form.submit() + + event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='New event') + event.cancelled = True + event.save() + + call_command('send_email_notifications') + # no notification is sent for old event + assert len(mailoutbox) == 1 + assert 'New event' in mailoutbox[0].subject -- 2.20.1