Projet

Général

Profil

0002-agendas-add-email-notifications-for-events-44158.patch

Valentin Deniaud, 01 septembre 2020 17:09

Télécharger (32,2 ko)

Voir les différences:

Subject: [PATCH 2/2] agendas: add email notifications for events (#44158)

 .../commands/send_email_notifications.py      |  65 ++++++++++
 .../migrations/0058_auto_20200812_1211.py     | 106 ++++++++++++++++
 chrono/agendas/models.py                      | 113 ++++++++++++++++++
 .../agendas/event_notification_body.html      |   8 ++
 .../agendas/event_notification_body.txt       |   8 ++
 chrono/manager/forms.py                       |  18 +++
 .../manager_agenda_notifications_form.html    |  34 ++++++
 .../manager_events_agenda_settings.html       |  22 ++++
 chrono/manager/urls.py                        |   5 +
 chrono/manager/views.py                       |  26 +++-
 chrono/settings.py                            |   2 +
 tests/settings.py                             |   2 +
 tests/test_agendas.py                         |  93 +++++++++++++-
 tests/test_import_export.py                   |  27 +++++
 tests/test_manager.py                         |  72 ++++++++++-
 15 files changed, 594 insertions(+), 7 deletions(-)
 create mode 100644 chrono/agendas/management/commands/send_email_notifications.py
 create mode 100644 chrono/agendas/migrations/0058_auto_20200812_1211.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
chrono/agendas/management/commands/send_email_notifications.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 urllib.parse import urljoin
18

  
19
from django.conf import settings
20
from django.core.mail import send_mail
21
from django.core.management.base import BaseCommand
22
from django.db.transaction import atomic
23
from django.template.loader import render_to_string
24
from django.utils import timezone
25
from django.utils.translation import ugettext_lazy as _
26

  
27
from chrono.agendas.models import Agenda
28

  
29

  
30
class Command(BaseCommand):
31
    EMAIL_SUBJECTS = {
32
        'almost_full': _('Alert: event "%s" is almost full (90%%)'),
33
        'full': _('Alert: event "%s" is full'),
34
        'cancelled': _('Alert: event "%s" is cancelled'),
35
    }
36
    help = 'Send email notifications'
37

  
38
    def handle(self, **options):
39
        agendas = Agenda.objects.filter(notifications_settings__isnull=False).select_related(
40
            'notifications_settings'
41
        )
42
        for agenda in agendas:
43
            for notification_type in agenda.notifications_settings.get_notification_types():
44
                recipients = notification_type.get_recipients()
45
                if not recipients:
46
                    continue
47

  
48
                status = notification_type.related_field
49
                filter_kwargs = {status: True, status + '_notification_timestamp__isnull': True}
50
                events = agenda.event_set.filter(**filter_kwargs)
51
                for event in events:
52
                    self.send_notification(event, status, recipients)
53

  
54
    def send_notification(self, event, status, recipients):
55
        subject = self.EMAIL_SUBJECTS[status] % event
56
        ctx = {'event': event, 'event_url': urljoin(settings.SITE_BASE_URL, event.get_absolute_view_url())}
57
        ctx.update(settings.TEMPLATE_VARS)
58
        body = render_to_string('agendas/event_notification_body.txt', ctx)
59
        html_body = render_to_string('agendas/event_notification_body.html', ctx)
60

  
61
        timestamp = timezone.now()
62
        with atomic():
63
            setattr(event, status + '_notification_timestamp', timestamp)
64
            event.save()
65
            send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, recipients, html_message=html_body)
chrono/agendas/migrations/0058_auto_20200812_1211.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-08-12 10:11
3
from __future__ import unicode_literals
4

  
5
import django.contrib.postgres.fields
6
from django.db import migrations, models
7
import django.db.models.deletion
8

  
9

  
10
class Migration(migrations.Migration):
11

  
12
    dependencies = [
13
        ('agendas', '0057_event_almost_full'),
14
    ]
15

  
16
    operations = [
17
        migrations.CreateModel(
18
            name='AgendaNotificationsSettings',
19
            fields=[
20
                (
21
                    'id',
22
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
23
                ),
24
                (
25
                    'almost_full_event',
26
                    models.CharField(
27
                        blank=True,
28
                        choices=[
29
                            ('edit-role', 'Edit Role'),
30
                            ('view-role', 'View Role'),
31
                            ('use-email-field', 'Specify email addresses manually'),
32
                        ],
33
                        max_length=16,
34
                        verbose_name='Almost full event (90%)',
35
                    ),
36
                ),
37
                (
38
                    'almost_full_event_emails',
39
                    django.contrib.postgres.fields.ArrayField(
40
                        base_field=models.EmailField(max_length=254), blank=True, null=True, size=None
41
                    ),
42
                ),
43
                (
44
                    'full_event',
45
                    models.CharField(
46
                        blank=True,
47
                        choices=[
48
                            ('edit-role', 'Edit Role'),
49
                            ('view-role', 'View Role'),
50
                            ('use-email-field', 'Specify email addresses manually'),
51
                        ],
52
                        max_length=16,
53
                        verbose_name='Full event',
54
                    ),
55
                ),
56
                (
57
                    'full_event_emails',
58
                    django.contrib.postgres.fields.ArrayField(
59
                        base_field=models.EmailField(max_length=254), blank=True, null=True, size=None
60
                    ),
61
                ),
62
                (
63
                    'cancelled_event',
64
                    models.CharField(
65
                        blank=True,
66
                        choices=[
67
                            ('edit-role', 'Edit Role'),
68
                            ('view-role', 'View Role'),
69
                            ('use-email-field', 'Specify email addresses manually'),
70
                        ],
71
                        max_length=16,
72
                        verbose_name='Cancelled event',
73
                    ),
74
                ),
75
                (
76
                    'cancelled_event_emails',
77
                    django.contrib.postgres.fields.ArrayField(
78
                        base_field=models.EmailField(max_length=254), blank=True, null=True, size=None
79
                    ),
80
                ),
81
                (
82
                    'agenda',
83
                    models.OneToOneField(
84
                        on_delete=django.db.models.deletion.CASCADE,
85
                        related_name='notifications_settings',
86
                        to='agendas.Agenda',
87
                    ),
88
                ),
89
            ],
90
        ),
91
        migrations.AddField(
92
            model_name='event',
93
            name='almost_full_notification_timestamp',
94
            field=models.DateTimeField(blank=True, null=True),
95
        ),
96
        migrations.AddField(
97
            model_name='event',
98
            name='cancelled_notification_timestamp',
99
            field=models.DateTimeField(blank=True, null=True),
100
        ),
101
        migrations.AddField(
102
            model_name='event',
103
            name='full_notification_timestamp',
104
            field=models.DateTimeField(blank=True, null=True),
105
        ),
106
    ]
chrono/agendas/models.py
29 29
import django
30 30
from django.conf import settings
31 31
from django.contrib.auth.models import Group
32
from django.contrib.postgres.fields import ArrayField
32 33
from django.core.exceptions import FieldDoesNotExist
33 34
from django.core.exceptions import ValidationError
34 35
from django.core.validators import MaxValueValidator
......
271 272
        }
272 273
        if self.kind == 'events':
273 274
            agenda['events'] = [x.export_json() for x in self.event_set.all()]
275
            if hasattr(self, 'notifications_settings'):
276
                agenda['notifications_settings'] = self.notifications_settings.export_json()
274 277
        elif self.kind == 'meetings':
275 278
            agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.all()]
276 279
            agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()]
......
285 288
        permissions = data.pop('permissions') or {}
286 289
        if data['kind'] == 'events':
287 290
            events = data.pop('events')
291
            notifications_settings = data.pop('notifications_settings', None)
288 292
        elif data['kind'] == 'meetings':
289 293
            meetingtypes = data.pop('meetingtypes')
290 294
            desks = data.pop('desks')
......
312 316
        if data['kind'] == 'events':
313 317
            if overwrite:
314 318
                Event.objects.filter(agenda=agenda).delete()
319
                AgendaNotificationsSettings.objects.filter(agenda=agenda).delete()
315 320
            for event_data in events:
316 321
                event_data['agenda'] = agenda
317 322
                Event.import_json(event_data).save()
323
            if notifications_settings:
324
                notifications_settings['agenda'] = agenda
325
                AgendaNotificationsSettings.import_json(notifications_settings).save()
318 326
        elif data['kind'] == 'meetings':
319 327
            if overwrite:
320 328
                MeetingType.objects.filter(agenda=agenda).delete()
......
783 791
    desk = models.ForeignKey('Desk', null=True, on_delete=models.CASCADE)
784 792
    resources = models.ManyToManyField('Resource')
785 793

  
794
    almost_full_notification_timestamp = models.DateTimeField(null=True, blank=True)
795
    full_notification_timestamp = models.DateTimeField(null=True, blank=True)
796
    cancelled_notification_timestamp = models.DateTimeField(null=True, blank=True)
797

  
786 798
    class Meta:
787 799
        ordering = ['agenda', 'start_datetime', 'duration', 'label']
788 800
        unique_together = ('agenda', 'slug')
......
908 920
    def get_absolute_url(self):
909 921
        return reverse('chrono-manager-event-edit', kwargs={'pk': self.agenda.id, 'event_pk': self.id})
910 922

  
923
    def get_absolute_view_url(self):
924
        return reverse('chrono-manager-event-view', kwargs={'pk': self.agenda.id, 'event_pk': self.id})
925

  
911 926
    @classmethod
912 927
    def import_json(cls, data):
913 928
        data['start_datetime'] = make_aware(
......
1474 1489

  
1475 1490
    class Meta:
1476 1491
        ordering = ['-timestamp']
1492

  
1493

  
1494
class NotificationType:
1495
    def __init__(self, name, related_field, settings):
1496
        self.name = name
1497
        self.related_field = related_field
1498
        self.settings = settings
1499

  
1500
    def get_recipients(self):
1501
        choice = getattr(self.settings, self.name)
1502
        if not choice:
1503
            return []
1504

  
1505
        if choice == self.settings.EMAIL_FIELD:
1506
            return getattr(self.settings, self.name + '_emails')
1507

  
1508
        role = self.settings.get_role_from_choice(choice)
1509
        if not role or not hasattr(role, 'role'):
1510
            return []
1511
        emails = role.role.emails
1512
        if role.role.emails_to_members:
1513
            emails.extend(role.user_set.values_list('email', flat=True))
1514
        return emails
1515

  
1516
    @property
1517
    def display_value(self):
1518
        choice = getattr(self.settings, self.name)
1519
        if not choice:
1520
            return ''
1521

  
1522
        if choice == self.settings.EMAIL_FIELD:
1523
            emails = getattr(self.settings, self.name + '_emails')
1524
            return ', '.join(emails)
1525

  
1526
        role = self.settings.get_role_from_choice(choice)
1527
        if role:
1528
            display_name = getattr(self.settings, 'get_%s_display' % self.name)()
1529
            return '%s (%s)' % (display_name, role)
1530

  
1531
    @property
1532
    def label(self):
1533
        return self.settings._meta.get_field(self.name).verbose_name
1534

  
1535

  
1536
class AgendaNotificationsSettings(models.Model):
1537
    EMAIL_FIELD = 'use-email-field'
1538
    VIEW_ROLE = 'view-role'
1539
    EDIT_ROLE = 'edit-role'
1540

  
1541
    CHOICES = [
1542
        (EDIT_ROLE, _('Edit Role')),
1543
        (VIEW_ROLE, _('View Role')),
1544
        (EMAIL_FIELD, _('Specify email addresses manually')),
1545
    ]
1546

  
1547
    agenda = models.OneToOneField(Agenda, on_delete=models.CASCADE, related_name='notifications_settings')
1548

  
1549
    almost_full_event = models.CharField(
1550
        max_length=16, blank=True, choices=CHOICES, verbose_name=_('Almost full event (90%)')
1551
    )
1552
    almost_full_event_emails = ArrayField(models.EmailField(), blank=True, null=True)
1553

  
1554
    full_event = models.CharField(max_length=16, blank=True, choices=CHOICES, verbose_name=_('Full event'))
1555
    full_event_emails = ArrayField(models.EmailField(), blank=True, null=True)
1556

  
1557
    cancelled_event = models.CharField(
1558
        max_length=16, blank=True, choices=CHOICES, verbose_name=_('Cancelled event')
1559
    )
1560
    cancelled_event_emails = ArrayField(models.EmailField(), blank=True, null=True)
1561

  
1562
    @classmethod
1563
    def get_email_field_names(cls):
1564
        return [field.name for field in cls._meta.get_fields() if isinstance(field, ArrayField)]
1565

  
1566
    def get_notification_types(self):
1567
        for field in ['almost_full_event', 'full_event', 'cancelled_event']:
1568
            yield NotificationType(name=field, related_field=field.replace('_event', ''), settings=self)
1569

  
1570
    def get_role_from_choice(self, choice):
1571
        if choice == self.EDIT_ROLE:
1572
            return self.agenda.edit_role
1573
        elif choice == self.VIEW_ROLE:
1574
            return self.agenda.view_role
1575

  
1576
    @classmethod
1577
    def import_json(cls, data):
1578
        data = clean_import_data(cls, data)
1579
        return cls(**data)
1580

  
1581
    def export_json(self):
1582
        return {
1583
            'almost_full_event': self.almost_full_event,
1584
            'almost_full_event_emails': self.almost_full_event_emails,
1585
            'full_event': self.full_event,
1586
            'full_event_emails': self.full_event_emails,
1587
            'cancelled_event': self.cancelled_event,
1588
            'cancelled_event_emails': self.cancelled_event_emails,
1589
        }
chrono/agendas/templates/agendas/event_notification_body.html
1
{% load i18n %}
2
{% autoescape off %}
3
{% blocktrans %}
4
Hi,
5

  
6
You have been notified because the status of event "{{ event }}" has changed.
7
{% endblocktrans %}
8
<a href="{{ event_url }}">{% trans "View event" %}</a>.
chrono/agendas/templates/agendas/event_notification_body.txt
1
{% load i18n %}
2
{% autoescape off %}
3
{% blocktrans %}
4
Hi,
5

  
6
You have been notified because the status of event "{{ event }}" has changed.
7
You can view it here: {{ event_url }}.
8
{% endblocktrans %}
chrono/manager/forms.py
40 40
    VirtualMember,
41 41
    Resource,
42 42
    Category,
43
    AgendaNotificationsSettings,
43 44
    WEEKDAYS_LIST,
44 45
)
45 46

  
......
508 509
    class Meta:
509 510
        model = Event
510 511
        fields = []
512

  
513

  
514
class AgendaNotificationsForm(forms.ModelForm):
515
    class Meta:
516
        model = AgendaNotificationsSettings
517
        fields = '__all__'
518
        widgets = {
519
            'agenda': forms.HiddenInput(),
520
        }
521

  
522
    def __init__(self, *args, **kwargs):
523
        super().__init__(*args, **kwargs)
524

  
525
        for email_field in AgendaNotificationsSettings.get_email_field_names():
526
            self.fields[email_field].widget.attrs['size'] = 80
527
            self.fields[email_field].label = ''
528
            self.fields[email_field].help_text = _('Enter a comma separated list of email addresses.')
chrono/manager/templates/chrono/manager_agenda_notifications_form.html
1
{% extends "chrono/manager_agenda_view.html" %}
2
{% load i18n %}
3

  
4
{% block breadcrumb %}
5
{{ block.super }}
6
<a href="">{% trans "Notification settings" %}</a>
7
{% endblock %}
8

  
9
{% block appbar %}
10
<h2>{% trans "Notification 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

  
22
  <script>
23
  $('select').change(function(){
24
    role_field_id = $(this).attr('id')
25
    email_field_id = '#' + role_field_id + '_emails'
26
    if ($(this).val() == 'use-email-field')
27
      $(email_field_id).parent('p').show();
28
    else
29
      $(email_field_id).parent('p').hide();
30
  });
31
  $('select').trigger('change');
32
  </script>
33
</form>
34
{% endblock %}
chrono/manager/templates/chrono/manager_events_agenda_settings.html
30 30
</div>
31 31
</div>
32 32

  
33
<div class="section">
34
<h3>{% trans "Notifications" %}</h3>
35
<div>
36
<ul>
37
{% for notification_type in object.notifications_settings.get_notification_types %}
38
  {% with display_value=notification_type.display_value label=notification_type.label %}
39
  {% if display_value %}
40
  <li>
41
  {% blocktrans %}
42
  {{ label }}: {{ display_value }} will be notified.
43
  {% endblocktrans %}
44
  </li>
45
  {% endif %}
46
  {% endwith %}
47
{% empty %}
48
{% trans "Notifications are disabled for this agenda." %}
49
{% endfor %}
50
</ul>
51
<a rel="popup" href="{% url 'chrono-manager-agenda-notifications-settings' pk=object.id %}">{% trans 'Configure' %}</a>
52
</div>
53
</div>
54

  
33 55
{% endblock %}
chrono/manager/urls.py
73 73
        views.agenda_import_events,
74 74
        name='chrono-manager-agenda-import-events',
75 75
    ),
76
    url(
77
        r'^agendas/(?P<pk>\d+)/notifications$',
78
        views.agenda_notifications_settings,
79
        name='chrono-manager-agenda-notifications-settings',
80
    ),
76 81
    url(
77 82
        r'^agendas/(?P<pk>\d+)/events/(?P<event_pk>\d+)/$',
78 83
        views.event_view,
chrono/manager/views.py
23 23

  
24 24
from django.contrib import messages
25 25
from django.core.exceptions import PermissionDenied
26
from django.db.models import Q
26
from django.db.models import Q, F
27 27
from django.db.models import Min, Max
28 28
from django.http import Http404, HttpResponse, HttpResponseRedirect
29 29
from django.shortcuts import get_object_or_404
......
63 63
    Resource,
64 64
    Category,
65 65
    EventCancellationReport,
66
    AgendaNotificationsSettings,
66 67
)
67 68

  
68 69
from .forms import (
......
90 91
    CategoryEditForm,
91 92
    BookingCancelForm,
92 93
    EventCancelForm,
94
    AgendaNotificationsForm,
93 95
)
94 96
from .utils import import_site
95 97

  
......
1345 1347
agenda_import_events = AgendaImportEventsView.as_view()
1346 1348

  
1347 1349

  
1350
class AgendaNotificationsSettingsView(ManagedAgendaMixin, UpdateView):
1351
    template_name = 'chrono/manager_agenda_notifications_form.html'
1352
    model = AgendaNotificationsSettings
1353
    form_class = AgendaNotificationsForm
1354

  
1355
    def get_object(self):
1356
        try:
1357
            return self.agenda.notifications_settings
1358
        except AgendaNotificationsSettings.DoesNotExist:
1359
            # prevent old events from sending notifications
1360
            statuses = ('almost_full', 'full', 'cancelled')
1361
            timestamp = now()
1362
            for status in statuses:
1363
                filter_kwargs = {status: True}
1364
                update_kwargs = {status + '_notification_timestamp': timestamp}
1365
                self.agenda.event_set.filter(**filter_kwargs).update(**update_kwargs)
1366
            return AgendaNotificationsSettings.objects.create(agenda=self.agenda)
1367

  
1368

  
1369
agenda_notifications_settings = AgendaNotificationsSettingsView.as_view()
1370

  
1371

  
1348 1372
class EventDetailView(ViewableAgendaMixin, DetailView):
1349 1373
    model = Event
1350 1374
    pk_url_kwarg = 'event_pk'
chrono/settings.py
166 166
# we use 28s by default: timeout just before web server, which is usually 30s
167 167
REQUESTS_TIMEOUT = 28
168 168

  
169
TEMPLATE_VARS = {}
170

  
169 171
local_settings_file = os.environ.get(
170 172
    'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
171 173
)
tests/settings.py
25 25
        }
26 26
    },
27 27
}
28

  
29
SITE_BASE_URL = 'https://example.com'
tests/test_agendas.py
4 4
import requests
5 5

  
6 6

  
7
from django.contrib.auth.models import Group
7
from django.contrib.auth.models import Group, User
8 8
from django.core.files.base import ContentFile
9 9
from django.core.management import call_command
10 10
from django.utils.timezone import localtime, make_aware, now
......
23 23
    TimePeriodExceptionSource,
24 24
    VirtualMember,
25 25
    EventCancellationReport,
26
    AgendaNotificationsSettings,
26 27
)
27 28

  
28 29
pytestmark = pytest.mark.django_db
......
1075 1076
    freezer.move_to('2020-03-01')
1076 1077
    call_command('cancel_events')
1077 1078
    assert not EventCancellationReport.objects.exists()
1079

  
1080

  
1081
@mock.patch('django.contrib.auth.models.Group.role', create=True)
1082
@pytest.mark.parametrize(
1083
    'emails_to_members,emails',
1084
    [(False, []), (False, ['test@entrouvert.com']), (True, []), (True, ['test@entrouvert.com']),],
1085
)
1086
def test_agenda_notifications_role_email(mocked_role, emails_to_members, emails, mailoutbox):
1087
    group = Group.objects.create(name='group')
1088
    user = User.objects.create(username='user', email='user@entrouvert.com')
1089
    user.groups.add(group)
1090
    mocked_role.emails_to_members = emails_to_members
1091
    mocked_role.emails = emails
1092
    expected_recipients = emails
1093
    if emails_to_members:
1094
        expected_recipients.append(user.email)
1095
    expected_email_count = 1 if emails else 0
1096

  
1097
    agenda = Agenda.objects.create(label='Foo bar', kind='event', edit_role=group)
1098

  
1099
    event = Event.objects.create(agenda=agenda, places=10, start_datetime=now(), label='Hop')
1100
    settings = AgendaNotificationsSettings.objects.create(agenda=agenda)
1101
    settings.almost_full_event = AgendaNotificationsSettings.EDIT_ROLE
1102
    settings.save()
1103

  
1104
    # book 9/10 places to reach almost full state
1105
    for i in range(9):
1106
        Booking.objects.create(event=event)
1107
    event.refresh_from_db()
1108
    assert event.almost_full
1109

  
1110
    call_command('send_email_notifications')
1111
    assert len(mailoutbox) == expected_email_count
1112
    if mailoutbox:
1113
        assert mailoutbox[0].recipients() == expected_recipients
1114
        assert mailoutbox[0].subject == 'Alert: event "Hop" is almost full (90%)'
1115

  
1116
    # no new email on subsequent run
1117
    call_command('send_email_notifications')
1118
    assert len(mailoutbox) == expected_email_count
1119

  
1120

  
1121
def test_agenda_notifications_email_list(mailoutbox):
1122
    agenda = Agenda.objects.create(label='Foo bar', kind='event')
1123

  
1124
    event = Event.objects.create(agenda=agenda, places=10, start_datetime=now(), label='Hop')
1125
    settings = AgendaNotificationsSettings.objects.create(agenda=agenda)
1126
    settings.full_event = AgendaNotificationsSettings.EMAIL_FIELD
1127
    settings.full_event_emails = recipients = ['hop@entrouvert.com', 'top@entrouvert.com']
1128
    settings.save()
1129

  
1130
    for i in range(10):
1131
        Booking.objects.create(event=event)
1132
    event.refresh_from_db()
1133
    assert event.full
1134

  
1135
    call_command('send_email_notifications')
1136
    assert len(mailoutbox) == 1
1137
    assert mailoutbox[0].recipients() == recipients
1138
    assert mailoutbox[0].subject == 'Alert: event "Hop" is full'
1139
    assert (
1140
        'view it here: https://example.com/manage/agendas/%s/events/%s/' % (agenda.pk, event.pk,)
1141
        in mailoutbox[0].body
1142
    )
1143

  
1144
    # no new email on subsequent run
1145
    call_command('send_email_notifications')
1146
    assert len(mailoutbox) == 1
1147

  
1148

  
1149
def test_agenda_notifications_cancelled(mailoutbox):
1150
    agenda = Agenda.objects.create(label='Foo bar', kind='event')
1151

  
1152
    event = Event.objects.create(agenda=agenda, places=10, start_datetime=now(), label='Hop')
1153
    settings = AgendaNotificationsSettings.objects.create(agenda=agenda)
1154
    settings.cancelled_event = AgendaNotificationsSettings.EMAIL_FIELD
1155
    settings.cancelled_event_emails = recipients = ['hop@entrouvert.com', 'top@entrouvert.com']
1156
    settings.save()
1157

  
1158
    event.cancelled = True
1159
    event.save()
1160

  
1161
    call_command('send_email_notifications')
1162
    assert len(mailoutbox) == 1
1163
    assert mailoutbox[0].recipients() == recipients
1164
    assert mailoutbox[0].subject == 'Alert: event "Hop" is cancelled'
1165

  
1166
    # no new email on subsequent run
1167
    call_command('send_email_notifications')
1168
    assert len(mailoutbox) == 1
tests/test_import_export.py
28 28
    AgendaImportError,
29 29
    MeetingType,
30 30
    VirtualMember,
31
    AgendaNotificationsSettings,
31 32
)
32 33
from chrono.manager.utils import import_site
33 34

  
......
456 457
    with pytest.raises(AgendaImportError) as excinfo:
457 458
        import_site(payload)
458 459
    assert str(excinfo.value) == 'Bad slug format "meeting-type&"'
460

  
461

  
462
def test_import_export_notification_settings():
463
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
464
    settings = AgendaNotificationsSettings.objects.create(
465
        agenda=agenda,
466
        almost_full_event=AgendaNotificationsSettings.EDIT_ROLE,
467
        full_event=AgendaNotificationsSettings.VIEW_ROLE,
468
        cancelled_event=AgendaNotificationsSettings.EMAIL_FIELD,
469
        cancelled_event_emails=['hop@entrouvert.com', 'top@entrouvert.com'],
470
    )
471
    output = get_output_of_command('export_site')
472
    payload = json.loads(output)
473

  
474
    agenda.delete()
475
    assert not AgendaNotificationsSettings.objects.exists()
476

  
477
    import_site(payload)
478
    agenda = Agenda.objects.first()
479
    AgendaNotificationsSettings.objects.get(
480
        agenda=agenda,
481
        almost_full_event=AgendaNotificationsSettings.EDIT_ROLE,
482
        full_event=AgendaNotificationsSettings.VIEW_ROLE,
483
        cancelled_event=AgendaNotificationsSettings.EMAIL_FIELD,
484
        cancelled_event_emails=['hop@entrouvert.com', 'top@entrouvert.com'],
485
    )
tests/test_manager.py
49 49

  
50 50

  
51 51
@pytest.fixture
52
def manager_user():
52
def managers_group():
53
    group, _ = Group.objects.get_or_create(name='Managers')
54
    return group
55

  
56

  
57
@pytest.fixture
58
def manager_user(managers_group):
53 59
    try:
54 60
        user = User.objects.get(username='manager')
55 61
    except User.DoesNotExist:
56 62
        user = User.objects.create_user('manager', password='manager')
57
    group, created = Group.objects.get_or_create(name='Managers')
58
    if created:
59
        group.save()
60
    user.groups.set([group])
63
    user.groups.set([managers_group])
61 64
    return user
62 65

  
63 66

  
......
3809 3812
    resp = resp.click('Cancel', href='/cancel')
3810 3813
    assert 'event has bookings with no callback url configured' in resp.text
3811 3814
    assert 'Proceed with cancellation' not in resp.text
3815

  
3816

  
3817
def test_agenda_notifications(app, admin_user, managers_group):
3818
    agenda = Agenda.objects.create(label='Events', kind='events')
3819

  
3820
    login(app)
3821
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
3822

  
3823
    assert 'Notifications' in resp.text
3824
    assert 'Notifications are disabled' in resp.text
3825

  
3826
    resp = resp.click('Configure')
3827
    resp.form['almost_full_event'] = 'edit-role'
3828
    resp.form['full_event'] = 'view-role'
3829
    resp.form['cancelled_event'] = 'use-email-field'
3830
    resp.form['cancelled_event_emails'] = 'hop@entrouvert.com, top@entrouvert.com'
3831
    resp = resp.form.submit().follow()
3832

  
3833
    settings = agenda.notifications_settings
3834
    assert settings.almost_full_event == 'edit-role'
3835
    assert settings.full_event == 'view-role'
3836
    assert settings.cancelled_event == 'use-email-field'
3837
    assert settings.cancelled_event_emails == ['hop@entrouvert.com', 'top@entrouvert.com']
3838

  
3839
    assert 'Cancelled event: hop@entrouvert.com, top@entrouvert.com will be notified' in resp.text
3840
    assert not 'Full event:' in resp.text
3841
    assert not 'Almost full event (90%):' in resp.text
3842

  
3843
    agenda.view_role = managers_group
3844
    agenda.edit_role = Group.objects.create(name='hop')
3845
    agenda.save()
3846

  
3847
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
3848
    assert 'Almost full event (90%): Edit Role (hop) will be notified' in resp.text
3849
    assert 'Full event: View Role (Managers) will be notified' in resp.text
3850

  
3851

  
3852
def test_agenda_notifications_no_old_events(app, admin_user, mailoutbox):
3853
    agenda = Agenda.objects.create(label='Events', kind='events')
3854
    event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Old event')
3855
    event.cancelled = True
3856
    event.save()
3857

  
3858
    login(app)
3859
    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
3860

  
3861
    resp = resp.click('Configure')
3862
    resp.form['cancelled_event'] = 'use-email-field'
3863
    resp.form['cancelled_event_emails'] = 'hop@entrouvert.com'
3864
    resp.form.submit()
3865

  
3866
    event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='New event')
3867
    event.cancelled = True
3868
    event.save()
3869

  
3870
    call_command('send_email_notifications')
3871
    # no notification is sent for old event
3872
    assert len(mailoutbox) == 1
3873
    assert 'New event' in mailoutbox[0].subject
3812
-