Skip to content

Commit

Permalink
Merge pull request #293 from hmpf/fallback-filter
Browse files Browse the repository at this point in the history
Add a fallback filter for notifications, currently in order to suppress notifications on acks for everyone, since the frontend does not support setting that particular filter yet.
  • Loading branch information
hmpf authored Jun 10, 2021
2 parents 63927d9 + ababc14 commit 127a76e
Show file tree
Hide file tree
Showing 13 changed files with 164 additions and 38 deletions.
18 changes: 18 additions & 0 deletions docs/site-specific-settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,25 @@ interface. To enable it:
* Set ``DEFAULT_SMS_MEDIA="argus.notificationprofile.media.sms_as_email.SMSNotification"``.
* Set ``SMS_GATEWAY_ADDRESS`` to the email address of the gateway.

Using the fallback notification filter
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The setting ``ARGUS_FALLBACK_FILTER`` is a dict, by default undefined. You can
set this to ensure a systemwide fallback filter for everyone:

Examples:

Do not send notifications on ACKED events::

ARGUS_FALLBACK_FILTER = {"acked": False}

Ignore low priority incidents by default::

ARGUS_FALLBACK_FILTER = {"maxlevel": 3}

Do both::

ARGUS_FALLBACK_FILTER = {"acked": False, "maxlevel": 3}

Realtime updates
----------------
Expand Down
5 changes: 5 additions & 0 deletions src/argus/incident/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Prevent import loops
# DO NOT import anything here, ever

INCIDENT_LEVELS = (1, 2, 3, 4, 5)
INCIDENT_LEVEL_CHOICES = zip(INCIDENT_LEVELS, map(str, INCIDENT_LEVELS))
14 changes: 12 additions & 2 deletions src/argus/incident/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from argus.auth.models import User
from argus.util.datetime_utils import INFINITY_REPR, get_infinity_repr
from .constants import INCIDENT_LEVELS, INCIDENT_LEVEL_CHOICES
from .fields import DateTimeInfinityField
from .validators import validate_lowercase, validate_key

Expand Down Expand Up @@ -197,8 +198,9 @@ def _generate_acked_query():

# TODO: review whether fields should be nullable, and on_delete modes
class Incident(models.Model):
LEVELS = (1, 2, 3, 4, 5)
LEVEL_CHOICES = zip(LEVELS, map(str, LEVELS))
# Prevent import loop
LEVELS = INCIDENT_LEVELS
LEVEL_CHOICES = INCIDENT_LEVEL_CHOICES

start_time = models.DateTimeField(help_text="The time the incident was created.")
end_time = DateTimeInfinityField(
Expand Down Expand Up @@ -308,6 +310,14 @@ def set_closed(self, actor: User):
self.save(update_fields=["end_time"])
Event.objects.create(incident=self, actor=actor, timestamp=self.end_time, type=Event.Type.CLOSE)

def create_ack(self, actor: User, timestamp=None, description="", expiration=None):
timestamp = timestamp if timestamp else timezone.now()
event = Event.objects.create(
incident=self, actor=actor, timestamp=timestamp, type=Event.Type.ACKNOWLEDGE, description=description
)
ack = Acknowledgement.objects.create(event=event, expiration=expiration)
return ack

def pp_details_url(self):
"Merge Incident.details_url with Source.base_url"
path = self.details_url.strip()
Expand Down
8 changes: 5 additions & 3 deletions src/argus/incident/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,12 @@ def create(self, validated_data: dict):
assert "actor" in validated_data
incident = validated_data.pop("incident")
actor = validated_data.pop("actor")

expiration = validated_data.get("expiration", None)
event_data = validated_data.pop("event")
event = Event.objects.create(incident=incident, actor=actor, **event_data)
return Acknowledgement.objects.create(event=event, **validated_data)
timestamp = event_data.pop("timestamp")
description = event_data.get("description", "")
ack = incident.create_ack(actor, timestamp=timestamp, description=description, expiration=expiration)
return ack

def to_internal_value(self, data: dict):
if "type" not in data["event"]:
Expand Down
8 changes: 8 additions & 0 deletions src/argus/notificationprofile/apps.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from django.apps import AppConfig
from django.conf import settings
from django.core.checks import register
from django.db.models.signals import post_save


Expand All @@ -7,6 +9,12 @@ class NotificationprofileConfig(AppConfig):
label = "argus_notificationprofile"

def ready(self):
# Signals
from .signals import create_default_timeslot

post_save.connect(create_default_timeslot, "argus_auth.User")

# Settings validation
from .checks import fallback_filter_check

register(fallback_filter_check)
22 changes: 22 additions & 0 deletions src/argus/notificationprofile/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.conf import settings
from django.core.checks import Warning

from .validators import validate_jsonfilter


__all__ = ["fallback_filter_check"]


def fallback_filter_check(app_configs, **kwargs):
errors = []
fallback_filter = getattr(settings, "ARGUS_FALLBACK_FILTER", {})
if not validate_jsonfilter(fallback_filter):
errors.append(
Warning(
'The ARGUS_FALLBACK_FILTER setting is invalid and has been set to "{}"',
hint="See the docs for the format of the ARGUS_FALLBACK_FILTER setting",
obj=fallback_filter,
id="argus_notificationprofile.W001",
)
)
return errors
1 change: 1 addition & 0 deletions src/argus/notificationprofile/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DEPRECATED_FILTER_NAMES = ("sourceSystemIds", "tags")
1 change: 1 addition & 0 deletions src/argus/notificationprofile/media/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def send_notifications_to_users(event: Event):
return
# TODO: only send one notification per medium per user
LOG.info('Notification: sending event "%s"', event)
LOG.debug('Fallback filter set to "%s"', getattr(settings, "ARGUS_FALLBACK_FILTER", {}))
sent = False
for profile in NotificationProfile.objects.select_related("user"):
LOG.debug('Notification: checking profile "%s"', profile)
Expand Down
15 changes: 11 additions & 4 deletions src/argus/notificationprofile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from operator import or_
from typing import TYPE_CHECKING

from django.conf import settings
from django.db import models
from django.utils import timezone
from django.contrib.postgres.fields import JSONField
Expand All @@ -14,6 +15,8 @@

from argus.auth.models import User

from .constants import DEPRECATED_FILTER_NAMES

if TYPE_CHECKING:
from argus.incident.models import Incident

Expand Down Expand Up @@ -94,10 +97,12 @@ class FilterWrapper:
TRINARY_FILTERS = ("open", "acked", "stateful")

def __init__(self, filterblob):
self.fallback_filter = getattr(settings, "ARGUS_FALLBACK_FILTER", {})
self.filter = filterblob

def _get_tristate(self, tristate):
return self.filter.get(tristate, None)
fallback_filter = self.fallback_filter.get(tristate, None)
return self.filter.get(tristate, fallback_filter)

def are_tristates_empty(self):
for tristate in self.TRINARY_FILTERS:
Expand All @@ -106,7 +111,8 @@ def are_tristates_empty(self):
return True

def is_maxlevel_empty(self):
return not self.filter.get("maxlevel", None)
fallback_filter = self.fallback_filter.get("maxlevel", None)
return not self.filter.get("maxlevel", fallback_filter)

@property
def is_empty(self):
Expand All @@ -130,14 +136,15 @@ def incident_fits_tristates(self, incident):
def incident_fits_maxlevel(self, incident):
if self.is_maxlevel_empty():
return None
return incident.level <= self.filter["maxlevel"]
fallback_filter = self.fallback_filter.get("maxlevel", None)
return incident.level <= min(filter(None, (self.filter["maxlevel"], fallback_filter)))

def incident_fits(self, incident):
return self.incident_fits_tristates(incident) and self.incident_fits_maxlevel(incident)


class Filter(models.Model):
FILTER_NAMES = set(("sourceSystemIds", "tags"))
FILTER_NAMES = set(DEPRECATED_FILTER_NAMES)
user = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name="filters")
name = models.CharField(max_length=40)
filter_string = models.TextField()
Expand Down
27 changes: 27 additions & 0 deletions src/argus/notificationprofile/primitive_serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from rest_framework import serializers

from argus.incident.constants import INCIDENT_LEVELS


class FilterBlobSerializer(serializers.Serializer):
sourceSystemIds = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=True,
required=False,
)
tags = serializers.ListField(
child=serializers.CharField(min_length=3),
allow_empty=True,
required=False,
)
open = serializers.BooleanField(required=False, allow_null=True)
acked = serializers.BooleanField(required=False, allow_null=True)
stateful = serializers.BooleanField(required=False, allow_null=True)
maxlevel = serializers.IntegerField(
required=False, allow_null=True, max_value=max(INCIDENT_LEVELS), min_value=min(INCIDENT_LEVELS)
)


class FilterPreviewSerializer(serializers.Serializer):
sourceSystemIds = serializers.ListField(serializers.IntegerField(min_value=1), allow_empty=True)
tags = serializers.ListField(serializers.CharField(min_length=3), allow_empty=True)
25 changes: 1 addition & 24 deletions src/argus/notificationprofile/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from argus.auth.serializers import PhoneNumberSerializer
from argus.incident.models import SourceSystem, Tag, Incident

from .primitive_serializers import FilterBlobSerializer, FilterPreviewSerializer
from .models import Filter, NotificationProfile, TimeRecurrence, Timeslot
from .validators import validate_filter_string

Expand Down Expand Up @@ -90,25 +91,6 @@ def update(self, timeslot: Timeslot, validated_data: dict):
return timeslot


class FilterBlobSerializer(serializers.Serializer):
sourceSystemIds = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=True,
required=False,
)
tags = serializers.ListField(
child=serializers.CharField(min_length=3),
allow_empty=True,
required=False,
)
open = serializers.BooleanField(required=False, allow_null=True)
acked = serializers.BooleanField(required=False, allow_null=True)
stateful = serializers.BooleanField(required=False, allow_null=True)
maxlevel = serializers.IntegerField(
required=False, allow_null=True, max_value=max(Incident.LEVELS), min_value=min(Incident.LEVELS)
)


class FilterSerializer(serializers.ModelSerializer):
filter_string = serializers.CharField(
validators=[validate_filter_string],
Expand All @@ -126,11 +108,6 @@ class Meta:
]


class FilterPreviewSerializer(serializers.Serializer):
sourceSystemIds = serializers.ListField(serializers.IntegerField(min_value=1), allow_empty=True)
tags = serializers.ListField(serializers.CharField(min_length=3), allow_empty=True)


class ResponseNotificationProfileSerializer(serializers.ModelSerializer):
timeslot = TimeslotSerializer()
filters = FilterSerializer(many=True)
Expand Down
21 changes: 17 additions & 4 deletions src/argus/notificationprofile/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

from rest_framework import serializers

from .models import Filter
from .constants import DEPRECATED_FILTER_NAMES
from .primitive_serializers import FilterBlobSerializer


def validate_filter_string(value: Union[str, dict]):
Expand All @@ -19,16 +20,28 @@ def validate_filter_string(value: Union[str, dict]):

errors = []
keys_in_filterstring = set(json_dict.keys())
found = Filter.FILTER_NAMES.intersection(keys_in_filterstring)
filter_names = set(DEPRECATED_FILTER_NAMES)
found = filter_names.intersection(keys_in_filterstring)
if not found:
errors.append(serializers.ValidationError("No known fieldnames in filterstring.", "none_found"))
missing = Filter.FILTER_NAMES.difference(keys_in_filterstring)
missing = filter_names.difference(keys_in_filterstring)
if missing:
pp_missing = ", ".join(missing)
errors.append(serializers.ValidationError(f"Filterstring is missing fieldname(s): {pp_missing}", "missing"))
unknown = keys_in_filterstring.difference(Filter.FILTER_NAMES)
unknown = keys_in_filterstring.difference(filter_names)
if unknown:
pp_unknown = ", ".join(unknown)
errors.append(serializers.ValidationError(f"Unknown fieldname(s) in filterstring: {pp_unknown}", "unknown"))
if errors:
raise serializers.ValidationError(errors)


def validate_jsonfilter(value: dict):
if not isinstance(value, dict):
raise serializers.ValidationError("Filter is not a dict")
if not value:
return True
serializer = FilterBlobSerializer(data=value)
if serializer.is_valid():
return True
raise serializers.ValidationError("Filter is not valid")
37 changes: 36 additions & 1 deletion tests/notificationprofile/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import unittest
from unittest.mock import Mock

from django.test import TestCase, tag
from django.test import TestCase, tag, override_settings
from django.utils.dateparse import parse_datetime, parse_time
from django.utils.timezone import make_aware

Expand Down Expand Up @@ -41,13 +41,25 @@ def test_are_tristates_empty_ignores_garbage(self):
self.assertTrue(result)

def test_are_tristates_empty_is_false(self):
# tristate == None is equivalent to tristate missing from dict
empty_filter = FilterWrapper({})
result = empty_filter.are_tristates_empty()
self.assertTrue(result)
empty_filter2 = FilterWrapper({"open": None})
result = empty_filter2.are_tristates_empty()
self.assertTrue(result)

@override_settings(ARGUS_FALLBACK_FILTER={"acked": False})
def test_are_tristates_empty_with_fallback(self):
from django.conf import settings

self.assertEqual(
{"acked": False}, getattr(settings, "ARGUS_FALLBACK_FILTER", {}), "Test hasn't updated settings"
)
empty_filter = FilterWrapper({})
result = empty_filter.are_tristates_empty()
self.assertFalse(result)

def test_are_tristates_empty_is_true(self):
filter = FilterWrapper({"open": False})
result = filter.are_tristates_empty()
Expand All @@ -62,6 +74,20 @@ def test_incident_fits_tristates_no_tristates_set(self):
result = empty_filter.incident_fits_tristates(incident)
self.assertEqual(result, None)

@override_settings(ARGUS_FALLBACK_FILTER={"acked": True})
def test_incident_fits_tristates_no_tristates_set_with_fallback(self):
incident = Mock()
# Shouldn't match
incident.acked = False
empty_filter = FilterWrapper({})
result = empty_filter.incident_fits_tristates(incident)
self.assertEqual(result, False)
# Should match
incident.acked = True
empty_filter = FilterWrapper({})
result = empty_filter.incident_fits_tristates(incident)
self.assertEqual(result, True)

def test_incident_fits_tristates_is_true(self):
incident = Mock()
incident.open = True
Expand All @@ -80,6 +106,15 @@ def test_incident_fits_tristates_is_false(self):
result = empty_filter.incident_fits_tristates(incident)
self.assertFalse(result)

@override_settings(ARGUS_FALLBACK_FILTER={"acked": True})
def test_incident_fits_tristates_fallback_should_not_override(self):
incident = Mock()
# Should match
incident.acked = False
filter = FilterWrapper({"acked": False})
result = filter.incident_fits_tristates(incident)
self.assertEqual(result, True)


@tag("unittest")
class FilterWrapperMaxlevelTests(unittest.TestCase):
Expand Down

0 comments on commit 127a76e

Please sign in to comment.