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

Media refactor: prep for sending SMSes in addition to email #105

Merged
merged 5 commits into from
Aug 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ A recap of the environment variables that can be set by default follows.
* SECRET_KEY, used internally by django, should be about 50 chars of ascii
noise (but avoid backspaces!)

There are also settings (not env-variables) for which notification plugins to use:

DEFAULT_SMS_MEDIA, which by default is unset, since there is no standardized
way of sending SMSes. See **Notifications and notification plugins**.

DEFAULT_EMAIL_MEDIA, which is included and uses Django's email backend. It is
better to switch out the email backend than replcaing this plugin.

*A Gmail account with "Allow less secure apps" turned on, was used in the development of this project.*

### Running tests
Expand Down Expand Up @@ -391,3 +399,9 @@ All endpoints require requests to contain a header with key `Authorization` and

### ER diagram
![ER diagram](img/ER_model.png)

## Notifications and notification plugins

A notification plugin is a class that inherits from `argus.notificationprofile.media.base.NotificationMedium`. It has a `send(incident, user, **kwargs)` static method that does the actual sending.

The included `argus.notificationprofile.media.email.EmailNotification` needs only `incident` and `user`, while an SMS medium in addition needs a `phone_number`. A `phone_number` is a string that includes the international calling code, see for instance [Wikipedia: List of mobile telephone prefixes by country](https://en.wikipedia.org/wiki/List_of_mobile_telephone_prefixes_by_country).
3 changes: 2 additions & 1 deletion src/argus/incident/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@

from argus.auth.models import User
from argus.drf.permissions import IsOwnerOrReadOnly, IsSuperuserOrReadOnly
from argus.notificationprofile.notification_media import background_send_notifications_to_users
from argus.notificationprofile.media import background_send_notifications_to_users

from . import mappings
from .forms import AddSourceSystemForm
from .models import (
Expand Down
93 changes: 93 additions & 0 deletions src/argus/notificationprofile/media/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import importlib
import logging
from multiprocessing import Process
from typing import List

from django.conf import settings
from django.db import connections

from argus.auth.models import User
from argus.incident.models import Incident

from ..models import NotificationProfile
from .email import EmailNotification


LOG = logging.getLogger(__name__)


__all__ = [
"send_notifications_to_users",
"background_send_notifications_to_users",
"send_notification",
"get_notification_media",
]


# Poor mans's plugins
MEDIA_CLASSES = {
NotificationProfile.Media.EMAIL: getattr(settings, "DEFAULT_EMAIL_MEDIA", EmailNotification),
NotificationProfile.Media.SMS: getattr(settings, "DEFAULT_SMS_MEDIA", None),
NotificationProfile.Media.SLACK: getattr(settings, "DEFAULT_SLACK_MEDIA", None),
}


def _import_class_from_dotted_path(dotted_path: str):
module_name, class_name = dotted_path.rsplit('.', 1)
module = importlib.import_module(module_name)
class_ = getattr(module, class_name)
return class_


for media_type, media_class in MEDIA_CLASSES.items():
if not media_class or not isinstance(media_class, str):
continue
# Dotted paths here!
# TODO: Raise Incident if media_class not importable
MEDIA_CLASSES[media_type] = _import_class_from_dotted_path(media_class)


def send_notifications_to_users(incident: Incident):
if not getattr(settings, "SEND_NOTIFICATIONS", False):
LOG.info('Notification: turned off sitewide, not sending for "%s"', incident)
return
# TODO: only send one notification per medium per user
LOG.info('Notification: sending incident "%s"', incident)
for profile in NotificationProfile.objects.select_related("user"):
if profile.incident_fits(incident):
send_notification(profile.user, profile, incident)
else:
LOG.info('Notification: no listeners for "%s"', incident)
return
LOG.info('Notification: incident "%s" sent!', incident)


def background_send_notifications_to_users(incident: Incident):
connections.close_all()
LOG.info('Notification: backgrounded: about to send incident "%s"', incident)
p = Process(target=send_notifications_to_users, args=(incident,))
p.start()
return p


def send_notification(user: User, profile: NotificationProfile, incident: Incident):
media = get_notification_media(profile.media)
for medium in media:
if medium is not None:
medium.send(incident, user, phone_number=profile.phone_number)
else:
LOG.warn("Notification: Could not send notification, nowhere to send it to")


def get_notification_media(model_representations: List[str]):
# This will never be a long list
media = [
MEDIA_CLASSES[representation]
for representation in model_representations
if MEDIA_CLASSES[representation]
]
if media:
return media
LOG.error("Notification: nowhere to send notifications!")
# TODO: Store error as incident
return ()
14 changes: 14 additions & 0 deletions src/argus/notificationprofile/media/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from abc import ABC, abstractmethod

from argus.auth.models import User
from argus.incident.models import Incident


__all__ = ['NotificationMedium']


class NotificationMedium(ABC):
@staticmethod
@abstractmethod
def send(incident: Incident, user: User, **kwargs):
pass
57 changes: 57 additions & 0 deletions src/argus/notificationprofile/media/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import json
import logging

from django.conf import settings
from django.template.loader import render_to_string
from rest_framework.renderers import JSONRenderer

from argus.incident.serializers import IncidentSerializer

from .base import NotificationMedium


LOG = logging.getLogger(__name__)

__all__ = ['EmailNotification']
hmpf marked this conversation as resolved.
Show resolved Hide resolved


def send_email_safely(function, additional_error=None, *args, **kwargs):
try:
result = function(*args, **kwargs)
return result
except ConnectionRefusedError as e:
EMAIL_HOST = getattr(settings, "EMAIL_HOST", None)
if not EMAIL_HOST:
LOG.error("Notification: Email: EMAIL_HOST not set, cannot send")
EMAIL_PORT = getattr(settings, "EMAIL_PORT", None)
if not EMAIL_PORT:
LOG.error("Notification: Email: EMAIL_PORT not set, cannot send")
if EMAIL_HOST and EMAIL_PORT:
LOG.error('Notification: Email: Connection refused to "%s", port "%s"', EMAIL_HOST, EMAIL_PORT)
if additional_error:
LOG.error(*additional_error)
hmpf marked this conversation as resolved.
Show resolved Hide resolved
# TODO: Store error as incident


class EmailNotification(NotificationMedium):
@staticmethod
def send(incident, user, **_):
if not user.email:
logging.getLogger("django.request").warning(
f"Cannot send email notification to user '{user}', as they have not set an email address."
)

title = f"Incident at {incident}"
incident_dict = IncidentSerializer(incident, context={IncidentSerializer.NO_PKS_KEY: True}).data
# Convert OrderedDicts to dicts
incident_dict = json.loads(JSONRenderer().render(incident_dict))

template_context = {
"title": title,
"incident_dict": incident_dict,
"longest_field_name_length": len(max(incident_dict, key=len)),
}
subject = f"{settings.NOTIFICATION_SUBJECT_PREFIX}{title}"
message = render_to_string("notificationprofile/email.txt", template_context)
html_message = render_to_string("notificationprofile/email.html", template_context)
send_email_safely(user.email_user, subject=subject, message=message, html_message=html_message)
114 changes: 0 additions & 114 deletions src/argus/notificationprofile/notification_media.py

This file was deleted.