From c2f3ae77fd8107439378dec37aa40647da96f1d1 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 12:18:32 +0100 Subject: [PATCH 01/66] Notifications: application structure --- fir_notifications/__init__.py | 1 + fir_notifications/apps.py | 10 ++++++++++ fir_notifications/migrations/__init__.py | 0 fir_notifications/models.py | 3 +++ fir_notifications/templatetags/__init__.py | 0 fir_notifications/urls.py | 6 ++++++ fir_notifications/views.py | 0 7 files changed, 20 insertions(+) create mode 100644 fir_notifications/__init__.py create mode 100644 fir_notifications/apps.py create mode 100644 fir_notifications/migrations/__init__.py create mode 100644 fir_notifications/models.py create mode 100644 fir_notifications/templatetags/__init__.py create mode 100644 fir_notifications/urls.py create mode 100644 fir_notifications/views.py diff --git a/fir_notifications/__init__.py b/fir_notifications/__init__.py new file mode 100644 index 00000000..ac79acc6 --- /dev/null +++ b/fir_notifications/__init__.py @@ -0,0 +1 @@ +default_app_config = 'fir_notifications.apps.NotificationsConfig' diff --git a/fir_notifications/apps.py b/fir_notifications/apps.py new file mode 100644 index 00000000..f14bf75d --- /dev/null +++ b/fir_notifications/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class NotificationsConfig(AppConfig): + name = 'fir_notifications' + verbose_name = _('Notifications') + + def ready(self): + pass diff --git a/fir_notifications/migrations/__init__.py b/fir_notifications/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fir_notifications/models.py b/fir_notifications/models.py new file mode 100644 index 00000000..72b97ff3 --- /dev/null +++ b/fir_notifications/models.py @@ -0,0 +1,3 @@ +from __future__ import unicode_literals + +from django.db import models \ No newline at end of file diff --git a/fir_notifications/templatetags/__init__.py b/fir_notifications/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fir_notifications/urls.py b/fir_notifications/urls.py new file mode 100644 index 00000000..83250604 --- /dev/null +++ b/fir_notifications/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls import url + + +urlpatterns = [ + +] \ No newline at end of file diff --git a/fir_notifications/views.py b/fir_notifications/views.py new file mode 100644 index 00000000..e69de29b From 0d0bbced43de73d0f5f05dd3214ca378836d6c2f Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 12:28:13 +0100 Subject: [PATCH 02/66] Notifications: event and method registry --- fir_notifications/registry.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 fir_notifications/registry.py diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py new file mode 100644 index 00000000..16b334b7 --- /dev/null +++ b/fir_notifications/registry.py @@ -0,0 +1,34 @@ +from collections import OrderedDict + + +class Notifications(object): + def __init__(self): + self.methods = OrderedDict() + self.events = OrderedDict() + + def register_method(self, method, name=None, verbose_name=None): + if not method.server_configured: + return + if name is not None: + method.name = name + if verbose_name is not None: + method.verbose_name = verbose_name + if not method.verbose_name: + method.verbose_name = method.name + self.methods[method.name] = method + + def register_event(self, name, signal, model, callback, verbose_name=None): + if verbose_name is None: + verbose_name = name + self.events[name] = verbose_name + + signal.connect(callback, sender=model, dispatch_uid="fir_notifications.{}".format(name)) + + def get_event_choices(self): + return self.events.items() + + def get_method_choices(self): + return [(obj.name, obj.verbose_name) for obj in self.methods.values()] + + +registry = Notifications() \ No newline at end of file From 1edf7dad9a353900eb98c93f840b9b2edf375dc7 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 12:51:24 +0100 Subject: [PATCH 03/66] Notifications: models for notification method configuration and notification template --- fir_notifications/models.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/fir_notifications/models.py b/fir_notifications/models.py index 72b97ff3..9a6c5fea 100644 --- a/fir_notifications/models.py +++ b/fir_notifications/models.py @@ -1,3 +1,38 @@ from __future__ import unicode_literals -from django.db import models \ No newline at end of file +from django.db import models +from django.conf import settings +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ + +from fir_notifications.registry import registry + + +@python_2_unicode_compatible +class MethodConfiguration(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='method_preferences', verbose_name=_('user')) + key = models.CharField(max_length=60, choices=registry.get_method_choices(), verbose_name=_('method')) + value = models.TextField(verbose_name=_('configuration')) + + def __str__(self): + return "{user}: {method} configuration".format(user=self.user, method=self.key) + + class Meta: + verbose_name = _('method configuration') + verbose_name_plural = _('method configurations') + unique_together = (("user", "key"),) + index_together = ["user", "key"] + + +class NotificationTemplate(models.Model): + event = models.CharField(max_length=60, choices=registry.get_event_choices(), verbose_name=_('event')) + business_lines = models.ManyToManyField('incidents.BusinessLine', related_name='+', blank=True, + verbose_name=_('business line')) + subject = models.CharField(max_length=200, blank=True, default="", verbose_name=_('subject')) + short_description = models.TextField(blank=True, default="", verbose_name=_('short description')) + description = models.TextField(blank=True, default="", verbose_name=_('description')) + + class Meta: + verbose_name = _('notification template') + verbose_name_plural = _('notification templates') + From eeed03d2185b75e5494e5b1b84231d5a729e733c Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 12:52:00 +0100 Subject: [PATCH 04/66] Notifications: method configuration form --- fir_notifications/forms.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 fir_notifications/forms.py diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py new file mode 100644 index 00000000..8061bace --- /dev/null +++ b/fir_notifications/forms.py @@ -0,0 +1,23 @@ +import json + +from django.forms import forms + +from fir_notifications.models import MethodConfiguration + + +class MethodConfigurationForm(forms.Form): + def __init__(self, *args, **kwargs): + self.method = kwargs.pop('method') + self.user = kwargs.pop('user', None) + super(MethodConfigurationForm, self).__init__(*args, **kwargs) + for option_id, option_field in self.method.options.items(): + self.fields[option_id] = option_field + self.title = _("Configure %(method)s" % {'method': self.method.verbose_name}) + + def save(self, *args, **kwargs): + if self.user is None: + return None + json_value = json.dumps(self.cleaned_data) + config, created = MethodConfiguration.objects.update_or_create(user=self.user, key=self.method.name, + defaults={'value': json_value}) + return config From 996bc72555f9b3448f38d0f400eb126e6187f006 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 12:55:49 +0100 Subject: [PATCH 05/66] Notifications: template form --- fir_notifications/forms.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py index 8061bace..9cd60cd6 100644 --- a/fir_notifications/forms.py +++ b/fir_notifications/forms.py @@ -1,7 +1,8 @@ import json -from django.forms import forms +from django import forms +from fir_notifications.registry import registry from fir_notifications.models import MethodConfiguration @@ -21,3 +22,10 @@ def save(self, *args, **kwargs): config, created = MethodConfiguration.objects.update_or_create(user=self.user, key=self.method.name, defaults={'value': json_value}) return config + + +class NotificationTemplateForm(forms.ModelForm): + event = forms.ChoiceField(choices=registry.get_event_choices()) + + class Meta: + fields = '__all__' From 1fe944344b42148002f3640fb5815a161d2c25ae Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 12:56:52 +0100 Subject: [PATCH 06/66] Notifications: template admin --- fir_notifications/admin.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 fir_notifications/admin.py diff --git a/fir_notifications/admin.py b/fir_notifications/admin.py new file mode 100644 index 00000000..76a22461 --- /dev/null +++ b/fir_notifications/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin +from django.conf import settings +from django.utils.translation import ugettext_lazy as _, pgettext_lazy + +from fir_plugins.admin import MarkdownModelAdmin +from fir_notifications.models import MethodConfiguration, NotificationTemplate +from fir_notifications.forms import NotificationTemplateForm + + +class NotificationTemplateAdmin(MarkdownModelAdmin): + markdown_fields = ('description', 'short_description') + form = NotificationTemplateForm + list_display = ('event', 'business_lines_list') + + def business_lines_list(self, obj): + bls = obj.business_lines.all() + if bls.count(): + return ', '.join([bl.name for bl in bls]) + return pgettext_lazy('business lines', 'All') + business_lines_list.short_description = _('Business lines') + + +admin.site.register(NotificationTemplate, NotificationTemplateAdmin) +if settings.DEBUG: + admin.site.register(MethodConfiguration) From ffb0e5d31138431656dcf1375e60c7721fc57deb Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 13:01:02 +0100 Subject: [PATCH 07/66] Notifications: DB migration for method configuration and template --- fir_notifications/migrations/0001_initial.py | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 fir_notifications/migrations/0001_initial.py diff --git a/fir_notifications/migrations/0001_initial.py b/fir_notifications/migrations/0001_initial.py new file mode 100644 index 00000000..126b8683 --- /dev/null +++ b/fir_notifications/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.9 on 2017-01-14 13:00 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('incidents', '0009_add_incicent_permissions'), + ] + + operations = [ + migrations.CreateModel( + name='MethodConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=60, verbose_name='method')), + ('value', models.TextField(verbose_name='configuration')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='method_preferences', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'method configuration', + 'verbose_name_plural': 'method configurations', + }, + ), + migrations.CreateModel( + name='NotificationTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(max_length=60, verbose_name='event')), + ('subject', models.CharField(blank=True, default='', max_length=200, verbose_name='subject')), + ('short_description', models.TextField(blank=True, default='', verbose_name='short description')), + ('description', models.TextField(blank=True, default='', verbose_name='description')), + ('business_lines', models.ManyToManyField(blank=True, related_name='_notificationtemplate_business_lines_+', to='incidents.BusinessLine', verbose_name='business line')), + ], + options={ + 'verbose_name': 'notification template', + 'verbose_name_plural': 'notification templates', + }, + ), + migrations.AlterUniqueTogether( + name='methodconfiguration', + unique_together=set([('user', 'key')]), + ), + migrations.AlterIndexTogether( + name='methodconfiguration', + index_together=set([('user', 'key')]), + ), + ] From aa1ca012921475f0c8a2ec9e75b4b8f04060403b Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 13:03:52 +0100 Subject: [PATCH 08/66] Notifications: user preference model --- fir_notifications/models.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/fir_notifications/models.py b/fir_notifications/models.py index 9a6c5fea..816a7081 100644 --- a/fir_notifications/models.py +++ b/fir_notifications/models.py @@ -36,3 +36,22 @@ class Meta: verbose_name = _('notification template') verbose_name_plural = _('notification templates') + +@python_2_unicode_compatible +class NotificationPreference(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='notification_preferences', verbose_name=_('user')) + event = models.CharField(max_length=60, verbose_name=_('event')) + method = models.CharField(max_length=60, verbose_name=_('method')) + business_lines = models.ManyToManyField('incidents.BusinessLine', related_name='+', blank=True, + verbose_name=_('business lines')) + + def __str__(self): + return "{user}: {event} notification preference for {method}".format(user=self.user, + event=self.event, + method=self.method) + + class Meta: + verbose_name = _('notification preference') + verbose_name_plural = _('notification preferences') + unique_together = (("user", "event", "method"),) + index_together = ["user", "event", "method"] From 673e8551b620641be2ed28a182d279aac652fe33 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 13:04:41 +0100 Subject: [PATCH 09/66] Notifications: DB migration for user preference --- .../migrations/0002_user_preference.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 fir_notifications/migrations/0002_user_preference.py diff --git a/fir_notifications/migrations/0002_user_preference.py b/fir_notifications/migrations/0002_user_preference.py new file mode 100644 index 00000000..4e4a281b --- /dev/null +++ b/fir_notifications/migrations/0002_user_preference.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.9 on 2017-01-14 13:03 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('incidents', '0009_add_incicent_permissions'), + ('fir_notifications', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationPreference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(max_length=60, verbose_name='event')), + ('method', models.CharField(max_length=60, verbose_name='method')), + ('business_lines', models.ManyToManyField(blank=True, related_name='_notificationpreference_business_lines_+', to='incidents.BusinessLine', verbose_name='business lines')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preferences', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'notification preference', + 'verbose_name_plural': 'notification preferences', + }, + ), + migrations.AlterUniqueTogether( + name='notificationpreference', + unique_together=set([('user', 'event', 'method')]), + ), + migrations.AlterIndexTogether( + name='notificationpreference', + index_together=set([('user', 'event', 'method')]), + ), + ] From f38f137f7e75346156ede8566e4f27a99e1b6be0 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 13:15:32 +0100 Subject: [PATCH 10/66] Notifications: method base class --- fir_notifications/methods/__init__.py | 105 ++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 fir_notifications/methods/__init__.py diff --git a/fir_notifications/methods/__init__.py b/fir_notifications/methods/__init__.py new file mode 100644 index 00000000..cd8a6519 --- /dev/null +++ b/fir_notifications/methods/__init__.py @@ -0,0 +1,105 @@ +from django.template import Template, Context + +import json + + +class NotificationMethod(object): + """ + Base class for a notification method. + + Subclass this class to create a new notification method + """ + name = 'method_template' + verbose_name = 'Notification method template' + # This notification method uses the template subject + use_subject = False + # This notification method uses the template short description + use_short_description = False + # This notification method uses the template description + use_description = False + # Method configuration options (dict: index_name: form field instance) + options = {} + + def __init__(self): + self.server_configured = False + + def enabled(self, event, user, paths): + """ + Checks if this method is enabled for an event and its business lines in the user preferences + """ + from fir_notifications.models import NotificationPreference + try: + preference = NotificationPreference.objects.get(event=event, method=self.name, user=user) + except NotificationPreference.DoesNotExist: + return False + for bl in preference.business_lines.all(): + if any([bl.path.startswith(path) for path in paths]): + return True + return False + + @staticmethod + def prepare(template_object, instance, extra_context=None): + """ + Renders a notification template (subject, description, short description) for a given instance + which fired an event + """ + if extra_context is None: + extra_context = {} + extra_context.update({'instance': instance}) + context = Context(extra_context) + return { + 'subject': Template(getattr(template_object, 'subject', "")).render(context), + 'short_description': Template(getattr(template_object, 'short_description', "")).render(context), + 'description': Template(getattr(template_object, 'description', "")).render(context) + } + + def _get_template(self, templates): + """ + Choose the first matching template in a template list + """ + for template in templates: + if self.use_subject and template.subject is None: + continue + if self.use_short_description and template.short_description is None: + continue + if self.use_description and template.description is None: + continue + return template + return None + + def _get_configuration(self, user): + """ + Retrieve user configuration for this method as a dict + """ + from fir_notifications.models import MethodConfiguration + try: + string_config = MethodConfiguration.objects.get(user=user, key=self.name).value + except MethodConfiguration.DoesNotExist: + return {} + try: + return json.loads(string_config) + except: + return {} + + def send(self, event, users, instance, paths): + raise NotImplementedError + + def configured(self, user): + """ + Checks if this method is configured for a given user + """ + return self.server_configured and user.is_active + + def form(self, *args, **kwargs): + """ + Returns this method configuration form + """ + from fir_notifications.forms import MethodConfigurationForm + if not len(self.options): + return None + user = kwargs.pop('user', None) + if user is not None: + kwargs['initial'] = self._get_configuration(user) + kwargs['user'] = user + kwargs['method'] = self + return MethodConfigurationForm(*args, **kwargs) From 4f233a8f3c2682db8f7311b55db5a8254f363d5c Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 13:50:22 +0100 Subject: [PATCH 11/66] Notifications: celery task (as a shared task) --- fir_notifications/registry.py | 3 + fir_notifications/tasks.py | 102 ++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 fir_notifications/tasks.py diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py index 16b334b7..56457a0f 100644 --- a/fir_notifications/registry.py +++ b/fir_notifications/registry.py @@ -30,5 +30,8 @@ def get_event_choices(self): def get_method_choices(self): return [(obj.name, obj.verbose_name) for obj in self.methods.values()] + def get_methods(self): + return self.methods.values() + registry = Notifications() \ No newline at end of file diff --git a/fir_notifications/tasks.py b/fir_notifications/tasks.py new file mode 100644 index 00000000..1b1b300d --- /dev/null +++ b/fir_notifications/tasks.py @@ -0,0 +1,102 @@ +from __future__ import absolute_import + +from celery import shared_task +from django.db import models + +from django.contrib.auth.models import User, Permission +from incidents.models import BusinessLine + +_perm_id = None + + +def get_perm_id(): + global _perm_id + if _perm_id is not None: + return _perm_id + perm_obj = Permission.objects.get(content_type__app_label='incidents', + codename='view_incidents') + _perm_id = perm_obj.pk + return _perm_id + + +def get_templates(event, business_line=None): + from fir_notifications.models import NotificationTemplate + templates = list(NotificationTemplate.objects.filter(event=event, business_lines=business_line).order_by('id')) + return templates + + +def get_user_templates(event, business_lines): + global_users = User.objects.filter( + models.Q(groups__permissions=get_perm_id()) | models.Q(user_permissions=get_perm_id()) | models.Q( + is_superuser=True)).distinct() + global_templates = get_templates(event) + # User with global permission => global templates first + users = {user: list(global_templates) for user in global_users} + business_lines = {bl: bl.get_ancestors() for bl in BusinessLine.objects.filter(path__in=business_lines).order_by('depth')} + depth = 1 + all_templates = {} + while len(business_lines): + for lower in business_lines.keys(): + if lower not in all_templates: + all_templates[lower] = [] + path = business_lines[lower] + if len(path) > depth: + current_bl = path[depth-1] + templates = get_templates(event, current_bl) + else: + templates = get_templates(event, lower) + business_lines.pop(lower, None) + current_bl = lower + if len(templates): + users_done = [] + # User with global permission => top-down + for user in global_users: + users[user].extend(templates) + users_done.append(user) + role_users = User.objects.filter(accesscontrolentry__business_line=current_bl).filter( + accesscontrolentry__role__permissions=get_perm_id()).distinct() + # User with bl role => this bl templates first + for user in role_users: + users_done.append(user) + if user not in users: + to_add = list(templates) + to_add.extend(all_templates[lower]) + users[user] = to_add + else: + users[user] = list(templates).extend(users[user]) + # Other users => append the templates + for user in users: + if user not in users_done: + users[user].extend(templates) + all_templates[lower].extend(templates) + else: + role_users = User.objects.filter(accesscontrolentry__business_line=current_bl).filter( + accesscontrolentry__role__permissions=get_perm_id()).distinct() + for user in role_users: + if user not in users: + users[user] = list(all_templates[lower]) + depth += 1 + # User without global permission => global templates last + for user in users: + if user not in global_users: + users[user].extend(global_templates) + return users + + +@shared_task +def handle_notification(content_type, instance, business_lines, event): + from fir_notifications.registry import registry + from django.contrib.contenttypes.models import ContentType + try: + model = ContentType.objects.get_for_id(content_type).model_class() + except ContentType.DoesNotExist: + print("Unknown content type") + return + try: + instance = model.objects.get(id=instance) + except model.DoesNotExist: + print("Unknown instance") + return + users = get_user_templates(event, business_lines) + for method in registry.get_methods(): + method.send(event, users, instance, business_lines) From d54f0cd2d25bfa70986d291825cd5a4ae9a8086d Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 14:12:30 +0100 Subject: [PATCH 12/66] Notifications: Fix forms.py imports --- fir_notifications/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py index 9cd60cd6..440d0345 100644 --- a/fir_notifications/forms.py +++ b/fir_notifications/forms.py @@ -1,6 +1,7 @@ import json from django import forms +from django.utils.translation import ugettext_lazy as _ from fir_notifications.registry import registry from fir_notifications.models import MethodConfiguration From 1b0c4646aa2485a5130567dc984830aaaa55bd34 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 14:14:12 +0100 Subject: [PATCH 13/66] Notifications: Add method configuration view --- .../templates/fir_notifications/actions.html | 4 ++ .../fir_notifications/actions_form.html | 54 +++++++++++++++++++ .../plugins/user_profile_actions.html | 4 ++ .../templatetags/notifications.py | 24 +++++++++ fir_notifications/urls.py | 4 +- fir_notifications/views.py | 17 ++++++ 6 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 fir_notifications/templates/fir_notifications/actions.html create mode 100644 fir_notifications/templates/fir_notifications/actions_form.html create mode 100644 fir_notifications/templates/fir_notifications/plugins/user_profile_actions.html create mode 100644 fir_notifications/templatetags/notifications.py diff --git a/fir_notifications/templates/fir_notifications/actions.html b/fir_notifications/templates/fir_notifications/actions.html new file mode 100644 index 00000000..f164cde2 --- /dev/null +++ b/fir_notifications/templates/fir_notifications/actions.html @@ -0,0 +1,4 @@ +{% load i18n %} +{% for method, method_name in actions.items %} +
  • {% blocktrans %}Configure {{ method_name }}{% endblocktrans %}
  • +{% endfor %} \ No newline at end of file diff --git a/fir_notifications/templates/fir_notifications/actions_form.html b/fir_notifications/templates/fir_notifications/actions_form.html new file mode 100644 index 00000000..a5d4fe01 --- /dev/null +++ b/fir_notifications/templates/fir_notifications/actions_form.html @@ -0,0 +1,54 @@ +{% load i18n %} +{% load add_css_class %} + +{% for method, method_form in actions.items %} + +{% endfor %} + \ No newline at end of file diff --git a/fir_notifications/templates/fir_notifications/plugins/user_profile_actions.html b/fir_notifications/templates/fir_notifications/plugins/user_profile_actions.html new file mode 100644 index 00000000..46f9e736 --- /dev/null +++ b/fir_notifications/templates/fir_notifications/plugins/user_profile_actions.html @@ -0,0 +1,4 @@ +{% load notifications %} + +{% notification_forms %} +{% notification_actions %} \ No newline at end of file diff --git a/fir_notifications/templatetags/notifications.py b/fir_notifications/templatetags/notifications.py new file mode 100644 index 00000000..21de55c9 --- /dev/null +++ b/fir_notifications/templatetags/notifications.py @@ -0,0 +1,24 @@ +from django import template + +from fir_notifications.registry import registry + +register = template.Library() + + +@register.inclusion_tag('fir_notifications/actions.html') +def notification_actions(): + actions = {} + for method_name, method_object in registry.methods.items(): + if len(method_object.options): + actions[method_name] = method_object.verbose_name + return {'actions': actions} + + +@register.inclusion_tag('fir_notifications/actions_form.html', takes_context=True) +def notification_forms(context): + actions = {} + for method_name, method_object in registry.methods.items(): + if len(method_object.options): + actions[method_name] = method_object.form(user=context['user']) + return {'actions': actions} + diff --git a/fir_notifications/urls.py b/fir_notifications/urls.py index 83250604..dbb8a939 100644 --- a/fir_notifications/urls.py +++ b/fir_notifications/urls.py @@ -1,6 +1,8 @@ from django.conf.urls import url +from fir_notifications import views -urlpatterns = [ +urlpatterns = [ + url(r'^preferences/(?P[a-zA-Z0-9_]+)$', views.method_configuration, name='method_configuration'), ] \ No newline at end of file diff --git a/fir_notifications/views.py b/fir_notifications/views.py index e69de29b..e34e44e7 100644 --- a/fir_notifications/views.py +++ b/fir_notifications/views.py @@ -0,0 +1,17 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect +from django.views.decorators.http import require_POST + +from fir_notifications.registry import registry + + +@require_POST +@login_required +def method_configuration(request, method): + method_object = registry.methods.get(method, None) + if method is None: + return redirect('user:profile') + form = method_object.form(request.POST, user=request.user) + if form.is_valid(): + form.save() + return redirect('user:profile') \ No newline at end of file From d4ae90ba6fcf88ef2504cb86596676c1aab98e0f Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 14:22:07 +0100 Subject: [PATCH 14/66] Notifications: add Email method --- fir_notifications/forms.py | 12 ++++ fir_notifications/methods/__init__.py | 15 ++++- fir_notifications/methods/email.py | 83 +++++++++++++++++++++++++++ fir_notifications/registry.py | 5 +- 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 fir_notifications/methods/email.py diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py index 440d0345..30743c43 100644 --- a/fir_notifications/forms.py +++ b/fir_notifications/forms.py @@ -30,3 +30,15 @@ class NotificationTemplateForm(forms.ModelForm): class Meta: fields = '__all__' + + +class EmailMethodConfigurationForm(MethodConfigurationForm): + def save(self, *args, **kwargs): + if self.user is None or not self.user.email: + return None + try: + from djembe.models import Identity + except ImportError: + return None + config, created = Identity.objects.update_or_create(address=self.user.email, defaults=self.cleaned_data) + return config diff --git a/fir_notifications/methods/__init__.py b/fir_notifications/methods/__init__.py index cd8a6519..48a13829 100644 --- a/fir_notifications/methods/__init__.py +++ b/fir_notifications/methods/__init__.py @@ -1,6 +1,19 @@ +import json + from django.template import Template, Context +from django.conf import settings -import json + +class FakeRequest(object): + def __init__(self): + self.base = settings.EXTERNAL_URL + if self.base.endswith('/'): + self.base = self.base[:-1] + + def build_absolute_uri(self, location): + return "{}{}".format(self.base, location) + +request = FakeRequest() class NotificationMethod(object): diff --git a/fir_notifications/methods/email.py b/fir_notifications/methods/email.py new file mode 100644 index 00000000..bc80bafe --- /dev/null +++ b/fir_notifications/methods/email.py @@ -0,0 +1,83 @@ +import markdown2 +from django import forms +from django.conf import settings +from django.core import mail +from django.utils.translation import ugettext_lazy as _ + + +from fir_notifications.methods import NotificationMethod, request +from fir_plugins.links import registry as link_registry + + +class EmailMethod(NotificationMethod): + use_subject = True + use_description = True + name = 'email' + verbose_name = 'Email' + + def __init__(self): + super(NotificationMethod, self).__init__() + if hasattr(settings, 'NOTIFICATIONS_EMAIL_FROM'): + self.server_configured = True + if 'djembe' in settings.INSTALLED_APPS: + self.options['certificate'] = forms.CharField(required=False, + widget=forms.Textarea(attrs={'cols': 60, 'rows': 15}), + help_text=_('Encryption certificate in PEM format.')) + + def send(self, event, users, instance, paths): + from_address = settings.NOTIFICATIONS_EMAIL_FROM + reply_to = {} + if hasattr(settings, 'NOTIFICATIONS_EMAIL_REPLY_TO'): + reply_to = {'Reply-To': settings.NOTIFICATIONS_EMAIL_REPLY_TO, + 'Return-Path': settings.NOTIFICATIONS_EMAIL_REPLY_TO} + messages = [] + for user, templates in users.items(): + if not self.enabled(event, user, paths) or not user.email: + continue + template = self._get_template(templates) + if template is None: + continue + params = self.prepare(template, instance) + e = mail.EmailMultiAlternatives( + subject=params['subject'], + body=params['description'], + from_email=from_address, + to=[user.email, ], + headers=reply_to + ) + e.attach_alternative(markdown2.markdown(params['description'], extras=["link-patterns"], + link_patterns=link_registry.link_patterns(request), safe_mode=True), + 'text/html') + messages.append(e) + if len(messages): + connection = mail.get_connection() + connection.send_messages(messages) + + def configured(self, user): + return super(EmailMethod, self).configured(user) and user.email is not None + + def _get_configuration(self, user): + if not user.email: + return {} + try: + from djembe.models import Identity + except ImportError: + return {} + try: + identity = Identity.objects.get(address=user.email) + except Identity.DoesNotExist: + return {} + except Identity.MultipleObjectsReturned: + identity = Identity.objects.filter(address=user.email).first() + return {'certificate': identity.certificate} + + def form(self, *args, **kwargs): + from fir_notifications.forms import EmailMethodConfigurationForm + if not len(self.options): + return None + user = kwargs.pop('user', None) + if user is not None: + kwargs['initial'] = self._get_configuration(user) + kwargs['user'] = user + kwargs['method'] = self + return EmailMethodConfigurationForm(*args, **kwargs) diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py index 56457a0f..d90fbcba 100644 --- a/fir_notifications/registry.py +++ b/fir_notifications/registry.py @@ -1,5 +1,7 @@ from collections import OrderedDict +from fir_notifications.methods.email import EmailMethod + class Notifications(object): def __init__(self): @@ -34,4 +36,5 @@ def get_methods(self): return self.methods.values() -registry = Notifications() \ No newline at end of file +registry = Notifications() +registry.register_method(EmailMethod()) From 387e6f72c3334919895c0281d1c2bf9f1fa57a21 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 14:25:21 +0100 Subject: [PATCH 15/66] Notifications: add user preference in admin if DEBUG --- fir_notifications/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fir_notifications/admin.py b/fir_notifications/admin.py index 76a22461..5749e746 100644 --- a/fir_notifications/admin.py +++ b/fir_notifications/admin.py @@ -3,7 +3,7 @@ from django.utils.translation import ugettext_lazy as _, pgettext_lazy from fir_plugins.admin import MarkdownModelAdmin -from fir_notifications.models import MethodConfiguration, NotificationTemplate +from fir_notifications.models import MethodConfiguration, NotificationTemplate, NotificationPreference from fir_notifications.forms import NotificationTemplateForm @@ -22,4 +22,5 @@ def business_lines_list(self, obj): admin.site.register(NotificationTemplate, NotificationTemplateAdmin) if settings.DEBUG: + admin.site.register(NotificationPreference) admin.site.register(MethodConfiguration) From ad107cd00abe0788a5f4a49a33a7cd748a37b108 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 15:45:18 +0100 Subject: [PATCH 16/66] Notifications: fix super class init call in email method --- fir_notifications/methods/email.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fir_notifications/methods/email.py b/fir_notifications/methods/email.py index bc80bafe..3c0eb734 100644 --- a/fir_notifications/methods/email.py +++ b/fir_notifications/methods/email.py @@ -16,7 +16,7 @@ class EmailMethod(NotificationMethod): verbose_name = 'Email' def __init__(self): - super(NotificationMethod, self).__init__() + super(EmailMethod, self).__init__() if hasattr(settings, 'NOTIFICATIONS_EMAIL_FROM'): self.server_configured = True if 'djembe' in settings.INSTALLED_APPS: From a5f2523e91eb13958bd5e991abf3e522a77573b7 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 21:20:46 +0100 Subject: [PATCH 17/66] Notifications: add default setting EXTERNAL_URL --- fir/config/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fir/config/base.py b/fir/config/base.py index 9f352824..dbe3f0d9 100755 --- a/fir/config/base.py +++ b/fir/config/base.py @@ -155,3 +155,6 @@ # User can change his password 'CHANGE_PASSWORD': True } + +# External URL of your FIR application (used in fir_notification to render full URIs in templates) +EXTERNAL_URL = 'http://fir.example.com' From f03a98e8ba699e4d1243e502dba8eef421697968 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 21:22:39 +0100 Subject: [PATCH 18/66] Notifications: improve event registry --- fir_notifications/registry.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py index d90fbcba..79697c98 100644 --- a/fir_notifications/registry.py +++ b/fir_notifications/registry.py @@ -1,8 +1,26 @@ from collections import OrderedDict +from django.apps import apps +from django.utils.encoding import python_2_unicode_compatible + from fir_notifications.methods.email import EmailMethod +@python_2_unicode_compatible +class RegisteredEvent(object): + def __init__(self, name, model, verbose_name=None, section = None): + self.name = name + if section is None: + section = apps.get_app_config(model._meta.app_label).verbose_name + self.section = section + if verbose_name is None: + verbose_name = name + self.verbose_name = verbose_name + + def __str__(self): + return self.verbose_name + + class Notifications(object): def __init__(self): self.methods = OrderedDict() @@ -19,15 +37,15 @@ def register_method(self, method, name=None, verbose_name=None): method.verbose_name = method.name self.methods[method.name] = method - def register_event(self, name, signal, model, callback, verbose_name=None): + def register_event(self, name, signal, model, callback, verbose_name=None, section=None): if verbose_name is None: verbose_name = name - self.events[name] = verbose_name + self.events[name] = RegisteredEvent(name, model, verbose_name=verbose_name, section=section) signal.connect(callback, sender=model, dispatch_uid="fir_notifications.{}".format(name)) def get_event_choices(self): - return self.events.items() + return [(obj.name, obj.verbose_name) for obj in self.events.values()] def get_method_choices(self): return [(obj.name, obj.verbose_name) for obj in self.methods.values()] From ac1178b7e5df9f66a71c262190b109e7ef509ee1 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 21:24:00 +0100 Subject: [PATCH 19/66] Notifications: add notification user preferences view --- fir_notifications/forms.py | 75 +++++++++++++++++++ .../plugins/user_profile.html | 3 + .../fir_notifications/preferences.html | 39 ++++++++++ fir_notifications/urls.py | 1 + fir_notifications/views.py | 37 ++++++++- 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 fir_notifications/templates/fir_notifications/plugins/user_profile.html create mode 100644 fir_notifications/templates/fir_notifications/preferences.html diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py index 30743c43..b46195b1 100644 --- a/fir_notifications/forms.py +++ b/fir_notifications/forms.py @@ -1,4 +1,5 @@ import json +from collections import OrderedDict from django import forms from django.utils.translation import ugettext_lazy as _ @@ -42,3 +43,77 @@ def save(self, *args, **kwargs): return None config, created = Identity.objects.update_or_create(address=self.user.email, defaults=self.cleaned_data) return config + + +class NotificationPreferenceFormset(forms.BaseInlineFormSet): + def __init__(self, *args, **kwargs): + self.notifications = OrderedDict() + for e, verbose_e in registry.events.items(): + for m, verbose_m in registry.methods.items(): + self.notifications["{}_{}".format(e, m)] = {'event': e, + 'verbose_event': verbose_e, + 'method': m, + 'verbose_method': verbose_m.verbose_name} + self.min_num = len(self.notifications) + self.max_num = len(self.notifications) + self.can_delete = False + super(NotificationPreferenceFormset, self).__init__(*args, **kwargs) + + def _construct_form(self, i, **kwargs): + method = None + event = None + if self.is_bound and i < self.initial_form_count(): + pk_key = "%s-%s" % (self.add_prefix(i), self.model._meta.pk.name) + pk = self.data[pk_key] + pk_field = self.model._meta.pk + to_python = self._get_to_python(pk_field) + pk = to_python(pk) + instance = self._existing_object(pk) + notification = self.notifications.pop("{}_{}".format(instance.event, instance.method)) + event = notification['verbose_event'] + method = notification['verbose_method'] + kwargs['instance'] = instance + if i < self.initial_form_count() and 'instance' not in kwargs: + instance = self.get_queryset()[i] + notification = self.notifications.pop("{}_{}".format(instance.event, instance.method)) + event = notification['verbose_event'] + method = notification['verbose_method'] + kwargs['instance'] = self.get_queryset()[i] + if i >= self.initial_form_count() and self.notifications: + # Set initial values for extra forms + try: + key, initial = self.notifications.popitem() + event = initial['verbose_event'] + method = initial['method'] + kwargs['initial'] = {'event': initial['event'], 'method': initial['method']} + except IndexError: + pass + form = forms.BaseFormSet._construct_form(self, i, **kwargs) + if self.save_as_new: + # Remove the primary key from the form's data, we are only + # creating new instances + form.data[form.add_prefix(self._pk_field.name)] = None + + # Remove the foreign key from the form's data + form.data[form.add_prefix(self.fk.name)] = None + + # Set the fk value here so that the form can do its validation. + fk_value = self.instance.pk + if self.fk.remote_field.field_name != self.fk.remote_field.model._meta.pk.name: + fk_value = getattr(self.instance, self.fk.remote_field.field_name) + fk_value = getattr(fk_value, 'pk', fk_value) + setattr(form.instance, self.fk.get_attname(), fk_value) + setattr(form, 'get_notification_display', lambda: u"{} via {}".format(event.verbose_name, method)) + setattr(form, 'get_event', lambda: event) + return form + + @property + def labelled_forms(self): + fs_forms = {} + for form in self.forms: + label = form.get_event().section + if label not in fs_forms: + fs_forms[label] = [] + fs_forms[label].append(form) + fs_forms[label] = sorted(fs_forms[label], key=lambda form: form.get_event().name) + return fs_forms diff --git a/fir_notifications/templates/fir_notifications/plugins/user_profile.html b/fir_notifications/templates/fir_notifications/plugins/user_profile.html new file mode 100644 index 00000000..5a810fa2 --- /dev/null +++ b/fir_notifications/templates/fir_notifications/plugins/user_profile.html @@ -0,0 +1,3 @@ +
    + +
    \ No newline at end of file diff --git a/fir_notifications/templates/fir_notifications/preferences.html b/fir_notifications/templates/fir_notifications/preferences.html new file mode 100644 index 00000000..cae26661 --- /dev/null +++ b/fir_notifications/templates/fir_notifications/preferences.html @@ -0,0 +1,39 @@ +{% load i18n %} +{% load add_css_class %} + +{% if formset.forms %} +
    +
    +
    +

    {% trans "Notification preferences" %}

    +
    +
    +
    +
    + {% csrf_token %} + {{ formset.management_form }} + {% for label, forms in formset.labelled_forms.items %} +
    + {{ label }} + {% for form in forms %} +
    +
    + + {{ form.business_lines|add_css_class:"form-control" }} + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} +
    +
    + {% endfor %} +
    + {% endfor %} +
    +
    + +
    +
    +
    +
    +
    +{% endif %} \ No newline at end of file diff --git a/fir_notifications/urls.py b/fir_notifications/urls.py index dbb8a939..f7d8dff9 100644 --- a/fir_notifications/urls.py +++ b/fir_notifications/urls.py @@ -4,5 +4,6 @@ urlpatterns = [ + url(r'^preferences$', views.preferences, name='preferences'), url(r'^preferences/(?P[a-zA-Z0-9_]+)$', views.method_configuration, name='method_configuration'), ] \ No newline at end of file diff --git a/fir_notifications/views.py b/fir_notifications/views.py index e34e44e7..100cedd9 100644 --- a/fir_notifications/views.py +++ b/fir_notifications/views.py @@ -1,9 +1,15 @@ from django.contrib.auth.decorators import login_required -from django.shortcuts import redirect +from django import forms +from django.shortcuts import redirect, render from django.views.decorators.http import require_POST +from django.contrib.auth import get_user_model +from fir_notifications.forms import NotificationPreferenceFormset +from fir_notifications.models import NotificationPreference from fir_notifications.registry import registry +from incidents.models import BusinessLine + @require_POST @login_required @@ -14,4 +20,31 @@ def method_configuration(request, method): form = method_object.form(request.POST, user=request.user) if form.is_valid(): form.save() - return redirect('user:profile') \ No newline at end of file + return redirect('user:profile') + + +@login_required +def preferences(request): + + class NotificationPreferenceForm(forms.ModelForm): + event = forms.ChoiceField(choices=registry.get_event_choices(), disabled=True, widget=forms.HiddenInput()) + method = forms.ChoiceField(choices=registry.get_method_choices(), disabled=True, widget=forms.HiddenInput()) + business_lines = forms.ModelMultipleChoiceField(BusinessLine.authorization.for_user(request.user, + 'incidents.view_incidents'), + required=False) + + class Meta: + fields = "__all__" + + formset = forms.inlineformset_factory(get_user_model(), NotificationPreference, + formset=NotificationPreferenceFormset, + form=NotificationPreferenceForm) + if request.method == 'POST': + fs = formset(request.POST, instance=request.user) + if fs.is_valid(): + fs.save() + return redirect('user:profile') + else: + fs = formset(instance=request.user) + + return render(request, "fir_notifications/preferences.html", {'formset': fs}) From 5ad72b22d4cfbac8cb42567a134bf4bb4a8954f8 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 21:38:34 +0100 Subject: [PATCH 20/66] Notifications: add event creation decorator --- fir_notifications/decorators.py | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 fir_notifications/decorators.py diff --git a/fir_notifications/decorators.py b/fir_notifications/decorators.py new file mode 100644 index 00000000..ece33cff --- /dev/null +++ b/fir_notifications/decorators.py @@ -0,0 +1,39 @@ +from django.contrib.contenttypes.models import ContentType + +from fir_notifications.registry import registry +from fir_notifications.tasks import handle_notification + +from incidents.models import BusinessLine + + +def notification_event(event, signal, model, verbose_name=None, section=None): + """ + Decorates a Django signal handler to create a notification event + Args: + event: event name + signal: Django signal to listen to + model: Django model sending the signal (and event) + verbose_name: verbose name of the notification event + section: section in the user preference panel (default model application name) + + The signal handler function must return a tuple (model instance, business lines list concerned by the event) + + """ + def decorator_func(func): + def wrapper_func(*args, **kwargs): + instance, business_lines = func(*args, **kwargs) + if instance is None: + return instance, business_lines + if isinstance(business_lines, BusinessLine): + business_lines = [business_lines.path,] + else: + business_lines = list(business_lines.distinct().values_list('path', flat=True)) + handle_notification.delay(ContentType.objects.get_for_model(instance).pk, + instance.pk, + business_lines, + event) + return instance, business_lines + + registry.register_event(event, signal, model, wrapper_func, verbose_name, section) + return wrapper_func + return decorator_func \ No newline at end of file From 66f49a719daefdf030ed9e938bbc6b3e69173b7d Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 21:40:08 +0100 Subject: [PATCH 21/66] Notifications: create event and incident related notification events --- fir_notifications/models.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/fir_notifications/models.py b/fir_notifications/models.py index 816a7081..813ed64b 100644 --- a/fir_notifications/models.py +++ b/fir_notifications/models.py @@ -4,8 +4,10 @@ from django.conf import settings from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ +from fir_notifications.decorators import notification_event from fir_notifications.registry import registry +from incidents.models import model_created, Incident, model_updated @python_2_unicode_compatible @@ -55,3 +57,35 @@ class Meta: verbose_name_plural = _('notification preferences') unique_together = (("user", "event", "method"),) index_together = ["user", "event", "method"] + + +@notification_event('event:created', model_created, Incident, verbose_name=_('Event created'), + section=_('Event')) +def event_created(sender, instance, **kwargs): + if instance.is_incident: + return None, None + return instance, instance.concerned_business_lines + + +@notification_event('incident:created', model_created, Incident, verbose_name=_('Incident created'), + section=_('Incident')) +def incident_created(sender, instance, **kwargs): + if not instance.is_incident: + return None, None + return instance, instance.concerned_business_lines + + +@notification_event('event:updated', model_updated, Incident, verbose_name=_('Event updated'), + section=_('Event')) +def event_created(sender, instance, **kwargs): + if instance.is_incident: + return None, None + return instance, instance.concerned_business_lines + + +@notification_event('incident:updated', model_updated, Incident, verbose_name=_('Incident updated'), + section=_('Incident')) +def incident_created(sender, instance, **kwargs): + if not instance.is_incident: + return None, None + return instance, instance.concerned_business_lines From 53e16eefb055082b4a7301df8f189aff8a63d6ab Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 22:11:13 +0100 Subject: [PATCH 22/66] Notifications: move fake request to methods.utils --- fir_notifications/methods/__init__.py | 13 ------------- fir_notifications/methods/email.py | 3 ++- fir_notifications/methods/utils.py | 13 +++++++++++++ 3 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 fir_notifications/methods/utils.py diff --git a/fir_notifications/methods/__init__.py b/fir_notifications/methods/__init__.py index 48a13829..0cedde9b 100644 --- a/fir_notifications/methods/__init__.py +++ b/fir_notifications/methods/__init__.py @@ -1,19 +1,6 @@ import json from django.template import Template, Context -from django.conf import settings - - -class FakeRequest(object): - def __init__(self): - self.base = settings.EXTERNAL_URL - if self.base.endswith('/'): - self.base = self.base[:-1] - - def build_absolute_uri(self, location): - return "{}{}".format(self.base, location) - -request = FakeRequest() class NotificationMethod(object): diff --git a/fir_notifications/methods/email.py b/fir_notifications/methods/email.py index 3c0eb734..8e33ae4b 100644 --- a/fir_notifications/methods/email.py +++ b/fir_notifications/methods/email.py @@ -5,7 +5,8 @@ from django.utils.translation import ugettext_lazy as _ -from fir_notifications.methods import NotificationMethod, request +from fir_notifications.methods import NotificationMethod +from fir_notifications.methods.utils import request from fir_plugins.links import registry as link_registry diff --git a/fir_notifications/methods/utils.py b/fir_notifications/methods/utils.py new file mode 100644 index 00000000..434414ff --- /dev/null +++ b/fir_notifications/methods/utils.py @@ -0,0 +1,13 @@ +from django.conf import settings + + +class FakeRequest(object): + def __init__(self): + self.base = settings.EXTERNAL_URL + if self.base.endswith('/'): + self.base = self.base[:-1] + + def build_absolute_uri(self, location): + return "{}{}".format(self.base, location) + +request = FakeRequest() \ No newline at end of file From f2781a8816d4ace1de05986d63dc6c47e3b4de8f Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 22:21:34 +0100 Subject: [PATCH 23/66] Notifications: add XMPP method --- fir_notifications/methods/jabber.py | 88 +++++++++++++++++++++++++++++ fir_notifications/registry.py | 2 + fir_notifications/requirements.txt | 1 + 3 files changed, 91 insertions(+) create mode 100644 fir_notifications/methods/jabber.py create mode 100644 fir_notifications/requirements.txt diff --git a/fir_notifications/methods/jabber.py b/fir_notifications/methods/jabber.py new file mode 100644 index 00000000..d5b9abf2 --- /dev/null +++ b/fir_notifications/methods/jabber.py @@ -0,0 +1,88 @@ +import markdown2 +from django.conf import settings + +import xmpppy as xmpp +from django import forms + +from fir_notifications.methods import NotificationMethod +from fir_notifications.methods.utils import request +from fir_plugins.links import registry as link_registry +from django.utils.translation import ugettext_lazy as _ + + +class Client(xmpp.Client): + def __init__(self, *args, **kwargs): + kwargs['debug'] = [] + xmpp.Client.__init__(self, *args, **kwargs) + + def DisconnectHandler(self): + pass + + +class XmppMethod(NotificationMethod): + use_subject = True + use_short_description = True + name = 'xmpp' + verbose_name = 'XMPP' + options = { + 'jid': forms.CharField(max_length=100, label=_('Jabber ID')) + } + + def __init__(self): + super(NotificationMethod, self).__init__() + self.messages = [] + self.jid = getattr(settings, 'NOTIFICATIONS_XMPP_JID', None) + self.password = getattr(settings, 'NOTIFICATIONS_XMPP_PASSWORD', None) + if self.jid is None or self.password is None: + self.server_configured = False + return + self.server = getattr(settings, 'NOTIFICATIONS_XMPP_SERVER', None) + self.port = getattr(settings, 'NOTIFICATIONS_XMPP_SERVER_PORT', 5222) + self.connection_tuple = None + self.use_srv = True + self.jid = xmpp.JID(self.jid) + if self.server is not None: + self.connection_tuple = (self.server, self.port) + self.use_srv = False + self.client = Client(self.jid.getDomain()) + if not self.client.connect(server=self.connection_tuple, use_srv=self.use_srv): + self.server_configured = False + return + if not self.client.auth(self.jid.getNode(), self.password, resource=self.jid.getResource()): + self.server_configured = False + return + self.client.disconnected() + self.server_configured = True + + def send(self, event, users, instance, paths): + self.client.reconnectAndReauth() + for user, templates in users.items(): + jid = self._get_jid(user) + if not self.enabled(event, user, paths) or jid is None: + continue + template = self._get_template(templates) + if template is None: + continue + params = self.prepare(template, instance) + message = xmpp.protocol.Message(jid, body=params['short_description'].encode('utf-8'), + subject=params['subject'].encode('utf-8'), typ='chat') + html = xmpp.Node('html', {'xmlns': 'http://jabber.org/protocol/xhtml-im'}) + text = u"" + markdown2.markdown(params['short_description'], + extras=["link-patterns"], + link_patterns=link_registry.link_patterns( + request), + safe_mode=True) + u"" + html.addChild(node=xmpp.simplexml.XML2Node(text.encode('utf-8'))) + message.addChild(node=html) + + self.client.send(message) + self.client.disconnected() + + def _get_jid(self, user): + config = self._get_configuration(user) + if 'jid' in config: + return xmpp.JID(config['jid']) + return None + + def configured(self, user): + return super(XmppMethod, self).configured(user) and self._get_jid(user) is not None diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py index 79697c98..4de7a5aa 100644 --- a/fir_notifications/registry.py +++ b/fir_notifications/registry.py @@ -4,6 +4,7 @@ from django.utils.encoding import python_2_unicode_compatible from fir_notifications.methods.email import EmailMethod +from fir_notifications.methods.jabber import XmppMethod @python_2_unicode_compatible @@ -56,3 +57,4 @@ def get_methods(self): registry = Notifications() registry.register_method(EmailMethod()) +registry.register_method(XmppMethod()) diff --git a/fir_notifications/requirements.txt b/fir_notifications/requirements.txt new file mode 100644 index 00000000..78bd362e --- /dev/null +++ b/fir_notifications/requirements.txt @@ -0,0 +1 @@ +-e git+https://github.com/ArchipelProject/xmpppy.git@288b280c6ec534c100bfee871daa3bb707467a1a#egg=xmpppy \ No newline at end of file From d4ac392b7edcb02ecc7e7ee31efa3307a122b82c Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 22:26:00 +0100 Subject: [PATCH 24/66] Notifications: add Readme --- fir_notifications/README.md | 106 ++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 fir_notifications/README.md diff --git a/fir_notifications/README.md b/fir_notifications/README.md new file mode 100644 index 00000000..c8cf9000 --- /dev/null +++ b/fir_notifications/README.md @@ -0,0 +1,106 @@ +# Notifications plugin for FIR + +## Features + +This plugins allows you to launch asynchronous tasks with Celery and send notifications to users. + +## Installation + +In your FIR virtualenv, launch: + +```bash +(fir_env)$ pip install -r fir_notifications/requirements.txt +``` + +In *$FIR_HOME/fir/config/installed_app.txt*, add: + +``` +fir_notifications +``` + +In your *$FIR_HOME*, launch: + +```bash +(your_env)$ ./manage.py migrate fir_notifications +(your_env)$ ./manage.py collectstatic -y +``` + +You should configure fir_celery (broker and result backend). + +## Usage + +Users can subscribe to notifications via their profile page. + +Core FIR notifications: +* 'event:created': new event +* 'event:updated': update of an event +* 'incident:created': new incident +* 'incident:updated': update of an incident + +## Configuration + +### Celery + +`fir_notifications` uses the FIR plugin `fir_celery`. + +### Full URL in notification links + +To generate correct URL in notification, `fir_notifications` needs to know the external URL of the FIR site: + +``` python +EXTERNAL_URL = 'https://fir.example.com' +``` + +### Email notifications + +You have to configure [Django email backend](https://docs.djangoproject.com/en/1.9/topics/email/). + +In addition, `fir_notifications` uses two settings: + +``` python +# From address (required) +NOTIFICATIONS_EMAIL_FROM = 'fir@example.com' +# Reply to address (optional) +NOTIFICATIONS_EMAIL_REPLY_TO = None +``` + +To send signed/encrypted email notifications with S/MIME to users, install and configure [django-djembe](https://github.com/cabincode/django-djembe) and add it in your *installed_apps.txt*. + +### Jabber (XMPP) notifications + +Configure `fir_notifications`: + +``` python +# FIR user JID +NOTIFICATIONS_XMPP_JID = 'fir@example.com' +# Password for fir@example.com JID +NOTIFICATIONS_XMPP_PASSWORD = 'my secret password' +# XMPP server +NOTIFICATIONS_XMPP_SERVER = 'localhost' +# XMPP server port +NOTIFICATIONS_XMPP_PORT = 5222 +``` + +### Notification templates + +You have to create notification templates in the Django admin site. + +To render notifications, each notification method can use the fields `subject`, `description` or `short_description`: + +- Email uses `subject` and `description`. +- XMPP uses `subject` and `short_description`. + +## Hacking + +### Adding notification method + +You have to create a subclass of `NotificationMethod` from `fir_notifications.methods` and implement at least the `send` method. You can then register your method with `fir_notification.registry.registry.register_method`. + +If your configuration method needs some additional user defined settings, you have to list them in the class property `options`. See `EmailMethod` and `XmppMethod` for details. + +### Adding notification event + +Use the `@notification_event` decorator defined in `fir_notifications.decorators` to decorate a classic Django signal handler function. This handler must return a tuple with an instance of the notification model and a queryset of the concerned business lines. + + + From 05683de223e55d2d2810ec8c4faeaf5befe3b535 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 22:37:55 +0100 Subject: [PATCH 25/66] Notifications: add certificate form field label --- fir_notifications/methods/email.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fir_notifications/methods/email.py b/fir_notifications/methods/email.py index 8e33ae4b..377d7798 100644 --- a/fir_notifications/methods/email.py +++ b/fir_notifications/methods/email.py @@ -21,7 +21,7 @@ def __init__(self): if hasattr(settings, 'NOTIFICATIONS_EMAIL_FROM'): self.server_configured = True if 'djembe' in settings.INSTALLED_APPS: - self.options['certificate'] = forms.CharField(required=False, + self.options['certificate'] = forms.CharField(required=False, label=_('Certificate'), widget=forms.Textarea(attrs={'cols': 60, 'rows': 15}), help_text=_('Encryption certificate in PEM format.')) From 381455f88122f8cb5e15b860e1b118ae5247d08f Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sat, 14 Jan 2017 22:38:19 +0100 Subject: [PATCH 26/66] Notifications: add French translation --- .../locale/fr/LC_MESSAGES/django.po | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 fir_notifications/locale/fr/LC_MESSAGES/django.po diff --git a/fir_notifications/locale/fr/LC_MESSAGES/django.po b/fir_notifications/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 00000000..dc8f146e --- /dev/null +++ b/fir_notifications/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,151 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-01-14 22:33+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: admin.py:19 +msgctxt "business lines" +msgid "All" +msgstr "Toutes" + +#: admin.py:20 +msgid "Business lines" +msgstr "" + +#: apps.py:7 +msgid "Notifications" +msgstr "" + +#: forms.py:18 +#, python-format +msgid "Configure %(method)s" +msgstr "Configurer %(method)s" + +#: methods/email.py:24 +msgid "Certificate" +msgstr "Certificat" + +#: methods/email.py:26 +msgid "Encryption certificate in PEM format." +msgstr "Certificat de chiffrement au format PEM" + +#: methods/jabber.py:28 +msgid "Jabber ID" +msgstr "" + +#: models.py:15 models.py:44 +msgid "user" +msgstr "utilisateur" + +#: models.py:16 models.py:46 +msgid "method" +msgstr "méthode" + +#: models.py:17 +msgid "configuration" +msgstr "" + +#: models.py:23 +msgid "method configuration" +msgstr "configuration de méthode" + +#: models.py:24 +msgid "method configurations" +msgstr "configurations de méthode" + +#: models.py:30 models.py:45 +msgid "event" +msgstr "événement" + +#: models.py:32 +msgid "business line" +msgstr "" + +#: models.py:33 +msgid "subject" +msgstr "objet" + +#: models.py:34 +msgid "short description" +msgstr "description courte" + +#: models.py:35 +msgid "description" +msgstr "" + +#: models.py:38 +msgid "notification template" +msgstr "gabarit de notification" + +#: models.py:39 +msgid "notification templates" +msgstr "gabarits de notification" + +#: models.py:48 +msgid "business lines" +msgstr "" + +#: models.py:56 +msgid "notification preference" +msgstr "préférence de notification" + +#: models.py:57 +msgid "notification preferences" +msgstr "préférences de notification" + +#: models.py:62 +msgid "Event created" +msgstr "Événement créé" + +#: models.py:63 models.py:79 +msgid "Event" +msgstr "Événement" + +#: models.py:70 +msgid "Incident created" +msgstr "Incident créé" + +#: models.py:71 models.py:87 +msgid "Incident" +msgstr "Incident" + +#: models.py:78 +msgid "Event updated" +msgstr "Événement mis à jour" + +#: models.py:86 +msgid "Incident updated" +msgstr "Incident mis à jour" + +#: templates/fir_notifications/actions.html:3 +#, python-format +msgid "Configure %(method_name)s" +msgstr "Configurer %(method_name)s" + +#: templates/fir_notifications/actions_form.html:38 +msgid "Cancel" +msgstr "Annuler" + +#: templates/fir_notifications/actions_form.html:39 +#: templates/fir_notifications/preferences.html:33 +msgid "Save" +msgstr "Enregistrer" + +#: templates/fir_notifications/preferences.html:8 +msgid "Notification preferences" +msgstr "Préférences de notification" From 71e64bd4994a986eff57a2f4a83acd7ce33d3710 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Sun, 15 Jan 2017 11:47:22 +0100 Subject: [PATCH 27/66] Notifications: fix method display in user preferences --- fir_notifications/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py index b46195b1..8fce4d57 100644 --- a/fir_notifications/forms.py +++ b/fir_notifications/forms.py @@ -84,7 +84,7 @@ def _construct_form(self, i, **kwargs): try: key, initial = self.notifications.popitem() event = initial['verbose_event'] - method = initial['method'] + method = initial['verbose_method'] kwargs['initial'] = {'event': initial['event'], 'method': initial['method']} except IndexError: pass From 89c250e2973d0a6ddb05e862f56bb7de370e14f6 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 16 Jan 2017 07:48:23 +0100 Subject: [PATCH 28/66] Notifications: update Readme: How to send encrypted/signed email notifications (S/MIME) --- fir_notifications/README.md | 48 ++++++++++++++++++++++-- fir_notifications/requirements_smime.txt | 1 + 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 fir_notifications/requirements_smime.txt diff --git a/fir_notifications/README.md b/fir_notifications/README.md index c8cf9000..5f8e0205 100644 --- a/fir_notifications/README.md +++ b/fir_notifications/README.md @@ -9,7 +9,7 @@ This plugins allows you to launch asynchronous tasks with Celery and send notifi In your FIR virtualenv, launch: ```bash -(fir_env)$ pip install -r fir_notifications/requirements.txt +(fir-env)$ pip install -r fir_notifications/requirements.txt ``` In *$FIR_HOME/fir/config/installed_app.txt*, add: @@ -21,8 +21,7 @@ fir_notifications In your *$FIR_HOME*, launch: ```bash -(your_env)$ ./manage.py migrate fir_notifications -(your_env)$ ./manage.py collectstatic -y +(fir-env)$ ./manage.py migrate fir_notifications ``` You should configure fir_celery (broker and result backend). @@ -64,8 +63,51 @@ NOTIFICATIONS_EMAIL_FROM = 'fir@example.com' NOTIFICATIONS_EMAIL_REPLY_TO = None ``` +### S/MIME + To send signed/encrypted email notifications with S/MIME to users, install and configure [django-djembe](https://github.com/cabincode/django-djembe) and add it in your *installed_apps.txt*. +The following configuration example from the Djembe Readme can help you: + +``` bash +(fir-env)$ pip install -r fir_notifications/requirements_smime.txt +(fir-env)$ python manage.py migrate djembe +``` + +In *$FIR_HOME/fir/config/installed_app.txt*, add: + +``` +djembe +``` + +Change your email backend in your settings: + +``` python +EMAIL_BACKEND = 'djembe.backends.EncryptingSMTPBackend' +``` + +To use a cipher other than the default AES-256, specify it in your settings `DJEMBE_CIPHER`: + + +``` python +DJEMBE_CIPHER = 'des_ede3_cbc' # triple DES +``` +The intersection of M2Crypto's ciphers and RFC 3851 are: + +* `des_ede3_cbc` (required by the RFC) +* `aes_128_cbc` (recommended, not required, by the RFC) +* `aes_192_cbc` (recommended, not required, by the RFC) +* `aes_256_cbc` (recommended, not required, by the RFC) +* `rc2_40_cbc` (RFC requires support, but it's weak -- don't use it) + +RFC 5751 requires AES-128, and indicates that higher key lengths are of +course the future. It marks tripleDES with "SHOULD-", meaning it's on its +way out. + +To create signed notifications, in the admin site (*Djembe > Identities*), supply both a certificate and a private key which must not have a passphrase, with an `Address` that is the same as your setting `NOTIFICATIONS_EMAIL_FROM`. Any mail sent *from* this Identity's address will be signed with the private key. + +User certificates will be added from the user profile in FIR (*Configure Email*). + ### Jabber (XMPP) notifications Configure `fir_notifications`: diff --git a/fir_notifications/requirements_smime.txt b/fir_notifications/requirements_smime.txt new file mode 100644 index 00000000..e1abc67f --- /dev/null +++ b/fir_notifications/requirements_smime.txt @@ -0,0 +1 @@ +django-djembe==0.2.0 \ No newline at end of file From 897e2f1feb87aa8e748928126f7347f39b7c69b1 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 16 Jan 2017 08:00:15 +0100 Subject: [PATCH 29/66] Notifications: Readme, more on templates and template selection --- fir_notifications/README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/fir_notifications/README.md b/fir_notifications/README.md index 5f8e0205..25e8e6af 100644 --- a/fir_notifications/README.md +++ b/fir_notifications/README.md @@ -63,7 +63,7 @@ NOTIFICATIONS_EMAIL_FROM = 'fir@example.com' NOTIFICATIONS_EMAIL_REPLY_TO = None ``` -### S/MIME +#### S/MIME To send signed/encrypted email notifications with S/MIME to users, install and configure [django-djembe](https://github.com/cabincode/django-djembe) and add it in your *installed_apps.txt*. @@ -125,13 +125,20 @@ NOTIFICATIONS_XMPP_PORT = 5222 ### Notification templates -You have to create notification templates in the Django admin site. +You have to create at least onenotification template per notification event in the Django admin site. To render notifications, each notification method can use the fields `subject`, `description` or `short_description`: - Email uses `subject` and `description`. - XMPP uses `subject` and `short_description`. +These fields will accept Markdown formatted text and Django template language markups. The Django template context will contain an `instance`object, the instance of the object that fired the notification event. + +The Email `description` will generate a multipart message: a plain text part in Markdown and a HTML part rendered from this Markdown. The XMPP `short_description` will be rendered as HTML from Markdown. + +The template used to send the notification to the user will be chosen from the templates available to this user: +- For a user with global permissions (permission from a Django group), global templates (templates with no nusiness line attached to it) will be preferred. +- For a user with no global permission, the nearest business line template will be preferred, global templates will be used as a fallback. ## Hacking ### Adding notification method From 4eecbe367be660c3acb7a98e3b7a25d29c6a87d4 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 16 Jan 2017 08:01:08 +0100 Subject: [PATCH 30/66] Notifications: update Readme --- fir_notifications/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fir_notifications/README.md b/fir_notifications/README.md index 25e8e6af..356ef40c 100644 --- a/fir_notifications/README.md +++ b/fir_notifications/README.md @@ -2,7 +2,7 @@ ## Features -This plugins allows you to launch asynchronous tasks with Celery and send notifications to users. +This plugins allows you to send notifications to users. ## Installation From 7c46258ff79481392afd1f3c614efb130e04058b Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 16 Jan 2017 08:06:55 +0100 Subject: [PATCH 31/66] Notifications: document the event registry --- fir_notifications/registry.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py index 4de7a5aa..9201679e 100644 --- a/fir_notifications/registry.py +++ b/fir_notifications/registry.py @@ -28,6 +28,13 @@ def __init__(self): self.events = OrderedDict() def register_method(self, method, name=None, verbose_name=None): + """ + Registers a notification method, instance of a subclass of `fir_notifications.methods.NotificationMethod` + Args: + method: instance of the notification method + name: overrides the instance.name + verbose_name: overrides the instance.verbose_name + """ if not method.server_configured: return if name is not None: @@ -39,6 +46,16 @@ def register_method(self, method, name=None, verbose_name=None): self.methods[method.name] = method def register_event(self, name, signal, model, callback, verbose_name=None, section=None): + """ + Registers a notification event + Args: + name: event name + signal: Django signal to listen to + model: Django model sending the signal (and event) + callback: Django signal handler + verbose_name: verbose name of the event + section: section in the user preference panel (default model application name) + """ if verbose_name is None: verbose_name = name self.events[name] = RegisteredEvent(name, model, verbose_name=verbose_name, section=section) From e4f8b513865bf4fd0507d937994ff70abff3c919 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 16 Jan 2017 10:03:40 +0100 Subject: [PATCH 32/66] Notifications: Fix user preferences error (when a method is disabled after preferences where saved for this method) --- fir_notifications/forms.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py index 8fce4d57..b55930cf 100644 --- a/fir_notifications/forms.py +++ b/fir_notifications/forms.py @@ -57,6 +57,13 @@ def __init__(self, *args, **kwargs): self.min_num = len(self.notifications) self.max_num = len(self.notifications) self.can_delete = False + instance = kwargs.get('instance', None) + if instance is not None: + queryset = kwargs.get('queryset', None) + if queryset is None: + queryset = self.model._default_manager + qs = queryset.filter(event__in=registry.events.keys(), method__in= registry.methods.keys()) + kwargs['queryset'] = qs super(NotificationPreferenceFormset, self).__init__(*args, **kwargs) def _construct_form(self, i, **kwargs): From c59ec2225f217a92c5774cccbf389000bbb35d33 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 16 Jan 2017 10:06:46 +0100 Subject: [PATCH 33/66] Notifications: use settings defined in fir_email --- fir_notifications/README.md | 8 ++++---- fir_notifications/methods/email.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/fir_notifications/README.md b/fir_notifications/README.md index 356ef40c..e6ad752e 100644 --- a/fir_notifications/README.md +++ b/fir_notifications/README.md @@ -54,13 +54,13 @@ EXTERNAL_URL = 'https://fir.example.com' You have to configure [Django email backend](https://docs.djangoproject.com/en/1.9/topics/email/). -In addition, `fir_notifications` uses two settings: +In addition, `fir_notifications` uses two settings defined in `fir_email`: ``` python # From address (required) -NOTIFICATIONS_EMAIL_FROM = 'fir@example.com' +EMAIL_FROM = 'fir@example.com' # Reply to address (optional) -NOTIFICATIONS_EMAIL_REPLY_TO = None +REPLY_TO = None ``` #### S/MIME @@ -104,7 +104,7 @@ RFC 5751 requires AES-128, and indicates that higher key lengths are of course the future. It marks tripleDES with "SHOULD-", meaning it's on its way out. -To create signed notifications, in the admin site (*Djembe > Identities*), supply both a certificate and a private key which must not have a passphrase, with an `Address` that is the same as your setting `NOTIFICATIONS_EMAIL_FROM`. Any mail sent *from* this Identity's address will be signed with the private key. +To create signed notifications, in the admin site (*Djembe > Identities*), supply both a certificate and a private key which must not have a passphrase, with an `Address` that is the same as your setting `EMAIL_FROM`. Any mail sent *from* this Identity's address will be signed with the private key. User certificates will be added from the user profile in FIR (*Configure Email*). diff --git a/fir_notifications/methods/email.py b/fir_notifications/methods/email.py index 377d7798..50e28581 100644 --- a/fir_notifications/methods/email.py +++ b/fir_notifications/methods/email.py @@ -18,7 +18,7 @@ class EmailMethod(NotificationMethod): def __init__(self): super(EmailMethod, self).__init__() - if hasattr(settings, 'NOTIFICATIONS_EMAIL_FROM'): + if hasattr(settings, 'EMAIL_FROM') and settings.EMAIL_FROM is not None: self.server_configured = True if 'djembe' in settings.INSTALLED_APPS: self.options['certificate'] = forms.CharField(required=False, label=_('Certificate'), @@ -26,11 +26,11 @@ def __init__(self): help_text=_('Encryption certificate in PEM format.')) def send(self, event, users, instance, paths): - from_address = settings.NOTIFICATIONS_EMAIL_FROM + from_address = settings.EMAIL_FROM reply_to = {} - if hasattr(settings, 'NOTIFICATIONS_EMAIL_REPLY_TO'): - reply_to = {'Reply-To': settings.NOTIFICATIONS_EMAIL_REPLY_TO, - 'Return-Path': settings.NOTIFICATIONS_EMAIL_REPLY_TO} + if hasattr(settings, 'REPLY_TO'): + reply_to = {'Reply-To': settings.REPLY_TO, + 'Return-Path': settings.REPLY_TO} messages = [] for user, templates in users.items(): if not self.enabled(event, user, paths) or not user.email: From 84b6fe1e1afdaea042f86cb67deb46666247a922 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 16 Jan 2017 19:06:02 +0100 Subject: [PATCH 34/66] Notifications: fir_celery integration: remove shared_task and use celery_app --- fir_notifications/tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fir_notifications/tasks.py b/fir_notifications/tasks.py index 1b1b300d..f42f652c 100644 --- a/fir_notifications/tasks.py +++ b/fir_notifications/tasks.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import User, Permission from incidents.models import BusinessLine +from fir_celery.celeryconf import celery_app _perm_id = None @@ -83,7 +84,7 @@ def get_user_templates(event, business_lines): return users -@shared_task +@celery_app.task def handle_notification(content_type, instance, business_lines, event): from fir_notifications.registry import registry from django.contrib.contenttypes.models import ContentType From 73fad53b0a6e3dd2722979fd81d20c7365a76d64 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Tue, 17 Jan 2017 08:13:21 +0100 Subject: [PATCH 35/66] Notifications: rename duplicate signal handlers --- fir_notifications/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fir_notifications/models.py b/fir_notifications/models.py index 813ed64b..d2d13950 100644 --- a/fir_notifications/models.py +++ b/fir_notifications/models.py @@ -77,7 +77,7 @@ def incident_created(sender, instance, **kwargs): @notification_event('event:updated', model_updated, Incident, verbose_name=_('Event updated'), section=_('Event')) -def event_created(sender, instance, **kwargs): +def event_updated(sender, instance, **kwargs): if instance.is_incident: return None, None return instance, instance.concerned_business_lines @@ -85,7 +85,7 @@ def event_created(sender, instance, **kwargs): @notification_event('incident:updated', model_updated, Incident, verbose_name=_('Incident updated'), section=_('Incident')) -def incident_created(sender, instance, **kwargs): +def incident_updated(sender, instance, **kwargs): if not instance.is_incident: return None, None return instance, instance.concerned_business_lines From 1d9a9ff26a704398d7d17693e7480610af697c35 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Tue, 17 Jan 2017 09:14:55 +0100 Subject: [PATCH 36/66] Notifications: add model_status_changed signal in incidents --- incidents/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/incidents/models.py b/incidents/models.py index f141f61b..34e18a6b 100755 --- a/incidents/models.py +++ b/incidents/models.py @@ -50,6 +50,7 @@ model_created = Signal(providing_args=['instance']) model_updated = Signal(providing_args=['instance']) +model_status_changed = Signal(providing_args=['instance', 'previous_status']) class FIRModel: @@ -198,8 +199,10 @@ def is_open(self): return self.get_last_action != "Closed" def close_timeout(self): + previous_status = self.status self.status = 'C' self.save() + model_status_changed.send(sender=Incident, instance=self, previous_status=previous_status) c = Comments() c.comment = "Incident closed (timeout)" From 41ce12ef048647f5c3d8219341a0f305c6878e54 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Tue, 17 Jan 2017 09:15:27 +0100 Subject: [PATCH 37/66] Notifications: use model_status_changed signal in incidents views --- incidents/views.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/incidents/views.py b/incidents/views.py index 4c07f2aa..19c9afe3 100755 --- a/incidents/views.py +++ b/incidents/views.py @@ -8,7 +8,7 @@ from django.template.response import TemplateResponse from django.views.decorators.http import require_POST -from incidents.models import IncidentCategory, Incident, Comments, BusinessLine +from incidents.models import IncidentCategory, Incident, Comments, BusinessLine, model_status_changed from incidents.models import Label, Log, BaleCategory from incidents.models import Attribute, ValidAttribute, IncidentTemplate, Profile from incidents.models import IncidentForm, CommentForm @@ -338,13 +338,17 @@ def edit_incident(request, incident_id, authorization_target=None): starred = i.is_starred if request.method == "POST": + previous_status = i.status form = IncidentForm(request.POST, instance=i, for_user=request.user) if form.is_valid(): Comments.create_diff_comment(i, form.cleaned_data, request.user) - + if previous_status == form.cleaned_data['status']: + previous_status = None # update main BL form.save() + if previous_status is not None: + model_status_changed.send(sender=Incident, instance=i, previous_status=previous_status) i.refresh_main_business_lines() i.is_starred = starred i.save() @@ -386,6 +390,7 @@ def change_status(request, incident_id, status, authorization_target=None): pk=incident_id) else: i = authorization_target + previous_status = i.status i.status = status i.save() @@ -402,7 +407,7 @@ def change_status(request, incident_id, status, authorization_target=None): c.incident = i c.opened_by = request.user c.save() - + model_status_changed.send(sender=Incident, instance=i, previous_status=previous_status) return redirect('dashboard:main') @@ -522,8 +527,11 @@ def update_comment(request, comment_id): incident=c.incident) if c.action.name in ['Closed', 'Opened', 'Blocked']: - c.incident.status = c.action.name[0] - c.incident.save() + if c.action.name[0] != c.incident.status: + previous_status = c.incident.status + c.incident.status = c.action.name[0] + c.incident.save() + model_status_changed.send(sender=Incident, instance=c.incident, previous_status=previous_status) i.refresh_artifacts(c.comment) @@ -773,9 +781,11 @@ def comment(request, incident_id, authorization_target=None): log("Comment created: %s" % (com.comment[:20] + "..."), request.user, incident=com.incident) i.refresh_artifacts(com.comment) - if com.action.name in ['Closed', 'Opened', 'Blocked']: + if com.action.name in ['Closed', 'Opened', 'Blocked'] and com.incident.status != com.action.name[0]: + previous_status = com.incident.status com.incident.status = com.action.name[0] com.incident.save() + model_status_changed.send(sender=Incident, instance=com.incident, previous_status=previous_status) return render(request, 'events/_comment.html', {'event': i, 'comment': com}) else: From 596e03a90779d66565433f88ac552649f337380e Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Tue, 17 Jan 2017 09:35:43 +0100 Subject: [PATCH 38/66] Notifications: event and incident new notification events (commented, status changed) --- fir_notifications/README.md | 4 ++++ fir_notifications/models.py | 39 ++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/fir_notifications/README.md b/fir_notifications/README.md index e6ad752e..2713516a 100644 --- a/fir_notifications/README.md +++ b/fir_notifications/README.md @@ -35,6 +35,10 @@ Core FIR notifications: * 'event:updated': update of an event * 'incident:created': new incident * 'incident:updated': update of an incident +* 'event:commented': new comment added to an event +* 'incident:commented': new comment added to an incident +* 'event:status_changed': event status changed +* 'incident:status_changed': incident status changed ## Configuration diff --git a/fir_notifications/models.py b/fir_notifications/models.py index d2d13950..4b1603e3 100644 --- a/fir_notifications/models.py +++ b/fir_notifications/models.py @@ -2,12 +2,13 @@ from django.db import models from django.conf import settings +from django.db.models.signals import post_save from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from fir_notifications.decorators import notification_event from fir_notifications.registry import registry -from incidents.models import model_created, Incident, model_updated +from incidents.models import model_created, Incident, model_updated, Comments, model_status_changed @python_2_unicode_compatible @@ -89,3 +90,39 @@ def incident_updated(sender, instance, **kwargs): if not instance.is_incident: return None, None return instance, instance.concerned_business_lines + + +@notification_event('event:commented', post_save, Comments, verbose_name=_('Event commented'), + section=_('Event')) +def event_commented(sender, instance, **kwargs): + if not instance.incident and instance.incident.is_incident: + return None, None + if instance.action.name in ['Opened', 'Blocked', 'Closed']: + return None, None + return instance, instance.incident.concerned_business_lines + + +@notification_event('incident:commented', post_save, Comments, verbose_name=_('Incident commented'), + section=_('Incident')) +def incident_commented(sender, instance, **kwargs): + if not instance.incident and not instance.incident.is_incident: + return None, None + if instance.action.name in ['Opened', 'Blocked', 'Closed']: + return None, None + return instance, instance.incident.concerned_business_lines + + +@notification_event('event:status_changed', model_status_changed, Incident, verbose_name=_('Event status changed'), + section=_('Event')) +def event_status_changed(sender, instance, **kwargs): + if instance.is_incident: + return None, None + return instance, instance.concerned_business_lines + + +@notification_event('incident:status_changed', model_status_changed, Incident, verbose_name=_('Incident status changed'), + section=_('Incident')) +def incident_status_changed(sender, instance, **kwargs): + if not instance.is_incident: + return None, None + return instance, instance.concerned_business_lines \ No newline at end of file From 46c8441be47c315eb1583302721daa5b87f6bcb7 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Tue, 17 Jan 2017 09:38:26 +0100 Subject: [PATCH 39/66] Notifications: French translation for new notification events --- .../locale/fr/LC_MESSAGES/django.po | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/fir_notifications/locale/fr/LC_MESSAGES/django.po b/fir_notifications/locale/fr/LC_MESSAGES/django.po index dc8f146e..89980538 100644 --- a/fir_notifications/locale/fr/LC_MESSAGES/django.po +++ b/fir_notifications/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-01-14 22:33+0100\n" +"POT-Creation-Date: 2017-01-17 08:48+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -48,90 +48,110 @@ msgstr "Certificat de chiffrement au format PEM" msgid "Jabber ID" msgstr "" -#: models.py:15 models.py:44 +#: models.py:16 models.py:45 msgid "user" msgstr "utilisateur" -#: models.py:16 models.py:46 +#: models.py:17 models.py:47 msgid "method" msgstr "méthode" -#: models.py:17 +#: models.py:18 msgid "configuration" msgstr "" -#: models.py:23 +#: models.py:24 msgid "method configuration" msgstr "configuration de méthode" -#: models.py:24 +#: models.py:25 msgid "method configurations" msgstr "configurations de méthode" -#: models.py:30 models.py:45 +#: models.py:31 models.py:46 msgid "event" msgstr "événement" -#: models.py:32 +#: models.py:33 msgid "business line" msgstr "" -#: models.py:33 +#: models.py:34 msgid "subject" msgstr "objet" -#: models.py:34 +#: models.py:35 msgid "short description" msgstr "description courte" -#: models.py:35 +#: models.py:36 msgid "description" msgstr "" -#: models.py:38 +#: models.py:39 msgid "notification template" msgstr "gabarit de notification" -#: models.py:39 +#: models.py:40 msgid "notification templates" msgstr "gabarits de notification" -#: models.py:48 +#: models.py:49 msgid "business lines" msgstr "" -#: models.py:56 +#: models.py:57 msgid "notification preference" msgstr "préférence de notification" -#: models.py:57 +#: models.py:58 msgid "notification preferences" msgstr "préférences de notification" -#: models.py:62 +#: models.py:63 msgid "Event created" msgstr "Événement créé" -#: models.py:63 models.py:79 +#: models.py:64 models.py:80 models.py:96 models.py:118 msgid "Event" msgstr "Événement" -#: models.py:70 +#: models.py:71 msgid "Incident created" msgstr "Incident créé" -#: models.py:71 models.py:87 +#: models.py:72 models.py:88 models.py:107 models.py:126 msgid "Incident" msgstr "Incident" -#: models.py:78 +#: models.py:79 msgid "Event updated" msgstr "Événement mis à jour" -#: models.py:86 +#: models.py:87 msgid "Incident updated" msgstr "Incident mis à jour" +#: models.py:95 +#| msgid "Event created" +msgid "Event commented" +msgstr "Événement commenté" + +#: models.py:106 +#| msgid "Incident created" +msgid "Incident commented" +msgstr "Incident commenté" + +#: models.py:117 +#| msgid "Event created" +msgid "Event status changed" +msgstr "Statut de l'événement changé" + +#: models.py:125 +#| msgid "Incident created" +msgid "Incident status changed" +msgstr "Statut de l'incident changé" + #: templates/fir_notifications/actions.html:3 #, python-format msgid "Configure %(method_name)s" From 2bb5172b6bb64e1a20ea78576cb671a3efb7d4e0 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Tue, 17 Jan 2017 09:40:40 +0100 Subject: [PATCH 40/66] Notifications: notification events can be disabled in setting NOTIFICATIONS_DISABLED_EVENTS --- fir/config/base.py | 4 ++++ fir_notifications/README.md | 8 ++++++++ fir_notifications/registry.py | 3 +++ 3 files changed, 15 insertions(+) diff --git a/fir/config/base.py b/fir/config/base.py index dbe3f0d9..4620bd03 100755 --- a/fir/config/base.py +++ b/fir/config/base.py @@ -158,3 +158,7 @@ # External URL of your FIR application (used in fir_notification to render full URIs in templates) EXTERNAL_URL = 'http://fir.example.com' + +# Put notification events you don't want in this tuple +# Example: NOTIFICATIONS_DISABLED_EVENTS = ('event:created', 'incident:created') +NOTIFICATIONS_DISABLED_EVENTS = () diff --git a/fir_notifications/README.md b/fir_notifications/README.md index 2713516a..721a9f13 100644 --- a/fir_notifications/README.md +++ b/fir_notifications/README.md @@ -42,6 +42,14 @@ Core FIR notifications: ## Configuration +### Events + +You can disable notification events in the settings using the key `NOTIFICATIONS_DISABLED_EVENTS`: + +```python +NOTIFICATIONS_DISABLED_EVENTS = ('event:created', 'incident:created') +``` + ### Celery `fir_notifications` uses the FIR plugin `fir_celery`. diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py index 9201679e..77ef9a59 100644 --- a/fir_notifications/registry.py +++ b/fir_notifications/registry.py @@ -2,6 +2,7 @@ from django.apps import apps from django.utils.encoding import python_2_unicode_compatible +from django.conf import settings from fir_notifications.methods.email import EmailMethod from fir_notifications.methods.jabber import XmppMethod @@ -56,6 +57,8 @@ def register_event(self, name, signal, model, callback, verbose_name=None, secti verbose_name: verbose name of the event section: section in the user preference panel (default model application name) """ + if name in settings.NOTIFICATIONS_DISABLED_EVENTS: + return if verbose_name is None: verbose_name = name self.events[name] = RegisteredEvent(name, model, verbose_name=verbose_name, section=section) From b26007358afa821400af8acf9e42ab94f827f427 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Tue, 17 Jan 2017 10:04:23 +0100 Subject: [PATCH 41/66] Notifications: NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS setting --- fir/config/base.py | 3 ++ fir_notifications/README.md | 7 ++++ fir_notifications/models.py | 71 +++++++++++++++++++------------------ 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/fir/config/base.py b/fir/config/base.py index 4620bd03..b98d9bdd 100755 --- a/fir/config/base.py +++ b/fir/config/base.py @@ -162,3 +162,6 @@ # Put notification events you don't want in this tuple # Example: NOTIFICATIONS_DISABLED_EVENTS = ('event:created', 'incident:created') NOTIFICATIONS_DISABLED_EVENTS = () + +# Send 'incident:*' notification events for both Event and Incident if True +NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS = False diff --git a/fir_notifications/README.md b/fir_notifications/README.md index 721a9f13..51aeb53a 100644 --- a/fir_notifications/README.md +++ b/fir_notifications/README.md @@ -50,6 +50,13 @@ You can disable notification events in the settings using the key `NOTIFICATIONS NOTIFICATIONS_DISABLED_EVENTS = ('event:created', 'incident:created') ``` +If you don't want to send different notification events for Incidents and Events, you should enable this setting: + +```python +# Send 'incident:*' notification events for both Event and Incident if True +NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS = True +``` + ### Celery `fir_notifications` uses the FIR plugin `fir_celery`. diff --git a/fir_notifications/models.py b/fir_notifications/models.py index 4b1603e3..ff91db8e 100644 --- a/fir_notifications/models.py +++ b/fir_notifications/models.py @@ -60,12 +60,39 @@ class Meta: index_together = ["user", "event", "method"] -@notification_event('event:created', model_created, Incident, verbose_name=_('Event created'), - section=_('Event')) -def event_created(sender, instance, **kwargs): - if instance.is_incident: - return None, None - return instance, instance.concerned_business_lines +if not settings.NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS: + @notification_event('event:created', model_created, Incident, verbose_name=_('Event created'), + section=_('Event')) + def event_created(sender, instance, **kwargs): + if instance.is_incident: + return None, None + return instance, instance.concerned_business_lines + + + @notification_event('event:updated', model_updated, Incident, verbose_name=_('Event updated'), + section=_('Event')) + def event_updated(sender, instance, **kwargs): + if instance.is_incident: + return None, None + return instance, instance.concerned_business_lines + + + @notification_event('event:commented', post_save, Comments, verbose_name=_('Event commented'), + section=_('Event')) + def event_commented(sender, instance, **kwargs): + if not instance.incident and instance.incident.is_incident: + return None, None + if instance.action.name in ['Opened', 'Blocked', 'Closed']: + return None, None + return instance, instance.incident.concerned_business_lines + + + @notification_event('event:status_changed', model_status_changed, Incident, verbose_name=_('Event status changed'), + section=_('Event')) + def event_status_changed(sender, instance, **kwargs): + if instance.is_incident: + return None, None + return instance, instance.concerned_business_lines @notification_event('incident:created', model_created, Incident, verbose_name=_('Incident created'), @@ -76,53 +103,27 @@ def incident_created(sender, instance, **kwargs): return instance, instance.concerned_business_lines -@notification_event('event:updated', model_updated, Incident, verbose_name=_('Event updated'), - section=_('Event')) -def event_updated(sender, instance, **kwargs): - if instance.is_incident: - return None, None - return instance, instance.concerned_business_lines - - @notification_event('incident:updated', model_updated, Incident, verbose_name=_('Incident updated'), section=_('Incident')) def incident_updated(sender, instance, **kwargs): - if not instance.is_incident: + if not settings.NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS and not instance.is_incident: return None, None return instance, instance.concerned_business_lines -@notification_event('event:commented', post_save, Comments, verbose_name=_('Event commented'), - section=_('Event')) -def event_commented(sender, instance, **kwargs): - if not instance.incident and instance.incident.is_incident: - return None, None - if instance.action.name in ['Opened', 'Blocked', 'Closed']: - return None, None - return instance, instance.incident.concerned_business_lines - - @notification_event('incident:commented', post_save, Comments, verbose_name=_('Incident commented'), section=_('Incident')) def incident_commented(sender, instance, **kwargs): - if not instance.incident and not instance.incident.is_incident: + if not instance.incident and not settings.NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS and not instance.incident.is_incident: return None, None if instance.action.name in ['Opened', 'Blocked', 'Closed']: return None, None return instance, instance.incident.concerned_business_lines -@notification_event('event:status_changed', model_status_changed, Incident, verbose_name=_('Event status changed'), - section=_('Event')) -def event_status_changed(sender, instance, **kwargs): - if instance.is_incident: - return None, None - return instance, instance.concerned_business_lines - - @notification_event('incident:status_changed', model_status_changed, Incident, verbose_name=_('Incident status changed'), section=_('Incident')) def incident_status_changed(sender, instance, **kwargs): - if not instance.is_incident: + if not settings.NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS and not instance.is_incident: return None, None return instance, instance.concerned_business_lines \ No newline at end of file From 3649fa2b14f8bb22fc6f1fc7420fa605b77aa841 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 20 Jan 2017 10:31:52 +0100 Subject: [PATCH 42/66] Notifications: nove EXTERNAL_URL setting from base to production sample --- fir/config/base.py | 3 --- fir/config/production.py.sample | 3 +++ fir_notifications/methods/utils.py | 8 +++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/fir/config/base.py b/fir/config/base.py index b98d9bdd..4c72550a 100755 --- a/fir/config/base.py +++ b/fir/config/base.py @@ -156,9 +156,6 @@ 'CHANGE_PASSWORD': True } -# External URL of your FIR application (used in fir_notification to render full URIs in templates) -EXTERNAL_URL = 'http://fir.example.com' - # Put notification events you don't want in this tuple # Example: NOTIFICATIONS_DISABLED_EVENTS = ('event:created', 'incident:created') NOTIFICATIONS_DISABLED_EVENTS = () diff --git a/fir/config/production.py.sample b/fir/config/production.py.sample index 78345c74..5fff4821 100755 --- a/fir/config/production.py.sample +++ b/fir/config/production.py.sample @@ -71,3 +71,6 @@ LOGGING = { }, }, } + +# External URL of your FIR application (used in fir_notification to render full URIs in templates) +EXTERNAL_URL = 'http://fir.example.com' diff --git a/fir_notifications/methods/utils.py b/fir_notifications/methods/utils.py index 434414ff..dff74030 100644 --- a/fir_notifications/methods/utils.py +++ b/fir_notifications/methods/utils.py @@ -3,9 +3,11 @@ class FakeRequest(object): def __init__(self): - self.base = settings.EXTERNAL_URL - if self.base.endswith('/'): - self.base = self.base[:-1] + self.base = "" + if hasattr(settings, 'EXTERNAL_URL'): + self.base = settings.EXTERNAL_URL + if self.base.endswith('/'): + self.base = self.base[:-1] def build_absolute_uri(self, location): return "{}{}".format(self.base, location) From 70035a2eb3a77963f749306910ef6df643a7689d Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 20 Jan 2017 10:54:39 +0100 Subject: [PATCH 43/66] Notifications: move email logic into fir_email --- fir_email/helpers.py | 36 +++++++++++++++++++----------- fir_notifications/methods/email.py | 22 ++++-------------- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/fir_email/helpers.py b/fir_email/helpers.py index bec0f9ec..804f298d 100644 --- a/fir_email/helpers.py +++ b/fir_email/helpers.py @@ -13,7 +13,7 @@ def _combine_with_settings(values, setting): return values -def send(request, to, subject, body, behalf=None, cc='', bcc=''): +def prepare_email_message(to, subject, body, behalf=None, cc=None, bcc=None, request=None): reply_to = {} if hasattr(settings, 'REPLY_TO'): @@ -22,23 +22,33 @@ def send(request, to, subject, body, behalf=None, cc='', bcc=''): if behalf is None and hasattr(settings, 'EMAIL_FROM'): behalf = settings.EMAIL_FROM - cc = _combine_with_settings(cc, 'EMAIL_CC') - bcc = _combine_with_settings(bcc, 'EMAIL_BCC') + if not isinstance(to, (tuple, list)): + to = to.split(';') - e = EmailMultiAlternatives( + email_message = EmailMultiAlternatives( subject=subject, + body=body, from_email=behalf, - to=to.split(';'), + to=to, cc=cc, bcc=bcc, headers=reply_to ) - e.attach_alternative(markdown2.markdown( - body, - extras=["link-patterns", "tables", "code-friendly"], - link_patterns=registry.link_patterns(request), - safe_mode=True - ), + email_message.attach_alternative(markdown2.markdown( + body, + extras=["link-patterns", "tables", "code-friendly"], + link_patterns=registry.link_patterns(request), + safe_mode=True + ), 'text/html') - e.content_subtype = 'html' - e.send() + + return email_message + + +def send(request, to, subject, body, behalf=None, cc='', bcc=''): + + cc = _combine_with_settings(cc, 'EMAIL_CC') + bcc = _combine_with_settings(bcc, 'EMAIL_BCC') + + email_message = prepare_email_message(to, subject, body, behalf=behalf, cc=cc, bcc=bcc, request=request) + email_message.send() diff --git a/fir_notifications/methods/email.py b/fir_notifications/methods/email.py index 50e28581..1dd488a8 100644 --- a/fir_notifications/methods/email.py +++ b/fir_notifications/methods/email.py @@ -1,13 +1,12 @@ -import markdown2 from django import forms from django.conf import settings from django.core import mail from django.utils.translation import ugettext_lazy as _ +from fir_email.helpers import prepare_email_message from fir_notifications.methods import NotificationMethod from fir_notifications.methods.utils import request -from fir_plugins.links import registry as link_registry class EmailMethod(NotificationMethod): @@ -26,11 +25,6 @@ def __init__(self): help_text=_('Encryption certificate in PEM format.')) def send(self, event, users, instance, paths): - from_address = settings.EMAIL_FROM - reply_to = {} - if hasattr(settings, 'REPLY_TO'): - reply_to = {'Reply-To': settings.REPLY_TO, - 'Return-Path': settings.REPLY_TO} messages = [] for user, templates in users.items(): if not self.enabled(event, user, paths) or not user.email: @@ -39,17 +33,9 @@ def send(self, event, users, instance, paths): if template is None: continue params = self.prepare(template, instance) - e = mail.EmailMultiAlternatives( - subject=params['subject'], - body=params['description'], - from_email=from_address, - to=[user.email, ], - headers=reply_to - ) - e.attach_alternative(markdown2.markdown(params['description'], extras=["link-patterns"], - link_patterns=link_registry.link_patterns(request), safe_mode=True), - 'text/html') - messages.append(e) + email_message = prepare_email_message([user.email, ], params['subject'], params['description'], + request=request) + messages.append(email_message) if len(messages): connection = mail.get_connection() connection.send_messages(messages) From d049081b6d71ccf0b9ac99c0f84962e7972a2a57 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 20 Jan 2017 11:19:56 +0100 Subject: [PATCH 44/66] Notifications: fix xmpppy requirement install --- fir_notifications/methods/jabber.py | 2 +- fir_notifications/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fir_notifications/methods/jabber.py b/fir_notifications/methods/jabber.py index d5b9abf2..f41de560 100644 --- a/fir_notifications/methods/jabber.py +++ b/fir_notifications/methods/jabber.py @@ -1,7 +1,7 @@ import markdown2 from django.conf import settings -import xmpppy as xmpp +import xmpp from django import forms from fir_notifications.methods import NotificationMethod diff --git a/fir_notifications/requirements.txt b/fir_notifications/requirements.txt index 78bd362e..da8c3560 100644 --- a/fir_notifications/requirements.txt +++ b/fir_notifications/requirements.txt @@ -1 +1 @@ --e git+https://github.com/ArchipelProject/xmpppy.git@288b280c6ec534c100bfee871daa3bb707467a1a#egg=xmpppy \ No newline at end of file +https://github.com/ArchipelProject/xmpppy/zipball/288b280c6ec534c100bfee871daa3bb707467a1a \ No newline at end of file From 46784ac1d19fc4a0b7d7c29383c77f1bcd5d9826 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 20 Jan 2017 11:34:35 +0100 Subject: [PATCH 45/66] Move S/MIME requirements file into fir_email --- {fir_notifications => fir_email}/requirements_smime.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {fir_notifications => fir_email}/requirements_smime.txt (100%) diff --git a/fir_notifications/requirements_smime.txt b/fir_email/requirements_smime.txt similarity index 100% rename from fir_notifications/requirements_smime.txt rename to fir_email/requirements_smime.txt From e3a243a88c7b50da85bdf134ede41c462852b5da Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Wed, 25 Jan 2017 15:49:04 +0100 Subject: [PATCH 46/66] Notifications: remove S/MIME stuff from fir_notifications --- fir_notifications/forms.py | 12 ----------- fir_notifications/methods/email.py | 32 ------------------------------ 2 files changed, 44 deletions(-) diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py index b55930cf..88834a0d 100644 --- a/fir_notifications/forms.py +++ b/fir_notifications/forms.py @@ -33,18 +33,6 @@ class Meta: fields = '__all__' -class EmailMethodConfigurationForm(MethodConfigurationForm): - def save(self, *args, **kwargs): - if self.user is None or not self.user.email: - return None - try: - from djembe.models import Identity - except ImportError: - return None - config, created = Identity.objects.update_or_create(address=self.user.email, defaults=self.cleaned_data) - return config - - class NotificationPreferenceFormset(forms.BaseInlineFormSet): def __init__(self, *args, **kwargs): self.notifications = OrderedDict() diff --git a/fir_notifications/methods/email.py b/fir_notifications/methods/email.py index 1dd488a8..49f948cb 100644 --- a/fir_notifications/methods/email.py +++ b/fir_notifications/methods/email.py @@ -1,7 +1,5 @@ -from django import forms from django.conf import settings from django.core import mail -from django.utils.translation import ugettext_lazy as _ from fir_email.helpers import prepare_email_message @@ -19,10 +17,6 @@ def __init__(self): super(EmailMethod, self).__init__() if hasattr(settings, 'EMAIL_FROM') and settings.EMAIL_FROM is not None: self.server_configured = True - if 'djembe' in settings.INSTALLED_APPS: - self.options['certificate'] = forms.CharField(required=False, label=_('Certificate'), - widget=forms.Textarea(attrs={'cols': 60, 'rows': 15}), - help_text=_('Encryption certificate in PEM format.')) def send(self, event, users, instance, paths): messages = [] @@ -42,29 +36,3 @@ def send(self, event, users, instance, paths): def configured(self, user): return super(EmailMethod, self).configured(user) and user.email is not None - - def _get_configuration(self, user): - if not user.email: - return {} - try: - from djembe.models import Identity - except ImportError: - return {} - try: - identity = Identity.objects.get(address=user.email) - except Identity.DoesNotExist: - return {} - except Identity.MultipleObjectsReturned: - identity = Identity.objects.filter(address=user.email).first() - return {'certificate': identity.certificate} - - def form(self, *args, **kwargs): - from fir_notifications.forms import EmailMethodConfigurationForm - if not len(self.options): - return None - user = kwargs.pop('user', None) - if user is not None: - kwargs['initial'] = self._get_configuration(user) - kwargs['user'] = user - kwargs['method'] = self - return EmailMethodConfigurationForm(*args, **kwargs) From 3892eece4c2d40dd696a2e7548e2eb5ba492b7fd Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Wed, 25 Jan 2017 15:49:33 +0100 Subject: [PATCH 47/66] Notifications: remove S/MIME doc from fir_notifications --- fir_notifications/README.md | 56 +------------------------------------ 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/fir_notifications/README.md b/fir_notifications/README.md index 51aeb53a..3e81016f 100644 --- a/fir_notifications/README.md +++ b/fir_notifications/README.md @@ -71,61 +71,7 @@ EXTERNAL_URL = 'https://fir.example.com' ### Email notifications -You have to configure [Django email backend](https://docs.djangoproject.com/en/1.9/topics/email/). - -In addition, `fir_notifications` uses two settings defined in `fir_email`: - -``` python -# From address (required) -EMAIL_FROM = 'fir@example.com' -# Reply to address (optional) -REPLY_TO = None -``` - -#### S/MIME - -To send signed/encrypted email notifications with S/MIME to users, install and configure [django-djembe](https://github.com/cabincode/django-djembe) and add it in your *installed_apps.txt*. - -The following configuration example from the Djembe Readme can help you: - -``` bash -(fir-env)$ pip install -r fir_notifications/requirements_smime.txt -(fir-env)$ python manage.py migrate djembe -``` - -In *$FIR_HOME/fir/config/installed_app.txt*, add: - -``` -djembe -``` - -Change your email backend in your settings: - -``` python -EMAIL_BACKEND = 'djembe.backends.EncryptingSMTPBackend' -``` - -To use a cipher other than the default AES-256, specify it in your settings `DJEMBE_CIPHER`: - - -``` python -DJEMBE_CIPHER = 'des_ede3_cbc' # triple DES -``` -The intersection of M2Crypto's ciphers and RFC 3851 are: - -* `des_ede3_cbc` (required by the RFC) -* `aes_128_cbc` (recommended, not required, by the RFC) -* `aes_192_cbc` (recommended, not required, by the RFC) -* `aes_256_cbc` (recommended, not required, by the RFC) -* `rc2_40_cbc` (RFC requires support, but it's weak -- don't use it) - -RFC 5751 requires AES-128, and indicates that higher key lengths are of -course the future. It marks tripleDES with "SHOULD-", meaning it's on its -way out. - -To create signed notifications, in the admin site (*Djembe > Identities*), supply both a certificate and a private key which must not have a passphrase, with an `Address` that is the same as your setting `EMAIL_FROM`. Any mail sent *from* this Identity's address will be signed with the private key. - -User certificates will be added from the user profile in FIR (*Configure Email*). +Follow the `fir_email` [README](fir_email/README.md). ### Jabber (XMPP) notifications From 5f4f17ba06cce83f8e146cd4899dc5fac8de24ea Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Wed, 25 Jan 2017 15:51:51 +0100 Subject: [PATCH 48/66] Email: check Django-djembe configuration helper --- fir_email/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 fir_email/utils.py diff --git a/fir_email/utils.py b/fir_email/utils.py new file mode 100644 index 00000000..7a46b2f8 --- /dev/null +++ b/fir_email/utils.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def check_smime_status(): + if 'djembe' in settings.INSTALLED_APPS \ + and settings.EMAIL_BACKEND == 'djembe.backends.EncryptingSMTPBackend': + return True + return False From bd30af388c4ff3b7872065db48ee27b321738d61 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Wed, 25 Jan 2017 16:03:34 +0100 Subject: [PATCH 49/66] Email: User S/MIME certificate form --- fir_email/forms.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 fir_email/forms.py diff --git a/fir_email/forms.py b/fir_email/forms.py new file mode 100644 index 00000000..84208c74 --- /dev/null +++ b/fir_email/forms.py @@ -0,0 +1,54 @@ +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from fir_email.utils import check_smime_status + + +class SMIMECertificateForm(forms.Form): + certificate = forms.CharField(required=False, label=_('Certificate'), + widget=forms.Textarea(attrs={'cols': 60, 'rows': 15}), + help_text=_('Encryption certificate in PEM format.')) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user', None) + if self.user is not None and self.user.email and 'initial' not in kwargs: + kwargs['initial'] = self._get_certificate() + super(SMIMECertificateForm, self).__init__(*args, **kwargs) + + def _get_certificate(self): + data = {} + try: + from djembe.models import Identity + except ImportError: + return data + try: + identity = Identity.objects.get(address=self.user.email) + except Identity.DoesNotExist: + return data + except Identity.MultipleObjectsReturned: + identity = Identity.objects.filter(address=self.user.email).first() + data = {'certificate': identity.certificate} + return data + + def clean_certificate(self): + if not check_smime_status(): + raise forms.ValidationError(_('Improperly configured S/MIME: Email backend is incompatible')) + try: + from M2Crypto import X509 + certificate = self.cleaned_data['certificate'] + X509.load_cert_string(str(certificate)) + except ImportError: + raise forms.ValidationError(_('Improperly configured S/MIME: missing dependencies')) + except X509.X509Error: + raise forms.ValidationError(_('Invalid certificate: unknown format')) + return certificate + + def save(self, *args, **kwargs): + if self.user is None or not self.user.email: + return None + try: + from djembe.models import Identity + except ImportError: + return None + config, created = Identity.objects.update_or_create(address=self.user.email, defaults=self.cleaned_data) + return config \ No newline at end of file From 32441f0fcfa5b60a53aeb104fe961c3e1262f3e8 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Wed, 25 Jan 2017 16:04:04 +0100 Subject: [PATCH 50/66] Email: User certificate templates --- .../plugins/user_profile_actions.html | 2 + .../fir_email/smime_profile_action.html | 56 +++++++++++++++++++ fir_email/templatetags/__init__.py | 0 fir_email/templatetags/smime.py | 14 +++++ 4 files changed, 72 insertions(+) create mode 100644 fir_email/templates/fir_email/plugins/user_profile_actions.html create mode 100644 fir_email/templates/fir_email/smime_profile_action.html create mode 100644 fir_email/templatetags/__init__.py create mode 100644 fir_email/templatetags/smime.py diff --git a/fir_email/templates/fir_email/plugins/user_profile_actions.html b/fir_email/templates/fir_email/plugins/user_profile_actions.html new file mode 100644 index 00000000..dd8ddda0 --- /dev/null +++ b/fir_email/templates/fir_email/plugins/user_profile_actions.html @@ -0,0 +1,2 @@ +{% load smime %} +{% smime_profile_action %} \ No newline at end of file diff --git a/fir_email/templates/fir_email/smime_profile_action.html b/fir_email/templates/fir_email/smime_profile_action.html new file mode 100644 index 00000000..e33a5aac --- /dev/null +++ b/fir_email/templates/fir_email/smime_profile_action.html @@ -0,0 +1,56 @@ +{% load i18n %} +{% load add_css_class %} + +{% if smime_status %} +
  • {% blocktrans %}Set S/MIME certificate{% endblocktrans %}
  • + + + +{% endif %} \ No newline at end of file diff --git a/fir_email/templatetags/__init__.py b/fir_email/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fir_email/templatetags/smime.py b/fir_email/templatetags/smime.py new file mode 100644 index 00000000..e784284e --- /dev/null +++ b/fir_email/templatetags/smime.py @@ -0,0 +1,14 @@ +from django import template + +from fir_email.utils import check_smime_status +from fir_email.forms import SMIMECertificateForm + +register = template.Library() + + +@register.inclusion_tag('fir_email/smime_profile_action.html', takes_context=True) +def smime_profile_action(context): + if check_smime_status() and context.request.user.email: + form = SMIMECertificateForm(user=context.request.user) + return {'form': form, 'smime_status': True} + return {'form': None, 'smime_status': False} \ No newline at end of file From d80044594626cdc30bf4982886120903aacf5675 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Wed, 25 Jan 2017 16:05:10 +0100 Subject: [PATCH 51/66] Email: User certificate view --- fir_email/urls.py | 9 +++++++++ fir_email/views.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 fir_email/urls.py create mode 100644 fir_email/views.py diff --git a/fir_email/urls.py b/fir_email/urls.py new file mode 100644 index 00000000..edcf89d0 --- /dev/null +++ b/fir_email/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url +from fir_email.utils import check_smime_status +from fir_email import views + + +urlpatterns = [] + +if check_smime_status(): + urlpatterns.append(url(r'^user/certificate/$', views.smime_configuration, name='user-certificate')) diff --git a/fir_email/views.py b/fir_email/views.py new file mode 100644 index 00000000..b1ed33f6 --- /dev/null +++ b/fir_email/views.py @@ -0,0 +1,18 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect +from django.views.decorators.http import require_POST +from django.contrib import messages + +from fir_email.forms import SMIMECertificateForm + + +@require_POST +@login_required +def smime_configuration(request): + form = SMIMECertificateForm(request.POST, user=request.user) + if form.is_valid(): + form.save() + else: + for error in form.errors.items(): + messages.error(request, error[1]) + return redirect('user:profile') \ No newline at end of file From bff417d1be30c7158af1378379eb9d9f9b51c91c Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Wed, 25 Jan 2017 16:05:27 +0100 Subject: [PATCH 52/66] Email: Initial readme --- fir_email/README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 fir_email/README.md diff --git a/fir_email/README.md b/fir_email/README.md new file mode 100644 index 00000000..29aa19a6 --- /dev/null +++ b/fir_email/README.md @@ -0,0 +1,76 @@ +# Email helpers module + +## Configure FIR to send emails + +Follow the Django docs: [Django email backend](https://docs.djangoproject.com/en/1.9/topics/email/). + +In addition, you have to configure two settings: + +```python +# From address (required, string) +EMAIL_FROM = 'fir@example.com' +# Reply to address (optional, string) +REPLY_TO = None +``` + +## Adding CC and BCC recipients (for `fir_alerting` and `fir_abuse`) + +You can force FIR to add CC and BCC recipients by configuring these settings: + +```python +EMAIL_CC = ['cc@example.com',] +EMAIL_BCC = ['bcc@example.com',] +``` + +## Using S/MIME + +To send signed/encrypted emails with S/MIME to users, install and configure [django-djembe](https://github.com/cabincode/django-djembe) and add it in your *installed_apps.txt*. + +The following configuration example from the Djembe Readme can help you: + +### Install + +```bash +(fir-env)$ pip install -r fir_email/requirements_smime.txt +(fir-env)$ python manage.py migrate djembe +``` + +In *$FIR_HOME/fir/config/installed_app.txt*, add: + +``` +djembe +``` + +Change your email backend in your settings: + +```python +EMAIL_BACKEND = 'djembe.backends.EncryptingSMTPBackend' +``` + +### Ciphers + +To use a cipher other than the default AES-256, specify it in your settings `DJEMBE_CIPHER`: + + +```python +DJEMBE_CIPHER = 'des_ede3_cbc' # triple DES +``` +The intersection of M2Crypto's ciphers and RFC 3851 are: + +* `des_ede3_cbc` (required by the RFC) +* `aes_128_cbc` (recommended, not required, by the RFC) +* `aes_192_cbc` (recommended, not required, by the RFC) +* `aes_256_cbc` (recommended, not required, by the RFC) +* `rc2_40_cbc` (RFC requires support, but it's weak -- don't use it) + +RFC 5751 requires AES-128, and indicates that higher key lengths are of +course the future. It marks tripleDES with "SHOULD-", meaning it's on its +way out. + +### Signed email + +To create signed email, in the admin site (*Djembe > Identities*), supply both a certificate and a private key which must not have a passphrase, with an `Address` that is the same as your setting `EMAIL_FROM`. Any mail sent *from* this Identity's address will be signed with the private key. + +### User certificates (email encryption) + +User certificates will be added from the user profile in FIR (*Set S/MIME certificate*). From b3650f264f41e337d64fa691f61e9aefb32e804c Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Wed, 25 Jan 2017 16:06:18 +0100 Subject: [PATCH 53/66] Notifications: use Django messages framework to report form errors (method configuration) --- fir_notifications/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fir_notifications/views.py b/fir_notifications/views.py index 100cedd9..70a55105 100644 --- a/fir_notifications/views.py +++ b/fir_notifications/views.py @@ -1,5 +1,6 @@ from django.contrib.auth.decorators import login_required from django import forms +from django.contrib import messages from django.shortcuts import redirect, render from django.views.decorators.http import require_POST from django.contrib.auth import get_user_model @@ -20,6 +21,9 @@ def method_configuration(request, method): form = method_object.form(request.POST, user=request.user) if form.is_valid(): form.save() + else: + for error in form.errors.items(): + messages.error(request, error[1]) return redirect('user:profile') From 727da0d2ccade5b4f23685c012bcb2daad4ccbe0 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Thu, 26 Jan 2017 06:45:19 +0100 Subject: [PATCH 54/66] Email: Add fir_email as a FIR core app --- fir/config/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fir/config/base.py b/fir/config/base.py index 4c72550a..4397e4fa 100755 --- a/fir/config/base.py +++ b/fir/config/base.py @@ -95,6 +95,7 @@ 'incidents', 'fir_artifacts', 'treebeard', + 'fir_email' ) apps_file = os.path.join(BASE_DIR, 'fir', 'config', 'installed_apps.txt') From 7b5ad3cf89abfcd043020b89788d134141621d94 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Thu, 26 Jan 2017 06:51:07 +0100 Subject: [PATCH 55/66] Email: Add French translation --- fir_email/locale/fr/LC_MESSAGES/django.po | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 fir_email/locale/fr/LC_MESSAGES/django.po diff --git a/fir_email/locale/fr/LC_MESSAGES/django.po b/fir_email/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 00000000..8caf4a2e --- /dev/null +++ b/fir_email/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,55 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-01-26 06:46+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: forms.py:8 +msgid "Certificate" +msgstr "Certificat" + +#: forms.py:10 +msgid "Encryption certificate in PEM format." +msgstr "Certificat de chiffrement au format PEM." + +#: forms.py:35 +msgid "Improperly configured S/MIME: Email backend is incompatible" +msgstr "Mauvaise configuration de S/MIME : le moteur de messagerie est incompatible" + +#: forms.py:41 +msgid "Improperly configured S/MIME: missing dependencies" +msgstr "auvaise configuration de S/MIME : dépendances manquantes" + +#: forms.py:43 +msgid "Invalid certificate: unknown format" +msgstr "Certificat invalide : format inconnu" + +#: templates/fir_email/smime_profile_action.html:5 +msgid "Set S/MIME certificate" +msgstr "Définir le certificat S/MIME" + +#: templates/fir_email/smime_profile_action.html:13 +msgid "Set certificate" +msgstr "Définir le certificat" + +#: templates/fir_email/smime_profile_action.html:40 +msgid "Cancel" +msgstr "Annuler" + +#: templates/fir_email/smime_profile_action.html:41 +msgid "Save" +msgstr "Enregistrer" From ba108cdca589f920645a93b006bef910024f89d4 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Thu, 26 Jan 2017 06:52:42 +0100 Subject: [PATCH 56/66] Notifications: Update French translation --- .../locale/fr/LC_MESSAGES/django.po | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/fir_notifications/locale/fr/LC_MESSAGES/django.po b/fir_notifications/locale/fr/LC_MESSAGES/django.po index 89980538..bb09e748 100644 --- a/fir_notifications/locale/fr/LC_MESSAGES/django.po +++ b/fir_notifications/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-01-17 08:48+0100\n" +"POT-Creation-Date: 2017-01-26 06:51+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -36,14 +36,6 @@ msgstr "" msgid "Configure %(method)s" msgstr "Configurer %(method)s" -#: methods/email.py:24 -msgid "Certificate" -msgstr "Certificat" - -#: methods/email.py:26 -msgid "Encryption certificate in PEM format." -msgstr "Certificat de chiffrement au format PEM" - #: methods/jabber.py:28 msgid "Jabber ID" msgstr "" @@ -108,47 +100,43 @@ msgstr "préférence de notification" msgid "notification preferences" msgstr "préférences de notification" -#: models.py:63 +#: models.py:64 msgid "Event created" msgstr "Événement créé" -#: models.py:64 models.py:80 models.py:96 models.py:118 +#: models.py:65 models.py:73 models.py:81 models.py:91 msgid "Event" msgstr "Événement" -#: models.py:71 +#: models.py:72 +msgid "Event updated" +msgstr "Événement mis à jour" + +#: models.py:80 +msgid "Event commented" +msgstr "Événement commenté" + +#: models.py:90 +msgid "Event status changed" +msgstr "Statut de l'événement changé" + +#: models.py:98 msgid "Incident created" msgstr "Incident créé" -#: models.py:72 models.py:88 models.py:107 models.py:126 +#: models.py:99 models.py:107 models.py:115 models.py:125 msgid "Incident" msgstr "Incident" -#: models.py:79 -msgid "Event updated" -msgstr "Événement mis à jour" - -#: models.py:87 +#: models.py:106 msgid "Incident updated" msgstr "Incident mis à jour" -#: models.py:95 -#| msgid "Event created" -msgid "Event commented" -msgstr "Événement commenté" - -#: models.py:106 -#| msgid "Incident created" +#: models.py:114 msgid "Incident commented" msgstr "Incident commenté" -#: models.py:117 -#| msgid "Event created" -msgid "Event status changed" -msgstr "Statut de l'événement changé" - -#: models.py:125 -#| msgid "Incident created" +#: models.py:124 msgid "Incident status changed" msgstr "Statut de l'incident changé" From 2630d2c1a951904009994fab9655951cff546e10 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 27 Jan 2017 11:11:33 +0100 Subject: [PATCH 57/66] Add Select2 static files to user profile page --- incidents/templates/user/profile.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/incidents/templates/user/profile.html b/incidents/templates/user/profile.html index 9e9d635a..16c473db 100644 --- a/incidents/templates/user/profile.html +++ b/incidents/templates/user/profile.html @@ -4,6 +4,14 @@ {% load staticfiles %} {% load fir_plugins %} +{% block custom_css %} + + +{% endblock %} + +{% block custom_js %} + +{% endblock %} {% block content %}
    From 9d841ef512c28ceaf0af409570b6f4a068edde66 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 27 Jan 2017 11:12:22 +0100 Subject: [PATCH 58/66] Notifications: refactor user preferences UI --- fir_notifications/forms.py | 103 +++++------------- .../static/fir_notifications/notifications.js | 67 ++++++++++++ .../plugins/user_profile.html | 2 +- .../fir_notifications/subscribe.html | 42 +++++++ .../fir_notifications/subscriptions.html | 43 ++++++++ .../templatetags/notifications.py | 15 +++ fir_notifications/urls.py | 7 +- fir_notifications/views.py | 70 +++++++----- 8 files changed, 244 insertions(+), 105 deletions(-) create mode 100644 fir_notifications/static/fir_notifications/notifications.js create mode 100644 fir_notifications/templates/fir_notifications/subscribe.html create mode 100644 fir_notifications/templates/fir_notifications/subscriptions.html diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py index 88834a0d..df343f2e 100644 --- a/fir_notifications/forms.py +++ b/fir_notifications/forms.py @@ -1,11 +1,12 @@ import json -from collections import OrderedDict from django import forms from django.utils.translation import ugettext_lazy as _ +from incidents.models import BusinessLine + from fir_notifications.registry import registry -from fir_notifications.models import MethodConfiguration +from fir_notifications.models import MethodConfiguration, NotificationPreference class MethodConfigurationForm(forms.Form): @@ -33,82 +34,32 @@ class Meta: fields = '__all__' -class NotificationPreferenceFormset(forms.BaseInlineFormSet): +class NotificationPreferenceForm(forms.ModelForm): def __init__(self, *args, **kwargs): - self.notifications = OrderedDict() - for e, verbose_e in registry.events.items(): - for m, verbose_m in registry.methods.items(): - self.notifications["{}_{}".format(e, m)] = {'event': e, - 'verbose_event': verbose_e, - 'method': m, - 'verbose_method': verbose_m.verbose_name} - self.min_num = len(self.notifications) - self.max_num = len(self.notifications) - self.can_delete = False + self.user = kwargs.pop('user', None) instance = kwargs.get('instance', None) + if self.user is None and instance is not None: + self.user = instance.user + if instance is None and kwargs.get('data', None) is not None: + data = kwargs.get('data') + event = data.get('event', None) + method = data.get('method', None) + if event and method and self.user: + try: + kwargs['instance'] = NotificationPreference.objects.get(user=self.user, event=event, method=method) + except (NotificationPreference.DoesNotExist, NotificationPreference.MultipleObjectsReturned): + pass + super(NotificationPreferenceForm, self).__init__(*args, **kwargs) + self.fields['business_lines'].queryset = BusinessLine.authorization.for_user(self.user, + 'incidents.view_incidents') if instance is not None: - queryset = kwargs.get('queryset', None) - if queryset is None: - queryset = self.model._default_manager - qs = queryset.filter(event__in=registry.events.keys(), method__in= registry.methods.keys()) - kwargs['queryset'] = qs - super(NotificationPreferenceFormset, self).__init__(*args, **kwargs) - - def _construct_form(self, i, **kwargs): - method = None - event = None - if self.is_bound and i < self.initial_form_count(): - pk_key = "%s-%s" % (self.add_prefix(i), self.model._meta.pk.name) - pk = self.data[pk_key] - pk_field = self.model._meta.pk - to_python = self._get_to_python(pk_field) - pk = to_python(pk) - instance = self._existing_object(pk) - notification = self.notifications.pop("{}_{}".format(instance.event, instance.method)) - event = notification['verbose_event'] - method = notification['verbose_method'] - kwargs['instance'] = instance - if i < self.initial_form_count() and 'instance' not in kwargs: - instance = self.get_queryset()[i] - notification = self.notifications.pop("{}_{}".format(instance.event, instance.method)) - event = notification['verbose_event'] - method = notification['verbose_method'] - kwargs['instance'] = self.get_queryset()[i] - if i >= self.initial_form_count() and self.notifications: - # Set initial values for extra forms - try: - key, initial = self.notifications.popitem() - event = initial['verbose_event'] - method = initial['verbose_method'] - kwargs['initial'] = {'event': initial['event'], 'method': initial['method']} - except IndexError: - pass - form = forms.BaseFormSet._construct_form(self, i, **kwargs) - if self.save_as_new: - # Remove the primary key from the form's data, we are only - # creating new instances - form.data[form.add_prefix(self._pk_field.name)] = None + self.fields['event'].disabled = True + self.fields['method'].disabled = True - # Remove the foreign key from the form's data - form.data[form.add_prefix(self.fk.name)] = None + event = forms.ChoiceField(choices=registry.get_event_choices(), label=_('Event')) + method = forms.ChoiceField(choices=registry.get_method_choices(), label=_('Method')) + business_lines = forms.ModelMultipleChoiceField(BusinessLine.objects.all(), label=_('Business lines')) - # Set the fk value here so that the form can do its validation. - fk_value = self.instance.pk - if self.fk.remote_field.field_name != self.fk.remote_field.model._meta.pk.name: - fk_value = getattr(self.instance, self.fk.remote_field.field_name) - fk_value = getattr(fk_value, 'pk', fk_value) - setattr(form.instance, self.fk.get_attname(), fk_value) - setattr(form, 'get_notification_display', lambda: u"{} via {}".format(event.verbose_name, method)) - setattr(form, 'get_event', lambda: event) - return form - - @property - def labelled_forms(self): - fs_forms = {} - for form in self.forms: - label = form.get_event().section - if label not in fs_forms: - fs_forms[label] = [] - fs_forms[label].append(form) - fs_forms[label] = sorted(fs_forms[label], key=lambda form: form.get_event().name) - return fs_forms + class Meta: + exclude = ('user', ) + model = NotificationPreference diff --git a/fir_notifications/static/fir_notifications/notifications.js b/fir_notifications/static/fir_notifications/notifications.js new file mode 100644 index 00000000..9af314d6 --- /dev/null +++ b/fir_notifications/static/fir_notifications/notifications.js @@ -0,0 +1,67 @@ + + +function update_async_modals(elt) { + if(!elt) { + $('.modal-async').on('click', function (e) { + e.preventDefault(); + ajax_action($(this), modal_action); + }); + } else { + $(elt).find('.modal-async').on('click', function (e) { + e.preventDefault(); + ajax_action($(this), modal_action); + }); + } +} + + +$(function() { + update_async_modals(); +}); + +function ajax_action(elt, callback) { + var target = elt.data('target'); + $.ajax({ + url: elt.data('url'), + headers: {'X-CSRFToken': getCookie('csrftoken')}, + }).success(function(data) { + callback(data, target); + }); +} + +function modal_action(data, target_id) { + var target = $(target_id); + target.empty(); + target.html(data); + $(target_id+" .modal").modal('show'); + target.off('click', 'button[type=submit]'); + target.find("select").select2({ dropdownAutoWidth: true, width: '100%' }); + + target.first().focus(); + target.on('click', 'button[type=submit]', function(e) { + e.stopPropagation(); + e.preventDefault(); + var form = $(this).parents('form:first'); + var data = form.serialize(); + $.ajax({ + type: 'POST', + url: form.attr('action'), + data: data, + headers: {'X-CSRFToken': getCookie('csrftoken')}, + success: function (msg) { + + if (msg.status == 'success') { + $(target_id+" .modal").modal('hide'); + target.empty(); + location.reload(); + } + + else if (msg.status == 'error') { + var html = $.parseHTML(msg.data); + $(target_id+" .modal .modal-body").html($(html).find('.modal-body')); + target.find("select").select2({ dropdownAutoWidth: true, width: '100%' }); + } + } + }); + }); +} diff --git a/fir_notifications/templates/fir_notifications/plugins/user_profile.html b/fir_notifications/templates/fir_notifications/plugins/user_profile.html index 5a810fa2..bc615cdb 100644 --- a/fir_notifications/templates/fir_notifications/plugins/user_profile.html +++ b/fir_notifications/templates/fir_notifications/plugins/user_profile.html @@ -1,3 +1,3 @@ -
    +
    \ No newline at end of file diff --git a/fir_notifications/templates/fir_notifications/subscribe.html b/fir_notifications/templates/fir_notifications/subscribe.html new file mode 100644 index 00000000..27312b49 --- /dev/null +++ b/fir_notifications/templates/fir_notifications/subscribe.html @@ -0,0 +1,42 @@ +{% load add_css_class %} +{% load i18n %} + \ No newline at end of file diff --git a/fir_notifications/templates/fir_notifications/subscriptions.html b/fir_notifications/templates/fir_notifications/subscriptions.html new file mode 100644 index 00000000..38c77450 --- /dev/null +++ b/fir_notifications/templates/fir_notifications/subscriptions.html @@ -0,0 +1,43 @@ +{% load i18n %} +{% load notifications %} +{% load staticfiles %} + +
    +
    +
    +

    {% trans "Notification subscriptions" %}

    +
    +
    + + + + + + + + + + + + {% for preference in preferences %} + + + + + + + + {% endfor %} + +
    {% trans "Event" %}{% trans "Method" %}{% trans "Business lines" %}
    {{ preference.event|display_event }}{{ preference.method|display_method }}{{ preference.business_lines.all|join:', ' }}
    + {% csrf_token %} + +
    +
    + +
    +
    +
    +
    +
    + \ No newline at end of file diff --git a/fir_notifications/templatetags/notifications.py b/fir_notifications/templatetags/notifications.py index 21de55c9..ebc1cf8c 100644 --- a/fir_notifications/templatetags/notifications.py +++ b/fir_notifications/templatetags/notifications.py @@ -22,3 +22,18 @@ def notification_forms(context): actions[method_name] = method_object.form(user=context['user']) return {'actions': actions} + +@register.filter +def display_method(arg): + method = registry.methods.get(arg, None) + if method is None: + return 'Unknown' + return method.verbose_name + + +@register.filter +def display_event(arg): + event = registry.events.get(arg, None) + if event is None: + return 'Unknown' + return event.verbose_name diff --git a/fir_notifications/urls.py b/fir_notifications/urls.py index f7d8dff9..5f70674f 100644 --- a/fir_notifications/urls.py +++ b/fir_notifications/urls.py @@ -4,6 +4,9 @@ urlpatterns = [ - url(r'^preferences$', views.preferences, name='preferences'), - url(r'^preferences/(?P[a-zA-Z0-9_]+)$', views.method_configuration, name='method_configuration'), + url(r'^subscriptions$', views.subscriptions, name='subscriptions'), + url(r'^subscriptions/(?P\d+)$', views.edit_subscription, name='edit-subscription'), + url(r'^subscriptions/subscribe$', views.edit_subscription, name='subscribe'), + url(r'^subscriptions/(?P\d+)/unsubscribe$', views.unsubscribe, name='unsubscribe'), + url(r'^method/(?P[a-zA-Z0-9_]+)$', views.method_configuration, name='method_configuration'), ] \ No newline at end of file diff --git a/fir_notifications/views.py b/fir_notifications/views.py index 70a55105..557c7d80 100644 --- a/fir_notifications/views.py +++ b/fir_notifications/views.py @@ -1,16 +1,15 @@ from django.contrib.auth.decorators import login_required -from django import forms from django.contrib import messages -from django.shortcuts import redirect, render -from django.views.decorators.http import require_POST -from django.contrib.auth import get_user_model +from django.http import JsonResponse +from django.shortcuts import redirect, render, get_object_or_404 +from django.template.loader import render_to_string +from django.views.decorators.http import require_POST, require_GET +from django.utils.translation import ugettext_lazy as _ -from fir_notifications.forms import NotificationPreferenceFormset +from fir_notifications.forms import NotificationPreferenceForm from fir_notifications.models import NotificationPreference from fir_notifications.registry import registry -from incidents.models import BusinessLine - @require_POST @login_required @@ -27,28 +26,47 @@ def method_configuration(request, method): return redirect('user:profile') +@require_GET @login_required -def preferences(request): - - class NotificationPreferenceForm(forms.ModelForm): - event = forms.ChoiceField(choices=registry.get_event_choices(), disabled=True, widget=forms.HiddenInput()) - method = forms.ChoiceField(choices=registry.get_method_choices(), disabled=True, widget=forms.HiddenInput()) - business_lines = forms.ModelMultipleChoiceField(BusinessLine.authorization.for_user(request.user, - 'incidents.view_incidents'), - required=False) +def subscriptions(request): + instances = NotificationPreference.objects.filter(user=request.user, + event__in=registry.events.keys(), + method__in=registry.methods.keys(), + business_lines__isnull=False).distinct() + return render(request, "fir_notifications/subscriptions.html", {'preferences': instances}) - class Meta: - fields = "__all__" - formset = forms.inlineformset_factory(get_user_model(), NotificationPreference, - formset=NotificationPreferenceFormset, - form=NotificationPreferenceForm) +@login_required +def edit_subscription(request, object_id=None): + instance = None + if object_id is not None: + instance = get_object_or_404(NotificationPreference, pk=object_id, user=request.user) if request.method == 'POST': - fs = formset(request.POST, instance=request.user) - if fs.is_valid(): - fs.save() - return redirect('user:profile') + form = NotificationPreferenceForm(instance=instance, data=request.POST, user=request.user) + if form.is_valid(): + form.save() + return JsonResponse({'status': 'success'}) + else: + errors = render_to_string("fir_notifications/subscribe.html", + {'form': form}) + return JsonResponse({'status': 'error', 'data': errors}) else: - fs = formset(instance=request.user) + form = NotificationPreferenceForm(instance=instance, user=request.user) + return render(request, "fir_notifications/subscribe.html", {'form': form}) + - return render(request, "fir_notifications/preferences.html", {'formset': fs}) +@require_POST +@login_required +def unsubscribe(request, object_id=None): + if object_id is not None: + try: + instance = NotificationPreference.objects.get(pk=object_id, user=request.user) + instance.delete() + messages.info(request, _('Unsubscribed.')) + except NotificationPreference.DoesNotExist: + messages.error(request, _("Subscription does not exist.")) + except NotificationPreference.MultipleObjectsReturned: + messages.error(request, _("Subscription is invalid.")) + else: + messages.error(request, _("Subscription does not exist.")) + return redirect('user:profile') From d55410616a0c679f63aa5a839819faa5c40f9b45 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 27 Jan 2017 11:12:49 +0100 Subject: [PATCH 59/66] Notifications: Update French translation --- .../locale/fr/LC_MESSAGES/django.po | 76 ++++++++++++++----- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/fir_notifications/locale/fr/LC_MESSAGES/django.po b/fir_notifications/locale/fr/LC_MESSAGES/django.po index bb09e748..93152942 100644 --- a/fir_notifications/locale/fr/LC_MESSAGES/django.po +++ b/fir_notifications/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-01-26 06:51+0100\n" +"POT-Creation-Date: 2017-01-27 11:04+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -23,7 +23,7 @@ msgctxt "business lines" msgid "All" msgstr "Toutes" -#: admin.py:20 +#: admin.py:20 forms.py:61 templates/fir_notifications/subscriptions.html:16 msgid "Business lines" msgstr "" @@ -31,11 +31,20 @@ msgstr "" msgid "Notifications" msgstr "" -#: forms.py:18 +#: forms.py:19 #, python-format msgid "Configure %(method)s" msgstr "Configurer %(method)s" +#: forms.py:59 models.py:66 models.py:74 models.py:82 models.py:92 +#: templates/fir_notifications/subscriptions.html:14 +msgid "Event" +msgstr "Événement" + +#: forms.py:60 templates/fir_notifications/subscriptions.html:15 +msgid "Method" +msgstr "Méthode" + #: methods/jabber.py:28 msgid "Jabber ID" msgstr "" @@ -100,43 +109,39 @@ msgstr "préférence de notification" msgid "notification preferences" msgstr "préférences de notification" -#: models.py:64 +#: models.py:65 msgid "Event created" msgstr "Événement créé" -#: models.py:65 models.py:73 models.py:81 models.py:91 -msgid "Event" -msgstr "Événement" - -#: models.py:72 +#: models.py:73 msgid "Event updated" msgstr "Événement mis à jour" -#: models.py:80 +#: models.py:81 msgid "Event commented" msgstr "Événement commenté" -#: models.py:90 +#: models.py:91 msgid "Event status changed" msgstr "Statut de l'événement changé" -#: models.py:98 +#: models.py:99 msgid "Incident created" msgstr "Incident créé" -#: models.py:99 models.py:107 models.py:115 models.py:125 +#: models.py:100 models.py:108 models.py:116 models.py:126 msgid "Incident" msgstr "Incident" -#: models.py:106 +#: models.py:107 msgid "Incident updated" msgstr "Incident mis à jour" -#: models.py:114 +#: models.py:115 msgid "Incident commented" msgstr "Incident commenté" -#: models.py:124 +#: models.py:125 msgid "Incident status changed" msgstr "Statut de l'incident changé" @@ -146,14 +151,45 @@ msgid "Configure %(method_name)s" msgstr "Configurer %(method_name)s" #: templates/fir_notifications/actions_form.html:38 +#: templates/fir_notifications/subscribe.html:36 msgid "Cancel" msgstr "Annuler" #: templates/fir_notifications/actions_form.html:39 -#: templates/fir_notifications/preferences.html:33 +#: templates/fir_notifications/subscribe.html:37 msgid "Save" msgstr "Enregistrer" -#: templates/fir_notifications/preferences.html:8 -msgid "Notification preferences" -msgstr "Préférences de notification" +#: templates/fir_notifications/subscribe.html:9 +msgid "Notification subscription" +msgstr "Abonnement à une notification" + +#: templates/fir_notifications/subscriptions.html:8 +#| msgid "Notification subscription" +msgid "Notification subscriptions" +msgstr "Abonnements aux notifications" + +#: templates/fir_notifications/subscriptions.html:27 +msgid "Edit" +msgstr "Éditer" + +#: templates/fir_notifications/subscriptions.html:30 +msgid "Unsubscribe" +msgstr "Se désabonner" + +#: templates/fir_notifications/subscriptions.html:37 +msgid "Subscribe" +msgstr "S'abonner" + +#: views.py:65 +msgid "Unsubscribed." +msgstr "Désabonné." + +#: views.py:67 views.py:71 +msgid "Subscription does not exist." +msgstr "L'abonnement n'existe pas." + +#: views.py:69 +msgid "Subscription is invalid." +msgstr "Abonnement invalide." + From 683ddc0d1f9a9ea51363cb4aa7d6810cd302b7c1 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 27 Jan 2017 11:15:23 +0100 Subject: [PATCH 60/66] Notifications: change notification preferences default ordering --- .../migrations/0003_auto_20170127_1113.py | 24 +++++++++++++++++++ fir_notifications/models.py | 1 + 2 files changed, 25 insertions(+) create mode 100644 fir_notifications/migrations/0003_auto_20170127_1113.py diff --git a/fir_notifications/migrations/0003_auto_20170127_1113.py b/fir_notifications/migrations/0003_auto_20170127_1113.py new file mode 100644 index 00000000..47210bf8 --- /dev/null +++ b/fir_notifications/migrations/0003_auto_20170127_1113.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.9 on 2017-01-27 11:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fir_notifications', '0002_user_preference'), + ] + + operations = [ + migrations.AlterModelOptions( + name='notificationpreference', + options={'ordering': ['user', 'event', 'method'], 'verbose_name': 'notification preference', 'verbose_name_plural': 'notification preferences'}, + ), + migrations.AlterField( + model_name='methodconfiguration', + name='key', + field=models.CharField(choices=[(b'email', b'Email'), (b'xmpp', b'XMPP')], max_length=60, verbose_name='method'), + ), + ] diff --git a/fir_notifications/models.py b/fir_notifications/models.py index ff91db8e..d2132e97 100644 --- a/fir_notifications/models.py +++ b/fir_notifications/models.py @@ -58,6 +58,7 @@ class Meta: verbose_name_plural = _('notification preferences') unique_together = (("user", "event", "method"),) index_together = ["user", "event", "method"] + ordering = ['user', 'event', 'method'] if not settings.NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS: From f40fc395560a3962e7a7692493ce886fe53be456 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 27 Jan 2017 11:17:39 +0100 Subject: [PATCH 61/66] Notifications: sort events and methods choices --- fir_notifications/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py index 77ef9a59..4c423cbe 100644 --- a/fir_notifications/registry.py +++ b/fir_notifications/registry.py @@ -66,10 +66,10 @@ def register_event(self, name, signal, model, callback, verbose_name=None, secti signal.connect(callback, sender=model, dispatch_uid="fir_notifications.{}".format(name)) def get_event_choices(self): - return [(obj.name, obj.verbose_name) for obj in self.events.values()] + return sorted([(obj.name, obj.verbose_name) for obj in self.events.values()]) def get_method_choices(self): - return [(obj.name, obj.verbose_name) for obj in self.methods.values()] + return sorted([(obj.name, obj.verbose_name) for obj in self.methods.values()]) def get_methods(self): return self.methods.values() From fbfed55264ad8e014869074142ecf811ef13ba18 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 27 Jan 2017 11:55:32 +0100 Subject: [PATCH 62/66] Notifications: Show event section in event choices --- fir_notifications/registry.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py index 4c423cbe..ec0433e3 100644 --- a/fir_notifications/registry.py +++ b/fir_notifications/registry.py @@ -10,7 +10,7 @@ @python_2_unicode_compatible class RegisteredEvent(object): - def __init__(self, name, model, verbose_name=None, section = None): + def __init__(self, name, model, verbose_name=None, section=None): self.name = name if section is None: section = apps.get_app_config(model._meta.app_label).verbose_name @@ -66,7 +66,12 @@ def register_event(self, name, signal, model, callback, verbose_name=None, secti signal.connect(callback, sender=model, dispatch_uid="fir_notifications.{}".format(name)) def get_event_choices(self): - return sorted([(obj.name, obj.verbose_name) for obj in self.events.values()]) + results = OrderedDict() + for obj in self.events.values(): + if obj.section not in results: + results[obj.section] = list() + results[obj.section].append((obj.name, obj.verbose_name)) + return [(section, sorted(choices)) for section, choices in results.items()] def get_method_choices(self): return sorted([(obj.name, obj.verbose_name) for obj in self.methods.values()]) From 9c7487825514190a81c3e6cd6ee19def1d55aa2f Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Fri, 27 Jan 2017 11:56:00 +0100 Subject: [PATCH 63/66] Notifications: Show event section in user preferences --- .../templates/fir_notifications/subscriptions.html | 2 ++ fir_notifications/templatetags/notifications.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/fir_notifications/templates/fir_notifications/subscriptions.html b/fir_notifications/templates/fir_notifications/subscriptions.html index 38c77450..263d666e 100644 --- a/fir_notifications/templates/fir_notifications/subscriptions.html +++ b/fir_notifications/templates/fir_notifications/subscriptions.html @@ -11,6 +11,7 @@

    {% trans "Notification subscriptions" %}

    + @@ -21,6 +22,7 @@

    {% trans "Notification subscriptions" %}

    {% for preference in preferences %} + diff --git a/fir_notifications/templatetags/notifications.py b/fir_notifications/templatetags/notifications.py index ebc1cf8c..ad987639 100644 --- a/fir_notifications/templatetags/notifications.py +++ b/fir_notifications/templatetags/notifications.py @@ -37,3 +37,11 @@ def display_event(arg): if event is None: return 'Unknown' return event.verbose_name + + +@register.filter +def display_event_section(arg): + event = registry.events.get(arg, None) + if event is None: + return 'Unknown' + return event.section From 326a029210afc498993d3f91b6a4ee23aa66cb0b Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 30 Jan 2017 12:25:33 +0100 Subject: [PATCH 64/66] Notifications: XMPP notifications don't need a running XMPP server on start --- fir_notifications/methods/jabber.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/fir_notifications/methods/jabber.py b/fir_notifications/methods/jabber.py index f41de560..f2d26524 100644 --- a/fir_notifications/methods/jabber.py +++ b/fir_notifications/methods/jabber.py @@ -45,17 +45,23 @@ def __init__(self): self.connection_tuple = (self.server, self.port) self.use_srv = False self.client = Client(self.jid.getDomain()) - if not self.client.connect(server=self.connection_tuple, use_srv=self.use_srv): - self.server_configured = False - return - if not self.client.auth(self.jid.getNode(), self.password, resource=self.jid.getResource()): - self.server_configured = False - return - self.client.disconnected() + if self.client.connect(server=self.connection_tuple, use_srv=self.use_srv): + self.client.auth(self.jid.getNode(), self.password, resource=self.jid.getResource()) + self.client.disconnected() self.server_configured = True + def _ensure_connection(self): + if not hasattr(self.client, 'Dispatcher'): + if self.client.connect(server=self.connection_tuple, use_srv=self.use_srv): + if self.client.auth(self.jid.getNode(), self.password, resource=self.jid.getResource()): + return True + return False + return self.client.reconnectAndReauth() + def send(self, event, users, instance, paths): - self.client.reconnectAndReauth() + if not self._ensure_connection(): + print("Cannot contact the XMPP server") + return for user, templates in users.items(): jid = self._get_jid(user) if not self.enabled(event, user, paths) or jid is None: From ca0ab370e7605d8eabd242337171fe7ee6ebcd49 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 30 Jan 2017 12:26:26 +0100 Subject: [PATCH 65/66] Notifications: Fix missing user field in preference form --- fir_notifications/forms.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py index df343f2e..959219f0 100644 --- a/fir_notifications/forms.py +++ b/fir_notifications/forms.py @@ -2,6 +2,7 @@ from django import forms from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth import get_user_model from incidents.models import BusinessLine @@ -56,10 +57,16 @@ def __init__(self, *args, **kwargs): self.fields['event'].disabled = True self.fields['method'].disabled = True + def clean_user(self): + if self.user is None: + raise forms.ValidationError(_("Notification preference must be linked to a user.")) + return self.user + + user = forms.ModelChoiceField(queryset=get_user_model().objects.all(), widget=forms.HiddenInput(), required=False) event = forms.ChoiceField(choices=registry.get_event_choices(), label=_('Event')) method = forms.ChoiceField(choices=registry.get_method_choices(), label=_('Method')) business_lines = forms.ModelMultipleChoiceField(BusinessLine.objects.all(), label=_('Business lines')) class Meta: - exclude = ('user', ) + fields = '__all__' model = NotificationPreference From b16e68d261f6bc8c4ce3baad20e0616dd29af853 Mon Sep 17 00:00:00 2001 From: Gaetan Crahay Date: Mon, 30 Jan 2017 12:29:22 +0100 Subject: [PATCH 66/66] Notifications: update French translation --- .../locale/fr/LC_MESSAGES/django.po | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/fir_notifications/locale/fr/LC_MESSAGES/django.po b/fir_notifications/locale/fr/LC_MESSAGES/django.po index 93152942..552fe344 100644 --- a/fir_notifications/locale/fr/LC_MESSAGES/django.po +++ b/fir_notifications/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-01-27 11:04+0100\n" +"POT-Creation-Date: 2017-01-30 12:26+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -23,7 +23,7 @@ msgctxt "business lines" msgid "All" msgstr "Toutes" -#: admin.py:20 forms.py:61 templates/fir_notifications/subscriptions.html:16 +#: admin.py:20 forms.py:68 templates/fir_notifications/subscriptions.html:17 msgid "Business lines" msgstr "" @@ -31,17 +31,21 @@ msgstr "" msgid "Notifications" msgstr "" -#: forms.py:19 +#: forms.py:20 #, python-format msgid "Configure %(method)s" msgstr "Configurer %(method)s" -#: forms.py:59 models.py:66 models.py:74 models.py:82 models.py:92 -#: templates/fir_notifications/subscriptions.html:14 +#: forms.py:62 +msgid "Notification preference must be linked to a user." +msgstr "La préférence de notification doit être reliée à un utilisateur." + +#: forms.py:66 models.py:66 models.py:74 models.py:82 models.py:92 +#: templates/fir_notifications/subscriptions.html:15 msgid "Event" msgstr "Événement" -#: forms.py:60 templates/fir_notifications/subscriptions.html:15 +#: forms.py:67 templates/fir_notifications/subscriptions.html:16 msgid "Method" msgstr "Méthode" @@ -156,28 +160,36 @@ msgid "Cancel" msgstr "Annuler" #: templates/fir_notifications/actions_form.html:39 +#: templates/fir_notifications/preferences.html:33 #: templates/fir_notifications/subscribe.html:37 msgid "Save" msgstr "Enregistrer" +#: templates/fir_notifications/preferences.html:8 +msgid "Notification preferences" +msgstr "Préférences de notification" + #: templates/fir_notifications/subscribe.html:9 msgid "Notification subscription" msgstr "Abonnement à une notification" #: templates/fir_notifications/subscriptions.html:8 -#| msgid "Notification subscription" msgid "Notification subscriptions" msgstr "Abonnements aux notifications" -#: templates/fir_notifications/subscriptions.html:27 +#: templates/fir_notifications/subscriptions.html:14 +msgid "Section" +msgstr "" + +#: templates/fir_notifications/subscriptions.html:29 msgid "Edit" msgstr "Éditer" -#: templates/fir_notifications/subscriptions.html:30 +#: templates/fir_notifications/subscriptions.html:32 msgid "Unsubscribe" msgstr "Se désabonner" -#: templates/fir_notifications/subscriptions.html:37 +#: templates/fir_notifications/subscriptions.html:39 msgid "Subscribe" msgstr "S'abonner" @@ -192,4 +204,3 @@ msgstr "L'abonnement n'existe pas." #: views.py:69 msgid "Subscription is invalid." msgstr "Abonnement invalide." -
    {% trans "Section" %} {% trans "Event" %} {% trans "Method" %} {% trans "Business lines" %}
    {{ preference.event|display_event_section }} {{ preference.event|display_event }} {{ preference.method|display_method }} {{ preference.business_lines.all|join:', ' }}