diff --git a/fir/config/base.py b/fir/config/base.py index 9f352824..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') @@ -155,3 +156,10 @@ # User can change his password 'CHANGE_PASSWORD': True } + +# 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/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_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*). 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 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_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" diff --git a/fir_email/requirements_smime.txt b/fir_email/requirements_smime.txt new file mode 100644 index 00000000..e1abc67f --- /dev/null +++ b/fir_email/requirements_smime.txt @@ -0,0 +1 @@ +django-djembe==0.2.0 \ No newline at end of file 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 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/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 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 diff --git a/fir_notifications/README.md b/fir_notifications/README.md new file mode 100644 index 00000000..3e81016f --- /dev/null +++ b/fir_notifications/README.md @@ -0,0 +1,120 @@ +# Notifications plugin for FIR + +## Features + +This plugins allows you to 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 +(fir-env)$ ./manage.py migrate fir_notifications +``` + +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 +* '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 + +### Events + +You can disable notification events in the settings using the key `NOTIFICATIONS_DISABLED_EVENTS`: + +```python +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`. + +### 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 + +Follow the `fir_email` [README](fir_email/README.md). + +### 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 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 + +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. + + + 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/admin.py b/fir_notifications/admin.py new file mode 100644 index 00000000..5749e746 --- /dev/null +++ b/fir_notifications/admin.py @@ -0,0 +1,26 @@ +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, NotificationPreference +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(NotificationPreference) + admin.site.register(MethodConfiguration) 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/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 diff --git a/fir_notifications/forms.py b/fir_notifications/forms.py new file mode 100644 index 00000000..959219f0 --- /dev/null +++ b/fir_notifications/forms.py @@ -0,0 +1,72 @@ +import json + +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 + +from fir_notifications.registry import registry +from fir_notifications.models import MethodConfiguration, NotificationPreference + + +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 + + +class NotificationTemplateForm(forms.ModelForm): + event = forms.ChoiceField(choices=registry.get_event_choices()) + + class Meta: + fields = '__all__' + + +class NotificationPreferenceForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + 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: + 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: + fields = '__all__' + model = NotificationPreference 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..552fe344 --- /dev/null +++ b/fir_notifications/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,206 @@ +# 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-30 12:26+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 forms.py:68 templates/fir_notifications/subscriptions.html:17 +msgid "Business lines" +msgstr "" + +#: apps.py:7 +msgid "Notifications" +msgstr "" + +#: forms.py:20 +#, python-format +msgid "Configure %(method)s" +msgstr "Configurer %(method)s" + +#: 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:67 templates/fir_notifications/subscriptions.html:16 +msgid "Method" +msgstr "Méthode" + +#: methods/jabber.py:28 +msgid "Jabber ID" +msgstr "" + +#: models.py:16 models.py:45 +msgid "user" +msgstr "utilisateur" + +#: models.py:17 models.py:47 +msgid "method" +msgstr "méthode" + +#: models.py:18 +msgid "configuration" +msgstr "" + +#: models.py:24 +msgid "method configuration" +msgstr "configuration de méthode" + +#: models.py:25 +msgid "method configurations" +msgstr "configurations de méthode" + +#: models.py:31 models.py:46 +msgid "event" +msgstr "événement" + +#: models.py:33 +msgid "business line" +msgstr "" + +#: models.py:34 +msgid "subject" +msgstr "objet" + +#: models.py:35 +msgid "short description" +msgstr "description courte" + +#: models.py:36 +msgid "description" +msgstr "" + +#: models.py:39 +msgid "notification template" +msgstr "gabarit de notification" + +#: models.py:40 +msgid "notification templates" +msgstr "gabarits de notification" + +#: models.py:49 +msgid "business lines" +msgstr "" + +#: models.py:57 +msgid "notification preference" +msgstr "préférence de notification" + +#: models.py:58 +msgid "notification preferences" +msgstr "préférences de notification" + +#: models.py:65 +msgid "Event created" +msgstr "Événement créé" + +#: models.py:73 +msgid "Event updated" +msgstr "Événement mis à jour" + +#: models.py:81 +msgid "Event commented" +msgstr "Événement commenté" + +#: models.py:91 +msgid "Event status changed" +msgstr "Statut de l'événement changé" + +#: models.py:99 +msgid "Incident created" +msgstr "Incident créé" + +#: models.py:100 models.py:108 models.py:116 models.py:126 +msgid "Incident" +msgstr "Incident" + +#: models.py:107 +msgid "Incident updated" +msgstr "Incident mis à jour" + +#: models.py:115 +msgid "Incident commented" +msgstr "Incident commenté" + +#: models.py:125 +msgid "Incident status changed" +msgstr "Statut de l'incident changé" + +#: 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 +#: 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 subscriptions" +msgstr "Abonnements aux notifications" + +#: templates/fir_notifications/subscriptions.html:14 +msgid "Section" +msgstr "" + +#: templates/fir_notifications/subscriptions.html:29 +msgid "Edit" +msgstr "Éditer" + +#: templates/fir_notifications/subscriptions.html:32 +msgid "Unsubscribe" +msgstr "Se désabonner" + +#: templates/fir_notifications/subscriptions.html:39 +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." diff --git a/fir_notifications/methods/__init__.py b/fir_notifications/methods/__init__.py new file mode 100644 index 00000000..0cedde9b --- /dev/null +++ b/fir_notifications/methods/__init__.py @@ -0,0 +1,105 @@ +import json + +from django.template import Template, Context + + +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) diff --git a/fir_notifications/methods/email.py b/fir_notifications/methods/email.py new file mode 100644 index 00000000..49f948cb --- /dev/null +++ b/fir_notifications/methods/email.py @@ -0,0 +1,38 @@ +from django.conf import settings +from django.core import mail + +from fir_email.helpers import prepare_email_message + +from fir_notifications.methods import NotificationMethod +from fir_notifications.methods.utils import request + + +class EmailMethod(NotificationMethod): + use_subject = True + use_description = True + name = 'email' + verbose_name = 'Email' + + def __init__(self): + super(EmailMethod, self).__init__() + if hasattr(settings, 'EMAIL_FROM') and settings.EMAIL_FROM is not None: + self.server_configured = True + + def send(self, event, users, instance, paths): + 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) + 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) + + def configured(self, user): + return super(EmailMethod, self).configured(user) and user.email is not None diff --git a/fir_notifications/methods/jabber.py b/fir_notifications/methods/jabber.py new file mode 100644 index 00000000..f2d26524 --- /dev/null +++ b/fir_notifications/methods/jabber.py @@ -0,0 +1,94 @@ +import markdown2 +from django.conf import settings + +import 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 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): + 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: + 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/methods/utils.py b/fir_notifications/methods/utils.py new file mode 100644 index 00000000..dff74030 --- /dev/null +++ b/fir_notifications/methods/utils.py @@ -0,0 +1,15 @@ +from django.conf import settings + + +class FakeRequest(object): + def __init__(self): + 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) + +request = FakeRequest() \ No newline at end of file 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')]), + ), + ] 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')]), + ), + ] 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/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..d2132e97 --- /dev/null +++ b/fir_notifications/models.py @@ -0,0 +1,130 @@ +from __future__ import unicode_literals + +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, Comments, model_status_changed + + +@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') + + +@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"] + ordering = ['user', 'event', 'method'] + + +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'), + section=_('Incident')) +def incident_created(sender, instance, **kwargs): + if not 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 settings.NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS and not instance.is_incident: + return None, None + return instance, instance.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 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('incident:status_changed', model_status_changed, Incident, verbose_name=_('Incident status changed'), + section=_('Incident')) +def incident_status_changed(sender, instance, **kwargs): + 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 diff --git a/fir_notifications/registry.py b/fir_notifications/registry.py new file mode 100644 index 00000000..ec0433e3 --- /dev/null +++ b/fir_notifications/registry.py @@ -0,0 +1,85 @@ +from collections import OrderedDict + +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 + + +@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() + 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: + 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, 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 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) + + signal.connect(callback, sender=model, dispatch_uid="fir_notifications.{}".format(name)) + + def get_event_choices(self): + 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()]) + + def get_methods(self): + return self.methods.values() + + +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..da8c3560 --- /dev/null +++ b/fir_notifications/requirements.txt @@ -0,0 +1 @@ +https://github.com/ArchipelProject/xmpppy/zipball/288b280c6ec534c100bfee871daa3bb707467a1a \ No newline at end of file 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/tasks.py b/fir_notifications/tasks.py new file mode 100644 index 00000000..f42f652c --- /dev/null +++ b/fir_notifications/tasks.py @@ -0,0 +1,103 @@ +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 +from fir_celery.celeryconf import celery_app + +_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 + + +@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 + 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) 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.html b/fir_notifications/templates/fir_notifications/plugins/user_profile.html new file mode 100644 index 00000000..bc615cdb --- /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/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/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/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..263d666e --- /dev/null +++ b/fir_notifications/templates/fir_notifications/subscriptions.html @@ -0,0 +1,45 @@ +{% load i18n %} +{% load notifications %} +{% load staticfiles %} + +
    +
    +
    +

    {% trans "Notification subscriptions" %}

    +
    +
    + + + + + + + + + + + + + {% for preference in preferences %} + + + + + + + + + {% endfor %} + +
    {% 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:', ' }}
    + {% csrf_token %} + +
    +
    + +
    +
    +
    +
    +
    + \ 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/templatetags/notifications.py b/fir_notifications/templatetags/notifications.py new file mode 100644 index 00000000..ad987639 --- /dev/null +++ b/fir_notifications/templatetags/notifications.py @@ -0,0 +1,47 @@ +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} + + +@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 + + +@register.filter +def display_event_section(arg): + event = registry.events.get(arg, None) + if event is None: + return 'Unknown' + return event.section diff --git a/fir_notifications/urls.py b/fir_notifications/urls.py new file mode 100644 index 00000000..5f70674f --- /dev/null +++ b/fir_notifications/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import url + +from fir_notifications import views + + +urlpatterns = [ + 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 new file mode 100644 index 00000000..557c7d80 --- /dev/null +++ b/fir_notifications/views.py @@ -0,0 +1,72 @@ +from django.contrib.auth.decorators import login_required +from django.contrib import messages +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 NotificationPreferenceForm +from fir_notifications.models import NotificationPreference +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() + else: + for error in form.errors.items(): + messages.error(request, error[1]) + return redirect('user:profile') + + +@require_GET +@login_required +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}) + + +@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': + 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: + form = NotificationPreferenceForm(instance=instance, user=request.user) + return render(request, "fir_notifications/subscribe.html", {'form': form}) + + +@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') 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)" 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 %}
    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: