From 27c741213fe2c8134f42216f080992ccd2eaff9b Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Thu, 15 Aug 2024 16:34:33 +0800 Subject: [PATCH 1/3] Escalate deprecation messages (#4811) # What this PR does Add two deprecations messages for /escalate command 1. If integration is not yet upgraded to Grafana IRM Slack integration - just ephemeral message asking to upgrade 2. If integration is already upgraded - deprecation message, command will not work, but ask to use /grafana-irm escalate --- engine/apps/slack/scenarios/paging.py | 35 ++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index 7a2b2a3251..4cbd1d3283 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -1,6 +1,8 @@ import enum import json +import logging import typing +from urllib.parse import urljoin from uuid import uuid4 from django.conf import settings @@ -13,7 +15,7 @@ from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules from apps.slack.chatops_proxy_routing import make_private_metadata, make_value from apps.slack.constants import DIVIDER, PRIVATE_METADATA_MAX_LENGTH -from apps.slack.errors import SlackAPIChannelNotFoundError +from apps.slack.errors import SlackAPIChannelNotFoundError, SlackAPIError from apps.slack.scenarios import scenario_step from apps.slack.slash_command import SlashCommand from apps.slack.types import ( @@ -27,6 +29,8 @@ ScenarioRoute, ) +logger = logging.getLogger(__name__) + if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager @@ -138,6 +142,35 @@ def process_scenario( except KeyError: channel_id = payload["channel_id"] + if settings.UNIFIED_SLACK_APP_ENABLED: + if slack_team_identity.needs_reinstall: + organizations = _get_available_organizations(slack_team_identity, slack_user_identity) + # Provide a link to web if user has access only to one organization + if len(organizations) == 1: + link = urljoin(organizations[0].web_link, "settings?tab=ChatOps&chatOpsTab=Slack") + upgrade = f"<{link}|Upgrade>" + else: + upgrade = "Upgrade" # TODO: Add link to docs are available + msg = ( + f"The new Slack IRM integration is now available. f{upgrade} for a more powerful and flexible " + f"way to interact with Grafana IRM on Slack." + ) + try: + self._slack_client.chat_postEphemeral( + channel=channel_id, user=slack_user_identity.slack_id, text=msg + ) + except SlackAPIError: + # catch all exceptions to prevent the slash command from failing + logger.warning("StartDirectPaging: failed to send ephemeral message to user", exc_info=True) + else: + self._slack_client.chat_postEphemeral( + channel=channel_id, + user=slack_user_identity.slack_id, + text="The new Slack IRM integration is now available. Please use /grafana-irm escalate to " + "complete the action", + ) + return + private_metadata = { "channel_id": channel_id, "input_id_prefix": input_id_prefix, From 64bf1e5096d1d65fdac52ee19d89d0dd710a9f05 Mon Sep 17 00:00:00 2001 From: Yulya Artyukhina Date: Thu, 15 Aug 2024 16:20:55 +0200 Subject: [PATCH 2/3] Speed up internal api endpoints (#4830) # What this PR does Reduces number of calls to db for `/schedules`, `/alertgroups` and `/users` endpoints. Fixes the issue when there was an additional call to db to get organization url to build user avatar full link. ## Which issue(s) this PR closes Related to [issue link here] ## 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. --- engine/apps/alerts/models/alert_group.py | 3 ++- .../apps/alerts/models/alert_group_log_record.py | 5 +++-- engine/apps/alerts/models/resolution_note.py | 5 +++-- .../alerts/tasks/notify_ical_schedule_shift.py | 2 +- engine/apps/api/serializers/alert_group.py | 2 +- engine/apps/api/serializers/schedule_base.py | 3 ++- .../apps/api/serializers/schedule_polymorphic.py | 2 +- engine/apps/api/serializers/shift_swap.py | 5 ++++- engine/apps/api/serializers/user.py | 13 +++++++++++-- engine/apps/api/tests/test_alert_group.py | 2 +- engine/apps/api/tests/test_oncall_shift.py | 6 +++--- engine/apps/api/tests/test_schedules.py | 14 +++++++------- engine/apps/api/tests/test_shift_swaps.py | 2 +- engine/apps/api/tests/test_user.py | 8 ++++---- engine/apps/api/views/schedule.py | 4 +++- .../apps/base/models/user_notification_policy.py | 16 ++++++++++------ .../user_notification_policy_log_record.py | 5 +++-- engine/apps/schedules/models/on_call_schedule.py | 6 +++--- .../schedules/tests/test_on_call_schedule.py | 14 +++++++------- engine/apps/user_management/models/user.py | 13 ++++++++----- engine/apps/user_management/tests/test_sync.py | 6 +++--- 21 files changed, 81 insertions(+), 55 deletions(-) diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index dbfd97adf8..3fceaec0ff 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -559,6 +559,7 @@ def get_paged_users(self) -> typing.List[PagedUser]: user_ids: typing.Set[str] = set() users: typing.Dict[str, PagedUser] = {} + organization = self.channel.organization log_records = self.log_records.filter( type__in=(AlertGroupLogRecord.TYPE_DIRECT_PAGING, AlertGroupLogRecord.TYPE_UNPAGE_USER) @@ -594,7 +595,7 @@ def get_paged_users(self) -> typing.List[PagedUser]: "name": user.name, "username": user.username, "avatar": user.avatar_url, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), "important": important, "teams": [{"pk": t.public_primary_key, "name": t.name} for t in user.teams.all()], } diff --git a/engine/apps/alerts/models/alert_group_log_record.py b/engine/apps/alerts/models/alert_group_log_record.py index 7da4f247d1..3c4113a295 100644 --- a/engine/apps/alerts/models/alert_group_log_record.py +++ b/engine/apps/alerts/models/alert_group_log_record.py @@ -230,9 +230,10 @@ class AlertGroupLogRecord(models.Model): def render_log_line_json(self): time = humanize.naturaldelta(self.alert_group.started_at - self.created_at) created_at = DateTimeField().to_representation(self.created_at) - author = self.author.short() if self.author is not None else None + organization = self.alert_group.channel.organization + author = self.author.short(organization) if self.author is not None else None - sf = SlackFormatter(self.alert_group.channel.organization) + sf = SlackFormatter(organization) action = sf.format(self.rendered_log_line_action(substitute_author_with_tag=True)) action = clean_markup(action) diff --git a/engine/apps/alerts/models/resolution_note.py b/engine/apps/alerts/models/resolution_note.py index b60f70d8fd..1624cb658f 100644 --- a/engine/apps/alerts/models/resolution_note.py +++ b/engine/apps/alerts/models/resolution_note.py @@ -179,9 +179,10 @@ def recreate(self): def render_log_line_json(self): time = humanize.naturaldelta(self.alert_group.started_at - self.created_at) created_at = DateTimeField().to_representation(self.created_at) - author = self.author.short() if self.author is not None else None + organization = self.alert_group.channel.organization + author = self.author.short(organization) if self.author is not None else None - sf = SlackFormatter(self.alert_group.channel.organization) + sf = SlackFormatter(organization) action = sf.format(self.text) action = clean_markup(action) diff --git a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py index 9294353bf1..e872623d6d 100644 --- a/engine/apps/alerts/tasks/notify_ical_schedule_shift.py +++ b/engine/apps/alerts/tasks/notify_ical_schedule_shift.py @@ -37,7 +37,7 @@ def convert_prev_shifts_to_new_format(prev_shifts: dict, schedule: "OnCallSchedu "display_name": user.username, "email": user.email, "pk": user.public_primary_key, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(schedule.organization), }, ) for uid, shift in prev_shifts.items(): diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index 27c0910e87..d4b2e65d5e 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -232,7 +232,7 @@ def get_related_users(self, obj: "AlertGroup"): if log_record.author is not None and log_record.author.public_primary_key not in users_ids: users.append(log_record.author) users_ids.add(log_record.author.public_primary_key) - return UserShortSerializer(users, many=True).data + return UserShortSerializer(users, context=self.context, many=True).data class AlertGroupSerializer(AlertGroupListSerializer): diff --git a/engine/apps/api/serializers/schedule_base.py b/engine/apps/api/serializers/schedule_base.py index 98ad615df6..02de45d8cb 100644 --- a/engine/apps/api/serializers/schedule_base.py +++ b/engine/apps/api/serializers/schedule_base.py @@ -69,7 +69,8 @@ def get_warnings(self, obj): def get_on_call_now(self, obj): # Serializer context is set here: apps.api.views.schedule.ScheduleView.get_serializer_context users = self.context["oncall_users"].get(obj, []) - return [user.short() for user in users] + organization = self.context["request"].auth.organization + return [user.short(organization) for user in users] def get_number_of_escalation_chains(self, obj): # num_escalation_chains param added in queryset via annotate. Check ScheduleView.get_queryset diff --git a/engine/apps/api/serializers/schedule_polymorphic.py b/engine/apps/api/serializers/schedule_polymorphic.py index 29000e415c..e6588652c8 100644 --- a/engine/apps/api/serializers/schedule_polymorphic.py +++ b/engine/apps/api/serializers/schedule_polymorphic.py @@ -12,7 +12,7 @@ class PolymorphicScheduleSerializer(EagerLoadingMixin, PolymorphicSerializer): - SELECT_RELATED = ["organization", "user_group"] + SELECT_RELATED = ["organization", "user_group", "team"] resource_type_field_name = "type" diff --git a/engine/apps/api/serializers/shift_swap.py b/engine/apps/api/serializers/shift_swap.py index 5cea77445c..516958d8c1 100644 --- a/engine/apps/api/serializers/shift_swap.py +++ b/engine/apps/api/serializers/shift_swap.py @@ -103,11 +103,14 @@ class ShiftSwapRequestExpandedUsersListSerializer(BaseShiftSwapRequestListSerial def _serialize_user(self, user: "User") -> dict | None: user_data = None if user: + organization = ( + self.context["request"].auth.organization if self.context.get("request") else user.organization + ) user_data = { "display_name": user.username, "email": user.email, "pk": user.public_primary_key, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), } return user_data diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 2133b80f39..b9280e9b1a 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -83,7 +83,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): timezone = TimeZoneField(allow_null=True, required=False) avatar = serializers.URLField(source="avatar_url", read_only=True) - avatar_full = serializers.URLField(source="avatar_full_url", read_only=True) + avatar_full = serializers.SerializerMethodField() notification_chain_verbal = serializers.SerializerMethodField() cloud_connection_status = serializers.SerializerMethodField() working_hours = WorkingHoursSerializer(required=False) @@ -96,6 +96,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "mobileappauthtoken", "google_oauth2_user", ] + PREFETCH_RELATED = ["notification_policies"] class Meta: model = User @@ -165,6 +166,10 @@ def get_notification_chain_verbal(self, obj: User) -> NotificationChainVerbal: default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj) return {"default": " - ".join(default), "important": " - ".join(important)} + def get_avatar_full(self, obj): + organization = self.context["request"].auth.organization if self.context.get("request") else obj.organization + return obj.avatar_full_url(organization) + def get_cloud_connection_status(self, obj: User) -> CloudSyncStatus | None: is_open_source_with_cloud_notifications = self.context.get("is_open_source_with_cloud_notifications", None) is_open_source_with_cloud_notifications = ( @@ -307,7 +312,7 @@ class UserShortSerializer(serializers.ModelSerializer): username = serializers.CharField() pk = serializers.CharField(source="public_primary_key") avatar = serializers.CharField(source="avatar_url") - avatar_full = serializers.CharField(source="avatar_full_url") + avatar_full = serializers.SerializerMethodField() class Meta: model = User @@ -324,6 +329,10 @@ class Meta: "avatar_full", ] + def get_avatar_full(self, obj): + organization = self.context["request"].auth.organization if self.context.get("request") else obj.organization + return obj.avatar_full_url(organization) + class UserIsCurrentlyOnCallSerializer(UserShortSerializer, EagerLoadingMixin): context: UserSerializerContext diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index e9564b86c9..42808e85fe 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -2083,7 +2083,7 @@ def test_alert_group_paged_users( assert response.json()["paged_users"] == [ { "avatar": user2.avatar_url, - "avatar_full": user2.avatar_full_url, + "avatar_full": user2.avatar_full_url(user.organization), "id": user2.pk, "pk": user2.public_primary_key, "important": None, diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index 9ebaa9f6f1..e8a3a6f63f 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -1648,7 +1648,7 @@ def test_on_call_shift_preview( "display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email, - "avatar_full": other_user.avatar_full_url, + "avatar_full": other_user.avatar_full_url(organization), }, ], "source": "web", @@ -1978,7 +1978,7 @@ def test_on_call_shift_preview_update( "display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email, - "avatar_full": other_user.avatar_full_url, + "avatar_full": other_user.avatar_full_url(organization), }, ], "source": "web", @@ -2093,7 +2093,7 @@ def test_on_call_shift_preview_update_not_started_reuse_pk( "display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email, - "avatar_full": other_user.avatar_full_url, + "avatar_full": other_user.avatar_full_url(organization), }, ], "source": "web", diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index e0477ec003..65dcd58899 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -612,7 +612,7 @@ def test_get_detail_schedule_oncall_now_multipage_objects( "pk": user.public_primary_key, "username": user.username, "avatar": user.avatar_url, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), } ], "has_gaps": False, @@ -916,7 +916,7 @@ def test_events_calendar( "display_name": user.username, "pk": user.public_primary_key, "email": user.email, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), }, ], "missing_users": [], @@ -989,7 +989,7 @@ def test_filter_events_calendar( "display_name": user.username, "pk": user.public_primary_key, "email": user.email, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), }, ], "missing_users": [], @@ -1014,7 +1014,7 @@ def test_filter_events_calendar( "display_name": user.username, "pk": user.public_primary_key, "email": user.email, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), } ], "missing_users": [], @@ -1106,7 +1106,7 @@ def test_filter_events_range_calendar( "display_name": user.username, "pk": user.public_primary_key, "email": user.email, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), }, ], "missing_users": [], @@ -1197,7 +1197,7 @@ def test_filter_events_overrides( "display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email, - "avatar_full": other_user.avatar_full_url, + "avatar_full": other_user.avatar_full_url(organization), } ], "missing_users": [], @@ -1395,7 +1395,7 @@ def _serialized_user(u): "display_name": u.username, "email": u.email, "pk": u.public_primary_key, - "avatar_full": u.avatar_full_url, + "avatar_full": u.avatar_full_url(organization), } expected = [ diff --git a/engine/apps/api/tests/test_shift_swaps.py b/engine/apps/api/tests/test_shift_swaps.py index 229fa1b133..ae384f4061 100644 --- a/engine/apps/api/tests/test_shift_swaps.py +++ b/engine/apps/api/tests/test_shift_swaps.py @@ -61,7 +61,7 @@ def _serialized_user(u): "display_name": u.username, "email": u.email, "pk": u.public_primary_key, - "avatar_full": u.avatar_full_url, + "avatar_full": u.avatar_full_url(u.organization), } data["beneficiary"] = _serialized_user(ssr.beneficiary) diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index a4468f89e9..900a48eb23 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -63,7 +63,7 @@ def test_current_user( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": user.avatar_url, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), "has_google_oauth2_connected": False, "google_calendar_settings": None, "google_oauth2_token_is_missing_scopes": False, @@ -212,7 +212,7 @@ def test_update_user_cant_change_email_and_username( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, - "avatar_full": admin.avatar_full_url, + "avatar_full": admin.avatar_full_url(organization), "has_google_oauth2_connected": False, "google_calendar_settings": None, } @@ -264,7 +264,7 @@ def test_list_users( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, - "avatar_full": admin.avatar_full_url, + "avatar_full": admin.avatar_full_url(organization), "cloud_connection_status": None, "has_google_oauth2_connected": False, }, @@ -290,7 +290,7 @@ def test_list_users( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": editor.avatar_url, - "avatar_full": editor.avatar_full_url, + "avatar_full": editor.avatar_full_url(organization), "cloud_connection_status": None, "has_google_oauth2_connected": False, }, diff --git a/engine/apps/api/views/schedule.py b/engine/apps/api/views/schedule.py index 261851d6e6..b6d50c3d45 100644 --- a/engine/apps/api/views/schedule.py +++ b/engine/apps/api/views/schedule.py @@ -385,7 +385,9 @@ def filter_shift_swaps(self, request: Request, pk: str) -> Response: swap_requests = schedule.filter_swap_requests(datetime_start, datetime_end) - serialized_swap_requests = ShiftSwapRequestExpandedUsersListSerializer(swap_requests, many=True) + serialized_swap_requests = ShiftSwapRequestExpandedUsersListSerializer( + swap_requests, context={"request": self.request}, many=True + ) result = {"shift_swaps": serialized_swap_requests.data} return Response(result, status=status.HTTP_200_OK) diff --git a/engine/apps/base/models/user_notification_policy.py b/engine/apps/base/models/user_notification_policy.py index 40855dcca2..deb785f2a7 100644 --- a/engine/apps/base/models/user_notification_policy.py +++ b/engine/apps/base/models/user_notification_policy.py @@ -7,7 +7,6 @@ from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models -from django.db.models import Q from apps.base.messaging import get_messaging_backends from apps.user_management.models import User @@ -128,13 +127,18 @@ def __str__(self): @classmethod def get_short_verbals_for_user(cls, user: User) -> Tuple[Tuple[str, ...], Tuple[str, ...]]: - is_wait_step = Q(step=cls.Step.WAIT) - is_wait_step_configured = Q(wait_delay__isnull=False) + policies = user.notification_policies.all() - policies = cls.objects.filter(Q(user=user, step__isnull=False) & (~is_wait_step | is_wait_step_configured)) + default = () + important = () - default = tuple(str(policy.short_verbal) for policy in policies if policy.important is False) - important = tuple(str(policy.short_verbal) for policy in policies if policy.important is True) + for policy in policies: + if policy.step is None or (policy.step == cls.Step.WAIT and policy.wait_delay is None): + continue + if policy.important: + important += (policy.short_verbal,) + else: + default += (policy.short_verbal,) return default, important diff --git a/engine/apps/base/models/user_notification_policy_log_record.py b/engine/apps/base/models/user_notification_policy_log_record.py index 2d2f8a2ac1..3d7ab44e42 100644 --- a/engine/apps/base/models/user_notification_policy_log_record.py +++ b/engine/apps/base/models/user_notification_policy_log_record.py @@ -171,9 +171,10 @@ def rendered_notification_log_line(self, for_slack=False, html=False): def rendered_notification_log_line_json(self): time = humanize.naturaldelta(self.alert_group.started_at - self.created_at) created_at = DateTimeField().to_representation(self.created_at) - author = self.author.short() if self.author is not None else None + organization = self.alert_group.channel.organization + author = self.author.short(organization) if self.author is not None else None - sf = SlackFormatter(self.alert_group.channel.organization) + sf = SlackFormatter(organization) action = sf.format(self.render_log_line_action(substitute_author_with_tag=True)) action = clean_markup(action) diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 78636333f9..126b62048e 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -407,7 +407,7 @@ def filter_events( "display_name": user.username, "email": user.email, "pk": user.public_primary_key, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(self.organization), } for user in shift["users"] ], @@ -780,13 +780,13 @@ def _insert_event(index: int, event: ScheduleEvent) -> int: user_to_swap["pk"] = swap.benefactor.public_primary_key user_to_swap["display_name"] = swap.benefactor.username user_to_swap["email"] = swap.benefactor.email - user_to_swap["avatar_full"] = swap.benefactor.avatar_full_url + user_to_swap["avatar_full"] = swap.benefactor.avatar_full_url(self.organization) # add beneficiary user to details swap_details["user"] = { "display_name": swap.beneficiary.username, "email": swap.beneficiary.email, "pk": swap.beneficiary.public_primary_key, - "avatar_full": swap.beneficiary.avatar_full_url, + "avatar_full": swap.beneficiary.avatar_full_url(self.organization), } user_to_swap["swap_request"] = swap_details diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 4e249d67c6..77308544a1 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -98,7 +98,7 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched "display_name": user.username, "pk": user.public_primary_key, "email": user.email, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), }, ], "shift": {"pk": on_call_shift.public_primary_key}, @@ -127,7 +127,7 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched "display_name": user.username, "pk": user.public_primary_key, "email": user.email, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), }, ], "shift": {"pk": override.public_primary_key}, @@ -199,7 +199,7 @@ def test_filter_events_include_gaps(make_organization, make_user_for_organizatio "display_name": user.username, "pk": user.public_primary_key, "email": user.email, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), }, ], "shift": {"pk": on_call_shift.public_primary_key}, @@ -284,7 +284,7 @@ def test_filter_events_include_shift_info( "display_name": user.username, "pk": user.public_primary_key, "email": user.email, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), }, ], "shift": { @@ -899,7 +899,7 @@ def test_preview_shift(make_organization, make_user_for_organization, make_sched "display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email, - "avatar_full": other_user.avatar_full_url, + "avatar_full": other_user.avatar_full_url(organization), }, ], "shift": {"pk": new_shift.public_primary_key}, @@ -1001,7 +1001,7 @@ def test_preview_shift_do_not_change_rotation_events( "display_name": user.username, "pk": user.public_primary_key, "email": user.email, - "avatar_full": user.avatar_full_url, + "avatar_full": user.avatar_full_url(organization), }, ], "shift": {"pk": on_call_shift.public_primary_key}, @@ -1137,7 +1137,7 @@ def test_preview_override_shift(make_organization, make_user_for_organization, m "display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email, - "avatar_full": other_user.avatar_full_url, + "avatar_full": other_user.avatar_full_url(organization), }, ], "shift": {"pk": new_shift.public_primary_key}, diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index 376dd82a68..a5bff7aa07 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -195,9 +195,12 @@ def google_oauth2_token_is_missing_scopes(self) -> bool: return False return not google_utils.user_granted_all_required_scopes(self.google_oauth2_user.oauth_scope) - @property - def avatar_full_url(self): - return urljoin(self.organization.grafana_url, self.avatar_url) + def avatar_full_url(self, organization: "Organization"): + """ + Use arg `organization` instead of `self.organization` to avoid multiple requests to db when getting avatar for + users list + """ + return urljoin(organization.grafana_url, self.avatar_url) @property def verified_phone_number(self) -> str | None: @@ -295,12 +298,12 @@ def is_in_working_hours(self, dt: datetime.datetime, tz: typing.Optional[str] = return day_start <= dt <= day_end - def short(self): + def short(self, organization): return { "username": self.username, "pk": self.public_primary_key, "avatar": self.avatar_url, - "avatar_full": self.avatar_full_url, + "avatar_full": self.avatar_full_url(organization), } # Insight logs diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index 8399d3e174..43015b4adc 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -113,14 +113,14 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati assert updated_user is not None assert updated_user.name == api_users[0]["name"] assert updated_user.email == api_users[0]["email"] - assert updated_user.avatar_full_url == "https://test.test/test/1234" + assert updated_user.avatar_full_url(organization) == "https://test.test/test/1234" # check that missing users are created created_user = organization.users.filter(user_id=api_users[1]["userId"]).first() assert created_user is not None assert created_user.user_id == api_users[1]["userId"] assert created_user.name == api_users[1]["name"] - assert created_user.avatar_full_url == "https://test.test/test/1234" + assert created_user.avatar_full_url(organization) == "https://test.test/test/1234" @pytest.mark.django_db @@ -532,7 +532,7 @@ def test_get_or_create_user(make_organization, make_team, make_user_for_organiza assert user.user_id == sync_user.id assert user.name == sync_user.name assert user.email == sync_user.email - assert user.avatar_full_url == sync_user.avatar_url + assert user.avatar_full_url(organization) == sync_user.avatar_url assert organization.users.count() == 2 assert team.users.count() == 1 From 67fc52d56abc9af727d420edb3ae745f96deb864 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Thu, 15 Aug 2024 14:31:35 -0400 Subject: [PATCH 3/3] add `POST /escalation` public API endpoint + add public API docs for teams/organization endpoints (#4815) # What this PR does - Adds a `POST /escalation` public endpoint (equivalent to the internal direct paging API endpoint) - Adds public API documentation for teams and organization endpoints Screenshot 2024-08-15 at 12 49 40 ## Which issue(s) this PR closes Closes https://github.com/grafana/oncall-private/issues/2859 Closes https://github.com/grafana/oncall/issues/2448 ## 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. --- .../oncall-api-reference/escalation.md | 190 +++++++++++ .../oncall-api-reference/organizations.md | 73 +++++ docs/sources/oncall-api-reference/teams.md | 86 +++++ .../{paging.py => direct_paging.py} | 19 +- .../{test_paging.py => test_direct_paging.py} | 35 +++ engine/apps/api/urls.py | 2 +- .../api/views/{paging.py => direct_paging.py} | 2 +- .../apps/public_api/serializers/__init__.py | 1 + .../apps/public_api/serializers/escalation.py | 8 + .../apps/public_api/tests/test_escalation.py | 294 ++++++++++++++++++ engine/apps/public_api/urls.py | 1 + engine/apps/public_api/views/__init__.py | 1 + engine/apps/public_api/views/escalation.py | 46 +++ 13 files changed, 752 insertions(+), 6 deletions(-) create mode 100644 docs/sources/oncall-api-reference/escalation.md create mode 100644 docs/sources/oncall-api-reference/organizations.md create mode 100644 docs/sources/oncall-api-reference/teams.md rename engine/apps/api/serializers/{paging.py => direct_paging.py} (80%) rename engine/apps/api/tests/{test_paging.py => test_direct_paging.py} (88%) rename engine/apps/api/views/{paging.py => direct_paging.py} (96%) create mode 100644 engine/apps/public_api/serializers/escalation.py create mode 100644 engine/apps/public_api/tests/test_escalation.py create mode 100644 engine/apps/public_api/views/escalation.py diff --git a/docs/sources/oncall-api-reference/escalation.md b/docs/sources/oncall-api-reference/escalation.md new file mode 100644 index 0000000000..a37e84fbcb --- /dev/null +++ b/docs/sources/oncall-api-reference/escalation.md @@ -0,0 +1,190 @@ +--- +canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation/ +title: Escalation HTTP API +weight: 1200 +refs: + users: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/users + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/users + teams: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/teams + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/teams + manual-paging: + - pattern: /docs/oncall/ + destination: /docs/oncall//configure/integrations/references/manual + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/configure/integrations/references/manual +--- + +# Escalation HTTP API + +See [Manual paging integration](ref:manual-paging) for more background on how escalating to a team or user(s) works. + +## Escalate to a set of users + +For more details about how to fetch a user's Grafana OnCall ID, refer to the [Users](ref:users) public API documentation. + +```shell +curl "{{API_URL}}/api/v1/escalation/" \ + --request POST \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" \ + --data '{ + "title": "We are seeing a network outage in the datacenter", + "message": "I need help investigating, can you join the investigation?", + "source_url": "https://github.com/myorg/myrepo/issues/123", + "users": [ + { + "id": "U281SN24AVVJX", + "important": false + }, + { + "id": "U5AKCVNDEDUE7", + "important": true + } + ] + }' +``` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "IZHCC4GTNPZ93", + "integration_id": "CC3GZYZNIIEH5", + "route_id": "RDN8LITALJXCJ", + "alerts_count": 1, + "state": "firing", + "created_at": "2024-08-15T18:05:36.801215Z", + "resolved_at": null, + "resolved_by": null, + "acknowledged_at": null, + "acknowledged_by": null, + "title": "We're seeing a network outage in the datacenter", + "permalinks": { + "slack": null, + "slack_app": null, + "telegram": null, + "web": "http:///a/grafana-oncall-app/alert-groups/I5LAZ2MXGPUAH" + }, + "silenced_at": null +} +``` + +## Escalate to a team + +For more details about how to fetch a team's Grafana OnCall ID, refer to the [Teams](ref:teams) public API documentation. + +```shell +curl "{{API_URL}}/api/v1/escalation/" \ + --request POST \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" \ + --data '{ + "title": "We are seeing a network outage in the datacenter", + "message": "I need help investigating, can you join the investigation?", + "source_url": "https://github.com/myorg/myrepo/issues/123", + "team": "TI73TDU19W48J" + }' +``` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "IZHCC4GTNPZ93", + "integration_id": "CC3GZYZNIIEH5", + "route_id": "RDN8LITALJXCJ", + "alerts_count": 1, + "state": "firing", + "created_at": "2024-08-15T18:05:36.801215Z", + "resolved_at": null, + "resolved_by": null, + "acknowledged_at": null, + "acknowledged_by": null, + "title": "We're seeing a network outage in the datacenter", + "permalinks": { + "slack": null, + "slack_app": null, + "telegram": null, + "web": "http:///a/grafana-oncall-app/alert-groups/I5LAZ2MXGPUAH" + }, + "silenced_at": null +} +``` + +## Escalate to a set of user(s) for an existing Alert Group + +The following shows how you can escalate to a set of user(s) for an existing Alert Group. + +```shell +curl "{{API_URL}}/api/v1/escalation/" \ + --request POST \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" \ + --data '{ + "alert_group_id": "IZMRNNY8RFS94", + "users": [ + { + "id": "U281SN24AVVJX", + "important": false + }, + { + "id": "U5AKCVNDEDUE7", + "important": true + } + ] + }' +``` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "IZHCC4GTNPZ93", + "integration_id": "CC3GZYZNIIEH5", + "route_id": "RDN8LITALJXCJ", + "alerts_count": 1, + "state": "firing", + "created_at": "2024-08-15T18:05:36.801215Z", + "resolved_at": null, + "resolved_by": null, + "acknowledged_at": null, + "acknowledged_by": null, + "title": "We're seeing a network outage in the datacenter", + "permalinks": { + "slack": null, + "slack_app": null, + "telegram": null, + "web": "http:///a/grafana-oncall-app/alert-groups/I5LAZ2MXGPUAH" + }, + "silenced_at": null +} +``` + +| Parameter | Unique | Required | Description | +| -------------------- | :----: | :--------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `title` | No | No | Name of the Alert Group that will be created | +| `message` | No | No | Content of the Alert Group that will be created | +| `source_url` | No | No | Value that will be added in the Alert's payload as `oncall.permalink`. This can be useful to have the source URL/button autopopulated with a URL of interest. | +| `team` | No | Yes (see [Things to Note](#things-to-note)) | Grafana OnCall team ID. If specified, will use the "Direct Paging" Integration associated with this Grafana OnCall team, to create the Alert Group. | +| `users` | No | Yes (see [Things to Note](#things-to-note)) | List of user(s) to escalate to. See above request example for object schema. `id` represents the Grafana OnCall user's ID. `important` is a boolean representing whether to escalate the Alert Group using this user's default or important personal notification policy. | +| `alert_group_id` | No | No | If specified, will escalate the specified users for this Alert Group. | + +## Things to note + +- `team` and `users` are mutually exclusive in the request payload. If you would like to escalate to a team AND user(s), +first escalate to a team, then using the Alert Group ID returned in the response payload, add the required users to the +existing Alert Group +- `alert_group_id` is mutually exclusive with `title`, `message`, and `source_url`. Practically speaking this means that +if you are trying to escalate to a set of users on an existing Alert Group, you cannot update the `title`, `message`, or +`source_url` of that Alert Group +- If escalating to a set of users for an existing Alert Group, the Alert Group cannot be in a resolved state + +**HTTP request** + +`POST {{API_URL}}/api/v1/escalation/` diff --git a/docs/sources/oncall-api-reference/organizations.md b/docs/sources/oncall-api-reference/organizations.md new file mode 100644 index 0000000000..7c7abcae47 --- /dev/null +++ b/docs/sources/oncall-api-reference/organizations.md @@ -0,0 +1,73 @@ +--- +canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/organizations/ +title: Grafana OnCall organizations HTTP API +weight: 1500 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination +--- + +# Grafana OnCall organizations HTTP API + +## Get an organization + +This endpoint retrieves the organization object. + +```shell +curl "{{API_URL}}/api/v1/organizations/O53AAGWFBPE5W/" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +```` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "O53AAGWFBPE5W" +} +``` + +**HTTP request** + +`GET {{API_URL}}/api/v1/organizations//` + +| Parameter | Unique | Description | +| ---------- | :-----: | :----------------------------------------------------------------- | +| `id` | Yes | Organization ID | + +## List Organizations + +```shell +curl "{{API_URL}}/api/v1/organizations/" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +``` + +The above command returns JSON structured in the following way: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": "O53AAGWFBPE5W" + } + ], + "page_size": 25, + "current_page_number": 1, + "total_pages": 1 +} +``` + +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + +**HTTP request** + +`GET {{API_URL}}/api/v1/organizations/` diff --git a/docs/sources/oncall-api-reference/teams.md b/docs/sources/oncall-api-reference/teams.md new file mode 100644 index 0000000000..15c61e39ce --- /dev/null +++ b/docs/sources/oncall-api-reference/teams.md @@ -0,0 +1,86 @@ +--- +canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/teams/ +title: Grafana OnCall teams HTTP API +weight: 1500 +refs: + pagination: + - pattern: /docs/oncall/ + destination: /docs/oncall//oncall-api-reference/#pagination + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination +--- + +# Grafana OnCall teams HTTP API + +## Get a team + +This endpoint retrieves the team object. + +```shell +curl "{{API_URL}}/api/v1/teams/TI73TDU19W48J/" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +```` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "TI73TDU19W48J", + "name": "my test team", + "email": "", + "avatar_url": "/avatar/3f49c15916554246daa714b9bd0ee398" +} +``` + +**HTTP request** + +`GET {{API_URL}}/api/v1/teams//` + +| Parameter | Unique | Description | +| ---------- | :-----: | :----------------------------------------------------------------- | +| `id` | Yes/org | Team ID | +| `name` | Yes/org | Team name | +| `email` | Yes/org | Team e-mail | +| `avatar_url` | Yes | Avatar URL of the Grafana team | + +## List Teams + +```shell +curl "{{API_URL}}/api/v1/teams/" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +``` + +The above command returns JSON structured in the following way: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": "TI73TDU19W48J", + "name": "my test team", + "email": "", + "avatar_url": "/avatar/3f49c15916554246daa714b9bd0ee398" + } + ], + "page_size": 50, + "current_page_number": 1, + "total_pages": 1 +} +``` + +> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records. + +The following available filter parameter should be provided as a `GET` argument: + +- `name` (Exact match) + +**HTTP request** + +`GET {{API_URL}}/api/v1/teams/` diff --git a/engine/apps/api/serializers/paging.py b/engine/apps/api/serializers/direct_paging.py similarity index 80% rename from engine/apps/api/serializers/paging.py rename to engine/apps/api/serializers/direct_paging.py index ab3584bb3e..4cb1e646e6 100644 --- a/engine/apps/api/serializers/paging.py +++ b/engine/apps/api/serializers/direct_paging.py @@ -32,9 +32,11 @@ def validate(self, attrs): return attrs -class DirectPagingSerializer(serializers.Serializer): +class BasePagingSerializer(serializers.Serializer): context: SerializerContext + ALLOWS_GRAFANA_INCIDENT_ID = False + users = UserReferenceSerializer(many=True, required=False, default=list) team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault()) @@ -44,7 +46,6 @@ class DirectPagingSerializer(serializers.Serializer): title = serializers.CharField(required=False, default=None) message = serializers.CharField(required=False, default=None, allow_null=True) source_url = serializers.URLField(required=False, default=None, allow_null=True) - grafana_incident_id = serializers.CharField(required=False, default=None, allow_null=True) def validate(self, attrs): organization = self.context["organization"] @@ -52,13 +53,17 @@ def validate(self, attrs): title = attrs["title"] message = attrs["message"] source_url = attrs["source_url"] - grafana_incident_id = attrs["grafana_incident_id"] + grafana_incident_id = self.ALLOWS_GRAFANA_INCIDENT_ID and attrs.get("grafana_incident_id") if alert_group_id and (title or message or source_url or grafana_incident_id): raise serializers.ValidationError( - "alert_group_id and (title, message, source_url, grafana_incident_id) are mutually exclusive" + f"alert_group_id and (title, message, source_url{', grafana_incident_id' if self.ALLOWS_GRAFANA_INCIDENT_ID else ''}) " + "are mutually exclusive" ) + if attrs["users"] and attrs["team"]: + raise serializers.ValidationError("users and team are mutually exclusive") + if alert_group_id: try: attrs["alert_group"] = AlertGroup.objects.get( @@ -68,3 +73,9 @@ def validate(self, attrs): raise serializers.ValidationError("Alert group {} does not exist".format(alert_group_id)) return attrs + + +class DirectPagingSerializer(BasePagingSerializer): + ALLOWS_GRAFANA_INCIDENT_ID = True + + grafana_incident_id = serializers.CharField(required=False, default=None, allow_null=True) diff --git a/engine/apps/api/tests/test_paging.py b/engine/apps/api/tests/test_direct_paging.py similarity index 88% rename from engine/apps/api/tests/test_paging.py rename to engine/apps/api/tests/test_direct_paging.py index 24e5f32c8e..be52906526 100644 --- a/engine/apps/api/tests/test_paging.py +++ b/engine/apps/api/tests/test_direct_paging.py @@ -224,6 +224,41 @@ def test_direct_paging_no_user_or_team_specified( assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL +@pytest.mark.django_db +def test_direct_paging_both_team_and_users_specified( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_user, + make_team, +): + organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + team = make_team(organization=organization) + + # user must be part of the team + user.teams.add(team) + + client = APIClient() + url = reverse("api-internal:direct_paging") + + response = client.post( + url, + data={ + "team": team.public_primary_key, + "users": [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + ], + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["non_field_errors"] == ["users and team are mutually exclusive"] + + @pytest.mark.parametrize( "field_name,field_value", [ diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index 639d109434..5be32db025 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -9,6 +9,7 @@ from .views.alert_receive_channel_template import AlertReceiveChannelTemplateView from .views.alerts import AlertDetailView from .views.channel_filter import ChannelFilterView +from .views.direct_paging import DirectPagingAPIView from .views.escalation_chain import EscalationChainViewSet from .views.escalation_policy import EscalationPolicyView from .views.features import FeaturesAPIView @@ -23,7 +24,6 @@ OrganizationConfigChecksView, SetGeneralChannel, ) -from .views.paging import DirectPagingAPIView from .views.preview_template_options import PreviewTemplateOptionsView from .views.public_api_tokens import PublicApiTokenView from .views.resolution_note import ResolutionNoteView diff --git a/engine/apps/api/views/paging.py b/engine/apps/api/views/direct_paging.py similarity index 96% rename from engine/apps/api/views/paging.py rename to engine/apps/api/views/direct_paging.py index 537c6f4595..b5d7a9eb0a 100644 --- a/engine/apps/api/views/paging.py +++ b/engine/apps/api/views/direct_paging.py @@ -5,7 +5,7 @@ from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError, direct_paging from apps.api.permissions import RBACPermission -from apps.api.serializers.paging import DirectPagingSerializer +from apps.api.serializers.direct_paging import DirectPagingSerializer from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from common.api_helpers.exceptions import BadRequest diff --git a/engine/apps/public_api/serializers/__init__.py b/engine/apps/public_api/serializers/__init__.py index d01a7f2e35..21f9b08f84 100644 --- a/engine/apps/public_api/serializers/__init__.py +++ b/engine/apps/public_api/serializers/__init__.py @@ -1,4 +1,5 @@ from .alerts import AlertSerializer # noqa: F401 +from .escalation import EscalationSerializer # noqa: F401 from .escalation_chains import EscalationChainSerializer # noqa: F401 from .escalation_policies import EscalationPolicySerializer, EscalationPolicyUpdateSerializer # noqa: F401 from .incidents import IncidentSerializer # noqa: F401 diff --git a/engine/apps/public_api/serializers/escalation.py b/engine/apps/public_api/serializers/escalation.py new file mode 100644 index 0000000000..f2b41882fc --- /dev/null +++ b/engine/apps/public_api/serializers/escalation.py @@ -0,0 +1,8 @@ +from apps.api.serializers.direct_paging import BasePagingSerializer + + +class EscalationSerializer(BasePagingSerializer): + """ + Very similar to `apps.api.serializers.direct_paging.DirectPagingSerializer` except that + there is no `grafana_incident_id` attribute + """ diff --git a/engine/apps/public_api/tests/test_escalation.py b/engine/apps/public_api/tests/test_escalation.py new file mode 100644 index 0000000000..ebf8c4a0ce --- /dev/null +++ b/engine/apps/public_api/tests/test_escalation.py @@ -0,0 +1,294 @@ +from unittest import mock + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.alerts.models import AlertGroup +from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError + +title = "Custom title" +message = "Testing escalation with new alert group" +source_url = "https://www.example.com" + + +@pytest.mark.django_db +def test_escalation_new_alert_group( + make_organization_and_user_with_token, + make_user, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_token() + + users_to_page = [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + { + "id": make_user(organization=organization).public_primary_key, + "important": True, + }, + ] + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "users": users_to_page, + "title": title, + "message": message, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + + alert_groups = AlertGroup.objects.all() + assert alert_groups.count() == 1 + ag = alert_groups.get() + + assert response.json() == { + "id": ag.public_primary_key, + "integration_id": ag.channel.public_primary_key, + "route_id": ag.channel_filter.public_primary_key, + "alerts_count": 1, + "state": "firing", + "created_at": mock.ANY, + "resolved_at": None, + "resolved_by": None, + "acknowledged_at": None, + "acknowledged_by": None, + "title": title, + "permalinks": { + "slack": None, + "slack_app": None, + "telegram": None, + "web": f"a/grafana-oncall-app/alert-groups/{ag.public_primary_key}", + }, + "silenced_at": None, + } + + alert = ag.alerts.get() + + assert ag.web_title_cache == title + assert alert.title == title + assert alert.message == message + + +@pytest.mark.django_db +def test_escalation_team( + make_organization_and_user_with_token, + make_team, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_token() + team = make_team(organization=organization) + + # user must be part of the team + user.teams.add(team) + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "team": team.public_primary_key, + "message": message, + "source_url": source_url, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + + alert_group = AlertGroup.objects.get(public_primary_key=response.json()["id"]) + alert = alert_group.alerts.first() + + assert alert.raw_request_data["oncall"]["permalink"] == source_url + + +@pytest.mark.django_db +def test_escalation_existing_alert_group( + make_organization_and_user_with_token, + make_user, + make_alert_receive_channel, + make_alert_group, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_token() + + users_to_page = [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + { + "id": make_user( + organization=organization, + ).public_primary_key, + "important": True, + }, + ] + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={"users": users_to_page, "alert_group_id": alert_group.public_primary_key}, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == alert_group.public_primary_key + + +@pytest.mark.django_db +def test_escalation_existing_alert_group_resolved( + make_organization_and_user_with_token, + make_user, + make_alert_receive_channel, + make_alert_group, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_token() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=True) + + users_to_page = [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + ] + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "alert_group_id": alert_group.public_primary_key, + "users": users_to_page, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == DirectPagingAlertGroupResolvedError.DETAIL + + +@pytest.mark.django_db +def test_escalation_no_user_or_team_specified( + make_organization_and_user_with_token, + make_user_auth_headers, +): + _, user, token = make_organization_and_user_with_token() + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "team": None, + "users": [], + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL + + +@pytest.mark.django_db +def test_escalation_both_team_and_users_specified( + make_organization_and_user_with_token, + make_user_auth_headers, + make_user, + make_team, +): + organization, user, token = make_organization_and_user_with_token() + team = make_team(organization=organization) + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "team": team.public_primary_key, + "users": [ + { + "id": make_user(organization=organization).public_primary_key, + "important": False, + }, + ], + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["non_field_errors"] == ["users and team are mutually exclusive"] + + +@pytest.mark.parametrize( + "field_name,field_value", + [ + ("title", title), + ("message", message), + ("source_url", source_url), + ], +) +@pytest.mark.django_db +def test_escalation_alert_group_id_and_other_fields_are_mutually_exclusive( + make_organization_and_user_with_token, + make_team, + make_user_auth_headers, + make_alert_receive_channel, + make_alert_group, + field_name, + field_value, +): + error_msg = "alert_group_id and (title, message, source_url) are mutually exclusive" + + organization, user, token = make_organization_and_user_with_token() + team = make_team(organization=organization) + + # user must be part of the team + user.teams.add(team) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=True) + + client = APIClient() + url = reverse("api-public:escalation") + + response = client.post( + url, + data={ + "team": team.public_primary_key, + "alert_group_id": alert_group.public_primary_key, + field_name: field_value, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["non_field_errors"] == [error_msg] diff --git a/engine/apps/public_api/urls.py b/engine/apps/public_api/urls.py index 873b0e1071..7f36170bcd 100644 --- a/engine/apps/public_api/urls.py +++ b/engine/apps/public_api/urls.py @@ -34,4 +34,5 @@ optional_slash_path("info", views.InfoView.as_view(), name="info"), optional_slash_path("make_call", views.MakeCallView.as_view(), name="make_call"), optional_slash_path("send_sms", views.SendSMSView.as_view(), name="send_sms"), + optional_slash_path("escalation", views.EscalationView.as_view(), name="escalation"), ] diff --git a/engine/apps/public_api/views/__init__.py b/engine/apps/public_api/views/__init__.py index 47fad290dd..49a5f7281c 100644 --- a/engine/apps/public_api/views/__init__.py +++ b/engine/apps/public_api/views/__init__.py @@ -1,5 +1,6 @@ from .action import ActionView # noqa: F401 from .alerts import AlertView # noqa: F401 +from .escalation import EscalationView # noqa: F401 from .escalation_chains import EscalationChainView # noqa: F401 from .escalation_policies import EscalationPolicyView # noqa: F401 from .incidents import IncidentView # noqa: F401 diff --git a/engine/apps/public_api/views/escalation.py b/engine/apps/public_api/views/escalation.py new file mode 100644 index 0000000000..c7da1fe19a --- /dev/null +++ b/engine/apps/public_api/views/escalation.py @@ -0,0 +1,46 @@ +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError, direct_paging +from apps.auth_token.auth import ApiTokenAuthentication +from apps.public_api.serializers import EscalationSerializer, IncidentSerializer +from apps.public_api.throttlers import UserThrottle +from common.api_helpers.exceptions import BadRequest + + +class EscalationView(APIView): + """ + aka "Direct Paging" + """ + + authentication_classes = (ApiTokenAuthentication,) + permission_classes = (IsAuthenticated,) + + throttle_classes = [UserThrottle] + + def post(self, request): + user = request.user + organization = user.organization + + serializer = EscalationSerializer(data=request.data, context={"organization": organization, "request": request}) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + try: + alert_group = direct_paging( + organization=organization, + from_user=user, + message=validated_data["message"], + title=validated_data["title"], + source_url=validated_data["source_url"], + team=validated_data["team"], + users=[(user["instance"], user["important"]) for user in validated_data["users"]], + alert_group=validated_data["alert_group"], + ) + except DirectPagingAlertGroupResolvedError: + raise BadRequest(detail=DirectPagingAlertGroupResolvedError.DETAIL) + except DirectPagingUserTeamValidationError: + raise BadRequest(detail=DirectPagingUserTeamValidationError.DETAIL) + return Response(IncidentSerializer(alert_group).data, status=status.HTTP_200_OK)