From 8dc90230d70fde1cb2adf390835980ec8b9b520e Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 21 Oct 2024 13:48:07 -0300 Subject: [PATCH 1/4] Update shift change notification to consider microsecond timestamps (#5196) Related to https://github.com/grafana/support-escalations/issues/12893 --- .../tasks/notify_ical_schedule_shift.py | 14 +++++-- .../tests/test_notify_ical_schedule_shift.py | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py index e872623d6d..fb797cfb18 100644 --- a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py @@ -90,11 +90,17 @@ def notify_ical_schedule_shift(schedule_pk): prev_shifts = convert_prev_shifts_to_new_format(prev_shifts, schedule) prev_shifts_updated = True - # convert datetimes which was dumped to str back to datetime to calculate shift diff correct - str_format = "%Y-%m-%d %X%z" + def _parse_timestamp(timestamp: str) -> datetime.datetime: + # convert datetimes which was dumped to str back to datetime to calculate shift diff correct + try: + dt = datetime.datetime.strptime(timestamp, "%Y-%m-%d %X%z") + except ValueError: + dt = datetime.datetime.strptime(timestamp, "%Y-%m-%d %X.%f%z") + return dt + for prev_shift in prev_shifts: - prev_shift["start"] = datetime.datetime.strptime(prev_shift["start"], str_format) - prev_shift["end"] = datetime.datetime.strptime(prev_shift["end"], str_format) + prev_shift["start"] = _parse_timestamp(prev_shift["start"]) + prev_shift["end"] = _parse_timestamp(prev_shift["end"]) # get shifts in progress now now = datetime.datetime.now(datetime.timezone.utc) diff --git a/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py b/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py index 2833a826ff..deb5fc1e54 100644 --- a/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tests/test_notify_ical_schedule_shift.py @@ -498,6 +498,45 @@ def test_next_shift_changes_no_triggering_notification( assert not mock_slack_api_call.called +@pytest.mark.django_db +def test_current_shifts_using_microseconds( + make_organization_and_user_with_slack_identities, + make_user, + make_schedule, +): + organization, _, _, _ = make_organization_and_user_with_slack_identities() + user1 = make_user(organization=organization, username="user1") + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleCalendar, + name="test_schedule", + channel="channel", + prev_ical_file_overrides=None, + cached_ical_file_overrides=None, + ) + schedule.refresh_ical_file() + schedule.current_shifts = json.dumps( + { + "test_shift_uid": { + "users": [user1.pk], + "start": timezone.now().replace(microsecond=123456), + "end": timezone.now().replace(microsecond=654321) + timezone.timedelta(days=1), + "all_day": False, + "priority": 1, + "priority_increased_by": 0, + } + }, + default=str, + ) + schedule.empty_oncall = False + schedule.save() + + with patch("apps.slack.client.SlackClient.chat_postMessage") as mock_slack_api_call: + notify_ical_schedule_shift(schedule.pk) + + assert mock_slack_api_call.called + + @pytest.mark.django_db def test_lower_priority_changes_no_triggering_notification( make_organization_and_user_with_slack_identities, From 8d9014e33a8151f15470172a5dd1ee0fd62164d8 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Wed, 23 Oct 2024 12:19:01 -0600 Subject: [PATCH 2/4] Allow more escalation step wait times over public api (#5201) # What this PR does Remove restriction on wait times when changing escalation step wait times. 60-86400 seconds values are accepted. https://github.com/grafana/terraform-provider-grafana/pull/1855 removes the restriction in the terraform provider. ## Which issue(s) this PR closes Related to: https://github.com/grafana/support-escalations/issues/13065 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .../escalation_policies.md | 32 +++++++++---------- .../serializers/escalation_policies.py | 22 ++++++------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/sources/oncall-api-reference/escalation_policies.md b/docs/sources/oncall-api-reference/escalation_policies.md index 6e68b42d7c..502e835e77 100644 --- a/docs/sources/oncall-api-reference/escalation_policies.md +++ b/docs/sources/oncall-api-reference/escalation_policies.md @@ -38,22 +38,22 @@ The above command returns JSON structured in the following way: } ``` -| Parameter | Required | Description | -| ---------------------------------- | :--------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `escalation_chain_id` | Yes | Each escalation policy is assigned to a specific escalation chain. | -| `position` | Optional | Escalation policies execute one after another starting from `position=0`. `Position=-1` will put the escalation policy to the end of the list. A new escalation policy created with a position of an existing escalation policy will move the old one (and all following) down in the list. | -| `type` | Yes | One of: `wait`, `notify_persons`, `notify_person_next_each_time`, `notify_on_call_from_schedule`, `notify_user_group`, `trigger_webhook`, `resolve`, `notify_whole_channel`, `notify_if_time_from_to`, `declare_incident`. | -| `important` | Optional | Default is `false`. Will assign "important" to personal notification rules if `true`. This can be used to distinguish alerts on which you want to be notified immediately by phone. Applicable for types `notify_persons`, `notify_team_members`, `notify_on_call_from_schedule`, and `notify_user_group`. | -| `duration` | If type = `wait` | The duration, in seconds, when type `wait` is chosen. Valid values are: `60`, `300`, `900`, `1800`, `3600`. | -| `action_to_trigger` | If type = `trigger_webhook` | ID of a webhook. | -| `group_to_notify` | If type = `notify_user_group` | ID of a `User Group`. | -| `persons_to_notify` | If type = `notify_persons` | List of user IDs. | -| `persons_to_notify_next_each_time` | If type = `notify_person_next_each_time` | List of user IDs. | -| `notify_on_call _from_schedule` | If type = `notify_on_call_from_schedule` | ID of a Schedule. | -| `notify_if_time_from` | If type = `notify_if_time_from_to` | UTC time represents the beginning of the time period, for example `09:00:00Z`. | -| `notify_if_time_to` | If type = `notify_if_time_from_to` | UTC time represents the end of the time period, for example `18:00:00Z`. | -| `team_to_notify` | If type = `notify_team_members` | ID of a team. | -| `severity` | If type = `declare_incident` | Severity of the incident. | +| Parameter | Required | Description | +| ---------------------------------- |:----------------------------------------:|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `escalation_chain_id` | Yes | Each escalation policy is assigned to a specific escalation chain. | +| `position` | Optional | Escalation policies execute one after another starting from `position=0`. `Position=-1` will put the escalation policy to the end of the list. A new escalation policy created with a position of an existing escalation policy will move the old one (and all following) down in the list. | +| `type` | Yes | One of: `wait`, `notify_persons`, `notify_person_next_each_time`, `notify_on_call_from_schedule`, `notify_user_group`, `trigger_webhook`, `resolve`, `notify_whole_channel`, `notify_if_time_from_to`, `declare_incident`. | +| `important` | Optional | Default is `false`. Will assign "important" to personal notification rules if `true`. This can be used to distinguish alerts on which you want to be notified immediately by phone. Applicable for types `notify_persons`, `notify_team_members`, `notify_on_call_from_schedule`, and `notify_user_group`. | +| `duration` | If type = `wait` | The duration, in seconds, when type `wait` is chosen. Valid values are any number of seconds in the inclusive range `60 to 86400`. | +| `action_to_trigger` | If type = `trigger_webhook` | ID of a webhook. | +| `group_to_notify` | If type = `notify_user_group` | ID of a `User Group`. | +| `persons_to_notify` | If type = `notify_persons` | List of user IDs. | +| `persons_to_notify_next_each_time` | If type = `notify_person_next_each_time` | List of user IDs. | +| `notify_on_call _from_schedule` | If type = `notify_on_call_from_schedule` | ID of a Schedule. | +| `notify_if_time_from` | If type = `notify_if_time_from_to` | UTC time represents the beginning of the time period, for example `09:00:00Z`. | +| `notify_if_time_to` | If type = `notify_if_time_from_to` | UTC time represents the end of the time period, for example `18:00:00Z`. | +| `team_to_notify` | If type = `notify_team_members` | ID of a team. | +| `severity` | If type = `declare_incident` | Severity of the incident. | **HTTP request** diff --git a/engine/apps/public_api/serializers/escalation_policies.py b/engine/apps/public_api/serializers/escalation_policies.py index ad0abe788f..92796a5db6 100644 --- a/engine/apps/public_api/serializers/escalation_policies.py +++ b/engine/apps/public_api/serializers/escalation_policies.py @@ -1,4 +1,3 @@ -import time from datetime import timedelta from django.utils.functional import cached_property @@ -11,6 +10,7 @@ from apps.user_management.models import Team, User from apps.webhooks.models import Webhook from common.api_helpers.custom_fields import ( + DurationSecondsField, OrganizationFilteredPrimaryKeyRelatedField, UsersFilteredByOrganizationField, ) @@ -43,7 +43,15 @@ class EscalationPolicySerializer(EagerLoadingMixin, OrderedModelSerializer): queryset=EscalationChain.objects, source="escalation_chain" ) type = EscalationPolicyTypeField(source="step") - duration = serializers.ChoiceField(required=False, source="wait_delay", choices=EscalationPolicy.DURATION_CHOICES) + + duration = DurationSecondsField( + required=False, + source="wait_delay", + allow_null=True, + min_value=timedelta(minutes=1), + max_value=timedelta(hours=24), + ) + persons_to_notify = UsersFilteredByOrganizationField( queryset=User.objects, required=False, @@ -143,18 +151,10 @@ def to_representation(self, instance): result = super().to_representation(instance) result = self._get_field_to_represent(step, result) if "duration" in result and result["duration"] is not None: - result["duration"] = result["duration"].seconds + result["duration"] = int(float(result["duration"])) return result def to_internal_value(self, data): - if data.get("duration", None): - try: - time.strptime(data["duration"], "%H:%M:%S") - except (ValueError, TypeError): - try: - data["duration"] = str(timedelta(seconds=data["duration"])) - except (ValueError, TypeError): - raise BadRequest(detail="Invalid duration format") if data.get("persons_to_notify", []) is None: # terraform case data["persons_to_notify"] = [] if data.get("persons_to_notify_next_each_time", []) is None: # terraform case From 58d73742ea72d653f2f5e0254d0f0221d66eb1f4 Mon Sep 17 00:00:00 2001 From: Yulya Artyukhina Date: Thu, 24 Oct 2024 11:24:36 +0200 Subject: [PATCH 3/4] Add openAPI schema for some internal endpoints (#5037) # What this PR does Adds openAPI schema for following endpoints: - /escalation_chain - /escalation_policy - /channel_filter - /user_notification_policy ## Which issue(s) this PR closes Related to https://github.com/grafana/oncall-private/issues/2457 ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --------- Co-authored-by: Vadim Stepanov --- .../apps/alerts/models/escalation_policy.py | 2 +- .../alert_group_escalation_snapshot.py | 16 +- engine/apps/api/serializers/channel_filter.py | 68 +++++---- .../apps/api/serializers/escalation_chain.py | 4 +- .../apps/api/tests/test_escalation_policy.py | 142 ------------------ engine/apps/api/views/alert_group.py | 27 +--- engine/apps/api/views/channel_filter.py | 37 ++++- engine/apps/api/views/escalation_chain.py | 51 ++++++- engine/apps/api/views/escalation_policy.py | 67 +++++++-- .../api/views/user_notification_policy.py | 73 +++++++-- engine/common/api_helpers/custom_fields.py | 1 + engine/common/api_helpers/filters.py | 37 ++++- engine/settings/base.py | 4 + 13 files changed, 288 insertions(+), 241 deletions(-) diff --git a/engine/apps/alerts/models/escalation_policy.py b/engine/apps/alerts/models/escalation_policy.py index 28ea7022ac..e66a65c4f5 100644 --- a/engine/apps/alerts/models/escalation_policy.py +++ b/engine/apps/alerts/models/escalation_policy.py @@ -360,7 +360,7 @@ def sorted_users_queue(self): return sorted(self.notify_to_users_queue.all(), key=lambda user: (user.username or "", user.pk)) @property - def slack_integration_required(self): + def slack_integration_required(self) -> bool: if self.step in self.SLACK_INTEGRATION_REQUIRED_STEPS: return True else: diff --git a/engine/apps/api/serializers/alert_group_escalation_snapshot.py b/engine/apps/api/serializers/alert_group_escalation_snapshot.py index 296e8d2664..9848e4edc4 100644 --- a/engine/apps/api/serializers/alert_group_escalation_snapshot.py +++ b/engine/apps/api/serializers/alert_group_escalation_snapshot.py @@ -36,17 +36,17 @@ class Meta(EscalationPolicySerializer.Meta): class AlertGroupEscalationSnapshotAPISerializer(serializers.Serializer): """Serializes AlertGroup escalation snapshot for API endpoint""" - escalation_chain = serializers.SerializerMethodField() - channel_filter = serializers.SerializerMethodField() + class EscalationChainSnapshotAPISerializer(serializers.Serializer): + name = serializers.CharField() + + class ChannelFilterSnapshotAPISerializer(serializers.Serializer): + name = serializers.CharField(source="str_for_clients") + + escalation_chain = EscalationChainSnapshotAPISerializer(read_only=True, source="escalation_chain_snapshot") + channel_filter = ChannelFilterSnapshotAPISerializer(read_only=True, source="channel_filter_snapshot") escalation_policies = EscalationPolicySnapshotAPISerializer( source="escalation_policies_snapshots", many=True, read_only=True ) class Meta: fields = ["escalation_chain", "channel_filter", "escalation_policies"] - - def get_escalation_chain(self, obj): - return {"name": obj.escalation_chain_snapshot.name} - - def get_channel_filter(self, obj): - return {"name": obj.channel_filter_snapshot.str_for_clients} diff --git a/engine/apps/api/serializers/channel_filter.py b/engine/apps/api/serializers/channel_filter.py index 408759fb91..9f76dbe147 100644 --- a/engine/apps/api/serializers/channel_filter.py +++ b/engine/apps/api/serializers/channel_filter.py @@ -14,7 +14,17 @@ from common.utils import is_regex_valid +class SlackChannelDetails(typing.TypedDict): + display_name: str + slack_id: str + id: str + + class ChannelFilterSerializer(EagerLoadingMixin, serializers.ModelSerializer): + class TelegramChannelDetailsSerializer(serializers.Serializer): + display_name = serializers.CharField(source="channel_name") + id = serializers.CharField(source="channel_chat_id") + id = serializers.CharField(read_only=True, source="public_primary_key") alert_receive_channel = OrganizationFilteredPrimaryKeyRelatedField(queryset=AlertReceiveChannel.objects) escalation_chain = OrganizationFilteredPrimaryKeyRelatedField( @@ -28,7 +38,9 @@ class ChannelFilterSerializer(EagerLoadingMixin, serializers.ModelSerializer): telegram_channel = OrganizationFilteredPrimaryKeyRelatedField( queryset=TelegramToOrganizationConnector.objects, filter_field="organization", allow_null=True, required=False ) - telegram_channel_details = serializers.SerializerMethodField() + telegram_channel_details = TelegramChannelDetailsSerializer( + source="telegram_channel", read_only=True, allow_null=True + ) filtering_term_as_jinja2 = serializers.SerializerMethodField() filtering_term = serializers.CharField(required=False, allow_null=True, allow_blank=True) filtering_labels = LabelPairSerializer(many=True, required=False) @@ -86,7 +98,7 @@ def validate(self, data): raise serializers.ValidationError(["Expression type is incorrect"]) return data - def get_slack_channel(self, obj): + def get_slack_channel(self, obj) -> SlackChannelDetails | None: if obj.slack_channel_id is None: return None # display_name and id appears via annotate in ChannelFilterView.get_queryset() @@ -96,18 +108,6 @@ def get_slack_channel(self, obj): "id": obj.slack_channel_pk, } - def get_telegram_channel_details(self, obj) -> dict[str, typing.Any] | None: - if obj.telegram_channel_id is None: - return None - try: - telegram_channel = TelegramToOrganizationConnector.objects.get(pk=obj.telegram_channel_id) - return { - "display_name": telegram_channel.channel_name, - "id": telegram_channel.channel_chat_id, - } - except TelegramToOrganizationConnector.DoesNotExist: - return None - def validate_slack_channel(self, slack_channel_id): from apps.slack.models import SlackChannel @@ -182,21 +182,25 @@ class Meta: ] read_only_fields = ["created_at", "is_default"] + def _get_slack_channel(self, obj) -> SlackChannelDetails | None: + if obj.slack_channel_id is None: + return None + slack_team_identity = self.context["request"].auth.organization.slack_team_identity + if slack_team_identity is None: + return None + slack_channel = slack_team_identity.get_cached_channels(slack_id=obj.slack_channel_id).first() + if slack_channel is None: + return None + return { + "display_name": slack_channel.name, + "slack_id": slack_channel.slack_id, + "id": slack_channel.public_primary_key, + } + def to_representation(self, obj): """add correct slack channel data to result after instance creation/update""" result = super().to_representation(obj) - if obj.slack_channel_id is None: - result["slack_channel"] = None - else: - slack_team_identity = self.context["request"].auth.organization.slack_team_identity - if slack_team_identity is not None: - slack_channel = slack_team_identity.get_cached_channels(slack_id=obj.slack_channel_id).first() - if slack_channel: - result["slack_channel"] = { - "display_name": slack_channel.name, - "slack_id": obj.slack_channel_id, - "id": slack_channel.public_primary_key, - } + result["slack_channel"] = self._get_slack_channel(obj) return result def create(self, validated_data): @@ -218,3 +222,15 @@ def update(self, instance, validated_data): raise BadRequest(detail="Filtering term of default channel filter cannot be changed") return super().update(instance, validated_data) + + +class ChannelFilterUpdateResponseSerializer(ChannelFilterUpdateSerializer): + """ + This serializer is used in OpenAPI schema to show proper response structure, + as `slack_channel` field expects string on create/update and returns dict on response + """ + + slack_channel = serializers.SerializerMethodField() + + def get_slack_channel(self, obj) -> SlackChannelDetails | None: + return self._get_slack_channel(obj) diff --git a/engine/apps/api/serializers/escalation_chain.py b/engine/apps/api/serializers/escalation_chain.py index 45c76d7649..b284048c96 100644 --- a/engine/apps/api/serializers/escalation_chain.py +++ b/engine/apps/api/serializers/escalation_chain.py @@ -22,11 +22,11 @@ class EscalationChainListSerializer(EscalationChainSerializer): class Meta(EscalationChainSerializer.Meta): fields = [*EscalationChainSerializer.Meta.fields, "number_of_integrations", "number_of_routes"] - def get_number_of_integrations(self, obj): + def get_number_of_integrations(self, obj) -> int: # num_integrations param added in queryset via annotate. Check EscalationChainViewSet.get_queryset return getattr(obj, "num_integrations", 0) - def get_number_of_routes(self, obj): + def get_number_of_routes(self, obj) -> int: # num_routes param added in queryset via annotate. Check EscalationChainViewSet.get_queryset return getattr(obj, "num_routes", 0) diff --git a/engine/apps/api/tests/test_escalation_policy.py b/engine/apps/api/tests/test_escalation_policy.py index 0c1b329970..3ad2583cd5 100644 --- a/engine/apps/api/tests/test_escalation_policy.py +++ b/engine/apps/api/tests/test_escalation_policy.py @@ -854,148 +854,6 @@ def test_escalation_policy_switch_importance( assert response.json() == data_for_update -@pytest.mark.django_db -def test_escalation_policy_filter_by_user( - make_organization_and_user_with_plugin_token, - make_user_for_organization, - make_escalation_chain, - make_escalation_policy, - make_user_auth_headers, -): - organization, user, token = make_organization_and_user_with_plugin_token() - second_user = make_user_for_organization(organization) - escalation_chain = make_escalation_chain(organization) - - client = APIClient() - escalation_policy_with_one_user = make_escalation_policy( - escalation_chain=escalation_chain, - escalation_policy_step=EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS, - ) - - escalation_policy_with_two_users = make_escalation_policy( - escalation_chain=escalation_chain, - escalation_policy_step=EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS, - ) - - escalation_policy_with_one_user.notify_to_users_queue.set([user]) - escalation_policy_with_two_users.notify_to_users_queue.set([user, second_user]) - - expected_payload = [ - { - "id": escalation_policy_with_one_user.public_primary_key, - "step": 13, - "wait_delay": None, - "escalation_chain": escalation_chain.public_primary_key, - "notify_to_users_queue": [user.public_primary_key], - "from_time": None, - "to_time": None, - "num_alerts_in_window": None, - "num_minutes_in_window": None, - "slack_integration_required": False, - "custom_webhook": None, - "notify_schedule": None, - "notify_to_group": None, - "notify_to_team_members": None, - "severity": None, - "important": False, - }, - { - "id": escalation_policy_with_two_users.public_primary_key, - "step": 13, - "wait_delay": None, - "escalation_chain": escalation_chain.public_primary_key, - "notify_to_users_queue": [user.public_primary_key, second_user.public_primary_key], - "from_time": None, - "to_time": None, - "num_alerts_in_window": None, - "num_minutes_in_window": None, - "slack_integration_required": False, - "custom_webhook": None, - "notify_schedule": None, - "notify_to_group": None, - "notify_to_team_members": None, - "severity": None, - "important": False, - }, - ] - - url = reverse("api-internal:escalation_policy-list") - - response = client.get(f"{url}?user={user.public_primary_key}", format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == status.HTTP_200_OK - - result = response.json() - assert set(result[1]["notify_to_users_queue"]) == {user.public_primary_key, second_user.public_primary_key} - expected_payload[1]["notify_to_users_queue"] = result[1]["notify_to_users_queue"] - assert response.json() == expected_payload - - -@pytest.mark.django_db -def test_escalation_policy_filter_by_slack_channel( - make_organization_and_user_with_plugin_token, - make_user_auth_headers, - make_alert_receive_channel, - make_channel_filter, - make_escalation_chain, - make_slack_channel, - make_escalation_policy, -): - organization, user, token = make_organization_and_user_with_plugin_token() - alert_receive_channel = make_alert_receive_channel(organization) - slack_channel = make_slack_channel(organization.slack_team_identity) - escalation_chain = make_escalation_chain(organization) - other_escalation_chain = make_escalation_chain(organization) - make_channel_filter( - alert_receive_channel, - escalation_chain=escalation_chain, - is_default=False, - slack_channel_id=slack_channel.slack_id, - ) - - client = APIClient() - - make_escalation_policy( - escalation_chain=other_escalation_chain, - escalation_policy_step=EscalationPolicy.STEP_WAIT, - ) - - escalation_policy_from_alert_receive_channel_with_slack_channel = make_escalation_policy( - escalation_chain=escalation_chain, - escalation_policy_step=EscalationPolicy.STEP_WAIT, - ) - expected_payload = [ - { - "id": escalation_policy_from_alert_receive_channel_with_slack_channel.public_primary_key, - "step": 0, - "wait_delay": None, - "escalation_chain": escalation_chain.public_primary_key, - "notify_to_users_queue": [], - "from_time": None, - "to_time": None, - "num_alerts_in_window": None, - "num_minutes_in_window": None, - "slack_integration_required": False, - "custom_webhook": None, - "notify_schedule": None, - "notify_to_group": None, - "notify_to_team_members": None, - "severity": None, - "important": False, - }, - ] - - url = reverse("api-internal:escalation_policy-list") - - response = client.get( - f"{url}?slack_channel={slack_channel.slack_id}", format="json", **make_user_auth_headers(user, token) - ) - - assert response.status_code == status.HTTP_200_OK - - assert response.json() == expected_payload - - @pytest.mark.django_db def test_escalation_policy_escalation_options_webhooks( make_organization_and_user_with_plugin_token, diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index c937a78488..117fb9ce9d 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -14,7 +14,7 @@ from rest_framework.response import Response from apps.alerts.constants import ActionSource -from apps.alerts.models import AlertGroup, AlertReceiveChannel, EscalationChain, ResolutionNote +from apps.alerts.models import AlertGroup, AlertReceiveChannel, ResolutionNote from apps.alerts.paging import unpage_user from apps.alerts.tasks import delete_alert_group, send_update_resolution_note_signal from apps.alerts.utils import is_declare_incident_step_enabled @@ -35,6 +35,9 @@ DateRangeFilterMixin, ModelFieldFilterMixin, MultipleChoiceCharFilter, + get_escalation_chain_queryset, + get_integration_queryset, + get_user_queryset, ) from common.api_helpers.mixins import ( AlertGroupEnrichingMixin, @@ -45,27 +48,6 @@ from common.api_helpers.paginators import AlertGroupCursorPaginator -def get_integration_queryset(request): - if request is None: - return AlertReceiveChannel.objects.none() - - return AlertReceiveChannel.objects_with_maintenance.filter(organization=request.user.organization) - - -def get_escalation_chain_queryset(request): - if request is None: - return EscalationChain.objects.none() - - return EscalationChain.objects.filter(organization=request.user.organization) - - -def get_user_queryset(request): - if request is None: - return User.objects.none() - - return User.objects.filter(organization=request.user.organization).distinct() - - class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.FilterSet): """ Examples of possible date formats here https://docs.djangoproject.com/en/1.9/ref/settings/#datetime-input-formats @@ -892,6 +874,7 @@ def bulk_action_options(self, request): def get_alert_to_template(self, payload=None): return self.get_object().alerts.first() + @extend_schema(responses=AlertGroupEscalationSnapshotAPISerializer) @action(methods=["get"], detail=True) def escalation_snapshot(self, request, pk=None): alert_group = self.get_object() diff --git a/engine/apps/api/views/channel_filter.py b/engine/apps/api/views/channel_filter.py index 262c73d879..bf722a8d81 100644 --- a/engine/apps/api/views/channel_filter.py +++ b/engine/apps/api/views/channel_filter.py @@ -1,4 +1,6 @@ from django.db.models import OuterRef, Subquery +from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated @@ -9,11 +11,13 @@ from apps.api.serializers.channel_filter import ( ChannelFilterCreateSerializer, ChannelFilterSerializer, + ChannelFilterUpdateResponseSerializer, ChannelFilterUpdateSerializer, ) from apps.auth_token.auth import PluginAuthentication from apps.slack.models import SlackChannel from common.api_helpers.exceptions import BadRequest +from common.api_helpers.filters import ModelFieldFilterMixin, MultipleChoiceCharFilter, get_integration_queryset from common.api_helpers.mixins import ( CreateSerializerMixin, PublicPrimaryKeyMixin, @@ -24,6 +28,22 @@ from common.ordered_model.viewset import OrderedModelViewSet +class ChannelFilterFilter(ModelFieldFilterMixin, filters.FilterSet): + alert_receive_channel = MultipleChoiceCharFilter( + queryset=get_integration_queryset, + to_field_name="public_primary_key", + method=ModelFieldFilterMixin.filter_model_field.__name__, + ) + + +@extend_schema_view( + list=extend_schema(responses=ChannelFilterSerializer), + create=extend_schema(request=ChannelFilterCreateSerializer, responses=ChannelFilterUpdateResponseSerializer), + update=extend_schema(request=ChannelFilterUpdateSerializer, responses=ChannelFilterUpdateResponseSerializer), + partial_update=extend_schema( + request=ChannelFilterUpdateSerializer, responses=ChannelFilterUpdateResponseSerializer + ), +) class ChannelFilterView( TeamFilteringMixin, PublicPrimaryKeyMixin[ChannelFilter], @@ -31,6 +51,10 @@ class ChannelFilterView( UpdateSerializerMixin, OrderedModelViewSet, ): + """ + Internal API endpoints for channel filters (routes). + """ + authentication_classes = (PluginAuthentication,) permission_classes = (IsAuthenticated, RBACPermission) rbac_permissions = { @@ -45,19 +69,19 @@ class ChannelFilterView( "convert_from_regex_to_jinja2": [RBACPermission.Permissions.INTEGRATIONS_WRITE], } + queryset = ChannelFilter.objects.none() # needed for drf-spectacular introspection + model = ChannelFilter serializer_class = ChannelFilterSerializer update_serializer_class = ChannelFilterUpdateSerializer create_serializer_class = ChannelFilterCreateSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = ChannelFilterFilter + TEAM_LOOKUP = "alert_receive_channel__team" def get_queryset(self, ignore_filtering_by_available_teams=False): - alert_receive_channel_id = self.request.query_params.get("alert_receive_channel", None) - lookup_kwargs = {} - if alert_receive_channel_id: - lookup_kwargs = {"alert_receive_channel__public_primary_key": alert_receive_channel_id} - slack_channels_subq = SlackChannel.objects.filter( slack_id=OuterRef("slack_channel_id"), slack_team_identity=self.request.auth.organization.slack_team_identity, @@ -66,7 +90,6 @@ def get_queryset(self, ignore_filtering_by_available_teams=False): queryset = ChannelFilter.objects.filter( alert_receive_channel__organization=self.request.auth.organization, alert_receive_channel__deleted_at=None, - **lookup_kwargs, ).annotate( slack_channel_name=Subquery(slack_channels_subq.values("name")[:1]), slack_channel_pk=Subquery(slack_channels_subq.values("public_primary_key")[:1]), @@ -109,6 +132,7 @@ def perform_update(self, serializer): new_state=new_state, ) + @extend_schema(request=None, responses={status.HTTP_200_OK: None}) @action(detail=True, methods=["put"]) def move_to_position(self, request, pk): instance = self.get_object() @@ -117,6 +141,7 @@ def move_to_position(self, request, pk): return super().move_to_position(request, pk) + @extend_schema(request=None, responses=ChannelFilterSerializer) @action(detail=True, methods=["post"]) def convert_from_regex_to_jinja2(self, request, pk): instance = self.get_object() diff --git a/engine/apps/api/views/escalation_chain.py b/engine/apps/api/views/escalation_chain.py index 952da9223b..e48a96a484 100644 --- a/engine/apps/api/views/escalation_chain.py +++ b/engine/apps/api/views/escalation_chain.py @@ -1,7 +1,8 @@ from django.db.models import Count, Q from django_filters import rest_framework as filters +from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_view, inline_serializer from emoji import emojize -from rest_framework import status, viewsets +from rest_framework import serializers, status, viewsets from rest_framework.decorators import action from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated @@ -37,6 +38,17 @@ class EscalationChainFilter(ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, team = TeamModelMultipleChoiceFilter() +@extend_schema_view( + list=extend_schema( + responses=PolymorphicProxySerializer( + component_name="EscalationChainPolymorphic", + serializers=[EscalationChainListSerializer, FilterEscalationChainSerializer], + resource_type_field_name=None, + ) + ), + update=extend_schema(responses=EscalationChainSerializer), + partial_update=extend_schema(responses=EscalationChainSerializer), +) class EscalationChainViewSet( TeamFilteringMixin, PublicPrimaryKeyMixin[EscalationChain], @@ -44,6 +56,10 @@ class EscalationChainViewSet( ListSerializerMixin, viewsets.ModelViewSet, ): + """ + Internal API endpoints for escalation chains. + """ + authentication_classes = ( MobileAppAuthTokenAuthentication, PluginAuthentication, @@ -62,6 +78,8 @@ class EscalationChainViewSet( "filters": [RBACPermission.Permissions.ESCALATION_CHAINS_READ], } + queryset = EscalationChain.objects.none() # needed for drf-spectacular introspection + filter_backends = [SearchFilter, filters.DjangoFilterBackend] search_fields = ("name",) filterset_class = EscalationChainFilter @@ -127,6 +145,7 @@ def perform_update(self, serializer): new_state=new_state, ) + @extend_schema(responses=EscalationChainSerializer) @action(methods=["post"], detail=True) def copy(self, request, pk): obj = self.get_object() @@ -155,6 +174,24 @@ def copy(self, request, pk): ) return Response(serializer.data) + @extend_schema( + responses=inline_serializer( + name="EscalationChainDetails", + fields={ + "id": serializers.CharField(), + "display_name": serializers.CharField(), + "channel_filters": inline_serializer( + name="EscalationChainDetailsChannelFilter", + fields={ + "id": serializers.CharField(), + "display_name": serializers.CharField(), + }, + many=True, + ), + }, + many=True, + ) + ) @action(methods=["get"], detail=True) def details(self, request, pk): obj = self.get_object() @@ -181,6 +218,18 @@ def details(self, request, pk): )["channel_filters"].append(channel_filter_data) return Response(data.values()) + @extend_schema( + responses=inline_serializer( + name="EscalationChainFilters", + fields={ + "name": serializers.CharField(), + "type": serializers.CharField(), + "href": serializers.CharField(required=False), + "global": serializers.BooleanField(required=False), + }, + many=True, + ) + ) @action(methods=["get"], detail=False) def filters(self, request): api_root = "/api/internal/v1/" diff --git a/engine/apps/api/views/escalation_policy.py b/engine/apps/api/views/escalation_policy.py index eb502b5ce6..23a2d14b9c 100644 --- a/engine/apps/api/views/escalation_policy.py +++ b/engine/apps/api/views/escalation_policy.py @@ -2,6 +2,9 @@ from django.conf import settings from django.db.models import Q +from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer +from rest_framework import serializers from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -16,6 +19,11 @@ ) from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication +from common.api_helpers.filters import ( + ModelChoicePublicPrimaryKeyFilter, + ModelFieldFilterMixin, + get_escalation_chain_queryset, +) from common.api_helpers.mixins import ( CreateSerializerMixin, PublicPrimaryKeyMixin, @@ -29,6 +37,17 @@ logger = logging.getLogger(__name__) +class EscalationPolicyFilter(ModelFieldFilterMixin, filters.FilterSet): + escalation_chain = ModelChoicePublicPrimaryKeyFilter( + queryset=get_escalation_chain_queryset, + ) + + +@extend_schema_view( + list=extend_schema(responses=EscalationPolicySerializer), + update=extend_schema(responses=EscalationPolicyUpdateSerializer), + partial_update=extend_schema(responses=EscalationPolicyUpdateSerializer), +) class EscalationPolicyView( TeamFilteringMixin, PublicPrimaryKeyMixin[EscalationPolicy], @@ -36,6 +55,10 @@ class EscalationPolicyView( UpdateSerializerMixin, OrderedModelViewSet, ): + """ + Internal API endpoints for escalation policies. + """ + authentication_classes = ( MobileAppAuthTokenAuthentication, PluginAuthentication, @@ -56,31 +79,20 @@ class EscalationPolicyView( "move_to_position": [RBACPermission.Permissions.ESCALATION_CHAINS_WRITE], } + queryset = EscalationPolicy.objects.none() # needed for drf-spectacular introspection + model = EscalationPolicy serializer_class = EscalationPolicySerializer update_serializer_class = EscalationPolicyUpdateSerializer create_serializer_class = EscalationPolicyCreateSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = EscalationPolicyFilter + TEAM_LOOKUP = "escalation_chain__team" def get_queryset(self, ignore_filtering_by_available_teams=False): - escalation_chain_id = self.request.query_params.get("escalation_chain") - user_id = self.request.query_params.get("user") - slack_channel_id = self.request.query_params.get("slack_channel") - channel_filter_id = self.request.query_params.get("channel_filter") - - lookup_kwargs = {} - if escalation_chain_id is not None: - lookup_kwargs.update({"escalation_chain__public_primary_key": escalation_chain_id}) - if user_id is not None: - lookup_kwargs.update({"notify_to_users_queue__public_primary_key": user_id}) - if slack_channel_id is not None: - lookup_kwargs.update({"escalation_chain__channel_filters__slack_channel_id": slack_channel_id}) - if channel_filter_id is not None: - lookup_kwargs.update({"escalation_chain__channel_filters__public_primary_key": channel_filter_id}) - queryset = EscalationPolicy.objects.filter( - Q(**lookup_kwargs), Q(escalation_chain__organization=self.request.auth.organization), Q(escalation_chain__channel_filters__alert_receive_channel__deleted_at=None), Q(step__in=EscalationPolicy.INTERNAL_DB_STEPS) | Q(step__isnull=True), @@ -121,6 +133,19 @@ def perform_destroy(self, instance): ) instance.delete() + @extend_schema( + responses=inline_serializer( + name="EscalationPolicyOptions", + fields={ + "value": serializers.IntegerField(), + "display_name": serializers.CharField(), + "create_display_name": serializers.CharField(), + "slack_integration_required": serializers.BooleanField(), + "can_change_importance": serializers.BooleanField(), + }, + many=True, + ) + ) @action(detail=False, methods=["get"]) def escalation_options(self, request): grafana_declare_incident_enabled = is_declare_incident_step_enabled(organization=self.request.auth.organization) @@ -164,6 +189,16 @@ def num_minutes_in_window_options(self, request): ] return Response(choices) + @extend_schema( + responses=inline_serializer( + name="SeverityOptions", + fields={ + "value": serializers.CharField(), + "display_name": serializers.CharField(), + }, + many=True, + ) + ) @action(detail=False, methods=["get"]) def severity_options(self, request): organization = self.request.auth.organization diff --git a/engine/apps/api/views/user_notification_policy.py b/engine/apps/api/views/user_notification_policy.py index 6a1311c214..82f6cc11bd 100644 --- a/engine/apps/api/views/user_notification_policy.py +++ b/engine/apps/api/views/user_notification_policy.py @@ -1,5 +1,8 @@ from django.conf import settings from django.http import Http404 +from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer +from rest_framework import serializers from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -14,14 +17,29 @@ from apps.base.models import UserNotificationPolicy from apps.base.models.user_notification_policy import BUILT_IN_BACKENDS, NotificationChannelAPIOptions from apps.mobile_app.auth import MobileAppAuthTokenAuthentication -from apps.user_management.models import User -from common.api_helpers.exceptions import BadRequest +from common.api_helpers.filters import ModelChoicePublicPrimaryKeyFilter, get_user_queryset from common.api_helpers.mixins import UpdateSerializerMixin from common.insight_log import EntityEvent, write_resource_insight_log from common.ordered_model.viewset import OrderedModelViewSet +class UserNotificationPolicyFilter(filters.FilterSet): + important = filters.BooleanFilter() + user = ModelChoicePublicPrimaryKeyFilter( + queryset=get_user_queryset, + ) + + +@extend_schema_view( + list=extend_schema(responses=UserNotificationPolicySerializer), + update=extend_schema(responses=UserNotificationPolicyUpdateSerializer), + partial_update=extend_schema(responses=UserNotificationPolicyUpdateSerializer), +) class UserNotificationPolicyView(UpdateSerializerMixin, OrderedModelViewSet): + """ + Internal API endpoints for user notification policies. + """ + authentication_classes = ( MobileAppAuthTokenAuthentication, PluginAuthentication, @@ -54,25 +72,27 @@ class UserNotificationPolicyView(UpdateSerializerMixin, OrderedModelViewSet): "move_to_position", ], } + queryset = UserNotificationPolicy.objects.none() # needed for drf-spectacular introspection model = UserNotificationPolicy serializer_class = UserNotificationPolicySerializer update_serializer_class = UserNotificationPolicyUpdateSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = UserNotificationPolicyFilter + def get_queryset(self): - important = self.request.query_params.get("important", None) == "true" - try: - user_id = self.request.query_params.get("user", None) - except ValueError: - raise BadRequest(detail="Invalid user param") - if user_id is None or user_id == self.request.user.public_primary_key: - target_user = self.request.user - else: - try: - target_user = User.objects.get(public_primary_key=user_id) - except User.DoesNotExist: - raise BadRequest(detail="User does not exist") - queryset = UserNotificationPolicy.objects.filter(user=target_user, important=important) + # if there are no query params, set default value + lookup_kwargs = {} + important = self.request.query_params.get("important", None) + user_id = self.request.query_params.get("user", None) + if important is None: + lookup_kwargs.update({"important": False}) + if user_id is None: + lookup_kwargs.update({"user": self.request.user}) + queryset = UserNotificationPolicy.objects.filter( + **lookup_kwargs, user__organization=self.request.auth.organization + ) return self.serializer_class.setup_eager_loading(queryset) def get_object(self): @@ -128,6 +148,17 @@ def perform_destroy(self, instance): new_state=new_state, ) + @extend_schema( + responses=inline_serializer( + name="UserNotificationPolicyDelayOptions", + fields={ + "value": serializers.CharField(), + "sec_value": serializers.IntegerField(), + "display_name": serializers.CharField(), + }, + many=True, + ) + ) @action(detail=False, methods=["get"]) def delay_options(self, request): choices = [] @@ -135,6 +166,18 @@ def delay_options(self, request): choices.append({"value": str(item[0]), "sec_value": item[0], "display_name": item[1]}) return Response(choices) + @extend_schema( + responses=inline_serializer( + name="UserNotificationPolicyNotifyByOptions", + fields={ + "value": serializers.IntegerField(), + "display_name": serializers.CharField(), + "slack_integration_required": serializers.BooleanField(), + "telegram_integration_required": serializers.BooleanField(), + }, + many=True, + ) + ) @action(detail=False, methods=["get"]) def notify_by_options(self, request): """ diff --git a/engine/common/api_helpers/custom_fields.py b/engine/common/api_helpers/custom_fields.py index edf7f9b522..94c700186e 100644 --- a/engine/common/api_helpers/custom_fields.py +++ b/engine/common/api_helpers/custom_fields.py @@ -82,6 +82,7 @@ def validate_empty_values(self, data): return super().validate_empty_values(data) +@extend_schema_field(serializers.ListField(child=serializers.CharField())) class UsersFilteredByOrganizationField(serializers.Field): """ This field reduces queries count when accessing User many related field (ex: notify_to_users_queue). diff --git a/engine/common/api_helpers/filters.py b/engine/common/api_helpers/filters.py index 851ff6f6a8..22665ab7e5 100644 --- a/engine/common/api_helpers/filters.py +++ b/engine/common/api_helpers/filters.py @@ -7,7 +7,8 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from apps.user_management.models import Team +from apps.alerts.models import AlertReceiveChannel, EscalationChain +from apps.user_management.models import Team, User from common.api_helpers.exceptions import BadRequest NO_TEAM_VALUE = "null" @@ -61,11 +62,22 @@ def parse_custom_datetime_range(cls, value): @extend_schema_field(serializers.CharField) class MultipleChoiceCharFilter(filters.ModelMultipleChoiceFilter): - """MultipleChoiceCharFilter with an explicit schema. Otherwise, drf-specacular may generate a wrong schema.""" + """MultipleChoiceCharFilter with an explicit schema. Otherwise, drf-spectacular may generate a wrong schema.""" pass +@extend_schema_field(serializers.CharField) +class ModelChoicePublicPrimaryKeyFilter(filters.ModelChoiceFilter): + """ + ModelChoicePublicPrimaryKeyFilter with an explicit schema. Otherwise, drf-spectacular may generate a wrong schema. + """ + + def __init__(self, *args, **kwargs): + kwargs.setdefault("to_field_name", "public_primary_key") + super().__init__(*args, **kwargs) + + class ModelFieldFilterMixin: def filter_model_field(self, queryset, name, value): if not value: @@ -107,6 +119,27 @@ def filter_model_field_with_multiple_values(self, queryset, name, values): return queryset.filter(teams_lookup).distinct() +def get_escalation_chain_queryset(request): + if request is None: + return EscalationChain.objects.none() + + return EscalationChain.objects.filter(organization=request.user.organization) + + +def get_integration_queryset(request): + if request is None: + return AlertReceiveChannel.objects.none() + + return AlertReceiveChannel.objects_with_maintenance.filter(organization=request.user.organization) + + +def get_user_queryset(request): + if request is None: + return User.objects.none() + + return User.objects.filter(organization=request.user.organization).distinct() + + def get_team_queryset(request): if request is None: return Team.objects.none() diff --git a/engine/settings/base.py b/engine/settings/base.py index 94c82f9703..5b6eba8f14 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -353,6 +353,10 @@ class DatabaseTypes: "/alertgroups", "/alert_receive_channels", "/webhooks", + "/channel_filters", + "/escalation_chains", + "/escalation_policies", + "/notification_policies", # current user endpoint 👇, without trailing slash we pick-up /user_group endpoints, which we don't want for now "/user/", "/users", From 9a929e2459a6e04aae0198bd37abfa7a611e8c7c Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 24 Oct 2024 13:52:40 -0300 Subject: [PATCH 4/4] Add org setting to configure direct paging preferred policy (#5189) For context: https://raintank-corp.slack.com/archives/C01DHQ6LH1S/p1729267368387299?thread_ts=1729185737.051889&cid=C01DHQ6LH1S Next steps: - use the setting in frontend (add participants, add responders) and mobile_app - expose setting in org settings page --- engine/apps/api/serializers/organization.py | 1 + engine/apps/api/tests/test_organization.py | 7 +++++- engine/apps/slack/scenarios/paging.py | 10 ++++++-- .../tests/test_scenario_steps/test_paging.py | 24 +++++++++++++++---- ...n_direct_paging_prefer_important_policy.py | 18 ++++++++++++++ .../user_management/models/organization.py | 2 ++ 6 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 engine/apps/user_management/migrations/0024_organization_direct_paging_prefer_important_policy.py diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index cac0edd12a..124b51b78c 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -38,6 +38,7 @@ class Meta: "slack_channel", "rbac_enabled", "grafana_incident_enabled", + "direct_paging_prefer_important_policy", ] read_only_fields = [ "stack_slug", diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index 4d04d6c1e4..ecfb5bff3b 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -44,6 +44,7 @@ def test_get_organization( "slack_channel": None, "rbac_enabled": organization.is_rbac_permissions_enabled, "grafana_incident_enabled": organization.is_grafana_incident_enabled, + "direct_paging_prefer_important_policy": organization.direct_paging_prefer_important_policy, "is_resolution_note_required": False, "env_status": mock_env_status, "banner": mock_banner, @@ -75,7 +76,10 @@ def test_update_organization_settings(make_organization_and_user_with_plugin_tok client = APIClient() url = reverse("api-internal:api-organization") - data = {"is_resolution_note_required": True} + data = { + "is_resolution_note_required": True, + "direct_paging_prefer_important_policy": True, + } assert organization.is_resolution_note_required is False @@ -83,6 +87,7 @@ def test_update_organization_settings(make_organization_and_user_with_plugin_tok assert response.status_code == status.HTTP_200_OK organization.refresh_from_db() assert organization.is_resolution_note_required is True + assert organization.direct_paging_prefer_important_policy is True @pytest.mark.django_db diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index d944971ba5..f1981fc5e6 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -373,7 +373,10 @@ def process_scenario( # user is currently on-call error_msg = None try: - updated_payload = add_or_update_item(payload, DataKey.USERS, selected_user.pk, Policy.DEFAULT) + policy = Policy.DEFAULT + if selected_user.organization.direct_paging_prefer_important_policy: + policy = Policy.IMPORTANT + updated_payload = add_or_update_item(payload, DataKey.USERS, selected_user.pk, policy) except ValueError: updated_payload = payload error_msg = "Cannot add user, maximum responders exceeded" @@ -449,7 +452,10 @@ def process_scenario( error_msg = None try: - updated_payload = add_or_update_item(previous_view_payload, DataKey.USERS, selected_user.pk, Policy.DEFAULT) + policy = Policy.DEFAULT + if selected_user.organization.direct_paging_prefer_important_policy: + policy = Policy.IMPORTANT + updated_payload = add_or_update_item(previous_view_payload, DataKey.USERS, selected_user.pk, policy) except ValueError: updated_payload = payload error_msg = "Cannot add user, maximum responders exceeded" diff --git a/engine/apps/slack/tests/test_scenario_steps/test_paging.py b/engine/apps/slack/tests/test_scenario_steps/test_paging.py index 9c62d8e5a0..46c32f3cfc 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_paging.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_paging.py @@ -162,9 +162,15 @@ def test_initial_unauthorized(make_organization_and_user_with_slack_identities, ) +@pytest.mark.parametrize("use_important_policy", (False, True)) @pytest.mark.django_db -def test_add_user_no_warning(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift): +def test_add_user_no_warning( + make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift, use_important_policy +): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + if use_important_policy: + organization.direct_paging_prefer_important_policy = use_important_policy + organization.save() # set up schedule: user is on call schedule = make_schedule( organization, @@ -195,7 +201,10 @@ def test_add_user_no_warning(make_organization_and_user_with_slack_identities, m step.process_scenario(slack_user_identity, slack_team_identity, payload) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[DataKey.USERS] == {str(user.pk): Policy.DEFAULT} + if use_important_policy: + assert metadata[DataKey.USERS] == {str(user.pk): Policy.IMPORTANT} + else: + assert metadata[DataKey.USERS] == {str(user.pk): Policy.DEFAULT} @pytest.mark.django_db @@ -269,15 +278,20 @@ def test_add_user_raise_warning(make_organization_and_user_with_slack_identities assert metadata[DataKey.USERS] == {} +@pytest.mark.parametrize("use_important_policy", (False, True)) @pytest.mark.django_db -def test_change_user_policy(make_organization_and_user_with_slack_identities): +def test_change_user_policy(make_organization_and_user_with_slack_identities, use_important_policy): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() + if use_important_policy: + organization.direct_paging_prefer_important_policy = use_important_policy + organization.save() + value = Policy.IMPORTANT if not use_important_policy else Policy.DEFAULT payload = make_paging_view_slack_payload( selected_org=organization, actions=[ { "selected_option": { - "value": make_value({"action": Policy.IMPORTANT, "key": DataKey.USERS, "id": user.pk}, organization) + "value": make_value({"action": value, "key": DataKey.USERS, "id": user.pk}, organization) } } ], @@ -288,7 +302,7 @@ def test_change_user_policy(make_organization_and_user_with_slack_identities): step.process_scenario(slack_user_identity, slack_team_identity, payload) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) - assert metadata[DataKey.USERS] == {str(user.pk): Policy.IMPORTANT} + assert metadata[DataKey.USERS] == {str(user.pk): value} @pytest.mark.django_db diff --git a/engine/apps/user_management/migrations/0024_organization_direct_paging_prefer_important_policy.py b/engine/apps/user_management/migrations/0024_organization_direct_paging_prefer_important_policy.py new file mode 100644 index 0000000000..ef05593bbb --- /dev/null +++ b/engine/apps/user_management/migrations/0024_organization_direct_paging_prefer_important_policy.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-10-18 16:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0023_organization_is_grafana_irm_enabled'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='direct_paging_prefer_important_policy', + field=models.BooleanField(default=False, null=True), + ), + ] diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index a6dcc622ac..d9e74a432b 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -259,6 +259,8 @@ def _get_subscription_strategy(self): alert_group_table_columns: list[AlertGroupTableColumn] | None = JSONField(default=None, null=True) grafana_incident_backend_url = models.CharField(max_length=300, null=True, default=None) + direct_paging_prefer_important_policy = models.BooleanField(default=False, null=True) + class Meta: unique_together = ("stack_id", "org_id")