diff --git a/changelog.d/699.added.md b/changelog.d/699.added.md new file mode 100644 index 000000000..4fc08f4d0 --- /dev/null +++ b/changelog.d/699.added.md @@ -0,0 +1 @@ +Add filtering of events by a list of event types \ No newline at end of file diff --git a/changelog.d/699.removed.md b/changelog.d/699.removed.md new file mode 100644 index 000000000..68b884838 --- /dev/null +++ b/changelog.d/699.removed.md @@ -0,0 +1,3 @@ +Removed `"event_type"` from the V1 Filter API, it should only have been +available in V2 (since it was new) and it has never been in use by the +frontend. diff --git a/docs/api.rst b/docs/api.rst index 1ae2775c8..97fd4ce2b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -853,8 +853,7 @@ Notification profile endpoints "open": true, "acked": false, "stateful": true, - "maxlevel": 1, - "event_type": "STA" + "maxlevel": 1 } } @@ -1842,7 +1841,7 @@ Notification profile endpoints "acked": false, "stateful": true, "maxlevel": 1, - "event_type": "STA" + "event_types": ["STA"] } } diff --git a/src/argus/notificationprofile/V1/serializers.py b/src/argus/notificationprofile/V1/serializers.py index 98190ce3e..815ad7fde 100644 --- a/src/argus/notificationprofile/V1/serializers.py +++ b/src/argus/notificationprofile/V1/serializers.py @@ -3,19 +3,44 @@ from rest_framework import fields, serializers +from argus.incident.constants import INCIDENT_LEVELS from ..models import DestinationConfig, Filter, NotificationProfile -from ..primitive_serializers import FilterBlobSerializer +from ..primitive_serializers import CustomMultipleChoiceField from ..serializers import TimeslotSerializer from ..validators import validate_filter_string +class FilterPreviewSerializer(serializers.Serializer): + sourceSystemIds = serializers.ListField(child=serializers.IntegerField(min_value=1), allow_empty=True) + tags = serializers.ListField(child=serializers.CharField(min_length=3), allow_empty=True) + + +class FilterBlobSerializerV1(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 FilterSerializerV1(serializers.ModelSerializer): filter_string = serializers.CharField( validators=[validate_filter_string], help_text='Deprecated: Use "filter" instead', required=False, ) - filter = FilterBlobSerializer(required=False) + filter = FilterBlobSerializerV1(required=False) class Meta: model = Filter diff --git a/src/argus/notificationprofile/V1/views.py b/src/argus/notificationprofile/V1/views.py index 52b60cf84..6b6b4d76b 100644 --- a/src/argus/notificationprofile/V1/views.py +++ b/src/argus/notificationprofile/V1/views.py @@ -9,13 +9,13 @@ from argus.drf.permissions import IsOwner from argus.incident.serializers import IncidentSerializer from ..models import Filter, NotificationProfile -from ..primitive_serializers import FilterBlobSerializer from .serializers import ( FilterSerializerV1, + FilterBlobSerializerV1, + FilterPreviewSerializer, ResponseNotificationProfileSerializerV1, RequestNotificationProfileSerializerV1, ) -from ..serializers import FilterPreviewSerializer class FilterViewSetV1(viewsets.ModelViewSet): @@ -110,7 +110,7 @@ def preview(self, request, **_): Will eventually take over for the filterpreview endpoint """ filter_dict = request.data - serializer = FilterBlobSerializer(data=filter_dict) + serializer = FilterBlobSerializerV1(data=filter_dict) if not serializer.is_valid(): raise ValidationError(serializer.errors) diff --git a/src/argus/notificationprofile/migrations/0017_change_event_type_to_event_types.py b/src/argus/notificationprofile/migrations/0017_change_event_type_to_event_types.py new file mode 100644 index 000000000..d236d6b2f --- /dev/null +++ b/src/argus/notificationprofile/migrations/0017_change_event_type_to_event_types.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.11 on 2024-05-14 07:51 + +from django.db import migrations + + +def change_event_type_to_a_list_in_filter(apps, schema_editor): + Filter = apps.get_model("argus_notificationprofile", "Filter") + + for filter_ in Filter.objects.filter(filter__event_type__isnull=False): + if filter_.filter["event_type"]: + filter_.filter["event_types"] = [filter_.filter["event_type"]] + filter_.save() + del filter_.filter["event_type"] + filter_.save() + + +def change_event_types_to_a_string_in_filter(apps, schema_editor): + # will lose data + Filter = apps.get_model("argus_notificationprofile", "Filter") + + for filter_ in Filter.objects.filter(filter__event_types__isnull=False): + if filter_.filter["event_types"]: + filter_.filter["event_type"] = filter_.filter["event_types"][0] + filter_.save() + del filter_.filter["event_types"] + filter_.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("argus_notificationprofile", "0016_noop"), + ] + + operations = [ + migrations.RunPython( + change_event_type_to_a_list_in_filter, + change_event_types_to_a_string_in_filter, + ) + ] diff --git a/src/argus/notificationprofile/models.py b/src/argus/notificationprofile/models.py index 42981971c..6beb5bbff 100644 --- a/src/argus/notificationprofile/models.py +++ b/src/argus/notificationprofile/models.py @@ -117,9 +117,9 @@ def is_maxlevel_empty(self): fallback_filter = self.fallback_filter.get("maxlevel", None) return not self.filter.get("maxlevel", fallback_filter) - def is_event_type_empty(self): - fallback_filter = self.fallback_filter.get("event_type", None) - return not self.filter.get("event_type", fallback_filter) + def is_event_types_empty(self): + fallback_filter = self.fallback_filter.get("event_types", None) + return not self.filter.get("event_types", fallback_filter) def are_source_system_ids_empty(self): fallback_filter = self.fallback_filter.get("sourceSystemIds", None) @@ -136,7 +136,7 @@ def is_empty(self): and self.are_tags_empty() and self.are_tristates_empty() and self.is_maxlevel_empty() - and self.is_event_type_empty() + and self.is_event_types_empty() ) def get_incident_tristate_checks(self, incident) -> Dict[str, TriState]: @@ -161,10 +161,10 @@ def incident_fits_maxlevel(self, incident): return incident.level <= min(filter(None, (self.filter["maxlevel"], fallback_filter))) def event_fits(self, event): - if self.is_event_type_empty(): + if self.is_event_types_empty(): return True - fallback_filter = self.fallback_filter.get("event_type", None) - return event.type == self.filter.get("event_type", fallback_filter) + fallback_filter = self.fallback_filter.get("event_types", None) + return event.type in self.filter.get("event_types", fallback_filter) class Filter(models.Model): diff --git a/src/argus/notificationprofile/primitive_serializers.py b/src/argus/notificationprofile/primitive_serializers.py index 3c0ad1412..6ab65cc57 100644 --- a/src/argus/notificationprofile/primitive_serializers.py +++ b/src/argus/notificationprofile/primitive_serializers.py @@ -1,29 +1,6 @@ from rest_framework import serializers -from argus.incident.constants import INCIDENT_LEVELS -from argus.incident.models import Event - -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) - ) - event_type = serializers.ChoiceField(choices=Event.Type.choices, required=False, allow_null=True) - - -class FilterPreviewSerializer(serializers.Serializer): - sourceSystemIds = serializers.ListField(child=serializers.IntegerField(min_value=1), allow_empty=True) - tags = serializers.ListField(child=serializers.CharField(min_length=3), allow_empty=True) +class CustomMultipleChoiceField(serializers.MultipleChoiceField): + def to_internal_value(self, value): + return list(super().to_internal_value(value)) diff --git a/src/argus/notificationprofile/serializers.py b/src/argus/notificationprofile/serializers.py index ac57ead1a..880591e42 100644 --- a/src/argus/notificationprofile/serializers.py +++ b/src/argus/notificationprofile/serializers.py @@ -1,10 +1,35 @@ from rest_framework import fields, serializers -from .primitive_serializers import FilterBlobSerializer, FilterPreviewSerializer +from argus.incident.constants import INCIDENT_LEVELS +from argus.incident.models import Event +from .primitive_serializers import CustomMultipleChoiceField from .media import api_safely_get_medium_object from .models import DestinationConfig, Filter, Media, NotificationProfile, TimeRecurrence, 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) + ) + event_types = CustomMultipleChoiceField( + choices=Event.Type.choices, + required=False, + ) + + class TimeRecurrenceSerializer(serializers.ModelSerializer): ALL_DAY_KEY = "all_day" diff --git a/src/argus/notificationprofile/validators.py b/src/argus/notificationprofile/validators.py index 908ae69de..e45b1f506 100644 --- a/src/argus/notificationprofile/validators.py +++ b/src/argus/notificationprofile/validators.py @@ -4,7 +4,7 @@ from rest_framework import serializers from .constants import DEPRECATED_FILTER_NAMES -from .primitive_serializers import FilterBlobSerializer +from .serializers import FilterBlobSerializer def validate_filter_string(value: Union[str, dict]): diff --git a/src/argus/notificationprofile/views.py b/src/argus/notificationprofile/views.py index 484cea70d..9d6607e58 100644 --- a/src/argus/notificationprofile/views.py +++ b/src/argus/notificationprofile/views.py @@ -18,11 +18,10 @@ from argus.notificationprofile.media import api_safely_get_medium_object from argus.notificationprofile.media.base import NotificationMedium from .models import DestinationConfig, Filter, Media, NotificationProfile, Timeslot -from .primitive_serializers import FilterBlobSerializer from .serializers import ( DuplicateDestinationSerializer, FilterSerializer, - FilterPreviewSerializer, + FilterBlobSerializer, JSONSchemaSerializer, MediaSerializer, ResponseDestinationConfigSerializer, @@ -245,7 +244,6 @@ def destroy(self, request, *args, **kwargs): # TODO: change HTTP method to GET, and get query data from URL class FilterPreviewView(APIView): - @extend_schema(request=FilterPreviewSerializer, responses={"200": IncidentSerializer}) def post(self, request, format=None): """ POST a filter, get a list of filtered incidents back diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 000000000..f75a709b1 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,13 @@ +from django.db import connection +from django.db.migrations.executor import MigrationExecutor + + +class Migrator: + def __init__(self, connection=connection): + self.executor = MigrationExecutor(connection) + + def migrate(self, app_label: str, migration: str): + target = [(app_label, migration)] + self.executor.loader.build_graph() + self.executor.migrate(target) + self.apps = self.executor.loader.project_state(target).apps diff --git a/tests/notificationprofile/migrations/__init__.py b/tests/notificationprofile/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/notificationprofile/migrations/test_0017_change_event_type_to_event_types.py b/tests/notificationprofile/migrations/test_0017_change_event_type_to_event_types.py new file mode 100644 index 000000000..6762b8696 --- /dev/null +++ b/tests/notificationprofile/migrations/test_0017_change_event_type_to_event_types.py @@ -0,0 +1,122 @@ +from django.test import TestCase + +from tests.helpers import Migrator + + +class MigrationTest(TestCase): + base_migration = ("argus_notificationprofile", "0016_noop") + test_migration = ("argus_notificationprofile", "0017_change_event_type_to_event_types") + + def setUp(self): + self.migrator = Migrator() + self.migrator.migrate(*self.base_migration) + + def test_forward_empty_changes_nothing(self): + # Cannot use factories :( + User = self.migrator.apps.get_model("argus_auth", "User") + Filter = self.migrator.apps.get_model("argus_notificationprofile", "Filter") + user = User.objects.create(username="foo", password="vbnh") + + filter_ = Filter.objects.create( + user = user, + name = "1", + filter = {}, + ) + self.migrator.migrate(*self.test_migration) + result = Filter.objects.get(id=filter_.pk) + self.assertEqual(result.filter, {}) + + def test_forward_remove_redundant_filter(self): + # Cannot use factories :( + User = self.migrator.apps.get_model("argus_auth", "User") + Filter = self.migrator.apps.get_model("argus_notificationprofile", "Filter") + user = User.objects.create(username="foo", password="vbnh") + + filter_ = Filter.objects.create( + user = user, + name = "1", + filter = {"event_type": None}, + ) + self.migrator.migrate(*self.test_migration) + result = Filter.objects.get(id=filter_.pk) + self.assertEqual(result.filter, {}) + + def test_forward_change_actual_filter(self): + # Cannot use factories :( + User = self.migrator.apps.get_model("argus_auth", "User") + Filter = self.migrator.apps.get_model("argus_notificationprofile", "Filter") + user = User.objects.create(username="foo", password="vbnh") + + filter_ = Filter.objects.create( + user = user, + name = "1", + filter = {"event_type": "BAH HUMBUG"}, + ) + self.migrator.migrate(*self.test_migration) + result = Filter.objects.get(id=filter_.pk) + self.assertEqual(result.filter, {'event_types': ['BAH HUMBUG']}) + + def test_backward_empty_changes_nothing(self): + # Cannot use factories :( + User = self.migrator.apps.get_model("argus_auth", "User") + Filter = self.migrator.apps.get_model("argus_notificationprofile", "Filter") + user = User.objects.create(username="foo", password="vbnh") + + filter_ = Filter.objects.create( + user = user, + name = "1", + filter = {}, + ) + self.migrator.migrate(*self.test_migration) + self.migrator.migrate(*self.base_migration) + result = Filter.objects.get(id=filter_.pk) + self.assertEqual(result.filter, {}) + + def test_backward_remove_redundant_filter(self): + # Cannot use factories :( + User = self.migrator.apps.get_model("argus_auth", "User") + Filter = self.migrator.apps.get_model("argus_notificationprofile", "Filter") + user = User.objects.create(username="foo", password="vbnh") + + filter_ = Filter.objects.create( + user = user, + name = "1", + filter = {}, + ) + self.migrator.migrate(*self.test_migration) + filter_.filter = {"event_types": []} + self.migrator.migrate(*self.base_migration) + result = Filter.objects.get(id=filter_.pk) + self.assertEqual(result.filter, {}) + + def test_backward_dont_recreate_redundant_filter(self): + # Cannot use factories :( + User = self.migrator.apps.get_model("argus_auth", "User") + Filter = self.migrator.apps.get_model("argus_notificationprofile", "Filter") + user = User.objects.create(username="foo", password="vbnh") + + filter_ = Filter.objects.create( + user = user, + name = "1", + filter = {"event_type": None}, + ) + self.migrator.migrate(*self.test_migration) + self.migrator.migrate(*self.base_migration) + result = Filter.objects.get(id=filter_.pk) + self.assertEqual(result.filter, {}) + + def test_backward_change_actual_filter(self): + # Cannot use factories :( + User = self.migrator.apps.get_model("argus_auth", "User") + Filter = self.migrator.apps.get_model("argus_notificationprofile", "Filter") + user = User.objects.create(username="foo", password="vbnh") + + filter_ = Filter.objects.create( + user = user, + name = "1", + filter = {"event_type": "BAH HUMBUG"}, + ) + self.migrator.migrate(*self.test_migration) + self.migrator.migrate(*self.base_migration) + result = Filter.objects.get(id=filter_.pk) + self.assertEqual(result.filter, {'event_type': 'BAH HUMBUG'}) diff --git a/tests/notificationprofile/test_media.py b/tests/notificationprofile/test_media.py index d09f64669..ab2efe2bb 100644 --- a/tests/notificationprofile/test_media.py +++ b/tests/notificationprofile/test_media.py @@ -1,5 +1,4 @@ -from django.test import TestCase -import json +from django.test import TestCase, override_settings from argus.auth.factories import PersonUserFactory from argus.incident.factories import EventFactory @@ -100,6 +99,43 @@ def test_find_destinations_for_ack_event_without_acknowledgement(self): destinations = find_destinations_for_event(event) self.assertIn(ack_destination, destinations) + def test_find_destinations_by_filtering_by_event_types(self): + ack_event_type_profile = factories.NotificationProfileFactory( + user=PersonUserFactory(), + timeslot=self.timeslot, + active=True, + ) + ack_event_type_filter = factories.FilterFactory( + user=ack_event_type_profile.user, + filter={"event_types": [Event.Type.ACKNOWLEDGE]}, + ) + ack_event_type_profile.filters.add(ack_event_type_filter) + ack_destination = ack_event_type_profile.user.destinations.get() # default email + ack_event_type_profile.destinations.add(ack_destination) + + event = EventFactory(type=Event.Type.ACKNOWLEDGE) + destinations = find_destinations_for_event(event) + self.assertIn(ack_destination, destinations) + + @override_settings(ARGUS_FALLBACK_FILTER={"event_types": ["ACK"]}) + def test_find_destinations_by_filtering_by_event_types_using_fallback_filter(self): + ack_event_type_profile = factories.NotificationProfileFactory( + user=PersonUserFactory(), + timeslot=self.timeslot, + active=True, + ) + ack_event_type_filter = factories.FilterFactory( + user=ack_event_type_profile.user, + filter={}, + ) + ack_event_type_profile.filters.add(ack_event_type_filter) + ack_destination = ack_event_type_profile.user.destinations.get() # default email + ack_event_type_profile.destinations.add(ack_destination) + + event = EventFactory(type=Event.Type.ACKNOWLEDGE) + destinations = find_destinations_for_event(event) + self.assertIn(ack_destination, destinations) + def test_find_destinations_for_many_events(self): incident1 = create_fake_incident() event1 = incident1.events.get(type=Event.Type.INCIDENT_START) diff --git a/tests/notificationprofile/test_models.py b/tests/notificationprofile/test_models.py index 26208732b..154a8729d 100644 --- a/tests/notificationprofile/test_models.py +++ b/tests/notificationprofile/test_models.py @@ -185,11 +185,11 @@ class FilterWrapperEventTypeEmptyTests(unittest.TestCase): def test_when_filter_is_empty_is_event_type_empty_should_return_true(self): empty_filter = FilterWrapper({}) - self.assertTrue(empty_filter.is_event_type_empty()) + self.assertTrue(empty_filter.is_event_types_empty()) def test_when_event_filter_exists_is_event_type_empty_should_return_false(self): - empty_filter = FilterWrapper({"event_type": "whatever"}) - self.assertFalse(empty_filter.is_event_type_empty()) + empty_filter = FilterWrapper({"event_types": ["whatever"]}) + self.assertFalse(empty_filter.is_event_types_empty()) @tag("unittest") @@ -204,15 +204,15 @@ def test_when_event_filter_is_empty_any_event_should_fit(self): def test_when_event_filter_is_set_event_with_matching_type_should_fit(self): event = Mock() - event_type = ("CHI", "Incident change") + event_type = Event.Type.INCIDENT_CHANGE event.type = event_type - filter = FilterWrapper({"event_type": event_type}) + filter = FilterWrapper({"event_types": [event_type]}) self.assertTrue(filter.event_fits(event)) def test_when_event_filter_is_set_event_with_not_matching_type_should_not_fit(self): event = Mock() - event.type = ("CHI", "Incident change") - filter = FilterWrapper({"event_type": ("ACK", "Acknowledge")}) + event.type = Event.Type.INCIDENT_CHANGE + filter = FilterWrapper({"event_types": [Event.Type.ACKNOWLEDGE]}) self.assertFalse(filter.event_fits(event)) diff --git a/tests/notificationprofile/test_views.py b/tests/notificationprofile/test_views.py index 0bd4866cc..deabbae45 100644 --- a/tests/notificationprofile/test_views.py +++ b/tests/notificationprofile/test_views.py @@ -383,6 +383,26 @@ def test_should_create_filter_with_valid_values(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertTrue(Filter.objects.filter(pk=response.data["pk"]).exists()) + def test_should_create_filter_with_event_types(self): + filter_name = "test-filter" + response = self.user1_rest_client.post( + path=self.ENDPOINT, + data={ + "name": filter_name, + "filter": { + "event_types": [ + "STA", + "END", + "CLO", + "REO", + "OTH", + ] + }, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(Filter.objects.filter(pk=response.data["pk"]).exists()) + def test_should_update_filter_name_with_valid_values(self): filter1_pk = self.filter1.pk filter1_path = f"{self.ENDPOINT}{filter1_pk}/"