Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notification plugin #159

Merged
merged 66 commits into from
Feb 13, 2017
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
c2f3ae7
Notifications: application structure
gcrahay Jan 14, 2017
0d0bbce
Notifications: event and method registry
gcrahay Jan 14, 2017
1edf7da
Notifications: models for notification method configuration and notif…
gcrahay Jan 14, 2017
eeed03d
Notifications: method configuration form
gcrahay Jan 14, 2017
996bc72
Notifications: template form
gcrahay Jan 14, 2017
1fe9443
Notifications: template admin
gcrahay Jan 14, 2017
ffb0e5d
Notifications: DB migration for method configuration and template
gcrahay Jan 14, 2017
aa1ca01
Notifications: user preference model
gcrahay Jan 14, 2017
673e855
Notifications: DB migration for user preference
gcrahay Jan 14, 2017
f38f137
Notifications: method base class
gcrahay Jan 14, 2017
4f233a8
Notifications: celery task (as a shared task)
gcrahay Jan 14, 2017
d54f0cd
Notifications: Fix forms.py imports
gcrahay Jan 14, 2017
1b0c464
Notifications: Add method configuration view
gcrahay Jan 14, 2017
d4ae90b
Notifications: add Email method
gcrahay Jan 14, 2017
387e6f7
Notifications: add user preference in admin if DEBUG
gcrahay Jan 14, 2017
ad107cd
Notifications: fix super class init call in email method
gcrahay Jan 14, 2017
a5f2523
Notifications: add default setting EXTERNAL_URL
gcrahay Jan 14, 2017
f03a98e
Notifications: improve event registry
gcrahay Jan 14, 2017
ac1178b
Notifications: add notification user preferences view
gcrahay Jan 14, 2017
5ad72b2
Notifications: add event creation decorator
gcrahay Jan 14, 2017
66f49a7
Notifications: create event and incident related notification events
gcrahay Jan 14, 2017
53e16ee
Notifications: move fake request to methods.utils
gcrahay Jan 14, 2017
f2781a8
Notifications: add XMPP method
gcrahay Jan 14, 2017
d4ac392
Notifications: add Readme
gcrahay Jan 14, 2017
05683de
Notifications: add certificate form field label
gcrahay Jan 14, 2017
381455f
Notifications: add French translation
gcrahay Jan 14, 2017
71e64bd
Notifications: fix method display in user preferences
gcrahay Jan 15, 2017
89c250e
Notifications: update Readme: How to send encrypted/signed email noti…
gcrahay Jan 16, 2017
897e2f1
Notifications: Readme, more on templates and template selection
gcrahay Jan 16, 2017
4eecbe3
Notifications: update Readme
gcrahay Jan 16, 2017
7c46258
Notifications: document the event registry
gcrahay Jan 16, 2017
e4f8b51
Notifications: Fix user preferences error (when a method is disabled …
gcrahay Jan 16, 2017
c59ec22
Notifications: use settings defined in fir_email
gcrahay Jan 16, 2017
84b6fe1
Notifications: fir_celery integration: remove shared_task and use cel…
gcrahay Jan 16, 2017
73fad53
Notifications: rename duplicate signal handlers
gcrahay Jan 17, 2017
1d9a9ff
Notifications: add model_status_changed signal in incidents
gcrahay Jan 17, 2017
41ce12e
Notifications: use model_status_changed signal in incidents views
gcrahay Jan 17, 2017
596e03a
Notifications: event and incident new notification events (commented,…
gcrahay Jan 17, 2017
46c8441
Notifications: French translation for new notification events
gcrahay Jan 17, 2017
2bb5172
Notifications: notification events can be disabled in setting NOTIFIC…
gcrahay Jan 17, 2017
b260073
Notifications: NOTIFICATIONS_MERGE_INCIDENTS_AND_EVENTS setting
gcrahay Jan 17, 2017
3649fa2
Notifications: nove EXTERNAL_URL setting from base to production sample
gcrahay Jan 20, 2017
70035a2
Notifications: move email logic into fir_email
gcrahay Jan 20, 2017
d049081
Notifications: fix xmpppy requirement install
gcrahay Jan 20, 2017
46784ac
Move S/MIME requirements file into fir_email
gcrahay Jan 20, 2017
e3a243a
Notifications: remove S/MIME stuff from fir_notifications
gcrahay Jan 25, 2017
3892eec
Notifications: remove S/MIME doc from fir_notifications
gcrahay Jan 25, 2017
5f4f17b
Email: check Django-djembe configuration helper
gcrahay Jan 25, 2017
bd30af3
Email: User S/MIME certificate form
gcrahay Jan 25, 2017
32441f0
Email: User certificate templates
gcrahay Jan 25, 2017
d800445
Email: User certificate view
gcrahay Jan 25, 2017
bff417d
Email: Initial readme
gcrahay Jan 25, 2017
b3650f2
Notifications: use Django messages framework to report form errors (m…
gcrahay Jan 25, 2017
727da0d
Email: Add fir_email as a FIR core app
gcrahay Jan 26, 2017
7b5ad3c
Email: Add French translation
gcrahay Jan 26, 2017
ba108cd
Notifications: Update French translation
gcrahay Jan 26, 2017
2630d2c
Add Select2 static files to user profile page
gcrahay Jan 27, 2017
9d841ef
Notifications: refactor user preferences UI
gcrahay Jan 27, 2017
d554106
Notifications: Update French translation
gcrahay Jan 27, 2017
683ddc0
Notifications: change notification preferences default ordering
gcrahay Jan 27, 2017
f40fc39
Notifications: sort events and methods choices
gcrahay Jan 27, 2017
fbfed55
Notifications: Show event section in event choices
gcrahay Jan 27, 2017
9c74878
Notifications: Show event section in user preferences
gcrahay Jan 27, 2017
326a029
Notifications: XMPP notifications don't need a running XMPP server on…
gcrahay Jan 30, 2017
ca0ab37
Notifications: Fix missing user field in preference form
gcrahay Jan 30, 2017
b16e68d
Notifications: update French translation
gcrahay Jan 30, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions fir/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,13 @@
# 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'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in production.py.sample rather than in base.py


# 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
174 changes: 174 additions & 0 deletions fir_notifications/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a problem with this command. xmpppywas not in the PYTHON path afterwards. I had to tweak the module's installed files. Did you have the same issue ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I reproduced this issue in a fresh virtualenv. I'll fix that.

```

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the documentation about email, S/MIME, etc. should be in fir_email

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I'll start this module doc.


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 = '[email protected]'
# 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*).

### Jabber (XMPP) notifications
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this should be user preferences rather than global instance configuration options.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the FIR instance XMPP settings, not the user's. The JID is the sending JID.


Configure `fir_notifications`:

``` python
# FIR user JID
NOTIFICATIONS_XMPP_JID = '[email protected]'
# Password for [email protected] JID
NOTIFICATIONS_XMPP_PASSWORD = 'my secret password'
# XMPP server
NOTIFICATIONS_XMPP_SERVER = 'localhost'
# XMPP server port
NOTIFICATIONS_XMPP_PORT = 5222
```

### Notification templates
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this.

Why have both short_description and description if no method is going to use both. Would it not be better to have a field for the method concerned by this template ?

Also, what is the reason for having different templates for different business lines ?

I also feel this might be better as user preferences, maybe even per subscribed notification.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The notification templates module is designed to be:

  • method agnostic:
    • if you add a method, you won't have to create a bunch of templates
    • description vs short_description: you won't have a long body in a XMPP notification or a potential SMS one
  • centrally managed:
    • normal users don'have to know the Django template language
    • FIR admins will choose what details will be shown in notifications
  • business lines focused
    • example: non technical users don't need to view the artifacts in their notifications


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be added later, but a Webhook method would be nice, as requested in #161


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.



1 change: 1 addition & 0 deletions fir_notifications/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = 'fir_notifications.apps.NotificationsConfig'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the point of this AppConfig ? Is it only to change the name of the application in the Admin panel ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and it is a Django best practice

26 changes: 26 additions & 0 deletions fir_notifications/admin.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions fir_notifications/apps.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions fir_notifications/decorators.py
Original file line number Diff line number Diff line change
@@ -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
126 changes: 126 additions & 0 deletions fir_notifications/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import json
from collections import OrderedDict

from django import forms
from django.utils.translation import ugettext_lazy as _

from fir_notifications.registry import registry
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


class NotificationTemplateForm(forms.ModelForm):
event = forms.ChoiceField(choices=registry.get_event_choices())

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()
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
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):
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

# 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
Loading