-
Notifications
You must be signed in to change notification settings - Fork 506
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
Notification plugin #159
Changes from 41 commits
c2f3ae7
0d0bbce
1edf7da
eeed03d
996bc72
1fe9443
ffb0e5d
aa1ca01
673e855
f38f137
4f233a8
d54f0cd
1b0c464
d4ae90b
387e6f7
ad107cd
a5f2523
f03a98e
ac1178b
5ad72b2
66f49a7
53e16ee
f2781a8
d4ac392
05683de
381455f
71e64bd
89c250e
897e2f1
4eecbe3
7c46258
e4f8b51
c59ec22
84b6fe1
73fad53
1d9a9ff
41ce12e
596e03a
46c8441
2bb5172
b260073
3649fa2
70035a2
d049081
46784ac
e3a243a
3892eec
5f4f17b
bd30af3
32441f0
d800445
bff417d
b3650f2
727da0d
7b5ad3c
ba108cd
2630d2c
9d841ef
d554106
683ddc0
f40fc39
fbfed55
9c74878
326a029
ca0ab37
b16e68d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had a problem with this command. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure about this. Why have both 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The notification templates module is designed to be:
|
||
|
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
default_app_config = 'fir_notifications.apps.NotificationsConfig' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, and it is a Django best practice |
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) |
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 |
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 |
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 |
There was a problem hiding this comment.
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 inbase.py