Skip to content

Commit

Permalink
move common functionality to base class
Browse files Browse the repository at this point in the history
  • Loading branch information
hmpf committed Nov 11, 2024
1 parent 0c89d3c commit c39d564
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 211 deletions.
67 changes: 56 additions & 11 deletions src/argus/notificationprofile/media/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

from rest_framework.exceptions import ValidationError

if TYPE_CHECKING:
import sys

Expand All @@ -25,42 +27,85 @@


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
- 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.
"""

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, instance: RequestDestinationConfigSerializer, dict_: dict, user: User) -> dict:
"""
Validates the settings of destination and returns a dict with
validated and cleaned data
"""
pass
form = cls.Form(dict_["settings"])
if not form.is_valid():
raise ValidationError(form.errors)
return form.cleaned_data

form = cls.clean(form)

if cls.has_duplicate(user.destinations):
raise ValidationError(cls.MEDIA_SETTINGS_KEY, f"{cls.MEDIA_NAME} already exists")

return form.cleaned_data

@classmethod
def clean(cls, 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.
"""
pass
return destination.settings.get(cls.MEDIA_SETTINGS_KEY)

# No querysets beyond this point!

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

get_relevant_addresses = get_relevant_destinations

@classmethod
@abstractmethod
Expand Down
145 changes: 145 additions & 0 deletions src/argus/notificationprofile/media/baseemail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from django import forms
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from rest_framework.exceptions import ValidationError

from argus.incident.models import Event
from .base import NotificationMedium
from ..models import DestinationConfig
from argus.util.datetime_utils import INFINITY, LOCAL_INFINITY

if TYPE_CHECKING:
import sys

from collections.abc import Iterable

from types import NoneType
from typing import Union, Set

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

from ..serializers import RequestDestinationConfigSerializer

User = get_user_model()

LOG = logging.getLogger(__name__)

__all__ = [
"modelinstance_to_dict",
"send_email_safely",
"EmailNotification",
]


def modelinstance_to_dict(obj):
dict_ = vars(obj).copy()
dict_.pop("_state")
return dict_


def send_email_safely(function, additional_error=None, *args, **kwargs) -> int:
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)
# TODO: Store error as incident


class EmailNotification(NotificationMedium):
MEDIA_SLUG = "email"
MEDIA_NAME = "Email"
MEDIA_SETTINGS_KEY = "email_address"
MEDIA_JSON_SCHEMA = {
"title": "Email Settings",
"description": "Settings for a DestinationConfig using email.",
"type": "object",
"required": [MEDIA_SETTINGS_KEY],
"properties": {
MEDIA_SETTINGS_KEY: {
"type": "string",
"title": "Email address",
},
},
}

class Form(forms.Form):
email_address = forms.EmailField()

@staticmethod
def create_message_context(event: Event):
"""Creates the subject, message and html message for the email"""
title = f"{event}"
incident_dict = modelinstance_to_dict(event.incident)
for field in ("id", "source_id"):
incident_dict.pop(field)
incident_dict["details_url"] = event.incident.pp_details_url()
if event.incident.end_time in {INFINITY, LOCAL_INFINITY}:
incident_dict["end_time"] = "Still open"

template_context = {
"title": title,
"event": event,
"incident_dict": incident_dict,
}
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)

return subject, message, html_message

@classmethod
def send(cls, event: Event, destinations: Iterable[DestinationConfig], **_) -> bool:
"""
Sends email about a given event to the given email destinations
Returns False if no email destinations were given and
True if emails were sent
"""
email_addresses = cls.get_relevant_addresses(destinations=destinations)
if not email_addresses:
return False
num_emails = len(email_addresses)

subject, message, html_message = cls.create_message_context(event=event)

failed = set()
for email_address in email_addresses:
sent = send_email_safely(
send_mail,
subject=subject,
message=message,
from_email=None,
recipient_list=[email_address],
html_message=html_message,
)
if not sent: # 0 for failure otherwise 1
failed.add(email_address)

if failed:
if num_emails == len(failed):
LOG.error("Email: Failed to send to any addresses")
return False
LOG.warn(
"Email: Failed to send to %i of %i addresses",
len(failed),
num_emails,
)
LOG.debug("Email: Failed to send to:", " ".join(failed))
return True
Loading

0 comments on commit c39d564

Please sign in to comment.