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

Move common functionality to destination base class #936

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
6 changes: 6 additions & 0 deletions changelog.d/+cleanup-destination-plugins.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Moved a lot of common infrastructure from our NotificationMedium subclasses to
the parent. This should make it easier to create more media of high quality.
Also, it should make it easier to use the plugins for validating the
settings-file elsewhere than just the API.

This might break 3rd party notification plugins.
137 changes: 94 additions & 43 deletions docs/integrations/notifications/writing-notification-plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,48 @@ implement the following:
Class constants
---------------

You need to set the constants `MEDIA_SLUG`, `MEDIA_NAME` and
`MEDIA_JSON_SCHEMA`.

The media name is the name of the service you want to send notifications by.
This is used only for display purposes so you might want to keep it short and
sweet. So for example `Email`, `SMS` or `MS Teams`.

The media slug is the slugified version of that, so the name simplified to only
contain lowercase letters, numbers, underscores and hyphens. Always have it
start with a letter, a-z. For example `email`, `sms` or `msteams`.

The media `json schema <https://json-schema.org/>`_ is a representation of how
a destination that will be used by this notification plugin should look like.
Such a destination should include all necessary information that is needed to
send notifications with your notification plugin. In case of SMS that is a
phone number or for MS Teams a webhook.
You must set the constants ``MEDIA_SLUG`, `MEDIA_NAME`` and
``MEDIA_JSON_SCHEMA``. If your plugin only takes or needs a single
configuration flag you should also set ``MEDIA_SETTINGS_KEY``.

MEDIA_NAME
The media name is the name of the service you want to send notifications by.
This is used only for display purposes so you might want to keep it short
and sweet. So for example ``"Email"``, ``"SMS"`` or ``"MS Teams"``.

MEDIA_SLUG
The media slug is the slugified version of that, so the name simplified to
only contain lowercase letters, numbers, underscores and hyphens. Always
have it start with a letter, a-z. For example ``"email"``, ``"sms"`` or
``"msteams"``.

MEDIA_JSON_SCHEMA
The media `json schema <https://json-schema.org/>`_ is a representation of
how a destination that will be used by this notification plugin should look
like, so that it is possible to autogenerate a form with javascript. It will
be accessible via the API. Such a destination should include all necessary
information that is needed to send notifications with your notification
plugin. In case of SMS that is a phone number or for MS Teams a webhook.

MEDIA_SETTINGS_KEY
The media settings key is the name of the most important key in the settings
JSON field. It is used to cut down on the amount of code you need to write
if there is only one piece of data you need to send the notification. Among
other things, it is used to check for duplicate entries, so in a way it acts
as the primary key for your plugin. For that reason, it must be required in
the json schema.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
the json schema.
the json schema. For example for email it is ``"email_address"`` or for SMS
it is ``"phone_number"``.


Form
The ``forms.Form`` used to validate the settings-field.

Class methods for sending notifications
---------------------------------------

.. autoclass:: argus.notificationprofile.media.base.NotificationMedium
:members: send

This MUST be overridden.

The ``send`` method is the method that does the actual sending of the
notification. It gets the Argus event and a list of destinations as input and
returns a boolean indicating if the sending was successful.
Expand All @@ -62,35 +81,67 @@ The rest is very dependent on the notification medium and, if used, the Python
library. The given event can be used to extract relevant information that
should be included in the message that will be sent to each destination.

Class methods for destinations
------------------------------
Helper class methods
--------------------

.. autoclass:: argus.notificationprofile.media.base.NotificationMedium
:members: get_label, has_duplicate, raise_if_not_deletable, update, validate
:noindex:

Your implementation of ``get_label`` should show a reasonable representation
for a destination of that type that makes it easy to identify. For SMS that
would simply be the phone number.

The method ``has_duplicate`` will receive a QuerySet of destinations and a dict
of settings for a possible destination and should return True if a destination
with such settings exists in the given QuerySet.

``raise_if_not_deletable`` should check if a given destination can be deleted.
This is used in case some destinations are synced from an outside source and
should not be able to be deleted by a user. If that is the case a
``NotDeletableError`` should be raised. If not simply return None.

The method ``update`` only has to be implemented if the regular update method
of Django isn't sufficient. This can be the case if additional settings need to
be updated.

Finally the function ``validate`` makes sure that a destination with the given
settings can be updated or created. The function ``has_duplicate`` can be used
here to ensure that not two destinations with the same settings will be
created. Additionally the settings themselves should also be validated. For
example for SMS the given phone number will be checked. Django forms can be
helpful for validation. A ``ValidationError`` should be raised if the given
settings are invalid and the validated and cleaned data should be returned if
not.
With a little luck you might not need to override any of these.

clean
This method will do any additional cleaning beyond what is defined by the
defined ``Form``. Expects a valid form instance and returns the updated
valid form instance.

get_label
Your implementation of ``get_label`` should show a reasonable representation
for a destination of that type that makes it easy to identify. For SMS that
would simply be the phone number. By default it shows the label stored in
the destination. If no label have been set, it uses MEDIA_SETTINGS_KEY to
look up the most important piece of information in the settings and uses
that directly. The included plugins need not override ``get_label`` for this
reason. If the label would be very long, for instance if the needed setting
is a very long url (40+ characters), you ought to write your own
``get_label``.

get_relevant_destination_settings
Used by ``send``. You should only need to override this if the key in
MEDIA_SETTINGS_KEY is insuffcient to look up the actual configuration of the
destinations of the type set by MEDIA_SLUG.

has_duplicate
The method ``has_duplicate`` will receive a QuerySet of destinations and
a dict of settings for a possible destination and should return True if
a destination with such settings exists in the given QuerySet. By default it
will use MEDIA_SETTINGS_KEY to lookup the most important piece of
information in the settings.

raise_if_not_deletable
``raise_if_not_deletable`` should check if a given destination can be
deleted. This is necessary in case the destination is in use by a profile,
or some destinations are synced from an outside source or otherwise
auto-generated, and should not be able to be deleted by a user. If that is
the case a ``NotDeletableError`` should be raised. If not simply return
None.

update
The method ``update`` only has to be implemented if the regular update
method is insufficient. This can be the case if there is more than one
key-value pair in settings that need to be updated.

validate
The function ``validate`` makes sure that a destination with the given
settings can be updated or created. It uses the ``validate_settings`` method
to validate the settings-field, and a form (CommonDestinationConfigForm) to
validate the media and label-fields. The validated form is returned if ok,
otherwise a ``ValidationError`` should be raised. It is unlikely that you
will ever need to override this method.

validate_settings
This method validates the actual contents of the settings-field using the
``Form`` that is defined. The function ``has_duplicate`` can be used here to
ensure that not two destinations with the same settings will be created.
A ``ValidationError`` should be raised if the given settings are invalid,
and the validated and cleaned data should be returned if not.
134 changes: 113 additions & 21 deletions src/argus/notificationprofile/media/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,62 +3,142 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

from django import forms
from django.core.exceptions import ValidationError

from ..models import DestinationConfig

if TYPE_CHECKING:
from collections.abc import Iterable

from types import NoneType
from typing import Union
from typing import Union, Generator

from django.contrib.auth import get_user_model
from django.db.models.query import QuerySet

from argus.incident.models import Event
from ..models import DestinationConfig
from ..serializers import RequestDestinationConfigSerializer

User = get_user_model()


__all__ = ["NotificationMedium"]


class CommonDestinationConfigForm(forms.ModelForm):
class Meta:
model = DestinationConfig
fields = ["media", "label"]


class NotificationMedium(ABC):
"""
Must be defined by subclasses:

Class attributes:

- MEDIA_SLUG: short string id for the medium, lowercase
- MEDIA_NAME: human friendly id for the medium
- MEDIA_SETTINGS_KEY: the field in settings that is specific for this medium
hmpf marked this conversation as resolved.
Show resolved Hide resolved
- MEDIA_JSON_SCHEMA: A json-schema to describe the settings field to
javascript, used by the API
- Form: a django.forms.Form that validates the contents of the
settings-field. MEDIA_SETTINGS_KEY must be the field-name of a required
field in the form.

Class methods:

- send(event, destinations): How to send the given event to the given
destinations of type MEDIA_SLUG.
"""

error_messages = {
"readonly_medium": "Media cannot be updated, only settings.",
"readonly_user": "User cannot be changed, only settings.",
"settings_type": "Settings has to be a dictionary.",
}

class NotDeletableError(Exception):
"""
Custom exception class that is raised when a destination cannot be
deleted
"""

@classmethod
@abstractmethod
def validate(cls, instance: RequestDestinationConfigSerializer, dict: dict, user: User) -> dict:
def validate(
cls, data: dict, user: User, instance: DestinationConfig = None, exception_class=ValidationError
) -> dict:
Copy link
Contributor

Choose a reason for hiding this comment

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

return type should be CommonDestinationConfigForm?

if instance:
if data.get("media", "") != instance.media.slug:
Copy link
Contributor

Choose a reason for hiding this comment

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

But in the case of updating an instance (PATCH) when no media is given then this will raise this error

Suggested change
if data.get("media", "") != instance.media.slug:
if "media" in data.keys() and data["media"] != instance.media.slug:

raise exception_class(cls.error_messages["readonly_media"])
form = CommonDestinationConfigForm(data, instance=instance)
if instance.user != user:
raise exception_class(cls.error_messages["readonly_user"])
else:
form = CommonDestinationConfigForm(data)

if not form.is_valid():
raise exception_class(form.errors)

settings_form = cls.validate_settings(data)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
settings_form = cls.validate_settings(data)
settings_form = cls.validate_settings(data, user)

form.cleaned_data["settings"] = settings_form.cleaned_data
form.cleaned_data["user"] = user
return form

@classmethod
def validate_settings(cls, data: dict, user: User, exception_class=ValidationError) -> dict:
"""
Validates the settings of destination and returns a dict with
validated and cleaned data
"""
pass
form = cls.Form(data["settings"])
if not form.is_valid():
raise exception_class(form.errors)

form = cls.clean(form)

if cls.has_duplicate(user.destinations, data["settings"]):
raise exception_class(cls.MEDIA_SETTINGS_KEY, f"{cls.MEDIA_NAME} already exists")

return form.cleaned_data

@classmethod
def clean(cls, form: forms.Form) -> forms.Form:
"""Can change the cleaned data of a valid form"""
return form

@classmethod
@abstractmethod
def has_duplicate(cls, queryset: QuerySet, settings: dict) -> bool:
"""
Returns True if a destination with the given settings already exists
in the given queryset
"""
pass
key = f"settings__{cls.MEDIA_SETTINGS_KEY}"
value = settings[cls.MEDIA_SETTINGS_KEY]
return queryset.filter(media_id=cls.MEDIA_SLUG, **{key: value}).exists()

@staticmethod
@abstractmethod
def get_label(destination: DestinationConfig) -> str:
@classmethod
def get_label(cls, destination: DestinationConfig) -> str:
"""
Returns a descriptive label for this destination.
Returns a descriptive label for this destination if none is stored
"""
pass
return destination.label if destination.label else destination.settings.get(cls.MEDIA_SETTINGS_KEY)

# No querysets beyond this point!

@classmethod
def _get_relevant_destinations(cls, destinations: Iterable[DestinationConfig]) -> Generator[DestinationConfig]:
return (destination for destination in destinations if destination.media_id == cls.MEDIA_SLUG)

@classmethod
def get_relevant_addresses(cls, destinations: Iterable[DestinationConfig]) -> set[DestinationConfig]:
def get_relevant_destination_settings(cls, destinations: Iterable[DestinationConfig]) -> set[str]:
"""Returns a set of addresses the message should be sent to"""
pass
destinations = [
destination.settings[cls.MEDIA_SETTINGS_KEY] for destination in cls._get_relevant_destinations(destinations)
]
return set(destinations)

get_relevant_addresses = get_relevant_destination_settings

@classmethod
@abstractmethod
Expand All @@ -79,11 +159,23 @@ def raise_if_not_deletable(cls, destination: DestinationConfig) -> NoneType:
f"Cannot delete this destination since it is in use in the notification profile(s): {profiles}."
)

@staticmethod
def update(destination: DestinationConfig, validated_data: dict) -> Union[DestinationConfig, NoneType]:
@classmethod
def _update_destination(cls, destination: DestinationConfig, validated_data: dict) -> DestinationConfig:
# adapted from rest_framework.serializers.ModelSerializer.update
# DestinationConfig MUST NOT have any m2m-relations so this is safe

for attr, value in validated_data.items():
setattr(destination, attr, value)

return destination

@classmethod
def update(cls, destination: DestinationConfig, validated_data: dict) -> Union[DestinationConfig, NoneType]:
"""
Updates a destination in case the normal update function is not
sufficient and returns the updated destination in that case,
returns None otherwise
Updates a destination

Override in case the normal update function is not sufficient
"""
return None
instance = cls._update_destination(destination, validated_data)
instance.save()
return instance
Loading
Loading