From 697248dc75dc1a2afb73dbd257613fe453019089 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Fri, 27 Oct 2023 12:12:07 -0400 Subject: [PATCH] Add responders improvements (#3128) # What this PR does https://www.loom.com/share/c5e10b5ec51343d0954c6f41cfd6a5fb ## Summary of backend changes - Add `AlertReceiveChannel.get_orgs_direct_paging_integrations` method and `AlertReceiveChannel.is_contactable` property. These are needed to be able to (optionally) filter down teams, in the `GET /teams` internal API endpoint ([here](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R63)), to just teams that have a "contactable" Direct Paging integration - `engine/apps/alerts/paging.py` - update these functions to support new UX. In short `direct_paging` no longer takes a list of `ScheduleNotifications` or an `EscalationChain` object - add `user_is_oncall` helper function - add `_construct_title` helper function. In short if no `title` is provided, which is the case for Direct Pages originating from OnCall (either UI or Slack), then the format is `f"{from_user.username} is paging to join escalation"` - `engine/apps/api/serializers/team.py` - add `number_of_users_currently_oncall` attribute to response schema ([code](https://github.com/grafana/oncall/pull/3128/files#diff-26af48f796c9e987a76447586dd0f92349783d6ea6a0b6039a2f0f28bd58c2ebR45-R52)) - `engine/apps/api/serializers/user.py` - add `is_currently_oncall` attribute to response schema ([code](https://github.com/grafana/oncall/pull/3128/files#diff-6744b5544ebb120437af98a996da5ad7d48ee1139a6112c7e3904010ab98f232R157-R162)) - `engine/apps/api/views/team.py` - add support for two new optional query params `only_include_notifiable_teams` and `include_no_team` ([code](https://github.com/grafana/oncall/pull/3128/files#diff-a4bd76e557f7e11dafb28a52c1034c075028c693b3c12d702d53c07fc6f24c05R55-R70)) - `engine/apps/api/views/user.py` - in the `GET /users` internal API endpoint, when specifying the `search` query param now also search on `teams__name` ([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R223); this is a new UX requirement) - add support for a new optional query param, `is_currently_oncall`, to allow filtering users based on.. whether they are currently on call or not ([code](https://github.com/grafana/oncall/pull/3128/files#diff-30309629484ad28e6fe09816e1bd226226d652ea977b6f3b6775976c729bf4b5R272-R282)) - remove `check_availability` endpoint (no longer used with new UX; also removed references in frontend code) - `engine/apps/slack/scenarios/paging.py` and `engine/apps/slack/scenarios/manage_responders.py` - update Slack workflows to support new UX. Schedules are no longer a concept here. When creating a new alert group via `/escalate` the user either specifies a team and/or user(s) (they must specify at least one of the two and validation is done here to check this). When adding responders to an existing alert group it's simply a list of users that they can add, no more schedules. - add `Organization.slack_is_configured` and `Organization.telegram_is_configured` properties. These are needed to support [this new functionality ](https://github.com/grafana/oncall/pull/3128/files#diff-9d96504027309f2bd1e95352bac1433b09b60eb4fafb611b52a6c15ed16cbc48R271-R272) in the `AlertReceiveChannel` model. ## Summary of frontend changes - Refactor/rename `EscalationVariants` component to `AddResponders` + remove `grafana-plugin/src/containers/UserWarningModal` (no longer needed with new UX) - Remove `grafana-plugin/src/models/user.ts` as it seemed to be a duplicate of `grafana-plugin/src/models/user/user.types.ts` Related to https://github.com/grafana/incident/issues/4278 - Closes #3115 - Closes #3116 - Closes #3117 - Closes #3118 - Closes #3177 ## TODO - [x] make frontend changes - [x] update Slack backend functionality - [x] update public documentation - [x] add/update e2e tests ## Post-deploy To-dos - [ ] update dev/ops/production Slack bots to update `/escalate` command description (should now say "Direct page a team or user(s)") ## 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] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 7 + docs/sources/integrations/manual/index.md | 53 +- docs/sources/open-source/_index.md | 2 +- engine/apps/alerts/models/alert_group.py | 63 +- engine/apps/alerts/paging.py | 191 ++-- engine/apps/alerts/tests/test_alert_group.py | 45 + engine/apps/alerts/tests/test_paging.py | 359 ++++---- engine/apps/api/serializers/alert_group.py | 8 +- engine/apps/api/serializers/paging.py | 51 +- engine/apps/api/serializers/schedule_base.py | 2 +- engine/apps/api/serializers/team.py | 30 +- engine/apps/api/serializers/user.py | 44 +- engine/apps/api/tests/test_alert_group.py | 13 +- engine/apps/api/tests/test_paging.py | 171 ++-- engine/apps/api/tests/test_schedules.py | 2 +- engine/apps/api/tests/test_team.py | 122 ++- engine/apps/api/tests/test_user.py | 128 ++- engine/apps/api/views/paging.py | 24 +- engine/apps/api/views/team.py | 42 +- engine/apps/api/views/user.py | 55 +- engine/apps/schedules/ical_utils.py | 8 +- .../tests/test_custom_on_call_shift.py | 4 +- .../apps/slack/scenarios/manage_responders.py | 95 +- engine/apps/slack/scenarios/paging.py | 605 ++++--------- .../test_manage_responders.py | 152 +--- .../tests/test_scenario_steps/test_paging.py | 196 +--- .../user_management/models/organization.py | 18 + engine/apps/user_management/models/user.py | 1 + .../tests/test_organization.py | 47 + .../e2e-tests/alerts/directPaging.test.ts | 36 +- grafana-plugin/e2e-tests/utils/alertGroup.ts | 10 - grafana-plugin/src/components/GForm/GForm.tsx | 16 +- .../src/components/GTable/GTable.tsx | 7 +- .../ManualAlertGroup.config.ts | 14 +- .../ManualAlertGroup.module.css | 16 - .../ManualAlertGroup/ManualAlertGroup.tsx | 243 +---- .../AddResponders/AddResponders.helpers.ts | 14 + .../AddResponders/AddResponders.module.scss | 65 ++ .../AddResponders/AddResponders.test.tsx | 106 +++ .../AddResponders/AddResponders.tsx | 229 +++++ .../AddResponders/AddResponders.types.ts | 20 + .../__snapshots__/AddResponders.test.tsx.snap | 856 ++++++++++++++++++ .../AddRespondersPopup.module.scss | 48 + .../AddRespondersPopup.test.tsx | 80 ++ .../AddRespondersPopup/AddRespondersPopup.tsx | 287 ++++++ .../AddRespondersPopup.test.tsx.snap | 402 ++++++++ .../NotificationPoliciesSelect.module.scss | 3 + .../NotificationPoliciesSelect.test.tsx | 17 + .../NotificationPoliciesSelect.tsx | 42 + .../NotificationPoliciesSelect.test.tsx.snap | 128 +++ .../TeamResponder/TeamResponder.test.tsx | 31 + .../parts/TeamResponder/TeamResponder.tsx | 37 + .../__snapshots__/TeamResponder.test.tsx.snap | 70 ++ .../UserResponder/UserResponder.test.tsx | 33 + .../parts/UserResponder/UserResponder.tsx | 53 ++ .../__snapshots__/UserResponder.test.tsx.snap | 141 +++ .../EscalationVariants.helpers.ts | 47 - .../EscalationVariants.module.scss | 105 --- .../EscalationVariants/EscalationVariants.tsx | 317 ------- .../EscalationVariants.types.ts | 15 - .../parts/EscalationVariantsPopup.tsx | 205 ----- .../UserWarningModal/UserWarning.module.scss | 26 - .../UserWarningModal/UserWarning.tsx | 184 ---- .../src/models/alertgroup/alertgroup.types.ts | 8 +- .../direct_paging/direct_paging.test.ts | 154 ++++ .../src/models/direct_paging/direct_paging.ts | 69 +- .../direct_paging/direct_paging.types.ts | 11 +- .../src/models/escalation_policy.ts | 2 +- .../src/models/grafana_team/grafana_team.ts | 10 +- .../models/grafana_team/grafana_team.types.ts | 1 + .../src/models/notification_policy.ts | 4 +- .../resolution_note/resolution_note.types.ts | 4 +- .../src/models/schedule/schedule.ts | 3 - grafana-plugin/src/models/user.ts | 28 - grafana-plugin/src/models/user/user.ts | 10 +- grafana-plugin/src/models/user/user.types.ts | 12 +- .../src/pages/incident/Incident.module.scss | 6 +- .../src/pages/incident/Incident.tsx | 75 +- .../src/pages/incident/parts/PagedUsers.tsx | 113 --- .../src/pages/incidents/Incidents.tsx | 2 +- 80 files changed, 4278 insertions(+), 2675 deletions(-) delete mode 100644 grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css create mode 100644 grafana-plugin/src/containers/AddResponders/AddResponders.helpers.ts create mode 100644 grafana-plugin/src/containers/AddResponders/AddResponders.module.scss create mode 100644 grafana-plugin/src/containers/AddResponders/AddResponders.test.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/AddResponders.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/AddResponders.types.ts create mode 100644 grafana-plugin/src/containers/AddResponders/__snapshots__/AddResponders.test.tsx.snap create mode 100644 grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss create mode 100644 grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.test.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/__snapshots__/AddRespondersPopup.test.tsx.snap create mode 100644 grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.module.scss create mode 100644 grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.test.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/__snapshots__/NotificationPoliciesSelect.test.tsx.snap create mode 100644 grafana-plugin/src/containers/AddResponders/parts/TeamResponder/TeamResponder.test.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/parts/TeamResponder/TeamResponder.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/parts/TeamResponder/__snapshots__/TeamResponder.test.tsx.snap create mode 100644 grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.test.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.tsx create mode 100644 grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap delete mode 100644 grafana-plugin/src/containers/EscalationVariants/EscalationVariants.helpers.ts delete mode 100644 grafana-plugin/src/containers/EscalationVariants/EscalationVariants.module.scss delete mode 100644 grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx delete mode 100644 grafana-plugin/src/containers/EscalationVariants/EscalationVariants.types.ts delete mode 100644 grafana-plugin/src/containers/EscalationVariants/parts/EscalationVariantsPopup.tsx delete mode 100644 grafana-plugin/src/containers/UserWarningModal/UserWarning.module.scss delete mode 100644 grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx create mode 100644 grafana-plugin/src/models/direct_paging/direct_paging.test.ts delete mode 100644 grafana-plugin/src/models/user.ts delete mode 100644 grafana-plugin/src/pages/incident/parts/PagedUsers.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed5c8d8..3e0053ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Simplify Direct Paging workflow. Now when using Direct Paging you either simply specify a team, or one or more users + to page by @joeyorlando ([#3128](https://github.com/grafana/oncall/pull/3128)) + ## v1.3.47 (2023-10-25) ### Fixed diff --git a/docs/sources/integrations/manual/index.md b/docs/sources/integrations/manual/index.md index cb7e31f6..36075bdf 100644 --- a/docs/sources/integrations/manual/index.md +++ b/docs/sources/integrations/manual/index.md @@ -22,36 +22,35 @@ However, sometimes you might need to page a [team][manage-teams] or request assi are not part of these pre-defined rules. For such ad-hoc scenarios, Grafana OnCall allows you to create an alert group, input necessary information, and decide -who will be alerted – a team, a user, or an on-call user from a specific schedule. +who will be alerted – a team, or a set of users. ## Page a team -Click on **+ New alert group** on the **Alert groups** page to start creating a new alert group. -From there, you can configure the alert group to notify a particular team and optionally include additional users or -schedules. Here are the inputs you need to fill in: +Click on **+ Escalation** on the **Alert groups** page to start creating a new alert group. +From there, you can configure the alert group to notify a particular team and optionally include additional users. Here are the inputs you need to fill in: -- **Title**: Write a brief and clear title for your alert group. -- **Message**: Optionally, add a message to provide more details or instructions. +- **Message**: Write a message to provide more details or instructions to those whom you are paging. - **Team**: Select the team you want to page. The team's -[direct paging integration](#learn-the-flow-and-handle-warnings) will be used for notification. -- **Additional Responders**: Optionally, include more responders for the alert group. -These could be any combination of users and schedules. -For each additional responder (user or schedule), you can select a notification policy: [default or important][notify]. + [direct paging integration](#learn-the-flow-and-handle-warnings) will be used for notification. _Note_ that you will only + see teams that have a "contactable" direct paging integration (ie. it has an escalation chain assigned to it, or has + at least one Chatops integration connected to send notifications to). +- **Users**: Include more users to the alert group. For each additional user, you can select a notification policy: + [default or important][notify]. > The same feature is also available as [**/escalate**][slack-escalate] Slack command. -## Add responders for an existing alert group +## Add users to an existing alert group -If you want to page more people for an existing alert group, you can do so using the **Notify additional responders** -button on the specific alert group's page. Here you can select more users, or choose users who are on-call for specific -schedules. The same functionality is available in Slack using the **Responders** button in the alert group's message. +If you want to page more people for an existing alert group, you can do so using the **+ Add** +button, within the "Participants" section on the specific alert group's page. The same functionality is available in +Slack using the **Responders** button in the alert group's message. -Notifying additional responders doesn't disrupt or interfere with the escalation chain configured for the alert group; -it simply adds more responders and notifies them immediately. Note that adding responders for an existing alert group +Notifying additional users doesn't disrupt or interfere with the escalation chain configured for the alert group; +it simply adds more responders and notifies them immediately. Note that adding users for an existing alert group will page them even if the alert group is silenced or acknowledged, but not if the alert group is resolved. > It's not possible to page a team for an existing alert group. To page a specific team, you need to -[create a new alert group](#page-a-team). +> [create a new alert group](#page-a-team). ## Learn the flow and handle warnings @@ -59,21 +58,17 @@ When you pick a team to page, Grafana OnCall will automatically use the right di "Direct paging" is a special kind of integration in Grafana OnCall that is unique per team and is used to send alerts to the team's ChatOps channels and start an appropriate escalation chain. -If a team hasn't set up a direct paging integration, or if the integration doesn't have any escalation chains connected, -Grafana OnCall will issue a warning. If this happens, consider -[setting up a direct paging integration](#set-up-direct-paging-for-a-team) for the team -(or reach out to the relevant team and suggest doing so). - ## Set up direct paging for a team -To create a direct paging integration for a team, click **+ New alert group** on the **Alert groups** page, choose the team, -and create an alert group, **regardless of any warnings**. This action automatically triggers Grafana OnCall to generate -a [direct paging integration](#learn-the-flow-and-handle-warnings) for the chosen team. Alternatively, navigate to -the **Integrations** page and create a new integration with type "Direct paging" from there, assigning it to the team. +By default all teams will have a direct paging integration created for them. However, these are not configured by default. +If a team does not have their direct paging integration configured, such that it is "contactable" (ie. it has an +escalation chain assigned to it, or has at least one Chatops integration connected to send notifications to), you will +not be able to direct page this team. If this happens, consider following the following steps for the team (or reach out +to the relevant team and suggest doing so). -After setting up the integration, you can customize its settings, link it to an escalation chain, -and configure associated ChatOps channels. -To confirm that the integration is functioning as intended, [create a new alert group](#page-a-team) +Navigate to the **Integrations** page and find the "Direct paging" integration for the team in question. From the +integration's detail page, you can customize its settings, link it to an escalation chain, and configure associated +ChatOps channels. To confirm that the integration is functioning as intended, [create a new alert group](#page-a-team) and select the same team for a test run. {{% docs/reference %}} diff --git a/docs/sources/open-source/_index.md b/docs/sources/open-source/_index.md index 8f9edf46..11107e4d 100644 --- a/docs/sources/open-source/_index.md +++ b/docs/sources/open-source/_index.md @@ -96,7 +96,7 @@ features: should_escape: false - command: /escalate url: /slack/interactive_api_endpoint/ - description: Direct page user(s) or schedule(s) + description: Direct page a team or user(s) should_escape: false oauth_config: redirect_urls: diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 0a29a776..21c0040d 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -70,6 +70,16 @@ class LogRecordUser(typing.TypedDict): avatar_full: str +class PagedUser(typing.TypedDict): + id: int + username: str + name: str + pk: str + avatar: str + avatar_full: str + important: bool + + class LogRecords(typing.TypedDict): time: str # humanized delta relative to now action: str # human-friendly description @@ -509,22 +519,57 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. def happened_while_maintenance(self): return self.root_alert_group is not None and self.root_alert_group.maintenance_uuid is not None - def get_paged_users(self) -> QuerySet[User]: + def get_paged_users(self) -> typing.List[PagedUser]: from apps.alerts.models import AlertGroupLogRecord - users_ids = set() - for log_record in self.log_records.filter( + user_ids: typing.Set[str] = set() + users: typing.List[PagedUser] = [] + + log_records = self.log_records.filter( type__in=(AlertGroupLogRecord.TYPE_DIRECT_PAGING, AlertGroupLogRecord.TYPE_UNPAGE_USER) - ): + ) + + for log_record in log_records: # filter paging events, track still active escalations info = log_record.get_step_specific_info() user_id = info.get("user") if info else None - if user_id is not None: - users_ids.add( - user_id - ) if log_record.type == AlertGroupLogRecord.TYPE_DIRECT_PAGING else users_ids.discard(user_id) + important = info.get("important") if info else None - return User.objects.filter(public_primary_key__in=users_ids) + if user_id is not None: + user_ids.add( + user_id + ) if log_record.type == AlertGroupLogRecord.TYPE_DIRECT_PAGING else user_ids.discard(user_id) + + user_instances = User.objects.filter(public_primary_key__in=user_ids) + user_map = {u.public_primary_key: u for u in user_instances} + + # mostly doing this second loop to avoid having to query each user individually in the first loop + for log_record in log_records: + # filter paging events, track still active escalations + info = log_record.get_step_specific_info() + user_id = info.get("user") if info else None + important = info.get("important") if info else False + + if user_id is not None and (user := user_map.get(user_id)) is not None: + if log_record.type == AlertGroupLogRecord.TYPE_DIRECT_PAGING: + # add the user + users.append( + { + "id": user.pk, + "pk": user.public_primary_key, + "name": user.name, + "username": user.username, + "avatar": user.avatar_url, + "avatar_full": user.avatar_full_url, + "important": important, + "teams": [{"pk": t.public_primary_key, "name": t.name} for t in user.teams.all()], + } + ) + else: + # user was unpaged at some point, remove them + users = [u for u in users if u["pk"] != user_id] + + return users def _get_response_time(self): """Return response_time based on current alert group status.""" diff --git a/engine/apps/alerts/paging.py b/engine/apps/alerts/paging.py index a4fbb27c..20885688 100644 --- a/engine/apps/alerts/paging.py +++ b/engine/apps/alerts/paging.py @@ -1,9 +1,7 @@ -import enum import typing from uuid import uuid4 from django.db import transaction -from django.db.models import Q from apps.alerts.models import ( Alert, @@ -11,43 +9,14 @@ from apps.alerts.models import ( AlertGroupLogRecord, AlertReceiveChannel, ChannelFilter, - EscalationChain, UserHasNotification, ) from apps.alerts.tasks.notify_user import notify_user_task -from apps.schedules.ical_utils import list_users_to_notify_from_ical +from apps.schedules.ical_utils import get_oncall_users_for_multiple_schedules from apps.schedules.models import OnCallSchedule from apps.user_management.models import Organization, Team, User - -class PagingError(enum.StrEnum): - USER_HAS_NO_NOTIFICATION_POLICY = "USER_HAS_NO_NOTIFICATION_POLICY" - USER_IS_NOT_ON_CALL = "USER_IS_NOT_ON_CALL" - - -# notifications: (User|Schedule, important) UserNotifications = list[tuple[User, bool]] -ScheduleNotifications = list[tuple[OnCallSchedule, bool]] - - -class NoNotificationPolicyWarning(typing.TypedDict): - error: typing.Literal[PagingError.USER_HAS_NO_NOTIFICATION_POLICY] - data: typing.Dict - - -ScheduleWarnings = typing.Dict[str, typing.List[str]] - - -class _NotOnCallWarningData(typing.TypedDict): - schedules: ScheduleWarnings - - -class NotOnCallWarning(typing.TypedDict): - error: typing.Literal[PagingError.USER_IS_NOT_ON_CALL] - data: _NotOnCallWarningData - - -AvailabilityWarning = NoNotificationPolicyWarning | NotOnCallWarning class DirectPagingAlertGroupResolvedError(Exception): @@ -56,6 +25,12 @@ class DirectPagingAlertGroupResolvedError(Exception): DETAIL = "Cannot add responders for a resolved alert group" # Returned in BadRequest responses and Slack warnings +class DirectPagingUserTeamValidationError(Exception): + """Raised when trying to use direct paging and no team or user is specified.""" + + DETAIL = "No team or user(s) specified" # Returned in BadRequest responses and Slack warnings + + class _OnCall(typing.TypedDict): title: str message: str @@ -69,12 +44,7 @@ class DirectPagingAlertPayload(typing.TypedDict): def _trigger_alert( - organization: Organization, - team: Team | None, - title: str, - message: str, - from_user: User, - escalation_chain: EscalationChain = None, + organization: Organization, team: Team | None, message: str, title: str, from_user: User ) -> AlertGroup: """Trigger manual integration alert from params.""" alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( @@ -87,29 +57,15 @@ def _trigger_alert( "verbal_name": f"Direct paging ({team.name if team else 'No'} team)", }, ) + + channel_filter = None if alert_receive_channel.default_channel_filter is None: - ChannelFilter.objects.create( + channel_filter = ChannelFilter.objects.create( alert_receive_channel=alert_receive_channel, notify_in_slack=True, is_default=True, ) - channel_filter = None - if escalation_chain is not None: - channel_filter, _ = ChannelFilter.objects.get_or_create( - alert_receive_channel=alert_receive_channel, - escalation_chain=escalation_chain, - is_default=False, - defaults={ - "filtering_term": f"escalate to {escalation_chain.name}", - "notify_in_slack": True, - }, - ) - - permalink = None - if not title: - title = "Message from {}".format(from_user.username) - payload: DirectPagingAlertPayload = { # Custom oncall property in payload to simplify rendering "oncall": { @@ -117,7 +73,7 @@ def _trigger_alert( "message": message, "uid": str(uuid4()), # avoid grouping "author_username": from_user.username, - "permalink": permalink, + "permalink": None, }, } @@ -134,107 +90,60 @@ def _trigger_alert( return alert.group -def check_user_availability(user: User) -> typing.List[AvailabilityWarning]: - """Check user availability to be paged. +def _construct_title(from_user: User, team: Team | None, users: UserNotifications) -> str: + title = f"{from_user.username} is paging" - Return a warnings list indicating `error` and any additional related `data`. - """ - warnings: typing.List[AvailabilityWarning] = [] - if not user.notification_policies.exists(): - warnings.append( - { - "error": PagingError.USER_HAS_NO_NOTIFICATION_POLICY, - "data": {}, - } - ) + names = [team.name] if team is not None else [] + names.extend([user.username for user, _ in users]) - is_on_call = False - schedules = OnCallSchedule.objects.filter( - Q(cached_ical_file_primary__contains=user.username) | Q(cached_ical_file_primary__contains=user.email), - organization=user.organization, - ) - schedules_data: ScheduleWarnings = {} - for s in schedules: - # keep track of schedules and on call users to suggest if needed - oncall_users = list_users_to_notify_from_ical(s) - schedules_data[s.name] = set(u.public_primary_key for u in oncall_users) - if user in oncall_users: - is_on_call = True - break + if (num_names := len(names)) == 1: + title += f" {names[0]}" + elif num_names > 1: + title += f" {', '.join(names[:-1])} and {names[-1]}" - if not is_on_call: - # user is not on-call - # TODO: check working hours - warnings.append( - { - "error": PagingError.USER_IS_NOT_ON_CALL, - "data": {"schedules": schedules_data}, - } - ) + title += " to join escalation" - return warnings + return title def direct_paging( organization: Organization, - team: Team | None, from_user: User, - title: str = None, - message: str = None, + message: str, + title: str | None = None, + team: Team | None = None, users: UserNotifications | None = None, - schedules: ScheduleNotifications | None = None, - escalation_chain: EscalationChain | None = None, alert_group: AlertGroup | None = None, ) -> AlertGroup | None: - """Trigger escalation targeting given users/schedules. + """Trigger escalation targeting given team/users. If an alert group is given, update escalation to include the specified users. - Otherwise, create a new alert using given title and message. - + Otherwise, create a new alert using given message. """ - if users is None: users = [] - if schedules is None: - schedules = [] - - if escalation_chain is not None and alert_group is not None: - raise ValueError("Cannot change an existing alert group escalation chain") + if not users and team is None: + raise DirectPagingUserTeamValidationError # Cannot add responders to a resolved alert group if alert_group and alert_group.resolved: raise DirectPagingAlertGroupResolvedError + if title is None: + title = _construct_title(from_user, team, users) + # create alert group if needed if alert_group is None: - alert_group = _trigger_alert(organization, team, title, message, from_user, escalation_chain=escalation_chain) + alert_group = _trigger_alert(organization, team, message, title, from_user) - # initialize direct paged users (without a schedule) - users = [(u, important, None) for u, important in users] - - # get on call users, add log entry for each schedule - for s, important in schedules: - oncall_users = list_users_to_notify_from_ical(s) - users += [(u, important, s) for u in oncall_users] + for u, important in users: alert_group.log_records.create( type=AlertGroupLogRecord.TYPE_DIRECT_PAGING, author=from_user, - reason=f"{from_user.username} paged schedule {s.name}", - step_specific_info={"schedule": s.public_primary_key}, - ) - - for u, important, schedule in users: - reason = f"{from_user.username} paged user {u.username}" - if schedule: - reason += f" (from schedule {schedule.name})" - alert_group.log_records.create( - type=AlertGroupLogRecord.TYPE_DIRECT_PAGING, - author=from_user, - reason=reason, + reason=f"{from_user.username} paged user {u.username}", step_specific_info={ "user": u.public_primary_key, - "schedule": schedule.public_primary_key if schedule else None, "important": important, }, ) @@ -263,3 +172,33 @@ def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None: ) except IndexError: return + + +def user_is_oncall(user: User) -> bool: + schedules_with_oncall_users = get_oncall_users_for_multiple_schedules(OnCallSchedule.objects.related_to_user(user)) + return user.pk in {user.pk for _, users in schedules_with_oncall_users.items() for user in users} + + +def integration_is_notifiable(integration: AlertReceiveChannel) -> bool: + """ + Returns true if: + - the integration has more than one channel filter associated with it + - the default channel filter has at least one notification method specified or an escalation chain associated with it + """ + if integration.channel_filters.count() > 1: + return True + + default_channel_filter = integration.default_channel_filter + if not default_channel_filter: + return False + + organization = integration.organization + notify_via_slack = organization.slack_is_configured and default_channel_filter.notify_in_slack + notify_via_telegram = organization.telegram_is_configured and default_channel_filter.notify_in_telegram + + notify_via_chatops = notify_via_slack or notify_via_telegram + custom_messaging_backend_configured = default_channel_filter.notification_backends is not None + + return ( + default_channel_filter.escalation_chain is not None or notify_via_chatops or custom_messaging_backend_configured + ) diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index 8beb6da5..927a5c0c 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -482,3 +482,48 @@ def test_alert_group_log_record_action_source( alert_group.un_attach_by_user(user, action_source=action_source) log_record = alert_group.log_records.last() assert (log_record.type, log_record.action_source) == (AlertGroupLogRecord.TYPE_UNATTACHED, action_source) + + +@pytest.mark.django_db +def test_alert_group_get_paged_users( + make_organization_and_user, + make_user_for_organization, + make_alert_receive_channel, + make_alert_group, +): + organization, user = make_organization_and_user() + other_user = make_user_for_organization(organization) + alert_receive_channel = make_alert_receive_channel(organization) + + def _make_log_record(alert_group, user, log_type, important=False): + alert_group.log_records.create( + type=log_type, + author=user, + reason="paged user", + step_specific_info={ + "user": user.public_primary_key, + "important": important, + }, + ) + + # user was paged - also check that important is persisted/available + alert_group = make_alert_group(alert_receive_channel) + _make_log_record(alert_group, user, AlertGroupLogRecord.TYPE_DIRECT_PAGING) + _make_log_record(alert_group, other_user, AlertGroupLogRecord.TYPE_DIRECT_PAGING, True) + + paged_users = {u["pk"]: u["important"] for u in alert_group.get_paged_users()} + + assert user.public_primary_key in paged_users + assert paged_users[user.public_primary_key] is False + + assert other_user.public_primary_key in paged_users + assert paged_users[other_user.public_primary_key] is True + + # user was paged and then unpaged + alert_group = make_alert_group(alert_receive_channel) + _make_log_record(alert_group, user, AlertGroupLogRecord.TYPE_DIRECT_PAGING) + _make_log_record(alert_group, user, AlertGroupLogRecord.TYPE_UNPAGE_USER) + + _make_log_record(alert_group, other_user, AlertGroupLogRecord.TYPE_DIRECT_PAGING) + + alert_group.get_paged_users()[0]["pk"] == other_user.public_primary_key diff --git a/engine/apps/alerts/tests/test_paging.py b/engine/apps/alerts/tests/test_paging.py index 64627314..96d5b746 100644 --- a/engine/apps/alerts/tests/test_paging.py +++ b/engine/apps/alerts/tests/test_paging.py @@ -4,8 +4,14 @@ import pytest from django.utils import timezone from apps.alerts.models import AlertGroup, AlertGroupLogRecord, UserHasNotification -from apps.alerts.paging import PagingError, check_user_availability, direct_paging, unpage_user -from apps.base.models import UserNotificationPolicy +from apps.alerts.paging import ( + DirectPagingUserTeamValidationError, + _construct_title, + direct_paging, + integration_is_notifiable, + unpage_user, + user_is_oncall, +) from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb @@ -16,12 +22,17 @@ def assert_log_record(alert_group, reason, log_type=AlertGroupLogRecord.TYPE_DIR assert log.get_step_specific_info() == expected_info -def setup_always_on_call_schedule(make_schedule, make_on_call_shift, organization, team, user, extra_users=None): - # setup on call schedule +@pytest.mark.django_db +def test_user_is_oncall(make_organization, make_user_for_organization, make_schedule, make_on_call_shift): + organization = make_organization() + not_oncall_user = make_user_for_organization(organization) + oncall_user = make_user_for_organization(organization) + + # set up schedule: user is on call schedule = make_schedule( organization, schedule_class=OnCallScheduleWeb, - team=team, + team=None, ) now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) start_date = now - timezone.timedelta(days=7) @@ -36,91 +47,11 @@ def setup_always_on_call_schedule(make_schedule, make_on_call_shift, organizatio on_call_shift = make_on_call_shift( organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data ) - on_call_shift.add_rolling_users([[user]]) - if extra_users: - # add old shifts for users - for i, u in enumerate(extra_users): - start_date = now - timezone.timedelta(days=14) - data = { - "start": start_date + timezone.timedelta(hours=i), - "rotation_start": start_date, - "duration": timezone.timedelta(hours=1), - "priority_level": 1, - "frequency": CustomOnCallShift.FREQUENCY_DAILY, - "schedule": schedule, - "until": start_date + timezone.timedelta(days=7), - } - on_call_shift = make_on_call_shift( - organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data - ) - on_call_shift.add_rolling_users([[u]]) - + on_call_shift.add_rolling_users([[oncall_user]]) schedule.refresh_ical_file() - return schedule - -@pytest.mark.django_db -def test_check_user_availability_no_policies(make_organization, make_user_for_organization): - organization = make_organization() - user = make_user_for_organization(organization) - - warnings = check_user_availability(user) - assert warnings == [ - {"data": {}, "error": PagingError.USER_HAS_NO_NOTIFICATION_POLICY}, - {"data": {"schedules": {}}, "error": PagingError.USER_IS_NOT_ON_CALL}, - ] - - -@pytest.mark.django_db -def test_check_user_availability_not_on_call( - make_organization, make_user_for_organization, make_user_notification_policy, make_schedule, make_on_call_shift -): - organization = make_organization() - user = make_user_for_organization(organization) - other_user = make_user_for_organization(organization) - make_user_notification_policy( - user=user, - step=UserNotificationPolicy.Step.NOTIFY, - notify_by=UserNotificationPolicy.NotificationChannel.SMS, - ) - - # setup on call schedule - schedule = setup_always_on_call_schedule( - make_schedule, make_on_call_shift, organization, None, other_user, extra_users=[user] - ) - - warnings = check_user_availability(user) - assert warnings == [ - { - "data": {"schedules": {schedule.name: {other_user.public_primary_key}}}, - "error": PagingError.USER_IS_NOT_ON_CALL, - }, - ] - - -@pytest.mark.django_db -def test_check_user_availability_on_call( - make_organization, - make_team, - make_user_for_organization, - make_user_notification_policy, - make_schedule, - make_on_call_shift, -): - organization = make_organization() - some_team = make_team(organization) - user = make_user_for_organization(organization) - make_user_notification_policy( - user=user, - step=UserNotificationPolicy.Step.NOTIFY, - notify_by=UserNotificationPolicy.NotificationChannel.SMS, - ) - - # setup on call schedule - setup_always_on_call_schedule(make_schedule, make_on_call_shift, organization, some_team, user) - - warnings = check_user_availability(user) - assert warnings == [] + assert user_is_oncall(not_oncall_user) is False + assert user_is_oncall(oncall_user) is True @pytest.mark.django_db @@ -129,65 +60,98 @@ def test_direct_paging_user(make_organization, make_user_for_organization): user = make_user_for_organization(organization) other_user = make_user_for_organization(organization) from_user = make_user_for_organization(organization) + msg = "Fire" with patch("apps.alerts.paging.notify_user_task") as notify_task: - direct_paging( - organization, None, from_user, title="Help!", message="Fire", users=[(user, False), (other_user, True)] - ) + direct_paging(organization, from_user, msg, users=[(user, False), (other_user, True)]) # alert group created alert_groups = AlertGroup.objects.all() assert alert_groups.count() == 1 ag = alert_groups.get() alert = ag.alerts.get() - assert alert.title == "Help!" - assert alert.message == "Fire" + + assert alert.title == f"{from_user.username} is paging {user.username} and {other_user.username} to join escalation" + assert alert.message == msg + # notifications sent for u, important in ((user, False), (other_user, True)): assert notify_task.apply_async.called_with( (u.pk, ag.pk), {"important": important, "notify_even_acknowledged": True, "notify_anyway": True} ) - expected_info = {"user": u.public_primary_key, "schedule": None, "important": important} + expected_info = {"user": u.public_primary_key, "important": important} assert_log_record(ag, f"{from_user.username} paged user {u.username}", expected_info=expected_info) @pytest.mark.django_db -def test_direct_paging_schedule( - make_organization, make_team, make_user_for_organization, make_schedule, make_on_call_shift -): +def test_direct_paging_team(make_organization, make_team, make_user_for_organization): organization = make_organization() - some_team = make_team(organization) from_user = make_user_for_organization(organization) - user = make_user_for_organization(organization) - other_user = make_user_for_organization(organization) + team = make_team(organization) + msg = "Fire" - # setup on call schedule - schedule = setup_always_on_call_schedule(make_schedule, make_on_call_shift, organization, some_team, user) - other_schedule = setup_always_on_call_schedule( - make_schedule, make_on_call_shift, organization, some_team, other_user - ) - - with patch("apps.alerts.paging.notify_user_task") as notify_task: - direct_paging(organization, None, from_user, schedules=[(schedule, False), (other_schedule, True)]) + direct_paging(organization, from_user, msg, team=team) # alert group created alert_groups = AlertGroup.objects.all() assert alert_groups.count() == 1 ag = alert_groups.get() alert = ag.alerts.get() - assert alert.title == f"Message from {from_user.username}" - assert alert.message is None - assert_log_record(ag, f"{from_user.username} paged schedule {schedule.name}") - assert_log_record(ag, f"{from_user.username} paged schedule {other_schedule.name}") - # notifications sent - for u, important, s in ((user, False, schedule), (other_user, True, other_schedule)): - assert notify_task.apply_async.called_with( - (u.pk, ag.pk), {"important": important, "notify_even_acknowledged": True, "notify_anyway": True} - ) - expected_info = {"user": u.public_primary_key, "schedule": s.public_primary_key, "important": important} - assert_log_record( - ag, f"{from_user.username} paged user {u.username} (from schedule {s.name})", expected_info=expected_info - ) + assert alert.title == f"{from_user.username} is paging {team.name} to join escalation" + assert alert.message == msg + + assert ag.channel.verbal_name == f"Direct paging ({team.name} team)" + assert ag.channel.team == team + + +@pytest.mark.django_db +def test_direct_paging_no_team(make_organization, make_user_for_organization): + organization = make_organization() + from_user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + msg = "Fire" + + direct_paging(organization, from_user, msg, users=[(other_user, False)]) + + # alert group created + alert_groups = AlertGroup.objects.all() + assert alert_groups.count() == 1 + ag = alert_groups.get() + alert = ag.alerts.get() + assert alert.title == f"{from_user.username} is paging {other_user.username} to join escalation" + assert alert.message == msg + + assert ag.channel.verbal_name == "Direct paging (No team)" + assert ag.channel.team is None + + +@pytest.mark.django_db +def test_direct_paging_custom_title(make_organization, make_user_for_organization): + organization = make_organization() + from_user = make_user_for_organization(organization) + other_user = make_user_for_organization(organization) + custom_title = "Custom title" + msg = "Fire" + + direct_paging(organization, from_user, msg, custom_title, users=[(other_user, False)]) + + # alert group created + alert_groups = AlertGroup.objects.all() + assert alert_groups.count() == 1 + ag = alert_groups.get() + + assert ag.web_title_cache == custom_title + assert ag.alerts.get().title == custom_title + + +@pytest.mark.django_db +def test_direct_paging_no_team_and_no_users(make_organization, make_user_for_organization): + organization = make_organization() + from_user = make_user_for_organization(organization) + msg = "Fire" + + with pytest.raises(DirectPagingUserTeamValidationError): + direct_paging(organization, from_user, msg) @pytest.mark.django_db @@ -201,12 +165,13 @@ def test_direct_paging_reusing_alert_group( alert_group = make_alert_group(alert_receive_channel=alert_receive_channel) with patch("apps.alerts.paging.notify_user_task") as notify_task: - direct_paging(organization, None, from_user, users=[(user, False)], alert_group=alert_group) + direct_paging(organization, from_user, "Fire!", users=[(user, False)], alert_group=alert_group) # no new alert group is created alert_groups = AlertGroup.objects.all() assert alert_groups.count() == 1 assert_log_record(alert_group, f"{from_user.username} paged user {user.username}") + # notifications sent ag = alert_groups.get() assert notify_task.apply_async.called_with( @@ -214,41 +179,6 @@ def test_direct_paging_reusing_alert_group( ) -@pytest.mark.django_db -def test_direct_paging_reusing_alert_group_custom_chain_raises( - make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group, make_escalation_chain -): - organization = make_organization() - from_user = make_user_for_organization(organization) - alert_receive_channel = make_alert_receive_channel(organization=organization) - alert_group = make_alert_group(alert_receive_channel=alert_receive_channel) - custom_chain = make_escalation_chain(organization) - - with pytest.raises(ValueError): - direct_paging(organization, None, from_user, alert_group=alert_group, escalation_chain=custom_chain) - - -@pytest.mark.django_db -def test_direct_paging_custom_chain( - make_organization, make_user_for_organization, make_alert_receive_channel, make_alert_group, make_escalation_chain -): - organization = make_organization() - from_user = make_user_for_organization(organization) - custom_chain = make_escalation_chain(organization) - - direct_paging(organization, None, from_user, escalation_chain=custom_chain) - - # alert group created - alert_groups = AlertGroup.objects.all() - assert alert_groups.count() == 1 - ag = alert_groups.get() - channel_filter = ag.channel_filter_with_respect_to_escalation_snapshot - assert channel_filter is not None - assert not channel_filter.is_default - assert channel_filter.notify_in_slack - assert ag.escalation_chain_with_respect_to_escalation_snapshot == custom_chain - - @pytest.mark.django_db def test_direct_paging_returns_alert_group(make_organization, make_user_for_organization): organization = make_organization() @@ -256,7 +186,7 @@ def test_direct_paging_returns_alert_group(make_organization, make_user_for_orga from_user = make_user_for_organization(organization) with patch("apps.alerts.paging.notify_user_task"): - alert_group = direct_paging(organization, None, from_user, title="Help!", message="Fire", users=[(user, False)]) + alert_group = direct_paging(organization, from_user, "Help!", users=[(user, False)]) # check alert group returned by direct paging is the same as the one created assert alert_group == AlertGroup.objects.get() @@ -301,15 +231,18 @@ def test_direct_paging_always_create_group(make_organization, make_user_for_orga organization = make_organization() user = make_user_for_organization(organization) from_user = make_user_for_organization(organization) + msg = "Help!" + users = [(user, False)] with patch("apps.alerts.paging.notify_user_task") as notify_task: # although calling twice with same params, there should be 2 alert groups - direct_paging(organization, None, from_user, title="Help!", users=[(user, False)]) - direct_paging(organization, None, from_user, title="Help!", users=[(user, False)]) + direct_paging(organization, from_user, msg, users=users) + direct_paging(organization, from_user, msg, users=users) # alert group created alert_groups = AlertGroup.objects.all() assert alert_groups.count() == 2 + # notifications sent assert notify_task.apply_async.called_with( (user.pk, alert_groups[0].pk), {"important": False, "notify_even_acknowledged": True, "notify_anyway": True} @@ -317,3 +250,109 @@ def test_direct_paging_always_create_group(make_organization, make_user_for_orga assert notify_task.apply_async.called_with( (user.pk, alert_groups[1].pk), {"important": False, "notify_even_acknowledged": True, "notify_anyway": True} ) + + +@pytest.mark.django_db +def test_construct_title(make_organization, make_team, make_user_for_organization): + organization = make_organization() + from_user = make_user_for_organization(organization) + user1 = make_user_for_organization(organization) + user2 = make_user_for_organization(organization) + user3 = make_user_for_organization(organization) + team = make_team(organization) + + def _title(middle_portion: str) -> str: + return f"{from_user.username} is paging {middle_portion} to join escalation" + + one_user = [(user1, False)] + two_users = [(user1, False), (user2, True)] + multiple_users = two_users + [(user3, False)] + + # no team specified + one user + assert _construct_title(from_user, None, one_user) == _title(user1.username) + + # no team specified + two users + assert _construct_title(from_user, None, two_users) == _title(f"{user1.username} and {user2.username}") + + # no team specified + multiple users + assert _construct_title(from_user, None, multiple_users) == _title( + f"{user1.username}, {user2.username} and {user3.username}" + ) + + # team specified + no users + assert _construct_title(from_user, team, []) == _title(team.name) + + # team specified + one user + assert _construct_title(from_user, team, one_user) == _title(f"{team.name} and {user1.username}") + + # team specified + two users + assert _construct_title(from_user, team, two_users) == _title(f"{team.name}, {user1.username} and {user2.username}") + + # team specified + multiple users + assert _construct_title(from_user, team, multiple_users) == _title( + f"{team.name}, {user1.username}, {user2.username} and {user3.username}" + ) + + +@pytest.mark.django_db +def test_integration_is_notifiable( + make_organization, + make_alert_receive_channel, + make_channel_filter, + make_escalation_chain, + make_slack_team_identity, + make_telegram_channel, +): + organization = make_organization() + + # integration has no default channel filter + arc = make_alert_receive_channel(organization) + make_channel_filter(arc, is_default=False) + assert integration_is_notifiable(arc) is False + + # integration has more than one channel filter + arc = make_alert_receive_channel(organization) + make_channel_filter(arc, is_default=False) + make_channel_filter(arc, is_default=False) + assert integration_is_notifiable(arc) is True + + # integration's default channel filter is setup to notify via slack but Slack is not configured for the org + arc = make_alert_receive_channel(organization) + make_channel_filter(arc, is_default=True, notify_in_slack=True) + assert integration_is_notifiable(arc) is False + + # integration's default channel filter is setup to notify via slack and Slack is configured for the org + arc = make_alert_receive_channel(organization) + slack_team_identity = make_slack_team_identity() + organization.slack_team_identity = slack_team_identity + organization.save() + + make_channel_filter(arc, is_default=True, notify_in_slack=True) + assert integration_is_notifiable(arc) is True + + # integration's default channel filter is setup to notify via telegram but Telegram is not configured for the org + arc = make_alert_receive_channel(organization) + make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True) + assert integration_is_notifiable(arc) is False + + # integration's default channel filter is setup to notify via telegram and Telegram is configured for the org + arc = make_alert_receive_channel(organization) + make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True) + make_telegram_channel(organization) + assert integration_is_notifiable(arc) is True + + # integration's default channel filter is contactable via a custom messaging backend + arc = make_alert_receive_channel(organization) + make_channel_filter( + arc, + is_default=True, + notify_in_slack=False, + notification_backends={"MSTEAMS": {"channel": "test", "enabled": True}}, + ) + assert integration_is_notifiable(arc) is True + + # integration's default channel filter has an escalation chain attached to it + arc = make_alert_receive_channel(organization) + escalation_chain = make_escalation_chain(organization) + make_channel_filter(arc, is_default=True, notify_in_slack=False, escalation_chain=escalation_chain) + assert integration_is_notifiable(arc) is True diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index cb851eed..3a46583f 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -15,7 +15,7 @@ from common.api_helpers.mixins import EagerLoadingMixin from .alert import AlertSerializer from .alert_receive_channel import FastAlertReceiveChannelSerializer from .alerts_field_cache_buster_mixin import AlertsFieldCacheBusterMixin -from .user import FastUserSerializer, UserShortSerializer +from .user import FastUserSerializer, PagedUserSerializer, UserShortSerializer logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -242,8 +242,6 @@ class AlertGroupSerializer(AlertGroupListSerializer): alerts = obj.alerts.order_by("-pk")[:100] return AlertSerializer(alerts, many=True).data - @extend_schema_field(UserShortSerializer(many=True)) + @extend_schema_field(PagedUserSerializer(many=True)) def get_paged_users(self, obj): - paged_users = obj.get_paged_users() - serializer = UserShortSerializer(paged_users, many=True) - return serializer.data + return obj.get_paged_users() diff --git a/engine/apps/api/serializers/paging.py b/engine/apps/api/serializers/paging.py index 68c05c9d..9c16087c 100644 --- a/engine/apps/api/serializers/paging.py +++ b/engine/apps/api/serializers/paging.py @@ -1,49 +1,42 @@ +import typing + from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from apps.alerts.models import AlertGroup +from apps.user_management.models import Organization from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.utils import CurrentTeamDefault +class SerializerContext(typing.TypedDict): + organization: Organization + + class UserReferenceSerializer(serializers.Serializer): + context: SerializerContext + id = serializers.CharField() important = serializers.BooleanField() instance = serializers.HiddenField(default=None) # set in UserReferenceSerializer.validate def validate(self, attrs): + id = attrs["id"] organization = self.context["organization"] try: - attrs["instance"] = organization.users.get(public_primary_key=attrs["id"]) + attrs["instance"] = organization.users.get(public_primary_key=id) except ObjectDoesNotExist: - raise serializers.ValidationError("User {} does not exist".format(attrs["id"])) - - return attrs - - -class ScheduleReferenceSerializer(serializers.Serializer): - id = serializers.CharField() - important = serializers.BooleanField() - instance = serializers.HiddenField(default=None) # set in ScheduleReferenceSerializer.validate - - def validate(self, attrs): - organization = self.context["organization"] - - try: - attrs["instance"] = organization.oncall_schedules.get(public_primary_key=attrs["id"]) - except ObjectDoesNotExist: - raise serializers.ValidationError("Schedule {} does not exist".format(attrs["id"])) + raise serializers.ValidationError(f"User {id} does not exist") return attrs class DirectPagingSerializer(serializers.Serializer): - users = UserReferenceSerializer(many=True, required=False, default=list) - schedules = ScheduleReferenceSerializer(many=True, required=False, default=list) + context: SerializerContext - escalation_chain_id = serializers.CharField(required=False, default=None) - escalation_chain = serializers.HiddenField(default=None) # set in DirectPagingSerializer.validate + users = UserReferenceSerializer(many=True, required=False, default=list) + team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault()) alert_group_id = serializers.CharField(required=False, default=None) alert_group = serializers.HiddenField(default=None) # set in DirectPagingSerializer.validate @@ -51,13 +44,8 @@ class DirectPagingSerializer(serializers.Serializer): title = serializers.CharField(required=False, default=None) message = serializers.CharField(required=False, default=None, allow_null=True) - team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault()) - def validate(self, attrs): organization = self.context["organization"] - - escalation_chain_id = attrs["escalation_chain_id"] - alert_group_id = attrs["alert_group_id"] title = attrs["title"] message = attrs["message"] @@ -65,9 +53,6 @@ class DirectPagingSerializer(serializers.Serializer): if alert_group_id and (title or message): raise serializers.ValidationError("alert_group_id and (title, message) are mutually exclusive") - if alert_group_id and escalation_chain_id: - raise serializers.ValidationError("escalation_chain_id is not supported for existing alert groups") - if alert_group_id: try: attrs["alert_group"] = AlertGroup.objects.get( @@ -76,10 +61,4 @@ class DirectPagingSerializer(serializers.Serializer): except ObjectDoesNotExist: raise serializers.ValidationError("Alert group {} does not exist".format(alert_group_id)) - if escalation_chain_id: - try: - attrs["escalation_chain"] = organization.escalation_chains.get(public_primary_key=escalation_chain_id) - except ObjectDoesNotExist: - raise serializers.ValidationError("Escalation chain {} does not exist".format(escalation_chain_id)) - return attrs diff --git a/engine/apps/api/serializers/schedule_base.py b/engine/apps/api/serializers/schedule_base.py index 1cb34913..fa6414a3 100644 --- a/engine/apps/api/serializers/schedule_base.py +++ b/engine/apps/api/serializers/schedule_base.py @@ -68,7 +68,7 @@ class ScheduleBaseSerializer(EagerLoadingMixin, serializers.ModelSerializer): 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.pk, []) + users = self.context["oncall_users"].get(obj, []) return [user.short() for user in users] def get_number_of_escalation_chains(self, obj): diff --git a/engine/apps/api/serializers/team.py b/engine/apps/api/serializers/team.py index f62af606..dd7e172d 100644 --- a/engine/apps/api/serializers/team.py +++ b/engine/apps/api/serializers/team.py @@ -1,11 +1,29 @@ +import typing + from rest_framework import serializers +from apps.schedules.ical_utils import SchedulesOnCallUsers from apps.user_management.models import Team -class TeamSerializer(serializers.ModelSerializer): +class TeamSerializerContext(typing.TypedDict): + schedules_with_oncall_users: SchedulesOnCallUsers + + +class FastTeamSerializer(serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") + class Meta: + model = Team + fields = ["id", "name", "email", "avatar_url"] + + +class TeamSerializer(serializers.ModelSerializer): + context: TeamSerializerContext + + id = serializers.CharField(read_only=True, source="public_primary_key") + number_of_users_currently_oncall = serializers.SerializerMethodField() + class Meta: model = Team fields = ( @@ -14,6 +32,7 @@ class TeamSerializer(serializers.ModelSerializer): "email", "avatar_url", "is_sharing_resources_to_all", + "number_of_users_currently_oncall", ) read_only_fields = [ @@ -22,3 +41,12 @@ class TeamSerializer(serializers.ModelSerializer): "email", "avatar_url", ] + + def get_number_of_users_currently_oncall(self, obj: Team) -> int: + num_of_users_oncall_for_team = 0 + + for schedule, users in self.context["schedules_with_oncall_users"].items(): + if schedule.team == obj: + num_of_users_oncall_for_team += len(users) + + return num_of_users_oncall_for_team diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 84cf8413..a0332dc7 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -1,5 +1,6 @@ import math import time +import typing from django.conf import settings from rest_framework import serializers @@ -9,6 +10,7 @@ from apps.base.messaging import get_messaging_backends from apps.base.models import UserNotificationPolicy from apps.base.utils import live_settings from apps.oss_installation.utils import cloud_user_identity_status +from apps.schedules.ical_utils import SchedulesOnCallUsers from apps.user_management.models import User from apps.user_management.models.user import default_working_hours from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, TimeZoneField @@ -18,6 +20,11 @@ from common.api_helpers.utils import check_phone_number_is_valid from .custom_serializers import DynamicFieldsModelSerializer from .organization import FastOrganizationSerializer from .slack_user_identity import SlackUserIdentitySerializer +from .team import FastTeamSerializer + + +class UserSerializerContext(typing.TypedDict): + schedules_with_oncall_users: SchedulesOnCallUsers class UserPermissionSerializer(serializers.Serializer): @@ -120,18 +127,18 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): else: return None - def get_messaging_backends(self, obj): + def get_messaging_backends(self, obj: User): serialized_data = {} supported_backends = get_messaging_backends() for backend_id, backend in supported_backends: serialized_data[backend_id] = backend.serialize_user(obj) return serialized_data - def get_notification_chain_verbal(self, obj): + def get_notification_chain_verbal(self, obj: User): default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj) return {"default": " - ".join(default), "important": " - ".join(important)} - def get_cloud_connection_status(self, obj): + def get_cloud_connection_status(self, obj: User): if settings.IS_OPEN_SOURCE and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: connector = self.context.get("connector", None) identities = self.context.get("cloud_identities", {}) @@ -261,3 +268,34 @@ class UserShortSerializer(serializers.ModelSerializer): "avatar", "avatar_full", ] + + +class UserLongSerializer(UserSerializer): + context: UserSerializerContext + + teams = FastTeamSerializer(read_only=True, many=True) + is_currently_oncall = serializers.SerializerMethodField() + + class Meta(UserSerializer.Meta): + fields = UserSerializer.Meta.fields + [ + "teams", + "is_currently_oncall", + ] + + def get_is_currently_oncall(self, obj: User) -> bool: + # Serializer context is set here: apps.api.views.user.UserView.get_serializer_context. + for users in self.context.get("schedules_with_oncall_users", {}).values(): + if obj in users: + return True + return False + + +class PagedUserSerializer(serializers.Serializer): + class Meta: + fields = [ + "username", + "pk", + "avatar", + "avatar_full", + "important", + ] diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 5b810973..5ecd4ebb 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -1841,7 +1841,18 @@ def test_alert_group_paged_users( url = reverse("api-internal:alertgroup-detail", kwargs={"pk": new_alert_group.public_primary_key}) response = client.get(url, format="json", **make_user_auth_headers(user, token)) - assert response.json()["paged_users"] == [user2.short()] + assert response.json()["paged_users"] == [ + { + "avatar": user2.avatar_url, + "avatar_full": user2.avatar_full_url, + "id": user2.pk, + "pk": user2.public_primary_key, + "important": None, + "name": user2.name, + "username": user2.username, + "teams": [], + } + ] @pytest.mark.django_db diff --git a/engine/apps/api/tests/test_paging.py b/engine/apps/api/tests/test_paging.py index 99dded1a..a45da9b8 100644 --- a/engine/apps/api/tests/test_paging.py +++ b/engine/apps/api/tests/test_paging.py @@ -3,17 +3,18 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from apps.alerts.paging import DirectPagingAlertGroupResolvedError +from apps.alerts.models import AlertGroup +from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError from apps.api.permissions import LegacyAccessControlRole -from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal + +title = "Custom title" +message = "Testing direct paging with new alert group" @pytest.mark.django_db def test_direct_paging_new_alert_group( make_organization_and_user_with_plugin_token, make_user, - make_schedule, - make_escalation_chain, make_user_auth_headers, ): organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) @@ -29,19 +30,6 @@ def test_direct_paging_new_alert_group( }, ] - schedules_to_page = [ - {"id": make_schedule(organization, schedule_class=OnCallScheduleICal).public_primary_key, "important": False}, - { - "id": make_schedule(organization, schedule_class=OnCallScheduleCalendar).public_primary_key, - "important": True, - }, - ] - - escalation_chain_to_page = make_escalation_chain(organization) - - title = "Test Alert Group" - message = "Testing direct paging with new alert group" - client = APIClient() url = reverse("api-internal:direct_paging") @@ -49,8 +37,6 @@ def test_direct_paging_new_alert_group( url, data={ "users": users_to_page, - "schedules": schedules_to_page, - "escalation_chain_id": escalation_chain_to_page.public_primary_key, "title": title, "message": message, }, @@ -61,12 +47,49 @@ def test_direct_paging_new_alert_group( assert response.status_code == status.HTTP_200_OK assert "alert_group_id" in response.json() + alert_groups = AlertGroup.objects.all() + assert alert_groups.count() == 1 + ag = alert_groups.get() + alert = ag.alerts.get() + + assert ag.web_title_cache == title + assert alert.title == title + assert alert.message == message + + +@pytest.mark.django_db +def test_direct_paging_page_team( + make_organization_and_user_with_plugin_token, + make_team, + make_user_auth_headers, +): + 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, + "message": message, + }, + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + assert "alert_group_id" in response.json() + @pytest.mark.django_db def test_direct_paging_existing_alert_group( make_organization_and_user_with_plugin_token, make_user, - make_schedule, make_alert_receive_channel, make_alert_group, make_user_auth_headers, @@ -84,14 +107,6 @@ def test_direct_paging_existing_alert_group( }, ] - schedules_to_page = [ - {"id": make_schedule(organization, schedule_class=OnCallScheduleICal).public_primary_key, "important": False}, - { - "id": make_schedule(organization, schedule_class=OnCallScheduleCalendar).public_primary_key, - "important": True, - }, - ] - alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) @@ -100,7 +115,7 @@ def test_direct_paging_existing_alert_group( response = client.post( url, - data={"users": users_to_page, "schedules": schedules_to_page, "alert_group_id": alert_group.public_primary_key}, + data={"users": users_to_page, "alert_group_id": alert_group.public_primary_key}, format="json", **make_user_auth_headers(user, token), ) @@ -109,43 +124,10 @@ def test_direct_paging_existing_alert_group( assert response.json()["alert_group_id"] == alert_group.public_primary_key -@pytest.mark.django_db -def test_direct_paging_existing_alert_group_and_escalation_chain( - make_organization_and_user_with_plugin_token, - make_user, - make_schedule, - make_escalation_chain, - make_alert_receive_channel, - make_alert_group, - make_user_auth_headers, -): - organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) - escalation_chain_to_page = make_escalation_chain(organization) - - alert_receive_channel = make_alert_receive_channel(organization) - alert_group = make_alert_group(alert_receive_channel) - - client = APIClient() - url = reverse("api-internal:direct_paging") - - response = client.post( - url, - data={ - "escalation_chain_id": escalation_chain_to_page.public_primary_key, - "alert_group_id": alert_group.public_primary_key, - }, - format="json", - **make_user_auth_headers(user, token), - ) - - assert response.status_code == status.HTTP_400_BAD_REQUEST - - @pytest.mark.django_db def test_direct_paging_existing_alert_group_resolved( make_organization_and_user_with_plugin_token, make_user, - make_schedule, make_alert_receive_channel, make_alert_group, make_user_auth_headers, @@ -180,34 +162,63 @@ def test_direct_paging_existing_alert_group_resolved( @pytest.mark.django_db -def test_direct_paging_no_title( +def test_direct_paging_no_user_or_team_specified( make_organization_and_user_with_plugin_token, - make_user, - make_schedule, - make_alert_group, make_user_auth_headers, ): - organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) - - users_to_page = [ - { - "id": make_user(organization=organization, role=LegacyAccessControlRole.ADMIN).public_primary_key, - "important": False, - }, - ] - - schedules_to_page = [ - {"id": make_schedule(organization, schedule_class=OnCallScheduleICal).public_primary_key, "important": False}, - ] + _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) client = APIClient() url = reverse("api-internal:direct_paging") response = client.post( url, - data={"users": users_to_page, "schedules": schedules_to_page}, + data={ + "team": None, + "users": [], + }, format="json", **make_user_auth_headers(user, token), ) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL + + +@pytest.mark.django_db +def test_direct_paging_alert_group_id_and_message_or_title_are_mutually_exclusive( + make_organization_and_user_with_plugin_token, + make_team, + make_user_auth_headers, + make_alert_receive_channel, + make_alert_group, +): + error_msg = "alert_group_id and (title, message) are mutually exclusive" + + 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) + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel, resolved=True) + + client = APIClient() + url = reverse("api-internal:direct_paging") + + base_data = {"team": team.public_primary_key, "alert_group_id": alert_group.public_primary_key} + + response = client.post( + url, data={**base_data, "message": message}, 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] + + response = client.post( + url, data={**base_data, "title": title}, 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/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py index 3385f0fd..14bbd227 100644 --- a/engine/apps/api/tests/test_schedules.py +++ b/engine/apps/api/tests/test_schedules.py @@ -2204,7 +2204,7 @@ def test_get_schedule_on_call_now( url = reverse("api-internal:schedule-list") with patch( "apps.api.views.schedule.get_oncall_users_for_multiple_schedules", - return_value={schedule.pk: [user]}, + return_value={schedule: [user]}, ): response = client.get(url, format="json", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/tests/test_team.py b/engine/apps/api/tests/test_team.py index 7b611e5c..a5a93a9a 100644 --- a/engine/apps/api/tests/test_team.py +++ b/engine/apps/api/tests/test_team.py @@ -1,10 +1,14 @@ +from unittest.mock import patch + import pytest from django.urls import reverse +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient +from apps.alerts.models import AlertReceiveChannel from apps.api.permissions import LegacyAccessControlRole -from apps.schedules.models import OnCallScheduleCalendar +from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleWeb from apps.user_management.models import Team GENERAL_TEAM = Team(public_primary_key="null", name="No team", email=None, avatar_url=None) @@ -17,6 +21,7 @@ def get_payload_from_team(team): "email": team.email, "avatar_url": team.avatar_url, "is_sharing_resources_to_all": team.is_sharing_resources_to_all, + "number_of_users_currently_oncall": 0, } @@ -35,14 +40,125 @@ def test_list_teams( team = make_team(organization) team.users.add(user) + general_team_payload = get_payload_from_team(GENERAL_TEAM) + team_payload = get_payload_from_team(team) + client = APIClient() url = reverse("api-internal:team-list") response = client.get(url, format="json", **make_user_auth_headers(user, token)) - expected_payload = [get_payload_from_team(GENERAL_TEAM), get_payload_from_team(team)] + assert response.status_code == status.HTTP_200_OK + assert response.json() == [general_team_payload, team_payload] + + url = reverse("api-internal:team-list") + response = client.get(f"{url}?include_no_team=false", format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK - assert response.json() == expected_payload + assert response.json() == [team_payload] + + +@pytest.mark.django_db +def test_list_teams_only_include_notifiable_teams( + make_organization, + make_team, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + make_alert_receive_channel, +): + organization = make_organization() + user = make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + team1 = make_team(organization) + team2 = make_team(organization) + + user.teams.set([team1, team2]) + + arc1 = make_alert_receive_channel( + organization, team=team1, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING + ) + make_alert_receive_channel(organization, team=team2, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) + + client = APIClient() + url = reverse("api-internal:team-list") + + with patch("apps.api.views.team.integration_is_notifiable", side_effect=lambda obj: obj.id == arc1.id): + response = client.get( + f"{url}?only_include_notifiable_teams=true&include_no_team=false", + format="json", + **make_user_auth_headers(user, token), + ) + assert response.status_code == status.HTTP_200_OK + assert response.json() == [get_payload_from_team(team1)] + + +@pytest.mark.django_db +def test_teams_number_of_users_currently_oncall_attribute_works_properly( + make_organization, + make_team, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization = make_organization() + user1 = make_user_for_organization(organization) + user2 = make_user_for_organization(organization) + user3 = make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + + team1 = make_team(organization) + team2 = make_team(organization) + team3 = make_team(organization) + + team1.users.set([user1, user2, user3]) + team2.users.set([user1]) + team3.users.set([user3]) + + def _make_schedule(team=None, oncall_users=[]): + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) + + if team: + schedule.team = team + schedule.save() + + if oncall_users: + on_call_shift = make_on_call_shift( + organization=organization, + shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + start=today, + rotation_start=today, + duration=timezone.timedelta(seconds=24 * 60 * 60), + priority_level=1, + frequency=CustomOnCallShift.FREQUENCY_DAILY, + schedule=schedule, + ) + on_call_shift.add_rolling_users([oncall_users]) + schedule.refresh_ical_file() + schedule.refresh_ical_final_schedule() + + _make_schedule(team=team1, oncall_users=[user1, user2]) + _make_schedule(team=team2, oncall_users=[user1]) + _make_schedule(team=team3, oncall_users=[]) + + client = APIClient() + url = reverse("api-internal:team-list") + + response = client.get(url, format="json", **make_user_auth_headers(user1, token)) + + number_of_oncall_users = { + team1.public_primary_key: 2, + team2.public_primary_key: 1, + team3.public_primary_key: 0, + "null": 0, # this covers the case of "No team" + } + + for team in response.json(): + assert team["number_of_users_currently_oncall"] == number_of_oncall_users[team["id"]] @pytest.mark.django_db diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 924cbcee..e918762c 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -1591,31 +1591,6 @@ def test_invalid_working_hours( assert response.status_code == status.HTTP_400_BAD_REQUEST -@pytest.mark.django_db -def test_check_availability(make_organization_and_user_with_plugin_token, make_user_auth_headers): - _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) - - client = APIClient() - url = reverse("api-internal:user-check-availability", kwargs={"pk": user.public_primary_key}) - - response = client.get(url, **make_user_auth_headers(user, token)) - - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_check_availability_other_user(make_organization_and_user_with_plugin_token, make_user, make_user_auth_headers): - _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) - user_to_check = make_user(organization=user.organization, role=LegacyAccessControlRole.ADMIN) - - client = APIClient() - url = reverse("api-internal:user-check-availability", kwargs={"pk": user_to_check.public_primary_key}) - - response = client.get(url, **make_user_auth_headers(user, token)) - - assert response.status_code == status.HTTP_200_OK - - @patch("apps.phone_notifications.phone_backend.PhoneBackend.send_verification_sms", return_value=Mock()) @patch("apps.phone_notifications.phone_backend.PhoneBackend.verify_phone_number", return_value=True) @patch( @@ -1922,3 +1897,106 @@ def test_upcoming_shifts_multiple_schedules( assert returned_data[i]["is_oncall"] is False assert returned_data[i]["current_shift"] is None assert returned_data[i]["next_shift"]["start"] == expected_start + + +@pytest.mark.django_db +def test_users_is_currently_oncall_attribute_works_properly( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization = make_organization() + user1 = make_user_for_organization(organization) + user2 = make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + ) + + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + + on_call_shift = make_on_call_shift( + organization=organization, + shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + start=today, + rotation_start=today, + duration=timezone.timedelta(seconds=24 * 60 * 60), + priority_level=1, + frequency=CustomOnCallShift.FREQUENCY_DAILY, + schedule=schedule, + ) + on_call_shift.add_rolling_users([[user1]]) + schedule.refresh_ical_file() + schedule.refresh_ical_final_schedule() + + client = APIClient() + url = f"{reverse('api-internal:user-list')}?short=false" + response = client.get(url, format="json", **make_user_auth_headers(user1, token)) + + oncall_statuses = { + user1.public_primary_key: True, + user2.public_primary_key: False, + } + + for user in response.json()["results"]: + assert user["teams"] == [] + assert user["is_currently_oncall"] == oncall_statuses[user["pk"]] + + +@pytest.mark.django_db +def test_list_users_filtered_by_is_currently_oncall( + make_organization, + make_user_for_organization, + make_token_for_organization, + make_user_auth_headers, + make_schedule, + make_on_call_shift, +): + organization = make_organization() + user1 = make_user_for_organization(organization) + user2 = make_user_for_organization(organization) + _, token = make_token_for_organization(organization) + + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + ) + + today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + + on_call_shift = make_on_call_shift( + organization=organization, + shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, + start=today, + rotation_start=today, + duration=timezone.timedelta(seconds=24 * 60 * 60), + priority_level=1, + frequency=CustomOnCallShift.FREQUENCY_DAILY, + schedule=schedule, + ) + on_call_shift.add_rolling_users([[user1]]) + schedule.refresh_ical_file() + schedule.refresh_ical_final_schedule() + + client = APIClient() + url = reverse("api-internal:user-list") + + response = client.get(f"{url}?is_currently_oncall=true", format="json", **make_user_auth_headers(user1, token)) + + response = response.json()["results"] + assert len(response) == 1 + assert response[0]["pk"] == user1.public_primary_key + + response = client.get(f"{url}?is_currently_oncall=false", format="json", **make_user_auth_headers(user1, token)) + + response = response.json()["results"] + user = response[0] + + assert len(response) == 1 + assert user["pk"] == user2.public_primary_key + assert user["teams"] == [] diff --git a/engine/apps/api/views/paging.py b/engine/apps/api/views/paging.py index 0f54f54d..ff48a648 100644 --- a/engine/apps/api/views/paging.py +++ b/engine/apps/api/views/paging.py @@ -3,7 +3,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.alerts.paging import DirectPagingAlertGroupResolvedError, direct_paging +from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError, direct_paging from apps.api.permissions import RBACPermission from apps.api.serializers.paging import DirectPagingSerializer from apps.auth_token.auth import PluginAuthentication @@ -20,30 +20,26 @@ class DirectPagingAPIView(APIView): def post(self, request): organization = request.auth.organization - from_user = request.user serializer = DirectPagingSerializer( data=request.data, context={"organization": organization, "request": request} ) serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data - users = [(user["instance"], user["important"]) for user in serializer.validated_data["users"]] - schedules = [ - (schedule["instance"], schedule["important"]) for schedule in serializer.validated_data["schedules"] - ] try: alert_group = direct_paging( organization=organization, - team=serializer.validated_data["team"], - from_user=from_user, - title=serializer.validated_data["title"], - message=serializer.validated_data["message"], - users=users, - schedules=schedules, - escalation_chain=serializer.validated_data["escalation_chain"], - alert_group=serializer.validated_data["alert_group"], + from_user=request.user, + message=validated_data["message"], + title=validated_data["title"], + 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(data={"alert_group_id": alert_group.public_primary_key}, status=status.HTTP_200_OK) diff --git a/engine/apps/api/views/team.py b/engine/apps/api/views/team.py index d0197504..4e6dd481 100644 --- a/engine/apps/api/views/team.py +++ b/engine/apps/api/views/team.py @@ -1,12 +1,15 @@ +from django.utils.functional import cached_property from rest_framework import mixins, viewsets from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from apps.alerts.paging import integration_is_notifiable from apps.api.permissions import RBACPermission from apps.api.serializers.team import TeamSerializer from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication +from apps.schedules.ical_utils import get_oncall_users_for_multiple_schedules from apps.user_management.models import Team from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -30,12 +33,41 @@ class TeamViewSet(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.UpdateMod def get_queryset(self): return self.request.user.available_teams + @cached_property + def schedules_with_oncall_users(self): + """ + The result of this method is cached and is reused for the whole lifetime of a request, + since self.get_serializer_context() is called multiple times for every instance in the queryset. + """ + team_ids = [t.id for t in self.filter_queryset(self.get_queryset())] + team_schedules = self.request.user.organization.oncall_schedules.filter(team__id__in=team_ids) + return get_oncall_users_for_multiple_schedules(team_schedules) + + def get_serializer_context(self): + context = super().get_serializer_context() + context.update({"schedules_with_oncall_users": self.schedules_with_oncall_users}) + return context + def list(self, request, *args, **kwargs): - """ - Adds general team to the queryset in a way that it always shows up first (even when not searched for). - """ - general_team = Team(public_primary_key="null", name="No team", email=None, avatar_url=None) - queryset = [general_team] + list(self.filter_queryset(self.get_queryset())) + general_team = [Team(public_primary_key="null", name="No team", email=None, avatar_url=None)] + queryset = self.filter_queryset(self.get_queryset()) + + if self.request.query_params.get("only_include_notifiable_teams", "false") == "true": + # filters down to only teams that have a direct paging integration that is "notifiable" + orgs_direct_paging_integrations = self.request.user.organization.get_direct_paging_integrations() + notifiable_direct_paging_integrations = [ + i for i in orgs_direct_paging_integrations if integration_is_notifiable(i) + ] + team_ids = [i.team.pk for i in notifiable_direct_paging_integrations if i.team is not None] + + queryset = queryset.filter(pk__in=team_ids) + + teams = list(queryset) + if self.request.query_params.get("include_no_team", "true") != "false": + # Adds general team to the queryset in a way that it always shows up first (even when not searched for). + queryset = general_team + teams + else: + queryset = teams serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 7c39364d..b6df3df3 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -6,6 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.utils import IntegrityError from django.urls import reverse from django.utils import timezone +from django.utils.functional import cached_property from django_filters import rest_framework as filters from rest_framework import mixins, status, viewsets from rest_framework.decorators import action @@ -15,7 +16,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from apps.alerts.paging import check_user_availability from apps.api.permissions import ( ALL_PERMISSION_CHOICES, IsOwnerOrHasRBACPermissions, @@ -29,6 +29,7 @@ from apps.api.serializers.user import ( CurrentUserSerializer, FilterUserSerializer, UserHiddenFieldsSerializer, + UserLongSerializer, UserSerializer, ) from apps.api.throttlers import ( @@ -57,6 +58,7 @@ from apps.phone_notifications.exceptions import ( ProviderNotSupports, ) from apps.phone_notifications.phone_backend import PhoneBackend +from apps.schedules.ical_utils import get_oncall_users_for_multiple_schedules from apps.schedules.models import OnCallSchedule from apps.telegram.client import TelegramClient from apps.telegram.models import TelegramVerificationCode @@ -219,18 +221,39 @@ class UserView( "^username", "^slack_user_identity__cached_slack_login", "^slack_user_identity__cached_name", + "^teams__name", ) filterset_class = UserFilter + @cached_property + def schedules_with_oncall_users(self): + """ + The result of this method is cached and is reused for the whole lifetime of a request, + since self.get_serializer_context() is called multiple times for every instance in the queryset. + """ + return get_oncall_users_for_multiple_schedules(self.request.user.organization.oncall_schedules.all()) + + def get_serializer_context(self): + context = super().get_serializer_context() + context.update({"schedules_with_oncall_users": self.schedules_with_oncall_users}) + return context + def get_serializer_class(self): request = self.request user = request.user kwargs = self.kwargs + query_params = request.query_params - is_filters_request = request.query_params.get("filters", "false") == "true" - if self.action in ["list"] and is_filters_request: + is_list_request = self.action in ["list"] + is_filters_request = query_params.get("filters", "false") == "true" + is_short_request = query_params.get("short", "true") == "false" + is_currently_oncall_request = query_params.get("is_currently_oncall", "").lower() in ["true", "false"] + + if is_list_request and is_filters_request: return self.get_filter_serializer_class() + elif is_list_request and (is_short_request or is_currently_oncall_request): + return UserLongSerializer is_users_own_data = kwargs.get("pk") is not None and kwargs.get("pk") == user.public_primary_key has_admin_permission = user_is_authorized(user, [RBACPermission.Permissions.USER_SETTINGS_ADMIN]) @@ -253,9 +276,24 @@ class UserView( def list(self, request, *args, **kwargs) -> Response: queryset = self.filter_queryset(self.get_queryset()) + + is_currently_oncall_query_param = request.query_params.get("is_currently_oncall", "").lower() + + def _get_oncall_user_ids(): + return {user.pk for _, users in self.schedules_with_oncall_users.items() for user in users} + + if is_currently_oncall_query_param == "true": + # client explicitly wants to filter out users that are on-call + queryset = queryset.filter(pk__in=_get_oncall_user_ids()) + elif is_currently_oncall_query_param == "false": + # user explicitly wants to filter out on-call users + queryset = queryset.exclude(pk__in=_get_oncall_user_ids()) + page = self.paginate_queryset(queryset) + if page is not None: - context = {"request": self.request, "format": self.format_kwarg, "view": self} + context = self.get_serializer_context() + if settings.IS_OPEN_SOURCE: if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: from apps.oss_installation.models import CloudConnector, CloudUserIdentity @@ -274,7 +312,8 @@ class UserView( return Response(serializer.data) def retrieve(self, request, *args, **kwargs) -> Response: - context = {"request": self.request, "format": self.format_kwarg, "view": self} + context = self.get_serializer_context() + try: instance = self.get_object() except NotFound: @@ -648,12 +687,6 @@ class UserView( return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) - @action(detail=True, methods=["get"]) - def check_availability(self, request, pk) -> Response: - user = self.get_object() - warnings = check_user_availability(user=user) - return Response(data={"warnings": warnings}, status=status.HTTP_200_OK) - def handle_phone_notificator_failed(exc: BaseFailed) -> Response: if exc.graceful_msg: diff --git a/engine/apps/schedules/ical_utils.py b/engine/apps/schedules/ical_utils.py index 6e04dc99..700645aa 100644 --- a/engine/apps/schedules/ical_utils.py +++ b/engine/apps/schedules/ical_utils.py @@ -46,7 +46,6 @@ if TYPE_CHECKING: from apps.schedules.models import OnCallSchedule from apps.schedules.models.on_call_schedule import OnCallScheduleQuerySet from apps.user_management.models import Organization, User - from apps.user_management.models.user import UserQuerySet logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -62,6 +61,7 @@ DatetimeInterval = namedtuple("DatetimeInterval", ["start", "end"]) DatetimeIntervals = typing.List[DatetimeInterval] IcalEvents = typing.List[IcalEvent] +SchedulesOnCallUsers = typing.Dict["OnCallSchedule", typing.List["User"]] def users_in_ical( @@ -368,7 +368,7 @@ def list_users_to_notify_from_ical_for_period( def get_oncall_users_for_multiple_schedules( schedules: typing.List["OnCallSchedule"], events_datetime=None -) -> typing.Dict["OnCallSchedule", UserQuerySet]: +) -> SchedulesOnCallUsers: if events_datetime is None: events_datetime = datetime.datetime.now(timezone.utc) @@ -377,11 +377,11 @@ def get_oncall_users_for_multiple_schedules( return {} # Get on-call users - oncall_users = {} + oncall_users: SchedulesOnCallUsers = {} for schedule in schedules: # pass user list to list_users_to_notify_from_ical schedule_oncall_users = list_users_to_notify_from_ical(schedule, events_datetime=events_datetime) - oncall_users.update({schedule.pk: schedule_oncall_users}) + oncall_users.update({schedule: schedule_oncall_users}) return oncall_users diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index 4f872bb7..1f904d0e 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -1297,7 +1297,7 @@ def test_get_oncall_users_for_empty_schedule( schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar) schedules = OnCallSchedule.objects.filter(pk=schedule.pk) - assert list(schedules.get_oncall_users()[schedule.pk]) == [] + assert list(schedules.get_oncall_users()[schedule]) == [] @pytest.mark.django_db @@ -1413,7 +1413,7 @@ def test_get_oncall_users_for_multiple_schedules_emails_case_insensitive( oncall_users = schedules.get_oncall_users(events_datetime=events_datetime) assert len(oncall_users) == 1 - assert list(oncall_users[schedule.pk]) == [user] + assert list(oncall_users[schedule]) == [user] @pytest.mark.django_db diff --git a/engine/apps/slack/scenarios/manage_responders.py b/engine/apps/slack/scenarios/manage_responders.py index b6c16a69..87a71ea5 100644 --- a/engine/apps/slack/scenarios/manage_responders.py +++ b/engine/apps/slack/scenarios/manage_responders.py @@ -1,15 +1,13 @@ import json import typing -from apps.alerts.paging import DirectPagingAlertGroupResolvedError, check_user_availability, direct_paging, unpage_user +from apps.alerts.paging import DirectPagingAlertGroupResolvedError, direct_paging, unpage_user, user_is_oncall from apps.slack.constants import DIVIDER from apps.slack.scenarios import scenario_step from apps.slack.scenarios.paging import ( - DIRECT_PAGING_SCHEDULE_SELECT_ID, DIRECT_PAGING_USER_SELECT_ID, + _display_confirm_participant_invitation_view, _generate_input_id_prefix, - _get_availability_warnings_view, - _get_schedules_select, _get_select_field_value, _get_users_select, ) @@ -18,12 +16,10 @@ from apps.slack.types import Block, BlockActionType, EventPayload, ModalView, Pa if typing.TYPE_CHECKING: from apps.alerts.models import AlertGroup - from apps.schedules.models import OnCallSchedule from apps.slack.models import SlackTeamIdentity, SlackUserIdentity from apps.user_management.models import User MANAGE_RESPONDERS_USER_SELECT_ID = "responders_user_select" -MANAGE_RESPONDERS_SCHEDULE_SELECT_ID = "responders_schedule_select" USER_DATA_KEY = "user" ALERT_GROUP_DATA_KEY = "alert_group_pk" @@ -62,25 +58,23 @@ class ManageRespondersUserChange(scenario_step.ScenarioStep): selected_user = _get_selected_user_from_payload(payload) organization = alert_group.channel.organization - # check availability - availability_warnings = check_user_availability(selected_user) - if availability_warnings: - # display warnings and require additional confirmation - view = _get_availability_warnings_view( - availability_warnings, - organization, - selected_user, - ManageRespondersConfirmUserChange.routing_uid(), - json.dumps({USER_DATA_KEY: selected_user.id, ALERT_GROUP_DATA_KEY: alert_group.pk}), + # check if user is on-call + if not user_is_oncall(selected_user): + # display additional confirmation modal + private_metadata = json.dumps({USER_DATA_KEY: selected_user.id, ALERT_GROUP_DATA_KEY: alert_group.pk}) + view = _display_confirm_participant_invitation_view( + ManageRespondersConfirmUserChange.routing_uid(), private_metadata ) + self._slack_client.views_push(trigger_id=payload["trigger_id"], view=view) else: try: # no warnings, proceed with paging direct_paging( organization=organization, - team=alert_group.channel.team, from_user=slack_user_identity.get_user(organization), + message=None, # reuse the message from the original alert + team=alert_group.channel.team, users=[(selected_user, False)], alert_group=alert_group, ) @@ -111,8 +105,9 @@ class ManageRespondersConfirmUserChange(scenario_step.ScenarioStep): try: direct_paging( organization=organization, - team=alert_group.channel.team, from_user=slack_user_identity.get_user(organization), + message=None, # reuse the message from the original alert + team=alert_group.channel.team, users=[(selected_user, False)], alert_group=alert_group, ) @@ -127,38 +122,6 @@ class ManageRespondersConfirmUserChange(scenario_step.ScenarioStep): ) -class ManageRespondersScheduleChange(scenario_step.ScenarioStep): - """Handle schedule selection in responders modal.""" - - def process_scenario( - self, - slack_user_identity: "SlackUserIdentity", - slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, - ) -> None: - alert_group = _get_alert_group_from_payload(payload) - selected_schedule = _get_selected_schedule_from_payload(payload) - organization = alert_group.channel.organization - - try: - direct_paging( - organization=organization, - team=alert_group.channel.team, - from_user=slack_user_identity.get_user(organization), - schedules=[(selected_schedule, False)], - alert_group=alert_group, - ) - view = render_dialog(alert_group) - except DirectPagingAlertGroupResolvedError: - view = render_dialog(alert_group, alert_group_resolved_warning=True) - - self._slack_client.views_update( - trigger_id=payload["trigger_id"], - view=view, - view_id=payload["view"]["id"], - ) - - class ManageRespondersRemoveUser(scenario_step.ScenarioStep): """Handle user removal in responders modal.""" @@ -195,12 +158,12 @@ def render_dialog(alert_group: "AlertGroup", alert_group_resolved_warning=False) Block.Section, { "type": "section", - "text": {"type": "mrkdwn", "text": f":bust_in_silhouette: *{user.name or user.username}*"}, + "text": {"type": "mrkdwn", "text": f":bust_in_silhouette: *{user['name'] or user['username']}*"}, "accessory": { "type": "button", "text": {"type": "plain_text", "text": "Remove", "emoji": True}, "action_id": ManageRespondersRemoveUser.routing_uid(), - "value": str(user.pk), + "value": str(user["id"]), }, }, ), @@ -220,16 +183,11 @@ def render_dialog(alert_group: "AlertGroup", alert_group_resolved_warning=False) ), ] - # Show user and schedule dropdowns + # Show user dropdown input_id_prefix = _generate_input_id_prefix() - blocks += [ + blocks.append( _get_users_select(alert_group.channel.organization, input_id_prefix, ManageRespondersUserChange.routing_uid()) - ] - blocks += [ - _get_schedules_select( - alert_group.channel.organization, input_id_prefix, ManageRespondersScheduleChange.routing_uid() - ) - ] + ) view: ModalView = { "type": "modal", @@ -262,17 +220,6 @@ def _get_selected_user_from_payload(payload: EventPayload) -> "User": return User.objects.get(pk=selected_user_id) -def _get_selected_schedule_from_payload(payload: EventPayload) -> "OnCallSchedule": - from apps.schedules.models import OnCallSchedule - - input_id_prefix = json.loads(payload["view"]["private_metadata"])["input_id_prefix"] - selected_schedule_id = _get_select_field_value( - payload, input_id_prefix, ManageRespondersScheduleChange.routing_uid(), DIRECT_PAGING_SCHEDULE_SELECT_ID - ) - - return OnCallSchedule.objects.get(pk=selected_schedule_id) - - def _get_alert_group_from_payload(payload: EventPayload) -> "AlertGroup": from apps.alerts.models import AlertGroup @@ -292,12 +239,6 @@ STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ "view_callback_id": ManageRespondersConfirmUserChange.routing_uid(), "step": ManageRespondersConfirmUserChange, }, - { - "payload_type": PayloadType.BLOCK_ACTIONS, - "block_action_type": BlockActionType.STATIC_SELECT, - "block_action_id": ManageRespondersScheduleChange.routing_uid(), - "step": ManageRespondersScheduleChange, - }, { "payload_type": PayloadType.BLOCK_ACTIONS, "block_action_type": BlockActionType.BUTTON, diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index 0ad4c7fe..dd40ac62 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -5,16 +5,11 @@ from uuid import uuid4 from django.conf import settings from django.db.models import Model, QuerySet +from rest_framework.response import Response -from apps.alerts.models import AlertReceiveChannel, EscalationChain -from apps.alerts.paging import ( - AvailabilityWarning, - PagingError, - ScheduleNotifications, - UserNotifications, - check_user_availability, - direct_paging, -) +from apps.alerts.models import AlertReceiveChannel +from apps.alerts.paging import DirectPagingUserTeamValidationError, UserNotifications, direct_paging, user_is_oncall +from apps.schedules.ical_utils import get_oncall_users_for_multiple_schedules from apps.slack.constants import DIVIDER, PRIVATE_METADATA_MAX_LENGTH from apps.slack.errors import SlackAPIChannelNotFoundError from apps.slack.scenarios import scenario_step @@ -32,7 +27,6 @@ from apps.slack.types import ( if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager - from apps.schedules.models import OnCallSchedule from apps.slack.models import SlackTeamIdentity, SlackUserIdentity from apps.user_management.models import Organization, Team, User @@ -40,10 +34,7 @@ if typing.TYPE_CHECKING: DIRECT_PAGING_TEAM_SELECT_ID = "paging_team_select" DIRECT_PAGING_ORG_SELECT_ID = "paging_org_select" DIRECT_PAGING_USER_SELECT_ID = "paging_user_select" -DIRECT_PAGING_SCHEDULE_SELECT_ID = "paging_schedule_select" -DIRECT_PAGING_TITLE_INPUT_ID = "paging_title_input" DIRECT_PAGING_MESSAGE_INPUT_ID = "paging_message_input" -DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID = "paging_additional_responders_input" DEFAULT_TEAM_VALUE = "default_team" @@ -65,11 +56,10 @@ ITEM_ACTIONS = ( ) -# helpers to manage current selected users/schedules state +# helpers to manage current selected users state class DataKey(enum.StrEnum): - SCHEDULES = "schedules" USERS = "users" @@ -97,7 +87,7 @@ def remove_item(payload: EventPayload, key: DataKey, item_pk: str) -> EventPaylo def reset_items(payload: EventPayload) -> EventPayload: metadata = json.loads(payload["view"]["private_metadata"]) - for key in (DataKey.USERS, DataKey.SCHEDULES): + for key in (DataKey.USERS,): metadata[key] = {} payload["view"]["private_metadata"] = json.dumps(metadata) return payload @@ -143,7 +133,6 @@ class StartDirectPaging(scenario_step.ScenarioStep): "input_id_prefix": input_id_prefix, "submit_routing_uid": FinishDirectPaging.routing_uid(), DataKey.USERS: {}, - DataKey.SCHEDULES: {}, } initial_payload = {"view": {"private_metadata": json.dumps(private_metadata)}} view = render_dialog(slack_user_identity, slack_team_identity, initial_payload, initial=True) @@ -162,7 +151,6 @@ class FinishDirectPaging(scenario_step.ScenarioStep): slack_team_identity: "SlackTeamIdentity", payload: EventPayload, ) -> None: - title = _get_title_from_payload(payload) message = _get_message_from_payload(payload) private_metadata = json.loads(payload["view"]["private_metadata"]) channel_id = private_metadata["channel_id"] @@ -173,33 +161,46 @@ class FinishDirectPaging(scenario_step.ScenarioStep): _, selected_team = _get_selected_team_from_payload(payload, input_id_prefix) user = slack_user_identity.get_user(selected_organization) - # Only pass users/schedules if additional responders checkbox is checked - selected_users: UserNotifications | None = None - selected_schedules: ScheduleNotifications | None = None + selected_users: UserNotifications = [ + (u, p == Policy.IMPORTANT) + for u, p in get_current_items(payload, DataKey.USERS, selected_organization.users) + ] - is_additional_responders_checked = _get_additional_responders_checked_from_payload(payload, input_id_prefix) - if is_additional_responders_checked: - selected_users = [ - (u, p == Policy.IMPORTANT) - for u, p in get_current_items(payload, DataKey.USERS, selected_organization.users) - ] - selected_schedules = [ - (s, p == Policy.IMPORTANT) - for s, p in get_current_items(payload, DataKey.SCHEDULES, selected_organization.oncall_schedules) + # trigger direct paging to selected team + users + try: + alert_group = direct_paging( + organization=selected_organization, + from_user=user, + message=message, + team=selected_team, + users=selected_users, + ) + except DirectPagingUserTeamValidationError: + # show validation warning messages + validation_errors: Block.AnyBlocks = [ + typing.cast( + Block.Section, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":warning: At least one team or one user must be selected to directly page", + }, + }, + ) ] - # trigger direct paging to selected team + users/schedules - alert_group = direct_paging( - selected_organization, - selected_team, - user, - title, - message, - selected_users, - selected_schedules, - ) + return Response( + { + "response_action": "update", + "view": render_dialog( + slack_user_identity, slack_team_identity, payload, validation_errors=validation_errors + ), + }, + status=200, + ) - text = ":white_check_mark: Alert group *{}* created: {}".format(title, alert_group.web_link) + text = f":white_check_mark: Escalation created: {alert_group.web_link}" try: self._slack_client.chat_postEphemeral( @@ -253,14 +254,10 @@ class OnPagingTeamChange(scenario_step.ScenarioStep): ) -class OnPagingCheckAdditionalResponders(OnPagingOrgChange): - """Check/uncheck additional responders checkbox.""" - - class OnPagingUserChange(scenario_step.ScenarioStep): """Add selected to user to the list. - It will perform a user availability check, pushing a new modal for additional confirmation if needed. + It will check to see if the user is on-call, pushing a new modal for additional confirmation if needed. """ def process_scenario( @@ -270,21 +267,30 @@ class OnPagingUserChange(scenario_step.ScenarioStep): payload: EventPayload, ) -> None: private_metadata = json.loads(payload["view"]["private_metadata"]) - selected_organization = _get_selected_org_from_payload( - payload, private_metadata["input_id_prefix"], slack_team_identity, slack_user_identity - ) selected_user = _get_selected_user_from_payload(payload, private_metadata["input_id_prefix"]) if selected_user is None: return - # check availability - availability_warnings = check_user_availability(selected_user) - if availability_warnings: - # display warnings and require additional confirmation - view = _display_availability_warnings(payload, availability_warnings, selected_organization, selected_user) + # check if user is on-call + if not user_is_oncall(selected_user): + # display additional confirmation modal + metadata = metadata = json.loads(payload["view"]["private_metadata"]) + private_metadata = json.dumps( + { + "state": payload["view"]["state"], + "input_id_prefix": metadata["input_id_prefix"], + "channel_id": metadata["channel_id"], + "submit_routing_uid": metadata["submit_routing_uid"], + DataKey.USERS: metadata[DataKey.USERS], + } + ) + + view = _display_confirm_participant_invitation_view( + OnPagingConfirmUserChange.routing_uid(), private_metadata + ) self._slack_client.views_push(trigger_id=payload["trigger_id"], view=view) else: - # user is available to be paged + # user is currently on-call error_msg = None try: updated_payload = add_or_update_item(payload, DataKey.USERS, selected_user.pk, Policy.DEFAULT) @@ -329,7 +335,7 @@ class OnPagingItemActionChange(scenario_step.ScenarioStep): class OnPagingConfirmUserChange(scenario_step.ScenarioStep): - """Confirm user selection despite not being available.""" + """Confirm user selection despite not being on-call.""" def process_scenario( self, @@ -345,7 +351,6 @@ class OnPagingConfirmUserChange(scenario_step.ScenarioStep): "input_id_prefix": metadata["input_id_prefix"], "submit_routing_uid": metadata["submit_routing_uid"], DataKey.USERS: metadata[DataKey.USERS], - DataKey.SCHEDULES: metadata[DataKey.SCHEDULES], } previous_view_payload = { "view": { @@ -355,6 +360,7 @@ class OnPagingConfirmUserChange(scenario_step.ScenarioStep): } # add selected user selected_user = _get_selected_user_from_payload(previous_view_payload, private_metadata["input_id_prefix"]) + error_msg = None try: updated_payload = add_or_update_item(previous_view_payload, DataKey.USERS, selected_user.pk, Policy.DEFAULT) @@ -369,33 +375,6 @@ class OnPagingConfirmUserChange(scenario_step.ScenarioStep): ) -class OnPagingScheduleChange(scenario_step.ScenarioStep): - """Add selected to user to the list. - - It will perform a user availability check, pushing a new modal for additional confirmation if needed. - """ - - def process_scenario( - self, - slack_user_identity: "SlackUserIdentity", - slack_team_identity: "SlackTeamIdentity", - payload: EventPayload, - ) -> None: - private_metadata = json.loads(payload["view"]["private_metadata"]) - selected_schedule = _get_selected_schedule_from_payload(payload, private_metadata["input_id_prefix"]) - if selected_schedule is None: - return - - error_msg = None - try: - updated_payload = add_or_update_item(payload, DataKey.SCHEDULES, selected_schedule.pk, Policy.DEFAULT) - except ValueError: - updated_payload = payload - error_msg = "Cannot add schedule, maximum responders exceeded" - view = render_dialog(slack_user_identity, slack_team_identity, updated_payload, error_msg=error_msg) - self._slack_client.views_update(trigger_id=payload["trigger_id"], view=view, view_id=payload["view"]["id"]) - - # slack view/blocks rendering helpers @@ -405,6 +384,7 @@ def render_dialog( payload: EventPayload, initial=False, error_msg=None, + validation_errors: typing.Optional[Block.AnyBlocks] = None, ) -> ModalView: private_metadata = json.loads(payload["view"]["private_metadata"]) submit_routing_uid = private_metadata.get("submit_routing_uid") @@ -419,7 +399,6 @@ def render_dialog( new_private_metadata["input_id_prefix"] = new_input_id_prefix selected_organization = available_organizations.first() is_team_selected, selected_team = False, None - is_additional_responders_checked = False else: # setup form using data/state old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata( @@ -429,18 +408,13 @@ def render_dialog( payload, old_input_id_prefix, slack_team_identity, slack_user_identity ) is_team_selected, selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix) - is_additional_responders_checked = _get_additional_responders_checked_from_payload(payload, old_input_id_prefix) - # widgets - team_select_blocks = _get_team_select_blocks( - slack_user_identity, selected_organization, is_team_selected, selected_team, new_input_id_prefix - ) - additional_responders_blocks = _get_additional_responders_blocks( - payload, selected_organization, new_input_id_prefix, is_additional_responders_checked, error_msg - ) + blocks: Block.AnyBlocks = [] - # Add title and message inputs - blocks: Block.AnyBlocks = [_get_title_input(payload), _get_message_input(payload)] + if validation_errors: + blocks += validation_errors + + blocks.append(_get_message_input(payload)) # Add organization select if more than one organization available for user if len(available_organizations) > 1: @@ -450,8 +424,22 @@ def render_dialog( blocks.append(organization_select) # Add team select and additional responders blocks - blocks += team_select_blocks - blocks += additional_responders_blocks + blocks += _get_team_select_blocks( + slack_user_identity, selected_organization, is_team_selected, selected_team, new_input_id_prefix + ) + blocks += _get_user_select_blocks(payload, selected_organization, new_input_id_prefix, error_msg) + + blocks.append( + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "*Note*: you *must* specify at least one team or one user to directly page.", + }, + ], + } + ) return _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) @@ -462,7 +450,7 @@ def _get_form_view(routing_uid: str, blocks: Block.AnyBlocks, private_metadata: "callback_id": routing_uid, "title": { "type": "plain_text", - "text": "Create Alert Group", + "text": "Create Escalation", }, "close": { "type": "plain_text", @@ -553,19 +541,9 @@ def _get_team_select_blocks( teams = user.available_teams team_options: typing.List[CompositionObjectOption] = [] - # Adding pseudo option for default team + initial_option_idx = 0 - team_options.append( - { - "text": { - "type": "plain_text", - "text": "No team", - "emoji": True, - }, - "value": DEFAULT_TEAM_VALUE, - } - ) - for idx, team in enumerate(teams, start=1): + for idx, team in enumerate(teams): if team == value: initial_option_idx = idx team_options.append( @@ -593,94 +571,83 @@ def _get_team_select_blocks( "options": team_options, }, "dispatch_action": True, + "optional": True, } + blocks: Block.AnyBlocks = [team_select] + # No context block if no team selected if not is_selected: - return [team_select] + return blocks team_select["element"]["initial_option"] = team_options[initial_option_idx] - return [team_select, _get_team_select_context(organization, value)] - -def _get_team_select_context(organization: "Organization", team: "Team") -> Block.Context: - team_name = team.name if team else "No team" alert_receive_channel = AlertReceiveChannel.objects.filter( organization=organization, - team=team, + team=value, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, ).first() - escalation_chains_exist = EscalationChain.objects.filter( - channel_filters__alert_receive_channel=alert_receive_channel - ).exists() - - if not alert_receive_channel: - context_text = ( - ":warning: *Direct paging integration missing*\n" - "The selected team doesn't have a direct paging integration configured and will not be notified. " - "If you proceed with the alert group, an empty direct paging integration will be created automatically for the team. " - "" - ) - elif not escalation_chains_exist: - context_text = ( - ":warning: *Direct paging integration not configured*\n" - "The direct paging integration for the selected team has no escalation chains configured. " - "If you proceed with the alert group, the team likely will not be notified. " - "" - ) - else: - context_text = f"Integration <{alert_receive_channel.web_link}|{alert_receive_channel.verbal_name} ({team_name})> will be used for notification." - - context: Block.Context = { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": context_text, - } - ], - } - return context - - -def _get_additional_responders_blocks( - payload: EventPayload, - organization: "Organization", - input_id_prefix, - is_additional_responders_checked: bool, - error_msg: str | None, -) -> Block.AnyBlocks: - checkbox_option: CompositionObjectOption = { - "text": { - "type": "plain_text", - "text": "Notify additional responders", - }, - } - - blocks: Block.AnyBlocks = [ - typing.cast( - Block.Input, - { - "type": "input", - "block_id": input_id_prefix + DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID, - "label": { - "type": "plain_text", - "text": "Additional responders", + blocks.append( + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"Integration <{alert_receive_channel.web_link}|{alert_receive_channel.verbal_name}> will be used for notification.", }, - "element": { - "type": "checkboxes", - "options": [checkbox_option], - "action_id": OnPagingCheckAdditionalResponders.routing_uid(), - }, - "optional": True, - "dispatch_action": True, + ], + } + ) + + return blocks + + +def _create_user_option_groups( + users: "RelatedManager['User']", max_options_per_group: int, option_group_label_text_prefix: str +) -> typing.List[CompositionObjectOptionGroup]: + user_options: typing.List[CompositionObjectOption] = [ + { + "text": { + "type": "plain_text", + "text": f"{user.name or user.username}", + "emoji": True, }, - ), + "value": f"{user.pk}", + } + for user in users ] - if is_additional_responders_checked: - blocks[0]["element"]["initial_options"] = [checkbox_option] + chunks = [user_options[x : x + max_options_per_group] for x in range(0, len(user_options), max_options_per_group)] + has_more_than_one_chunk = len(chunks) > 1 + + option_groups: typing.List[CompositionObjectOptionGroup] = [] + for idx, group in enumerate(chunks): + start = idx * max_options_per_group + 1 + end = idx * max_options_per_group + max_options_per_group + + if has_more_than_one_chunk: + label_text = f"{option_group_label_text_prefix} ({start}-{end})" + else: + label_text = option_group_label_text_prefix + + option_groups.append( + { + "label": {"type": "plain_text", "text": label_text}, + "options": group, + } + ) + + return option_groups + + +def _get_user_select_blocks( + payload: EventPayload, + organization: "Organization", + input_id_prefix: str, + error_msg: str | None, +) -> Block.AnyBlocks: + blocks: Block.AnyBlocks = [] if error_msg: blocks += [ @@ -697,128 +664,49 @@ def _get_additional_responders_blocks( ), ] - if is_additional_responders_checked: - users_select = _get_users_select(organization, input_id_prefix, OnPagingUserChange.routing_uid()) - schedules_select = _get_schedules_select(organization, input_id_prefix, OnPagingScheduleChange.routing_uid()) + blocks.append(_get_users_select(organization, input_id_prefix, OnPagingUserChange.routing_uid())) - blocks += [users_select, schedules_select] - # selected items - selected_users = get_current_items(payload, DataKey.USERS, organization.users) - selected_schedules = get_current_items(payload, DataKey.SCHEDULES, organization.oncall_schedules) - - if selected_users or selected_schedules: - blocks += [DIVIDER] - blocks += _get_selected_entries_list(input_id_prefix, DataKey.USERS, selected_users) - blocks += _get_selected_entries_list(input_id_prefix, DataKey.SCHEDULES, selected_schedules) - blocks += [DIVIDER] + # selected items + if selected_users := get_current_items(payload, DataKey.USERS, organization.users): + blocks += [DIVIDER] + blocks += _get_selected_entries_list(input_id_prefix, DataKey.USERS, selected_users) + blocks += [DIVIDER] return blocks def _get_users_select( organization: "Organization", input_id_prefix: str, action_id: str, max_options_per_group=MAX_STATIC_SELECT_OPTIONS -) -> Block.Context | Block.Section: - users = organization.users.all() +) -> Block.Context | Block.Input: + schedules = get_oncall_users_for_multiple_schedules(organization.oncall_schedules.all()) + oncall_user_pks = {user.pk for _, users in schedules.items() for user in users} - user_options: typing.List[CompositionObjectOption] = [ - { - "text": { - "type": "plain_text", - "text": f"{user.name or user.username}", - "emoji": True, - }, - "value": f"{user.pk}", - } - for user in users - ] + oncall_user_option_groups = _create_user_option_groups( + organization.users.filter(pk__in=oncall_user_pks), max_options_per_group, "On-call now" + ) + not_oncall_user_option_groups = _create_user_option_groups( + organization.users.exclude(pk__in=oncall_user_pks), max_options_per_group, "Not on-call" + ) - if not user_options: - return typing.cast( - Block.Context, {"type": "context", "elements": [{"type": "mrkdwn", "text": "No users available"}]} - ) - - user_select: Block.Section = { - "type": "section", - "text": {"type": "mrkdwn", "text": "Notify user"}, + if not oncall_user_option_groups and not not_oncall_user_option_groups: + return {"type": "context", "elements": [{"type": "mrkdwn", "text": "No users available"}]} + return { + "type": "input", "block_id": input_id_prefix + DIRECT_PAGING_USER_SELECT_ID, - "accessory": { + "label": { + "type": "plain_text", + "text": "User(s) to notify", + }, + "element": { "type": "static_select", + "action_id": action_id, "placeholder": {"type": "plain_text", "text": "Select user", "emoji": True}, - "action_id": action_id, + "option_groups": oncall_user_option_groups + not_oncall_user_option_groups, }, + "dispatch_action": True, + "optional": True, } - if len(user_options) > max_options_per_group: - user_select["accessory"]["option_groups"] = _get_option_groups(user_options, max_options_per_group) - else: - user_select["accessory"]["options"] = user_options - - return user_select - - -def _get_schedules_select( - organization: "Organization", input_id_prefix: str, action_id: str, max_options_per_group=MAX_STATIC_SELECT_OPTIONS -) -> Block.Context | Block.Section: - schedules = organization.oncall_schedules.all() - - schedule_options: typing.List[CompositionObjectOption] = [ - { - "text": { - "type": "plain_text", - "text": f"{schedule.name}", - "emoji": True, - }, - "value": f"{schedule.pk}", - } - for schedule in schedules - ] - - if not schedule_options: - return typing.cast( - Block.Context, - { - "type": "context", - "elements": [{"type": "mrkdwn", "text": "No schedules available"}], - }, - ) - - schedule_select: Block.Section = { - "type": "section", - "text": {"type": "mrkdwn", "text": "Notify schedule"}, - "block_id": input_id_prefix + DIRECT_PAGING_SCHEDULE_SELECT_ID, - "accessory": { - "type": "static_select", - "placeholder": {"type": "plain_text", "text": "Select schedule", "emoji": True}, - "action_id": action_id, - }, - } - - if len(schedule_options) > max_options_per_group: - schedule_select["accessory"]["option_groups"] = _get_option_groups(schedule_options, max_options_per_group) - else: - schedule_select["accessory"]["options"] = schedule_options - - return schedule_select - - -def _get_option_groups( - options: typing.List[CompositionObjectOption], max_options_per_group: int -) -> typing.List[CompositionObjectOptionGroup]: - chunks = [options[x : x + max_options_per_group] for x in range(0, len(options), max_options_per_group)] - - option_groups: typing.List[CompositionObjectOptionGroup] = [] - for idx, group in enumerate(chunks): - start = idx * max_options_per_group + 1 - end = idx * max_options_per_group + max_options_per_group - option_groups.append( - { - "label": {"type": "plain_text", "text": f"({start}-{end})"}, - "options": group, - } - ) - - return option_groups - def _get_selected_entries_list( input_id_prefix: str, key: DataKey, entries: typing.List[typing.Tuple[Model, Policy]] @@ -829,11 +717,7 @@ def _get_selected_entries_list( icon = ":bust_in_silhouette:" name = entry.name or entry.username extra = entry.timezone - else: - # schedule - icon = ":spiral_calendar_pad:" - name = entry.name - extra = None + current_entries.append( { "type": "section", @@ -855,69 +739,23 @@ def _get_selected_entries_list( return current_entries -def _display_availability_warnings( - payload: EventPayload, warnings: typing.List[AvailabilityWarning], organization: "Organization", user: "User" -) -> ModalView: - metadata = json.loads(payload["view"]["private_metadata"]) - return _get_availability_warnings_view( - warnings, - organization, - user, - OnPagingConfirmUserChange.routing_uid(), - json.dumps( - { - "state": payload["view"]["state"], - "input_id_prefix": metadata["input_id_prefix"], - "channel_id": metadata["channel_id"], - "submit_routing_uid": metadata["submit_routing_uid"], - DataKey.USERS: metadata[DataKey.USERS], - DataKey.SCHEDULES: metadata[DataKey.SCHEDULES], - } - ), - ) - - -def _get_availability_warnings_view( - warnings: typing.List[AvailabilityWarning], - organization: "Organization", - user: "User", - callback_id: str, - private_metadata: str, -) -> ModalView: - messages: typing.List[str] = [] - for w in warnings: - if w["error"] == PagingError.USER_IS_NOT_ON_CALL: - messages.append( - f":warning: User *{user.name or user.username}* is not on-call.\nWe recommend you to select on-call users first." - ) - schedules_available = w["data"].get("schedules", {}) - if schedules_available: - messages.append(":information_source: Currently on-call from schedules:") - for schedule, users in schedules_available.items(): - oncall_users = organization.users.filter(public_primary_key__in=users) - usernames = ", ".join(f"*{u.name or u.username}*" for u in oncall_users) - messages.append(f":spiral_calendar_pad: {schedule}: {usernames}") - elif w["error"] == PagingError.USER_HAS_NO_NOTIFICATION_POLICY: - messages.append(f":warning: User *{user.name or user.username}* has no notification policy setup.") - - view: ModalView = { +def _display_confirm_participant_invitation_view(callback_id: str, private_metadata: str) -> ModalView: + return { "type": "modal", "callback_id": callback_id, - "title": {"type": "plain_text", "text": "Are you sure?"}, + "title": {"type": "plain_text", "text": "Confirm user invitation"}, "submit": {"type": "plain_text", "text": "Confirm"}, "blocks": [ { "type": "section", "text": { "type": "mrkdwn", - "text": message, + "text": "This user is not currently on-call. We don't recommend to page users outside on-call hours.", }, } - for message in messages ], "private_metadata": private_metadata, } - return view def _get_selected_team_from_payload( @@ -931,23 +769,7 @@ def _get_selected_team_from_payload( if selected_team_id is None: return None, None - - if selected_team_id == DEFAULT_TEAM_VALUE: - return selected_team_id, None - - team = Team.objects.filter(pk=selected_team_id).first() - return selected_team_id, team - - -def _get_additional_responders_checked_from_payload(payload: EventPayload, input_id_prefix: str) -> bool: - try: - selected_options = payload["view"]["state"]["values"][ - input_id_prefix + DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID - ][OnPagingCheckAdditionalResponders.routing_uid()]["selected_options"] - except KeyError: - return False - - return len(selected_options) > 0 + return selected_team_id, Team.objects.filter(pk=selected_team_id).first() def _get_selected_user_from_payload(payload: EventPayload, input_id_prefix: str) -> typing.Optional["User"]: @@ -962,19 +784,6 @@ def _get_selected_user_from_payload(payload: EventPayload, input_id_prefix: str) return None -def _get_selected_schedule_from_payload( - payload: EventPayload, input_id_prefix: str -) -> typing.Optional["OnCallSchedule"]: - from apps.schedules.models import OnCallSchedule - - selected_schedule_id = _get_select_field_value( - payload, input_id_prefix, OnPagingScheduleChange.routing_uid(), DIRECT_PAGING_SCHEDULE_SELECT_ID - ) - if selected_schedule_id is not None: - return OnCallSchedule.objects.filter(pk=selected_schedule_id).first() - return None - - def _get_and_change_input_id_prefix_from_metadata( metadata: typing.Dict[str, str] ) -> typing.Tuple[str, str, typing.Dict[str, str]]: @@ -984,33 +793,6 @@ def _get_and_change_input_id_prefix_from_metadata( return old_input_id_prefix, new_input_id_prefix, metadata -def _get_title_input(payload: EventPayload) -> Block.Input: - title_input_block: Block.Input = { - "type": "input", - "block_id": DIRECT_PAGING_TITLE_INPUT_ID, - "label": { - "type": "plain_text", - "text": "Title", - }, - "element": { - "type": "plain_text_input", - "action_id": FinishDirectPaging.routing_uid(), - "placeholder": { - "type": "plain_text", - "text": " ", - }, - }, - } - if payload.get("text", None) is not None: - title_input_block["element"]["initial_value"] = payload["text"] - return title_input_block - - -def _get_title_from_payload(payload: EventPayload) -> str: - title = payload["view"]["state"]["values"][DIRECT_PAGING_TITLE_INPUT_ID][FinishDirectPaging.routing_uid()]["value"] - return title - - def _get_message_input(payload: EventPayload) -> Block.Input: message_input_block: Block.Input = { "type": "input", @@ -1028,7 +810,7 @@ def _get_message_input(payload: EventPayload) -> Block.Input: "text": " ", }, }, - "optional": True, + "optional": False, } if payload.get("message", {}).get("text") is not None: message_input_block["element"]["initial_value"] = payload["message"]["text"] @@ -1052,9 +834,12 @@ def _get_available_organizations( ) -# _generate_input_id_prefix returns uniq str to not to preserve input's values between view update -# https://api.slack.com/methods/views.update#markdown def _generate_input_id_prefix() -> str: + """ + returns unique string to not to preserve input's values between view update + + https://api.slack.com/methods/views.update#markdown + """ return str(uuid4()) @@ -1071,12 +856,6 @@ STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ "block_action_id": OnPagingTeamChange.routing_uid(), "step": OnPagingTeamChange, }, - { - "payload_type": PayloadType.BLOCK_ACTIONS, - "block_action_type": BlockActionType.CHECKBOXES, - "block_action_id": OnPagingCheckAdditionalResponders.routing_uid(), - "step": OnPagingCheckAdditionalResponders, - }, { "payload_type": PayloadType.BLOCK_ACTIONS, "block_action_type": BlockActionType.STATIC_SELECT, @@ -1088,12 +867,6 @@ STEPS_ROUTING: ScenarioRoute.RoutingSteps = [ "view_callback_id": OnPagingConfirmUserChange.routing_uid(), "step": OnPagingConfirmUserChange, }, - { - "payload_type": PayloadType.BLOCK_ACTIONS, - "block_action_type": BlockActionType.STATIC_SELECT, - "block_action_id": OnPagingScheduleChange.routing_uid(), - "step": OnPagingScheduleChange, - }, { "payload_type": PayloadType.BLOCK_ACTIONS, "block_action_type": BlockActionType.OVERFLOW, diff --git a/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py b/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py index da707823..682acc22 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py @@ -4,21 +4,17 @@ from unittest.mock import patch import pytest from django.utils import timezone -from apps.alerts.models import AlertGroup -from apps.alerts.paging import DirectPagingAlertGroupResolvedError from apps.base.models import UserNotificationPolicy from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb from apps.slack.scenarios.manage_responders import ( ALERT_GROUP_DATA_KEY, - DIRECT_PAGING_SCHEDULE_SELECT_ID, DIRECT_PAGING_USER_SELECT_ID, USER_DATA_KEY, ManageRespondersRemoveUser, - ManageRespondersScheduleChange, ManageRespondersUserChange, StartManageResponders, ) -from apps.slack.scenarios.paging import _get_schedules_select, _get_users_select +from apps.slack.scenarios.paging import _get_users_select ORGANIZATION_ID = 12 ALERT_GROUP_ID = 42 @@ -27,11 +23,7 @@ CHANNEL_ID = "123" MESSAGE_TS = "67" -def make_slack_payload( - user=None, - schedule=None, - actions=None, -): +def make_slack_payload(user=None, actions=None): payload = { "trigger_id": TRIGGER_ID, "view": { @@ -44,11 +36,6 @@ def make_slack_payload( "selected_option": {"value": user.pk} if user else None } }, - DIRECT_PAGING_SCHEDULE_SELECT_ID: { - ManageRespondersScheduleChange.routing_uid(): { - "selected_option": {"value": schedule.pk} if schedule else None - } - }, } }, }, @@ -155,74 +142,14 @@ def test_add_user_raise_warning(manage_responders_setup): text_from_blocks = "".join( b["text"]["text"] for b in mock_slack_api_call.call_args.kwargs["view"]["blocks"] if b["type"] == "section" ) - assert f"*{user.username}* is not on-call" in text_from_blocks + assert ( + "This user is not currently on-call. We don't recommend to page users outside on-call hours." + in text_from_blocks + ) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) assert metadata[USER_DATA_KEY] == user.pk -@pytest.mark.django_db -def test_add_schedule(manage_responders_setup, make_schedule, make_on_call_shift): - organization, user, slack_team_identity, slack_user_identity = manage_responders_setup - schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None) - now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - start_date = now - timezone.timedelta(days=7) - data = { - "start": start_date, - "rotation_start": start_date, - "duration": timezone.timedelta(hours=23, minutes=59, seconds=59), - "priority_level": 1, - "frequency": CustomOnCallShift.FREQUENCY_DAILY, - "schedule": schedule, - } - on_call_shift = make_on_call_shift( - organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data - ) - on_call_shift.add_rolling_users([[user]]) - schedule.refresh_ical_file() - payload = make_slack_payload(schedule=schedule) - - step = ManageRespondersScheduleChange(slack_team_identity, organization, user) - with patch.object(step._slack_client, "views_update") as mock_slack_api_call: - step.process_scenario(slack_user_identity, slack_team_identity, payload) - - assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["value"] == str(user.pk) - - -@pytest.mark.django_db -def test_add_schedule_alert_group_resolved( - manage_responders_setup, make_schedule, make_on_call_shift, make_user_notification_policy -): - organization, user, slack_team_identity, slack_user_identity = manage_responders_setup - AlertGroup.objects.filter(pk=ALERT_GROUP_ID).update(resolved=True) # resolve alert group - - schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None) - now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) - start_date = now - timezone.timedelta(days=7) - data = { - "start": start_date, - "rotation_start": start_date, - "duration": timezone.timedelta(hours=23, minutes=59, seconds=59), - "priority_level": 1, - "frequency": CustomOnCallShift.FREQUENCY_DAILY, - "schedule": schedule, - } - on_call_shift = make_on_call_shift( - organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data - ) - on_call_shift.add_rolling_users([[user]]) - schedule.refresh_ical_file() - payload = make_slack_payload(schedule=schedule) - - step = ManageRespondersScheduleChange(slack_team_identity, organization, user) - with patch.object(step._slack_client, "views_update") as mock_slack_api_call: - step.process_scenario(slack_user_identity, slack_team_identity, payload) - - assert ( - DirectPagingAlertGroupResolvedError.DETAIL - in mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["text"]["text"] - ) - - @pytest.mark.django_db def test_remove_user(manage_responders_setup): organization, user, slack_team_identity, slack_user_identity = manage_responders_setup @@ -233,42 +160,59 @@ def test_remove_user(manage_responders_setup): step.process_scenario(slack_user_identity, slack_team_identity, payload) # check there's no list of users in the view - assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["type"] != "button" + assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["element"]["type"] != "button" @pytest.mark.django_db -def test_get_users_select(make_organization, make_user): +def test_get_users_select(make_organization, make_user, make_schedule, make_on_call_shift): organization = make_organization() + + # not on-call users for _ in range(3): make_user(organization=organization) - select_options = _get_users_select(organization=organization, input_id_prefix="test", action_id="test") - assert len(select_options["accessory"]["options"]) == 3 - assert "option_groups" not in select_options["accessory"] + oncall_user = make_user(organization=organization) - select_option_groups = _get_users_select( + # set up schedule: user is on call + schedule = make_schedule( + organization, + schedule_class=OnCallScheduleWeb, + team=None, + ) + now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = now - timezone.timedelta(days=7) + data = { + "start": start_date, + "rotation_start": start_date, + "duration": timezone.timedelta(hours=23, minutes=59, seconds=59), + "priority_level": 1, + "frequency": CustomOnCallShift.FREQUENCY_DAILY, + "schedule": schedule, + } + on_call_shift = make_on_call_shift( + organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data + ) + on_call_shift.add_rolling_users([[oncall_user]]) + schedule.refresh_ical_file() + + select_input = _get_users_select( organization=organization, input_id_prefix="test", action_id="test", max_options_per_group=2 ) - assert len(select_option_groups["accessory"]["option_groups"]) == 2 - assert len(select_option_groups["accessory"]["option_groups"][0]["options"]) == 2 - assert len(select_option_groups["accessory"]["option_groups"][1]["options"]) == 1 - assert "options" not in select_option_groups["accessory"] + select_element = select_input["element"] + select_option_groups = select_element["option_groups"] -@pytest.mark.django_db -def test_get_schedules_select(make_organization, make_schedule): - organization = make_organization() - for _ in range(3): - make_schedule(organization, schedule_class=OnCallScheduleWeb) + assert len(select_option_groups) == 3 + assert "options" not in select_element - select_options = _get_schedules_select(organization=organization, input_id_prefix="test", action_id="test") - assert len(select_options["accessory"]["options"]) == 3 - assert "option_groups" not in select_options["accessory"] + oncall_options = select_option_groups[0] + not_oncall_options_group1 = select_option_groups[1] + not_oncall_options_group2 = select_option_groups[2] - select_option_groups = _get_schedules_select( - organization=organization, input_id_prefix="test", action_id="test", max_options_per_group=2 - ) - assert len(select_option_groups["accessory"]["option_groups"]) == 2 - assert len(select_option_groups["accessory"]["option_groups"][0]["options"]) == 2 - assert len(select_option_groups["accessory"]["option_groups"][1]["options"]) == 1 - assert "options" not in select_option_groups["accessory"] + assert len(oncall_options["options"]) == 1 + assert len(not_oncall_options_group1["options"]) == 2 + assert len(not_oncall_options_group2["options"]) == 1 + + assert oncall_options["label"]["text"] == "On-call now" + assert not_oncall_options_group1["label"]["text"] == "Not on-call (1-2)" + assert not_oncall_options_group2["label"]["text"] == "Not on-call (3-4)" 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 f4c2acfa..91e6cf50 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_paging.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_paging.py @@ -4,22 +4,16 @@ from unittest.mock import patch import pytest from django.utils import timezone -from apps.base.models import UserNotificationPolicy from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb from apps.slack.scenarios.paging import ( - DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID, DIRECT_PAGING_MESSAGE_INPUT_ID, DIRECT_PAGING_ORG_SELECT_ID, - DIRECT_PAGING_SCHEDULE_SELECT_ID, DIRECT_PAGING_TEAM_SELECT_ID, - DIRECT_PAGING_TITLE_INPUT_ID, DIRECT_PAGING_USER_SELECT_ID, DataKey, FinishDirectPaging, - OnPagingCheckAdditionalResponders, OnPagingItemActionChange, OnPagingOrgChange, - OnPagingScheduleChange, OnPagingTeamChange, OnPagingUserChange, Policy, @@ -29,16 +23,7 @@ from apps.slack.scenarios.paging import ( from apps.user_management.models import Organization -def make_slack_payload( - organization, - team=None, - user=None, - schedule=None, - additional_responders=False, - current_users=None, - current_schedules=None, - actions=None, -): +def make_slack_payload(organization, team=None, user=None, current_users=None, actions=None): payload = { "channel_id": "123", "trigger_id": "111", @@ -50,7 +35,6 @@ def make_slack_payload( "channel_id": "123", "submit_routing_uid": "FinishStepUID", DataKey.USERS: current_users or {}, - DataKey.SCHEDULES: current_schedules or {}, } ), "state": { @@ -61,20 +45,9 @@ def make_slack_payload( DIRECT_PAGING_TEAM_SELECT_ID: { OnPagingTeamChange.routing_uid(): {"selected_option": {"value": team.pk if team else None}} }, - DIRECT_PAGING_ADDITIONAL_RESPONDERS_INPUT_ID: { - OnPagingCheckAdditionalResponders.routing_uid(): { - "selected_options": ["something"] if additional_responders else [] - } - }, DIRECT_PAGING_USER_SELECT_ID: { OnPagingUserChange.routing_uid(): {"selected_option": {"value": user.pk} if user else None} }, - DIRECT_PAGING_SCHEDULE_SELECT_ID: { - OnPagingScheduleChange.routing_uid(): { - "selected_option": {"value": schedule.pk} if schedule else None - } - }, - DIRECT_PAGING_TITLE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Title"}}, DIRECT_PAGING_MESSAGE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Message"}}, } }, @@ -98,13 +71,10 @@ def test_initial_state( metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) assert metadata[DataKey.USERS] == {} - assert metadata[DataKey.SCHEDULES] == {} @pytest.mark.django_db -def test_add_user_no_warning( - make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift, make_user_notification_policy -): +def test_add_user_no_warning(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() # set up schedule: user is on call schedule = make_schedule( @@ -127,12 +97,6 @@ def test_add_user_no_warning( ) on_call_shift.add_rolling_users([[user]]) schedule.refresh_ical_file() - # setup notification policy - make_user_notification_policy( - user=user, - step=UserNotificationPolicy.Step.NOTIFY, - notify_by=UserNotificationPolicy.NotificationChannel.SMS, - ) payload = make_slack_payload(organization=organization, user=user) @@ -145,9 +109,7 @@ def test_add_user_no_warning( @pytest.mark.django_db -def test_add_user_maximum_exceeded( - make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift, make_user_notification_policy -): +def test_add_user_maximum_exceeded(make_organization_and_user_with_slack_identities, make_schedule, make_on_call_shift): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() # set up schedule: user is on call schedule = make_schedule( @@ -170,12 +132,6 @@ def test_add_user_maximum_exceeded( ) on_call_shift.add_rolling_users([[user]]) schedule.refresh_ical_file() - # setup notification policy - make_user_notification_policy( - user=user, - step=UserNotificationPolicy.Step.NOTIFY, - notify_by=UserNotificationPolicy.NotificationChannel.SMS, - ) payload = make_slack_payload(organization=organization, user=user) @@ -214,7 +170,10 @@ def test_add_user_raise_warning(make_organization_and_user_with_slack_identities text_from_blocks = "".join( b["text"]["text"] for b in mock_slack_api_call.call_args.kwargs["view"]["blocks"] if b["type"] == "section" ) - assert f"*{user.username}* is not on-call" in text_from_blocks + assert ( + "This user is not currently on-call. We don't recommend to page users outside on-call hours." + in text_from_blocks + ) metadata = json.loads(mock_slack_api_call.call_args.kwargs["view"]["private_metadata"]) assert metadata[DataKey.USERS] == {} @@ -252,147 +211,36 @@ def test_remove_user(make_organization_and_user_with_slack_identities): @pytest.mark.django_db -def test_trigger_paging_no_responders(make_organization_and_user_with_slack_identities): - organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() +def test_trigger_paging_no_team_or_user_selected(make_organization_and_user_with_slack_identities): + organization, _, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() payload = make_slack_payload(organization=organization) step = FinishDirectPaging(slack_team_identity) - with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging: - with patch.object(step._slack_client, "api_call"): - step.process_scenario(slack_user_identity, slack_team_identity, payload) - assert mock_direct_paging.called_with(organization, None, user, "The Title", "The Message") + with patch.object(step._slack_client, "api_call"): + response = step.process_scenario(slack_user_identity, slack_team_identity, payload) + + response = response.data + + assert response["response_action"] == "update" + assert ( + response["view"]["blocks"][0]["text"]["text"] + == ":warning: At least one team or one user must be selected to directly page" + ) @pytest.mark.django_db -def test_trigger_paging(make_organization_and_user_with_slack_identities, make_team, make_schedule): +def test_trigger_paging_additional_responders(make_organization_and_user_with_slack_identities, make_team): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() team = make_team(organization) - payload = make_slack_payload( - organization=organization, - team=team, - additional_responders=False, - ) + payload = make_slack_payload(organization=organization, team=team, current_users={str(user.pk): Policy.IMPORTANT}) step = FinishDirectPaging(slack_team_identity) with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging: with patch.object(step._slack_client, "api_call"): step.process_scenario(slack_user_identity, slack_team_identity, payload) - assert mock_direct_paging.called_with(organization, team, user, "The Title", "The Message", [], [], None) - - -@pytest.mark.django_db -def test_trigger_paging_additional_responders( - make_organization_and_user_with_slack_identities, make_team, make_schedule -): - organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - team = make_team(organization) - schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None) - payload = make_slack_payload( - organization=organization, - team=team, - additional_responders=True, - current_users={str(user.pk): Policy.IMPORTANT}, - current_schedules={str(schedule.pk): Policy.DEFAULT}, - ) - - step = FinishDirectPaging(slack_team_identity) - with patch("apps.slack.scenarios.paging.direct_paging") as mock_direct_paging: - with patch.object(step._slack_client, "api_call"): - step.process_scenario(slack_user_identity, slack_team_identity, payload) - - assert mock_direct_paging.called_with( - organization, team, user, "The Title", "The Message", [(user, True)], [(schedule, False)], None - ) - - -@pytest.mark.django_db -def test_add_schedule(make_organization_and_user_with_slack_identities, make_schedule): - organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None) - payload = make_slack_payload( - organization=organization, - schedule=schedule, - current_users={str(user.pk): Policy.IMPORTANT}, - ) - - step = OnPagingScheduleChange(slack_team_identity) - with patch.object(step._slack_client, "views_update") as mock_slack_api_call: - 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.SCHEDULES] == {str(schedule.pk): Policy.DEFAULT} - assert metadata[DataKey.USERS] == {str(user.pk): Policy.IMPORTANT} - - -@pytest.mark.django_db -def test_add_schedule_responders_exceeded(make_organization_and_user_with_slack_identities, make_schedule): - organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None) - payload = make_slack_payload( - organization=organization, - schedule=schedule, - current_users={str(user.pk): Policy.IMPORTANT}, - ) - - step = OnPagingScheduleChange(slack_team_identity) - with patch("apps.slack.scenarios.paging.PRIVATE_METADATA_MAX_LENGTH", 100): - with patch.object(step._slack_client, "views_update") as mock_slack_api_call: - step.process_scenario(slack_user_identity, slack_team_identity, payload) - - view_data = mock_slack_api_call.call_args.kwargs["view"] - metadata = json.loads(view_data["private_metadata"]) - # metadata unchanged, ignoring the prefix - original_metadata = json.loads(payload["view"]["private_metadata"]) - metadata.pop("input_id_prefix") - original_metadata.pop("input_id_prefix") - assert metadata == original_metadata - # error message is displayed - error_block = { - "type": "section", - "block_id": "error_message", - "text": {"type": "mrkdwn", "text": ":warning: Cannot add schedule, maximum responders exceeded"}, - } - assert error_block in view_data["blocks"] - - -@pytest.mark.django_db -def test_change_schedule_policy(make_organization_and_user_with_slack_identities, make_schedule): - organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None) - payload = make_slack_payload( - organization=organization, - current_users={str(user.pk): Policy.DEFAULT}, - actions=[{"selected_option": {"value": f"{Policy.IMPORTANT}|{DataKey.SCHEDULES}|{schedule.pk}"}}], - ) - - step = OnPagingItemActionChange(slack_team_identity) - with patch.object(step._slack_client, "views_update") as mock_slack_api_call: - 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.SCHEDULES] == {str(schedule.pk): Policy.IMPORTANT} - assert metadata[DataKey.USERS] == {str(user.pk): Policy.DEFAULT} - - -@pytest.mark.django_db -def test_remove_schedule(make_organization_and_user_with_slack_identities, make_schedule): - organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, team=None) - payload = make_slack_payload( - organization=organization, - current_users={str(user.pk): Policy.DEFAULT}, - actions=[{"selected_option": {"value": f"{Policy.REMOVE_ACTION}|{DataKey.SCHEDULES}|{schedule.pk}"}}], - ) - - step = OnPagingItemActionChange(slack_team_identity) - with patch.object(step._slack_client, "views_update") as mock_slack_api_call: - 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.SCHEDULES] == {} - assert metadata[DataKey.USERS] == {str(user.pk): Policy.DEFAULT} + mock_direct_paging.called_once_with(organization, user, "The Message", team, [(user, True)]) @pytest.mark.django_db diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index c14cf2e9..5a74531a 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -18,6 +18,7 @@ from common.public_primary_keys import generate_public_primary_key, increase_pub if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager + from apps.alerts.models import AlertReceiveChannel from apps.auth_token.models import ( ApiAuthToken, PluginAuthToken, @@ -27,6 +28,7 @@ if typing.TYPE_CHECKING: from apps.mobile_app.models import MobileAppAuthToken from apps.schedules.models import CustomOnCallShift, OnCallSchedule from apps.slack.models import SlackTeamIdentity + from apps.telegram.models import TelegramToOrganizationConnector from apps.user_management.models import Region, Team, User logger = logging.getLogger(__name__) @@ -77,6 +79,7 @@ class OrganizationManager(models.Manager): # this will remove the maintenance related columns that're no longer used on the organization object # class Organization(models.Model): class Organization(MaintainableObject): + alert_receive_channels: "RelatedManager['AlertReceiveChannel']" auth_tokens: "RelatedManager['ApiAuthToken']" custom_on_call_shifts: "RelatedManager['CustomOnCallShift']" migration_destination: typing.Optional["Region"] @@ -86,6 +89,7 @@ class Organization(MaintainableObject): schedule_export_token: "RelatedManager['ScheduleExportAuthToken']" slack_team_identity: typing.Optional["SlackTeamIdentity"] teams: "RelatedManager['Team']" + telegram_channel: "RelatedManager['TelegramToOrganizationConnector']" user_schedule_export_token: "RelatedManager['UserScheduleExportAuthToken']" users: "RelatedManager['User']" @@ -294,6 +298,11 @@ class Organization(MaintainableObject): new_channel=channel_name, ) + def get_direct_paging_integrations(self) -> "RelatedManager['AlertReceiveChannel']": + from apps.alerts.models import AlertReceiveChannel + + return self.alert_receive_channels.filter(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) + @property def web_link(self): return urljoin(self.grafana_url, "a/grafana-oncall-app/") @@ -303,6 +312,15 @@ class Organization(MaintainableObject): # It's a workaround to pass some unique identifier to the oncall gateway while proxying telegram requests return urljoin(self.grafana_url, f"a/grafana-oncall-app/?oncall-uuid={self.uuid}") + @property + def slack_is_configured(self) -> bool: + return self.slack_team_identity is not None + + @property + def telegram_is_configured(self) -> bool: + return self.telegram_channel.count() > 0 + + @classmethod def __str__(self): return f"{self.pk}: {self.org_title}" diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index e387f9d4..e75490bb 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -175,6 +175,7 @@ class User(models.Model): schedule_export_token: "RelatedManager['ScheduleExportAuthToken']" silenced_alert_groups: "RelatedManager['AlertGroup']" slack_user_identity: typing.Optional["SlackUserIdentity"] + teams: "RelatedManager['Team']" user_schedule_export_token: "RelatedManager['UserScheduleExportAuthToken']" wiped_alert_groups: "RelatedManager['AlertGroup']" diff --git a/engine/apps/user_management/tests/test_organization.py b/engine/apps/user_management/tests/test_organization.py index 4d9ab407..692eae90 100644 --- a/engine/apps/user_management/tests/test_organization.py +++ b/engine/apps/user_management/tests/test_organization.py @@ -195,3 +195,50 @@ def test_organization_hard_delete( for obj in cascading_objects: with pytest.raises(ObjectDoesNotExist): obj.refresh_from_db() + + +@pytest.mark.django_db +def test_slack_is_configured(make_organization, make_slack_team_identity): + organization = make_organization() + + assert organization.slack_is_configured is False + slack_team_identity = make_slack_team_identity() + organization.slack_team_identity = slack_team_identity + organization.save() + assert organization.slack_is_configured is True + + +@pytest.mark.django_db +def test_telegram_is_configured(make_organization, make_telegram_channel): + organization = make_organization() + assert organization.telegram_is_configured is False + make_telegram_channel(organization) + assert organization.telegram_is_configured is True + + +@pytest.mark.django_db +def test_get_direct_paging_integrations(make_organization, make_team, make_alert_receive_channel): + org1 = make_organization() + org1_team1 = make_team(org1) + org1_team2 = make_team(org1) + + org2 = make_organization() + + org1_direct_paging_integration1 = make_alert_receive_channel( + org1, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=org1_team1 + ) + org1_direct_paging_integration2 = make_alert_receive_channel( + org1, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=org1_team2 + ) + + make_alert_receive_channel(org1, integration=AlertReceiveChannel.INTEGRATION_ALERTMANAGER) + make_alert_receive_channel(org2, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) + + org1_direct_paging_integrations = org1.get_direct_paging_integrations() + org2_direct_paging_integrations = org2.get_direct_paging_integrations() + + assert len(org1_direct_paging_integrations) == 2 + assert len(org2_direct_paging_integrations) == 1 + + assert org1_direct_paging_integration1 in org1_direct_paging_integrations + assert org1_direct_paging_integration2 in org1_direct_paging_integrations diff --git a/grafana-plugin/e2e-tests/alerts/directPaging.test.ts b/grafana-plugin/e2e-tests/alerts/directPaging.test.ts index 122e5be9..572c9489 100644 --- a/grafana-plugin/e2e-tests/alerts/directPaging.test.ts +++ b/grafana-plugin/e2e-tests/alerts/directPaging.test.ts @@ -1,27 +1,31 @@ -import { test } from '../fixtures'; -import { clickButton, fillInInput, selectDropdownValue } from '../utils/forms'; -import { goToOnCallPage } from "../utils/navigation"; -import { verifyAlertGroupTitleAndMessageContainText } from "../utils/alertGroup"; +import { test, expect } from '../fixtures'; +import { clickButton, fillInInput } from '../utils/forms'; +import { goToOnCallPage } from '../utils/navigation'; -test('we can create an alert group for default team', async ({ adminRolePage }) => { +/** + * TODO: test that we can also direct page a team. This is a bit more involved because we need to + * create a team via the Grafana API then go and configure the team's direct paging integration so that + * it will show up in the dropdown (ie. create an escalation chain and assign it to the integration) + */ + +test('we can directly page a user', async ({ adminRolePage }) => { + const message = 'Help me please!'; const { page } = adminRolePage; await goToOnCallPage(page, 'alert-groups'); - await clickButton({ page, buttonText: 'New alert group' }); + await clickButton({ page, buttonText: 'Escalation' }); - await fillInInput(page, 'input[name="title"]', "Help me!"); - await fillInInput(page, 'textarea[name="message"]', "Help me please!"); + await fillInInput(page, 'textarea[name="message"]', message); + await clickButton({ page, buttonText: 'Invite' }); - await selectDropdownValue({ - page, - selectType: 'grafanaSelect', - placeholderText: "Select team", - value: "No team", - }); + const addRespondersPopup = page.getByTestId('add-responders-popup'); + + await addRespondersPopup.getByText('Users').click(); + await addRespondersPopup.getByText(adminRolePage.userName).click(); await clickButton({ page, buttonText: 'Create' }); // Check we are redirected to the alert group page - await page.waitForURL('**/alert-groups/I*'); // Alert group IDs always start with "I" - await verifyAlertGroupTitleAndMessageContainText(page, "Help me!", "Help me please!") + await page.waitForURL('**/alert-groups/I*'); // Alert group IDs always start with "I" + await expect(page.getByTestId('incident-message')).toContainText(message); }); diff --git a/grafana-plugin/e2e-tests/utils/alertGroup.ts b/grafana-plugin/e2e-tests/utils/alertGroup.ts index 2e415172..339aad01 100644 --- a/grafana-plugin/e2e-tests/utils/alertGroup.ts +++ b/grafana-plugin/e2e-tests/utils/alertGroup.ts @@ -100,13 +100,3 @@ export const verifyThatAlertGroupIsTriggered = async ( expect(await incidentTimelineContainsStep(page, triggeredStepText)).toBe(true); }; - - -export const verifyAlertGroupTitleAndMessageContainText = async ( - page: Page, - title: string, - message: string -): Promise => { - await expect(page.getByTestId('incident-title')).toContainText(title); - await expect(page.getByTestId('incident-message')).toContainText(message); -}; diff --git a/grafana-plugin/src/components/GForm/GForm.tsx b/grafana-plugin/src/components/GForm/GForm.tsx index 2d179af1..cbba9571 100644 --- a/grafana-plugin/src/components/GForm/GForm.tsx +++ b/grafana-plugin/src/components/GForm/GForm.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Field, Form, Input, InputControl, Select, Switch, TextArea } from '@grafana/ui'; +import { Field, Form, FormFieldErrors, Input, InputControl, Select, Switch, TextArea } from '@grafana/ui'; import { capitalCase } from 'change-case'; import cn from 'classnames/bind'; +import { isEmpty } from 'lodash-es'; import Collapse from 'components/Collapse/Collapse'; import { FormItem, FormItemType } from 'components/GForm/GForm.types'; @@ -20,6 +21,7 @@ interface GFormProps { form: { name: string; fields: FormItem[] }; data: any; onSubmit: (data: any) => void; + onChange?: (formIsValid: boolean) => void; customFieldSectionRenderer?: React.FC; onFieldRender?: ( @@ -190,7 +192,13 @@ class GForm extends React.Component { ? formItem.getDisabled(getValues()) : false; - const formControl = renderFormControl(formItem, register, control, disabled, this.onChange); + const formControl = renderFormControl( + formItem, + register, + control, + disabled, + this.onChange.bind(this, errors) + ); if (CustomFieldSectionRenderer && formItem.type === FormItemType.Other && formItem.render) { return ( @@ -245,7 +253,9 @@ class GForm extends React.Component { ); } - onChange = (field: any, value: string) => { + onChange = (errors: FormFieldErrors, field: any, value: string) => { + this.props.onChange?.(isEmpty(errors)); + field?.onChange(value); this.forceUpdate(); }; diff --git a/grafana-plugin/src/components/GTable/GTable.tsx b/grafana-plugin/src/components/GTable/GTable.tsx index a778c0db..ac6283f4 100644 --- a/grafana-plugin/src/components/GTable/GTable.tsx +++ b/grafana-plugin/src/components/GTable/GTable.tsx @@ -1,9 +1,10 @@ -import React, { FC, useCallback, useMemo, ChangeEvent } from 'react'; +import React, { useCallback, useMemo, ChangeEvent, ReactElement } from 'react'; import { Pagination, Checkbox, Icon } from '@grafana/ui'; import cn from 'classnames/bind'; import Table from 'rc-table'; import { TableProps } from 'rc-table/lib/Table'; +import { DefaultRecordType } from 'rc-table/lib/interface'; import styles from './GTable.module.css'; @@ -31,7 +32,7 @@ export interface Props extends TableProps { showHeader?: boolean; } -const GTable: FC = (props) => { +const GTable = (props: Props): ReactElement => { const { columns: columnsProp, data, @@ -139,7 +140,7 @@ const GTable: FC = (props) => { return (
- expandable={expandable} rowKey={rowKey} className={cx('filter-table', className)} diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.config.ts b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.config.ts index 35459f32..219083e6 100644 --- a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.config.ts +++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.config.ts @@ -1,19 +1,17 @@ import { FormItem, FormItemType } from 'components/GForm/GForm.types'; +export type ManualAlertGroupFormData = { + message: string; +}; + export const manualAlertFormConfig: { name: string; fields: FormItem[] } = { name: 'Manual Alert Group', fields: [ - { - name: 'title', - type: FormItemType.Input, - label: 'Title', - validation: { required: true }, - }, { name: 'message', type: FormItemType.TextArea, - label: 'Message (optional)', - validation: { required: false }, + label: 'What is going on?', + validation: { required: true }, }, ], }; diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css deleted file mode 100644 index 57b3da4c..00000000 --- a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.assign-responders-button { - display: flex; -} - -.info-block { - background: var(--secondary-background); - width: 100%; -} - -.responders-list { - list-style-type: none; - margin-bottom: 20px; - width: 100%; - background: var(--background-secondary); - padding: 10px 12px; -} diff --git a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx index 5c20486e..c20557b5 100644 --- a/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx +++ b/grafana-plugin/src/components/ManualAlertGroup/ManualAlertGroup.tsx @@ -1,39 +1,17 @@ import React, { FC, useCallback, useState } from 'react'; -import { - Alert, - Button, - Drawer, - Field, - HorizontalGroup, - Icon, - IconButton, - IconName, - Label, - LoadingPlaceholder, - Tooltip, - VerticalGroup, -} from '@grafana/ui'; -import cn from 'classnames/bind'; +import { Button, Drawer, HorizontalGroup, VerticalGroup } from '@grafana/ui'; +import { observer } from 'mobx-react'; import GForm from 'components/GForm/GForm'; -import PluginLink from 'components/PluginLink/PluginLink'; -import Text from 'components/Text/Text'; -import EscalationVariants from 'containers/EscalationVariants/EscalationVariants'; -import { prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers'; -import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect'; -import TeamName from 'containers/TeamName/TeamName'; +import AddResponders from 'containers/AddResponders/AddResponders'; +import { prepareForUpdate } from 'containers/AddResponders/AddResponders.helpers'; import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel'; -import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { Alert as AlertType } from 'models/alertgroup/alertgroup.types'; -import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; -import IntegrationHelper from 'pages/integration/Integration.helper'; import { useStore } from 'state/useStore'; import { openWarningNotification } from 'utils'; -import { manualAlertFormConfig } from './ManualAlertGroup.config'; - -import styles from './ManualAlertGroup.module.css'; +import { manualAlertFormConfig, ManualAlertGroupFormData } from './ManualAlertGroup.config'; interface ManualAlertGroupProps { onHide: () => void; @@ -41,201 +19,64 @@ interface ManualAlertGroupProps { alertReceiveChannelStore: AlertReceiveChannelStore; } -const cx = cn.bind(styles); +const data: ManualAlertGroupFormData = { + message: '', +}; -const ManualAlertGroup: FC = (props) => { - const store = useStore(); - const [userResponders, setUserResponders] = useState([]); - const [scheduleResponders, setScheduleResponders] = useState([]); - const { onHide, onCreate, alertReceiveChannelStore } = props; +const ManualAlertGroup: FC = observer(({ onCreate, onHide }) => { + const { directPagingStore } = useStore(); + const { selectedTeamResponder, selectedUserResponders } = directPagingStore; - const [selectedTeamId, setSelectedTeam] = useState(); - const [selectedTeamDirectPaging, setSelectedTeamDirectPaging] = useState(); - const [directPagingLoading, setdirectPagingLoading] = useState(); + const [formIsValid, setFormIsValid] = useState(false); - const [chatOpsAvailableChannels, setChatopsAvailableChannels] = useState(); + const onHideDrawer = useCallback(() => { + directPagingStore.resetSelectedUsers(); + directPagingStore.resetSelectedTeam(); + onHide(); + }, [onHide]); - const data = {}; + const hasSelectedEitherATeamOrAUser = selectedTeamResponder !== null || selectedUserResponders.length > 0; + const formIsSubmittable = hasSelectedEitherATeamOrAUser && formIsValid; - const handleFormSubmit = async (data) => { - if (selectedTeamId === undefined) { - openWarningNotification('Select team first'); - return; - } - store.directPagingStore - .createManualAlertRule(prepareForUpdate(userResponders, scheduleResponders, { team: selectedTeamId, ...data })) - .then(({ alert_group_id: id }: { alert_group_id: AlertType['pk'] }) => { - onCreate(id); - }) - .finally(() => { - onHide(); - }); - }; + // TODO: add a loading state while we're waiting to hear back from the API when submitting + // const [directPagingLoading, setdirectPagingLoading] = useState(); - const onUpdateSelectedTeam = async (selectedTeamId: GrafanaTeam['id']) => { - setdirectPagingLoading(true); - setSelectedTeamDirectPaging(null); - setSelectedTeam(selectedTeamId); - await alertReceiveChannelStore.updateItems({ team: selectedTeamId, integration: 'direct_paging' }); - const directPagingAlertReceiveChannel = - alertReceiveChannelStore.getSearchResult() && alertReceiveChannelStore.getSearchResult()[0]; - if (directPagingAlertReceiveChannel) { - setSelectedTeamDirectPaging(directPagingAlertReceiveChannel); - await alertReceiveChannelStore.updateChannelFilters(directPagingAlertReceiveChannel.id); - await store.slackChannelStore.updateItems(); + const handleFormSubmit = useCallback( + async (data: ManualAlertGroupFormData) => { + const transformedData = prepareForUpdate(selectedUserResponders, selectedTeamResponder, data); - // The code below is used to get the unique available chotops channels for all routes in integraion - // This is the workaround for IntegrationHelper.getChatOpsChannels, it should be moved to the helper - const filterIds = alertReceiveChannelStore.channelFilterIds[directPagingAlertReceiveChannel.id]; - let availableChannels = []; - let channelKeys = new Set(); - filterIds.map((channelFilterId) => { - IntegrationHelper.getChatOpsChannels(alertReceiveChannelStore.channelFilters[channelFilterId], store) - .filter((channel) => channel) - .map((channel) => { - if (!channelKeys.has(channel.name + channel.icon)) { - availableChannels.push(channel); - channelKeys.add(channel.name + channel.icon); - } - }); - }); - setChatopsAvailableChannels(Array.from(availableChannels)); - } - setdirectPagingLoading(false); - }; + const resp = await directPagingStore.createManualAlertRule(transformedData); - const onUpdateEscalationVariants = useCallback( - (value) => { - setUserResponders(value.userResponders); - setScheduleResponders(value.scheduleResponders); + if (!resp) { + openWarningNotification('There was an issue creating the alert group, please try again'); + return; + } + + directPagingStore.resetSelectedUsers(); + directPagingStore.resetSelectedTeam(); + + onCreate(resp.alert_group_id); + onHide(); }, - [userResponders, scheduleResponders] + [prepareForUpdate, selectedUserResponders, selectedTeamResponder] ); - const DirectPagingIntegrationVariants = ({ selectedTeamId, selectedTeamDirectPaging, chatOpsAvailableChannels }) => { - const escalationChainsExist = selectedTeamDirectPaging?.connected_escalations_chains_count !== 0; - - return ( - - {selectedTeamId && - (directPagingLoading ? ( - - ) : selectedTeamDirectPaging ? ( - - -
    -
  • - - - {selectedTeamDirectPaging.verbal_name} - - - Team: - - - {chatOpsAvailableChannels.length && ( - - {chatOpsAvailableChannels.map( - (chatOpsChannel: { name: string; icon: IconName }, chatOpsIndex) => ( -
    - {chatOpsChannel.icon && } - {chatOpsChannel.name || ''} -
    - ) - )} - - - -
    - )} - - - - - -
    -
  • -
- {!escalationChainsExist && ( - - - - The direct paging integration for the selected team has no escalation chains configured. -
- If you proceed with the alert group, the team likely will not be notified.
- - Learn more. - -
-
-
- )} -
- ) : ( - - - - The selected team doesn't have a direct paging integration configured and will not be notified.
- If you proceed with the alert group, an empty direct paging integration will be created automatically - for the team.
- - Learn more. - -
-
-
- ))} -
- ); - }; - return ( - + - - - - - - + + - - ); -}; +}); export default ManualAlertGroup; diff --git a/grafana-plugin/src/containers/AddResponders/AddResponders.helpers.ts b/grafana-plugin/src/containers/AddResponders/AddResponders.helpers.ts new file mode 100644 index 00000000..6d23a99b --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/AddResponders.helpers.ts @@ -0,0 +1,14 @@ +import { ManualAlertGroupFormData } from 'components/ManualAlertGroup/ManualAlertGroup.config'; +import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; + +import { UserResponders } from './AddResponders.types'; + +export const prepareForUpdate = ( + selectedUsers: UserResponders, + selectedTeam?: GrafanaTeam, + data?: ManualAlertGroupFormData +) => ({ + ...(data || {}), + team: selectedTeam ? selectedTeam.id : null, + users: selectedUsers.map(({ important, data: { pk } }) => ({ important, id: pk })), +}); diff --git a/grafana-plugin/src/containers/AddResponders/AddResponders.module.scss b/grafana-plugin/src/containers/AddResponders/AddResponders.module.scss new file mode 100644 index 00000000..a629ec01 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/AddResponders.module.scss @@ -0,0 +1,65 @@ +.body { + width: 100%; + position: relative; +} + +.responders-list { + list-style-type: none; + margin-bottom: 20px; + width: 100%; + + & > li .hover-button { + display: none; + } + + & > li:hover .hover-button { + display: inline-flex; + } + + & > li { + padding: 10px 12px; + width: 100%; + } + + & > li:hover { + background: var(--background-secondary); + } +} + +.timeline-icon-background { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--timeline-icon-background); + display: flex; + justify-content: center; + align-items: center; + + & > img { + width: 100%; + height: 100%; + } + + &--green { + background: #299c46; + } +} + +.responder-name { + max-width: 250px; + overflow: hidden; + white-space: nowrap; +} + +.confirm-participant-invitation-modal { + max-width: 550px; +} + +.confirm-participant-invitation-modal-select { + display: inline-flex; + margin: 0px 4px; +} + +.learn-more-link { + display: inline-block; +} diff --git a/grafana-plugin/src/containers/AddResponders/AddResponders.test.tsx b/grafana-plugin/src/containers/AddResponders/AddResponders.test.tsx new file mode 100644 index 00000000..e17e5483 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/AddResponders.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; +import { Provider } from 'mobx-react'; + +import AddResponders from './AddResponders'; + +jest.mock('./parts/AddRespondersPopup/AddRespondersPopup', () => ({ + __esModule: true, + default: () =>
AddRespondersPopup
, +})); + +jest.mock('containers/WithPermissionControl/WithPermissionControlTooltip', () => ({ + WithPermissionControlTooltip: ({ children }) =>
{children}
, +})); + +describe('AddResponders', () => { + const generateRemovePreviouslyPagedUserCallback = jest.fn(); + + test.each<'create' | 'update'>(['create', 'update'])('should render properly in %s mode', (mode) => { + const mockStoreValue = { + directPagingStore: { + selectedTeamResponder: null, + selectedUserResponders: [], + }, + }; + + const component = render( + + + + ); + expect(component.container).toMatchSnapshot(); + }); + + test.each([true, false])( + 'should properly display the add responders button when hideAddResponderButton is %s', + (hideAddResponderButton) => { + const mockStoreValue = { + directPagingStore: { + selectedTeamResponder: null, + selectedUserResponders: [], + }, + }; + + const component = render( + + + + ); + expect(component.container).toMatchSnapshot(); + } + ); + + test('should render selected team and users properly', () => { + const mockStoreValue = { + directPagingStore: { + selectedTeamResponder: { + id: 'asdfasdf', + avatar_url: 'https://example.com', + name: 'my test team', + }, + selectedUserResponders: [ + { + data: { + pk: 'mcvnm', + avatar: 'https://example.com/user123.png', + username: 'my test user', + }, + }, + { + data: { + pk: 'iuo', + avatar: 'https://example.com/user456.png', + username: 'my test user2', + }, + }, + ], + }, + }; + + const component = render( + + + + ); + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/AddResponders/AddResponders.tsx b/grafana-plugin/src/containers/AddResponders/AddResponders.tsx new file mode 100644 index 00000000..b7249d4e --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/AddResponders.tsx @@ -0,0 +1,229 @@ +import React, { useState, useCallback, useMemo } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { HorizontalGroup, Button, Modal, Alert, VerticalGroup, Icon } from '@grafana/ui'; +import cn from 'classnames/bind'; +import dayjs from 'dayjs'; +import { observer } from 'mobx-react'; + +import Block from 'components/GBlock/Block'; +import Text from 'components/Text/Text'; +import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; +import { Alert as AlertType } from 'models/alertgroup/alertgroup.types'; +import { getTimezone } from 'models/user/user.helpers'; +import { User } from 'models/user/user.types'; +import { useStore } from 'state/useStore'; +import { UserActions } from 'utils/authorization'; + +import styles from './AddResponders.module.scss'; +import { NotificationPolicyValue, UserResponder as UserResponderType } from './AddResponders.types'; +import AddRespondersPopup from './parts/AddRespondersPopup/AddRespondersPopup'; +import NotificationPoliciesSelect from './parts/NotificationPoliciesSelect/NotificationPoliciesSelect'; +import TeamResponder from './parts/TeamResponder/TeamResponder'; +import UserResponder from './parts/UserResponder/UserResponder'; + +const cx = cn.bind(styles); + +type Props = { + mode: 'create' | 'update'; + hideAddResponderButton?: boolean; + existingPagedUsers?: AlertType['paged_users']; + onAddNewParticipant?: (responder: UserResponderType) => Promise; + generateRemovePreviouslyPagedUserCallback?: (userId: string) => () => Promise; +}; + +const LearnMoreAboutNotificationPoliciesLink: React.FC = () => ( + + + + Learn more + + + + +); + +const AddResponders = observer( + ({ + mode, + hideAddResponderButton, + existingPagedUsers = [], + onAddNewParticipant, + generateRemovePreviouslyPagedUserCallback, + }: Props) => { + const { directPagingStore } = useStore(); + const { selectedTeamResponder, selectedUserResponders } = directPagingStore; + + const currentMoment = useMemo(() => dayjs(), []); + const isCreateMode = mode === 'create'; + + const [currentlyConsideredUser, setCurrentlyConsideredUser] = useState(null); + const [currentlyConsideredUserNotificationPolicy, setCurrentlyConsideredUserNotificationPolicy] = + useState(NotificationPolicyValue.Default); + + const [popupIsVisible, setPopupIsVisible] = useState(false); + const [showUserConfirmationModal, setShowUserConfirmationModal] = useState(false); + + const onChangeCurrentlyConsideredUserNotificationPolicy = useCallback( + ({ value }: SelectableValue) => { + setCurrentlyConsideredUserNotificationPolicy(value); + }, + [setCurrentlyConsideredUserNotificationPolicy] + ); + + const closeUserConfirmationModal = useCallback( + () => setShowUserConfirmationModal(false), + [setShowUserConfirmationModal] + ); + + const confirmCurrentlyConsideredUser = useCallback(async () => { + /** + * if we're in create mode (ie. manually creating an alert group), + * we need to add the user to the array of selected users + * otherwise, as soon as the modal is confirmed, we add the user to the pre-existing list of "paged users" + * for the alert group + */ + if (isCreateMode) { + directPagingStore.addUserToSelectedUsers(currentlyConsideredUser); + } else { + await onAddNewParticipant({ + important: Boolean(currentlyConsideredUserNotificationPolicy), + data: currentlyConsideredUser, + }); + } + + closeUserConfirmationModal(); + }, [ + isCreateMode, + directPagingStore, + currentlyConsideredUser, + currentlyConsideredUserNotificationPolicy, + closeUserConfirmationModal, + ]); + + return ( + <> +
+ + + + Participants + + {!hideAddResponderButton && ( + + + + )} + + {(selectedTeamResponder || existingPagedUsers.length > 0 || selectedUserResponders.length > 0) && ( + <> +
    + {selectedTeamResponder && ( + + )} + {existingPagedUsers.map((user) => ( + {}} + disableNotificationPolicySelect + handleDelete={generateRemovePreviouslyPagedUserCallback(user.pk)} + important={user.important} + data={user as unknown as User} + /> + ))} + {selectedUserResponders.map((responder, index) => ( + + directPagingStore.updateSelectedUserImportantStatus(index, Boolean(important)) + } + handleDelete={() => directPagingStore.removeSelectedUser(index)} + {...responder} + /> + ))} + {selectedUserResponders.length > 0 && ( + + about Default vs Important user personal + notification settings + + ) as any + } + /> + )} +
+ + )} +
+ +
+ {showUserConfirmationModal && ( + + + {!isCreateMode && ( +
+ + {currentlyConsideredUser.name || currentlyConsideredUser.username} (local time{' '} + {currentMoment.tz(getTimezone(currentlyConsideredUser)).format('HH:mm')}) will be notified using + +
+ +
+ notification settings. + +
+ )} + {!currentlyConsideredUser.is_currently_oncall && ( + + )} + + + + +
+
+ )} + + ); + } +); + +export default AddResponders; diff --git a/grafana-plugin/src/containers/AddResponders/AddResponders.types.ts b/grafana-plugin/src/containers/AddResponders/AddResponders.types.ts new file mode 100644 index 00000000..a53fbf5d --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/AddResponders.types.ts @@ -0,0 +1,20 @@ +import { SelectableValue } from '@grafana/data'; +import { ActionMeta } from '@grafana/ui'; + +import { User } from 'models/user/user.types'; + +export enum NotificationPolicyValue { + Default = 0, + Important = 1, +} + +export type UserResponder = { + data: User; + important: boolean; +}; +export type UserResponders = UserResponder[]; + +export type ResponderBaseProps = { + onImportantChange: (value: SelectableValue, actionMeta: ActionMeta) => void | {}; + handleDelete: React.MouseEventHandler; +}; diff --git a/grafana-plugin/src/containers/AddResponders/__snapshots__/AddResponders.test.tsx.snap b/grafana-plugin/src/containers/AddResponders/__snapshots__/AddResponders.test.tsx.snap new file mode 100644 index 00000000..1518707f --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/__snapshots__/AddResponders.test.tsx.snap @@ -0,0 +1,856 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddResponders should properly display the add responders button when hideAddResponderButton is false 1`] = ` +
+
+
+
+
+

+ + Participants + +

+
+
+
+ +
+
+
+
+
+ AddRespondersPopup +
+
+
+`; + +exports[`AddResponders should properly display the add responders button when hideAddResponderButton is true 1`] = ` +
+
+
+
+
+

+ + Participants + +

+
+
+
+
+ AddRespondersPopup +
+
+
+`; + +exports[`AddResponders should render properly in create mode 1`] = ` +
+
+
+
+
+

+ + Participants + +

+
+
+
+ +
+
+
+
+
+ AddRespondersPopup +
+
+
+`; + +exports[`AddResponders should render properly in update mode 1`] = ` +
+
+
+
+
+

+ + Participants + +

+
+
+
+ +
+
+
+
+
+ AddRespondersPopup +
+
+
+`; + +exports[`AddResponders should render selected team and users properly 1`] = ` +
+
+
+
+
+

+ + Participants + +

+
+
+
+ +
+
+
+
    +
  • +
    +
    +
    +
    +
    + +
    +
    +
    + + my test team + +
    +
    +
    +
    + +
    +
    +
  • +
  • +
    +
    +
    +
    +
    + +
    +
    +
    + + my test user3 + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + Choose +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    +
    + +
    +
    +
    + + my test user + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + Choose +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
  • +
  • +
    +
    +
    +
    +
    + +
    +
    +
    + + my test user2 + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + Choose +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
  • +
    +
    +
    + + + +
    +
    +
    +
    + + + +
    +
    + Learn more +
    +
    +
    + + + +
    +
    +
    +
    +
    + about Default vs Important user personal notification settings +
    +
    +
    +
    +
+
+
+ AddRespondersPopup +
+
+
+`; diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss new file mode 100644 index 00000000..2c2f92f5 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss @@ -0,0 +1,48 @@ +.add-responders-dropdown { + border: var(--border-medium); + position: absolute; + right: 16px; + top: 55px; + background: var(--primary-background); + width: 340px; + z-index: 10; +} + +.team-direct-paging-info-alert { + margin: 8px; +} + +.learn-more-link { + display: inline-block; +} + +.responder-item { + cursor: pointer; + width: 280px; + overflow: hidden; +} + +.responders-filters { + margin: 8px; +} + +.radio-buttons { + margin: 8px; +} + +.table { + overflow: auto; + padding: 4px 0px; + + & tr:hover { + background: var(--background-secondary) !important; + } + + & tbody tr:nth-child(odd) { + background: unset; + } +} + +.user-results-section-header { + padding: 10px 8px; +} diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.test.tsx b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.test.tsx new file mode 100644 index 00000000..af0ec1c9 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; +import { Provider } from 'mobx-react'; + +import AddRespondersPopup from './AddRespondersPopup'; + +describe('AddRespondersPopup', () => { + const teams = [ + { + pk: 1, + avatar_url: 'https://example.com', + name: 'my test team', + number_of_users_currently_oncall: 1, + }, + { + pk: 2, + avatar_url: 'https://example.com', + name: 'my test team 2', + number_of_users_currently_oncall: 0, + }, + ]; + + test('it renders teams properly', () => { + const mockStoreValue = { + directPagingStore: { + selectedTeamResponder: null, + }, + grafanaTeamStore: { + getSearchResult: jest.fn().mockReturnValue(teams), + }, + userStore: { + getSearchResult: jest.fn().mockReturnValue({ results: [] }), + }, + }; + + const component = render( + + + + ); + + expect(component.container).toMatchSnapshot(); + }); + + test('if a team is selected it shows an info alert', () => { + const mockStoreValue = { + directPagingStore: { + selectedTeamResponder: teams[0], + selectedUserResponders: [], + }, + grafanaTeamStore: { + getSearchResult: jest.fn().mockReturnValue(teams), + }, + userStore: { + getSearchResult: jest.fn().mockReturnValue({ results: [] }), + }, + }; + + const component = render( + + + + ); + + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx new file mode 100644 index 00000000..d36c3532 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx @@ -0,0 +1,287 @@ +import React, { useState, useCallback, useEffect, useRef, FC } from 'react'; + +import { Alert, HorizontalGroup, Icon, Input, RadioButtonGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; +import { ColumnsType } from 'rc-table/lib/interface'; + +import Avatar from 'components/Avatar/Avatar'; +import GTable from 'components/GTable/GTable'; +import Text from 'components/Text/Text'; +import { Alert as AlertType } from 'models/alertgroup/alertgroup.types'; +import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; +import { User } from 'models/user/user.types'; +import { useStore } from 'state/useStore'; +import { useDebouncedCallback, useOnClickOutside } from 'utils/hooks'; + +import styles from './AddRespondersPopup.module.scss'; + +type Props = { + mode: 'create' | 'update'; + visible: boolean; + setVisible: (value: boolean) => void; + + setCurrentlyConsideredUser: (user: User) => void; + setShowUserConfirmationModal: (value: boolean) => void; + + existingPagedUsers?: AlertType['paged_users']; +}; + +const cx = cn.bind(styles); + +enum TabOptions { + Teams = 'teams', + Users = 'users', +} + +/** + * TODO: properly filter out 'No team'. Right now it shows up on first render and then shortly thereafter the component + * re-renders with 'No team' filtered out + * + * TODO: properly fetch/show loading state when fetching users. Right now it shows an empty list on the initial network + * request, we can probably have a better experience here + */ +const AddRespondersPopup = observer( + ({ + mode, + visible, + setVisible, + existingPagedUsers = [], + setCurrentlyConsideredUser, + setShowUserConfirmationModal, + }: Props) => { + const { directPagingStore, grafanaTeamStore, userStore } = useStore(); + const { selectedTeamResponder, selectedUserResponders } = directPagingStore; + + const isCreateMode = mode === 'create'; + + const [activeOption, setActiveOption] = useState(isCreateMode ? TabOptions.Teams : TabOptions.Users); + const [searchTerm, setSearchTerm] = useState(''); + + const ref = useRef(); + const teamSearchResults = grafanaTeamStore.getSearchResult(); + + let userSearchResults = userStore.getSearchResult().results || []; + + /** + * in the context where some user(s) have already been paged (ex. on a direct paging generated + * alert group detail page), we should filter out the search results to not include these users + */ + if (existingPagedUsers.length > 0) { + const existingPagedUserIds = existingPagedUsers.map(({ pk }) => pk); + userSearchResults = userSearchResults.filter(({ pk }) => !existingPagedUserIds.includes(pk)); + } + + const usersCurrentlyOnCall = userSearchResults.filter(({ is_currently_oncall }) => is_currently_oncall); + const usersNotCurrentlyOnCall = userSearchResults.filter(({ is_currently_oncall }) => !is_currently_oncall); + + useOnClickOutside(ref, () => { + setVisible(false); + }); + + const handleSetSearchTerm = useCallback( + (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }, + [setSearchTerm] + ); + + const onClickUser = useCallback( + async (user: User) => { + if (isCreateMode && user.is_currently_oncall) { + directPagingStore.addUserToSelectedUsers(user); + } else { + setCurrentlyConsideredUser(user); + setShowUserConfirmationModal(true); + } + setVisible(false); + }, + [isCreateMode, userStore, directPagingStore, setShowUserConfirmationModal, setVisible] + ); + + const addTeamResponder = useCallback( + (team: GrafanaTeam) => { + setVisible(false); + directPagingStore.updateSelectedTeam(team); + + /** + * can't select more than one team so we mind as well auto-switch the selected tab + * to the users section in case the user wants to come back and user(s) + */ + setActiveOption(TabOptions.Users); + }, + [setVisible, directPagingStore, setActiveOption] + ); + + const handleSearchTermChange = useDebouncedCallback(() => { + if (isCreateMode && activeOption === TabOptions.Teams) { + grafanaTeamStore.updateItems(searchTerm, false, true); + } else { + userStore.updateItems({ searchTerm, short: 'false' }); + } + }, 500); + + useEffect(handleSearchTermChange, [searchTerm, activeOption]); + + const userIsSelected = useCallback( + (user: User) => selectedUserResponders.some((userResponder) => userResponder.data.pk === user.pk), + [selectedUserResponders] + ); + + const teamColumns: ColumnsType = [ + // TODO: how to make the rows span full width properly? + { + width: 300, + render: (team: GrafanaTeam) => { + const { avatar_url, name, number_of_users_currently_oncall } = team; + + return ( +
addTeamResponder(team)} className={cx('responder-item')}> + + + + {name} + + {number_of_users_currently_oncall > 0 && ( + + {number_of_users_currently_oncall} user{number_of_users_currently_oncall > 1 ? 's' : ''} on-call + + )} + +
+ ); + }, + key: 'Title', + }, + ]; + + const userColumns: ColumnsType = [ + // TODO: how to make the rows span full width properly? + { + width: 300, + render: (user: User) => { + const { avatar, name, username, teams } = user; + const disabled = userIsSelected(user); + + return ( +
(disabled ? undefined : onClickUser(user))} className={cx('responder-item')}> + + + + {name || username} + + {teams.length > 0 && {teams.map(({ name }) => name).join(', ')}} + +
+ ); + }, + key: 'username', + }, + { + width: 40, + render: (user: User) => (userIsSelected(user) ? : null), + key: 'Checked', + }, + ]; + + const UserResultsSection: FC<{ header: string; users: User[] }> = ({ header, users }) => + users.length > 0 && ( + <> + + {header} + + + emptyText={users ? 'No users found' : 'Loading...'} + rowKey="pk" + columns={userColumns} + data={users} + className={cx('table')} + showHeader={false} + /> + + ); + + return ( + visible && ( +
+ } + key="search" + className={cx('responders-filters')} + data-testid="add-responders-search-input" + value={searchTerm} + placeholder="Search" + // @ts-ignore + width={'unset'} + onChange={handleSetSearchTerm} + /> + {isCreateMode && ( + + )} + {activeOption === TabOptions.Teams && ( + <> + {selectedTeamResponder ? ( + + ) : ( + <> + + You can only page teams which have a Direct Paging integration that is configured.{' '} + + + + Learn more + + + + + + ) as any + } + /> + + emptyText={teamSearchResults ? 'No teams found' : 'Loading...'} + rowKey="id" + columns={teamColumns} + data={teamSearchResults} + className={cx('table')} + showHeader={false} + /> + + )} + + )} + {activeOption === TabOptions.Users && ( + <> + + + + )} +
+ ) + ); + } +); + +export default AddRespondersPopup; diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/__snapshots__/AddRespondersPopup.test.tsx.snap b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/__snapshots__/AddRespondersPopup.test.tsx.snap new file mode 100644 index 00000000..16a3c3c9 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/__snapshots__/AddRespondersPopup.test.tsx.snap @@ -0,0 +1,402 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddRespondersPopup if a team is selected it shows an info alert 1`] = ` +
+
+
+
+ +
+
+ + + +
+
+
+
+
+ + + + +
+
+
+
+ + + +
+
+
+
+ You can add only one team per escalation. Please remove the existing team before adding a new one. +
+
+
+
+
+`; + +exports[`AddRespondersPopup it renders teams properly 1`] = ` +
+
+
+
+ +
+
+ + + +
+
+
+
+
+ + + + +
+
+
+
+ + + +
+
+
+
+ + You can only page teams which have a Direct Paging integration that is configured. + + + +
+
+ Learn more +
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + +
+
+
+
+
+
+ +
+
+ + my test team + +
+
+
+
+ + 1 + user + + on-call + +
+
+
+
+
+
+
+
+
+ +
+
+ + my test team 2 + +
+
+
+
+
+
+
+ + + + + +`; diff --git a/grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.module.scss b/grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.module.scss new file mode 100644 index 00000000..e1065a9a --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.module.scss @@ -0,0 +1,3 @@ +.select { + width: 150px !important; +} diff --git a/grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.test.tsx b/grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.test.tsx new file mode 100644 index 00000000..c9914869 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import NotificationPoliciesSelect from './NotificationPoliciesSelect'; + +describe('NotificationPoliciesSelect', () => { + test('it renders properly', () => { + const component = render( {}} />); + expect(component.container).toMatchSnapshot(); + }); + + test('disabled state', async () => { + const component = render( {}} />); + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.tsx b/grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.tsx new file mode 100644 index 00000000..5a9e93e9 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect.tsx @@ -0,0 +1,42 @@ +import React, { FC } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { Select, ActionMeta } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import { NotificationPolicyValue } from 'containers/AddResponders/AddResponders.types'; + +import styles from './NotificationPoliciesSelect.module.scss'; + +const cx = cn.bind(styles); + +type Props = { + disabled?: boolean; + important: boolean; + onChange: (value: SelectableValue, actionMeta: ActionMeta) => void; +}; + +const NotificationPoliciesSelect: FC = ({ disabled = false, important, onChange }) => ( + + +
+
+ + + +
+
+ + + +`; + +exports[`NotificationPoliciesSelect it renders properly 1`] = ` +
+
+ + +
+
+
+ Default +
+ +
+
+
+ + + +
+
+
+
+
+`; diff --git a/grafana-plugin/src/containers/AddResponders/parts/TeamResponder/TeamResponder.test.tsx b/grafana-plugin/src/containers/AddResponders/parts/TeamResponder/TeamResponder.test.tsx new file mode 100644 index 00000000..e1d8800c --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/TeamResponder/TeamResponder.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; + +import TeamResponder from './TeamResponder'; + +describe('TeamResponder', () => { + const team = { + avatar_url: 'https://example.com', + name: 'my test team', + } as GrafanaTeam; + + test('it renders data properly', () => { + const component = render( {}} />); + expect(component.container).toMatchSnapshot(); + }); + + test('it calls the delete callback', async () => { + const handleDelete = jest.fn(); + + render(); + + const deleteIcon = await screen.findByTestId('team-responder-delete-icon'); + await userEvent.click(deleteIcon); + + expect(handleDelete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/grafana-plugin/src/containers/AddResponders/parts/TeamResponder/TeamResponder.tsx b/grafana-plugin/src/containers/AddResponders/parts/TeamResponder/TeamResponder.tsx new file mode 100644 index 00000000..ebdcad78 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/TeamResponder/TeamResponder.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; + +import { HorizontalGroup, IconButton } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Avatar from 'components/Avatar/Avatar'; +import Text from 'components/Text/Text'; +import styles from 'containers/AddResponders/AddResponders.module.scss'; +import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; + +const cx = cn.bind(styles); + +type Props = { + team: GrafanaTeam | null; + handleDelete: React.MouseEventHandler; +}; + +const TeamResponder: FC = ({ team: { avatar_url, name }, handleDelete }) => ( +
  • + + +
    + +
    + {name} +
    + +
    +
  • +); + +export default TeamResponder; diff --git a/grafana-plugin/src/containers/AddResponders/parts/TeamResponder/__snapshots__/TeamResponder.test.tsx.snap b/grafana-plugin/src/containers/AddResponders/parts/TeamResponder/__snapshots__/TeamResponder.test.tsx.snap new file mode 100644 index 00000000..420483ec --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/TeamResponder/__snapshots__/TeamResponder.test.tsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TeamResponder it renders data properly 1`] = ` +
    +
  • +
    +
    +
    +
    +
    + +
    +
    +
    + + my test team + +
    +
    +
    +
    + +
    +
    +
  • +
    +`; diff --git a/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.test.tsx b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.test.tsx new file mode 100644 index 00000000..c8a5e239 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { User } from 'models/user/user.types'; + +import UserResponder from './UserResponder'; + +describe('UserResponder', () => { + const user = { + avatar: 'http://avatar.com/', + username: 'johnsmith', + } as User; + + test('it renders data properly', () => { + const component = render( + {}} handleDelete={() => {}} /> + ); + expect(component.container).toMatchSnapshot(); + }); + + test('it calls the delete callback', async () => { + const handleDelete = jest.fn(); + + render( {}} handleDelete={handleDelete} />); + + const deleteIcon = await screen.findByTestId('user-responder-delete-icon'); + await userEvent.click(deleteIcon); + + expect(handleDelete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.tsx b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.tsx new file mode 100644 index 00000000..64e8eaad --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.tsx @@ -0,0 +1,53 @@ +import React, { FC } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { ActionMeta, HorizontalGroup, IconButton } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Avatar from 'components/Avatar/Avatar'; +import Text from 'components/Text/Text'; +import styles from 'containers/AddResponders/AddResponders.module.scss'; +import { UserResponder as UserResponderType } from 'containers/AddResponders/AddResponders.types'; +import NotificationPoliciesSelect from 'containers/AddResponders/parts/NotificationPoliciesSelect/NotificationPoliciesSelect'; + +const cx = cn.bind(styles); + +type Props = UserResponderType & { + onImportantChange: (value: SelectableValue, actionMeta: ActionMeta) => void | {}; + handleDelete: React.MouseEventHandler; + disableNotificationPolicySelect?: boolean; +}; + +const UserResponder: FC = ({ + important, + data: { avatar, username }, + onImportantChange, + handleDelete, + disableNotificationPolicySelect = false, +}) => ( +
  • + + +
    + +
    + {username} +
    + + + + +
    +
  • +); + +export default UserResponder; diff --git a/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap new file mode 100644 index 00000000..2311afb8 --- /dev/null +++ b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UserResponder it renders data properly 1`] = ` +
    +
  • +
    +
    +
    +
    +
    + +
    +
    +
    + + johnsmith + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + Important +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
  • +
    +`; diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.helpers.ts b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.helpers.ts deleted file mode 100644 index b8de4370..00000000 --- a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.helpers.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { User } from 'models/user/user.types'; - -import { ResponderType } from './EscalationVariants.types'; - -export const deduplicate = (value) => { - const deduplicatedUserResponders = []; - value.userResponders.forEach((userResponder) => { - if (!deduplicatedUserResponders.some((responder) => responder.data.pk === userResponder.data.pk)) { - deduplicatedUserResponders.push(userResponder); - } - }); - - const deduplicatedScheduleResponders = []; - value.scheduleResponders.forEach((scheduleResponder) => { - if (!deduplicatedScheduleResponders.some((responder) => responder.data.id === scheduleResponder.data.id)) { - deduplicatedScheduleResponders.push(scheduleResponder); - } - }); - - return { - ...value, - scheduleResponders: deduplicatedScheduleResponders, - userResponders: deduplicatedUserResponders, - }; -}; - -export function prepareForUpdate(userResponders, scheduleResponders, data?) { - return { - ...data, - users: userResponders.map((userResponder) => ({ important: userResponder.important, id: userResponder.data.pk })), - schedules: scheduleResponders.map((scheduleResponder) => ({ - important: scheduleResponder.important, - id: scheduleResponder.data.id, - })), - }; -} - -export function prepareForEdit(userResponders) { - return { - userResponders: (userResponders || []).map(({ pk }: { pk: User['pk'] }) => ({ - type: ResponderType.User, - data: { pk }, - important: false, - })), - scheduleResponders: [], - }; -} diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.module.scss b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.module.scss deleted file mode 100644 index 9bf528e1..00000000 --- a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.module.scss +++ /dev/null @@ -1,105 +0,0 @@ -.escalation-variants-dropdown { - border: var(--border-medium); - position: absolute; - background: var(--primary-background); - width: 340px; - z-index: 10; -} - -.assign-responders-picker { - padding: 8px 8px; - background: var(--primary-background); - height: 196px; -} - -.assign-responders-list { - height: 146px; - overflow: auto; -} - -.assign-responders-item { - overflow: scroll; -} - -.table { - height: 120px; - overflow: auto; - - & tr:hover { - background: var(--background-secondary) !important; - } - - & tbody tr:nth-child(odd) { - background: unset; - } -} - -.responders-filters { - margin: 8px; -} - -.responder-item { - cursor: pointer; - width: 280px; - overflow: hidden; -} - -.body { - width: 100%; -} - -.responders-list { - list-style-type: none; - margin-bottom: 20px; - width: 100%; - - & > li .hover-button { - display: none; - } - - & > li:hover .hover-button { - display: inline-flex; - } - - & > li { - padding: 10px 12px; - width: 100%; - } - - & > li:hover { - background: var(--background-secondary); - } -} - -.timeline-icon-background { - width: 28px; - height: 28px; - border-radius: 50%; - background: var(--timeline-icon-background); - display: flex; - justify-content: center; - align-items: center; - - & > img { - width: 100%; - height: 100%; - } - - &--green { - background: #299c46; - } -} - -.radio-buttons { - margin: 8px; -} - -.select { - width: 150px !important; -} - -.responder-name { - max-width: 250px; - overflow: hidden; - white-space: nowrap; -} diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx deleted file mode 100644 index 08f05cb7..00000000 --- a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import React, { useState, useCallback } from 'react'; - -import { SelectableValue } from '@grafana/data'; -import { HorizontalGroup, Icon, Select, IconButton, Label, Tooltip, Button } from '@grafana/ui'; -import cn from 'classnames/bind'; -import { observer } from 'mobx-react'; - -import Avatar from 'components/Avatar/Avatar'; -import PluginLink from 'components/PluginLink/PluginLink'; -import Text from 'components/Text/Text'; -import UserWarning from 'containers/UserWarningModal/UserWarning'; -import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; -import { User } from 'models/user/user.types'; -import { UserActions } from 'utils/authorization'; - -import { deduplicate } from './EscalationVariants.helpers'; -import styles from './EscalationVariants.module.scss'; -import { ResponderType, UserAvailability } from './EscalationVariants.types'; -import EscalationVariantsPopup from './parts/EscalationVariantsPopup'; - -const cx = cn.bind(styles); - -export interface EscalationVariantsProps { - onUpdateEscalationVariants: (data: any) => void; - value: { scheduleResponders; userResponders }; - variant?: 'secondary' | 'primary'; - hideSelected?: boolean; - disabled?: boolean; - withLabels?: boolean; -} - -const EscalationVariants = observer( - ({ - onUpdateEscalationVariants: propsOnUpdateEscalationVariants, - value, - variant = 'primary', - hideSelected = false, - disabled, - withLabels = false, - }: EscalationVariantsProps) => { - const [showEscalationVariants, setShowEscalationVariants] = useState(false); - - const [showUserWarningModal, setShowUserWarningModal] = useState(false); - const [selectedUser, setSelectedUser] = useState(undefined); - const [userAvailability, setUserAvailability] = useState(undefined); - - const onUpdateEscalationVariants = useCallback((newValue) => { - const deduplicatedValue = deduplicate(newValue); - - propsOnUpdateEscalationVariants(deduplicatedValue); - }, []); - - const getUserResponderImportChangeHandler = (index) => { - return ({ value: important }: SelectableValue) => { - const userResponders = [...value.userResponders]; - const userResponder = userResponders[index]; - userResponder.important = Boolean(important); - - onUpdateEscalationVariants({ - ...value, - userResponders, - }); - }; - }; - - const getUserResponderDeleteHandler = (index) => { - return () => { - const userResponders = [...value.userResponders]; - userResponders.splice(index, 1); - - onUpdateEscalationVariants({ - ...value, - userResponders, - }); - }; - }; - - const getScheduleResponderImportChangeHandler = (index) => { - return ({ value: important }: SelectableValue) => { - const scheduleResponders = [...value.scheduleResponders]; - const scheduleResponder = scheduleResponders[index]; - scheduleResponder.important = Boolean(important); - - onUpdateEscalationVariants({ - ...value, - scheduleResponders, - }); - }; - }; - - const getScheduleResponderDeleteHandler = (index) => { - return () => { - const scheduleResponders = [...value.scheduleResponders]; - scheduleResponders.splice(index, 1); - - onUpdateEscalationVariants({ - ...value, - scheduleResponders, - }); - }; - }; - - return ( - <> -
    - {!hideSelected && Boolean(value.userResponders.length || value.scheduleResponders.length) && ( - <> - -
      - {value.userResponders.map((responder, index) => ( - - ))} - {value.scheduleResponders.map((responder, index) => ( - - ))} -
    - - )} -
    - {withLabels && } - - - -
    - {showEscalationVariants && ( - - )} -
    - {showUserWarningModal && ( - { - setShowUserWarningModal(false); - setSelectedUser(null); - }} - onUserSelect={(user: User) => { - onUpdateEscalationVariants({ - ...value, - userResponders: [ - ...value.userResponders, - { - type: ResponderType.User, - data: user, - important: - user.notification_chain_verbal.important && !user.notification_chain_verbal.default - ? true - : false, - }, - ], - }); - }} - /> - )} - - ); - } -); - -const UserResponder = ({ important, data, onImportantChange, handleDelete }) => { - return ( -
  • - - -
    - -
    - {data?.username} - {data.notification_chain_verbal.default || data.notification_chain_verbal.important ? ( - - by - - notification policies - - - - - - - -
    -
  • - ); -}; - -export default EscalationVariants; diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.types.ts b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.types.ts deleted file mode 100644 index 0fac96ca..00000000 --- a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -export enum EscalationVariantsTab { - Schedules, - Escalations, - Users, -} - -export interface UserAvailability { - warnings: Array<{ error: string; data: any }>; -} - -export enum ResponderType { - User, - Schedule, - // EscalationChain, // for future -} diff --git a/grafana-plugin/src/containers/EscalationVariants/parts/EscalationVariantsPopup.tsx b/grafana-plugin/src/containers/EscalationVariants/parts/EscalationVariantsPopup.tsx deleted file mode 100644 index 11574543..00000000 --- a/grafana-plugin/src/containers/EscalationVariants/parts/EscalationVariantsPopup.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useState, useCallback, useEffect, useRef } from 'react'; - -import { Icon, Input, RadioButtonGroup } from '@grafana/ui'; -import cn from 'classnames/bind'; -import { observer } from 'mobx-react'; - -import GTable from 'components/GTable/GTable'; -import Text from 'components/Text/Text'; -import { EscalationVariantsProps } from 'containers/EscalationVariants/EscalationVariants'; -import styles from 'containers/EscalationVariants/EscalationVariants.module.scss'; -import { ResponderType, UserAvailability } from 'containers/EscalationVariants/EscalationVariants.types'; -import { Schedule } from 'models/schedule/schedule.types'; -import { User } from 'models/user/user.types'; -import { useStore } from 'state/useStore'; -import { useDebouncedCallback, useOnClickOutside } from 'utils/hooks'; - -interface EscalationVariantsPopupProps extends EscalationVariantsProps { - setShowEscalationVariants: (value: boolean) => void; - setShowUserWarningModal: (value: boolean) => void; - setSelectedUser: (user: User) => void; - setUserAvailability: (data: UserAvailability) => void; -} - -const cx = cn.bind(styles); - -const EscalationVariantsPopup = observer((props: EscalationVariantsPopupProps) => { - const { - onUpdateEscalationVariants, - setShowEscalationVariants, - value, - setSelectedUser, - setShowUserWarningModal, - setUserAvailability, - } = props; - - const store = useStore(); - - const [activeOption, setActiveOption] = useState('schedules'); - const [usersSearchTerm, setUsersSearchTerm] = useState(''); - const [schedulesSearchTerm, setSchedulesSearchTerm] = useState(''); - - const handleSetSchedulesSearchTerm = useCallback((e) => { - setSchedulesSearchTerm(e.target.value); - }, []); - - const handleSetUsersSearchTerm = useCallback((e) => { - setUsersSearchTerm(e.target.value); - }, []); - - const handleOptionChange = useCallback((option: string) => { - setActiveOption(option); - }, []); - - const addUserResponders = (user: User) => { - store.userStore.checkUserAvailability(user.pk).then((res) => { - setSelectedUser(user); - setUserAvailability(res); - setShowUserWarningModal(true); - }); - - setShowEscalationVariants(false); - }; - - const addSchedulesResponders = (schedule: Schedule) => { - setShowEscalationVariants(false); - onUpdateEscalationVariants({ - ...value, - scheduleResponders: [ - ...value.scheduleResponders, - { type: ResponderType.Schedule, data: schedule, important: false }, - ], - }); - }; - - const handleUsersSearchTermChange = useDebouncedCallback(() => { - store.userStore.updateItems(usersSearchTerm); - }, 500); - - useEffect(handleUsersSearchTermChange, [usersSearchTerm]); - - const handleSchedulesSearchTermChange = useDebouncedCallback(() => { - store.scheduleStore.updateItems(schedulesSearchTerm); - }, 500); - - useEffect(handleSchedulesSearchTermChange, [schedulesSearchTerm]); - - const scheduleColumns = [ - { - width: 300, - render: (schedule: Schedule) => { - const disabled = value.scheduleResponders.some( - (scheduleResponder) => scheduleResponder.data.id === schedule.id - ); - - return ( -
    (disabled ? undefined : addSchedulesResponders(schedule))} - className={cx('responder-item')} - > - {schedule.name} -
    - ); - }, - key: 'Title', - }, - { - width: 40, - render: (item: Schedule) => - value.scheduleResponders.some((scheduleResponder) => scheduleResponder.data.id === item.id) ? ( - - ) : null, - key: 'Checked', - }, - ]; - - const userColumns = [ - { - width: 300, - render: (user: User) => { - const disabled = value.userResponders.some((userResponder) => userResponder.data?.pk === user.pk); - return ( -
    (disabled ? undefined : addUserResponders(user))} className={cx('responder-item')}> - - {user.username} ({user.timezone}) - -
    - ); - }, - key: 'username', - }, - { - width: 40, - render: (item: User) => - value.userResponders.some((userResponder) => userResponder.data?.pk === item.pk) ? : null, - key: 'Checked', - }, - ]; - - const ref = useRef(); - - useOnClickOutside(ref, () => { - setShowEscalationVariants(false); - }); - - return ( -
    - - {activeOption === 'schedules' && ( - <> - } - key="schedules search" - className={cx('responders-filters')} - value={schedulesSearchTerm} - placeholder="Search schedules..." - // @ts-ignore - width={'unset'} - onChange={handleSetSchedulesSearchTerm} - /> - - - )} - {activeOption === 'users' && ( - <> - } - key="users search" - // @ts-ignore - width={'unset'} - className={cx('responders-filters')} - placeholder="Search users..." - value={usersSearchTerm} - onChange={handleSetUsersSearchTerm} - /> - - - )} -
    - ); -}); - -export default EscalationVariantsPopup; diff --git a/grafana-plugin/src/containers/UserWarningModal/UserWarning.module.scss b/grafana-plugin/src/containers/UserWarningModal/UserWarning.module.scss deleted file mode 100644 index 2ed15633..00000000 --- a/grafana-plugin/src/containers/UserWarningModal/UserWarning.module.scss +++ /dev/null @@ -1,26 +0,0 @@ -.user-warning { - margin: 16px; -} - -.users { - list-style-type: none; - margin-left: 23px; - width: 100%; - - & > li { - width: 100%; - background: var(--background-secondary); - margin-bottom: 4px; - padding: 14px 12px; - } -} -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #6ccf8e; -} - -.modal { - width: 650px; -} diff --git a/grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx b/grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx deleted file mode 100644 index 0c06642a..00000000 --- a/grafana-plugin/src/containers/UserWarningModal/UserWarning.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import React, { FC, useCallback, useEffect, useMemo } from 'react'; - -import { Button, HorizontalGroup, Icon, Modal, VerticalGroup } from '@grafana/ui'; -import cn from 'classnames/bind'; -import dayjs from 'dayjs'; - -import Text from 'components/Text/Text'; -import { UserAvailability } from 'containers/EscalationVariants/EscalationVariants.types'; -import { getTzOffsetString } from 'models/timezone/timezone.helpers'; -import { User } from 'models/user/user.types'; -import { useStore } from 'state/useStore'; - -import styles from './UserWarning.module.scss'; - -interface UserWarningProps { - onHide: () => void; - user: User; - userAvailability: UserAvailability; - onUserSelect: (user: User) => void; -} - -const cx = cn.bind(styles); - -const UserWarning: FC = (props) => { - const { onHide, user, userAvailability, onUserSelect } = props; - const store = useStore(); - - const { userStore } = store; - - const getUserSelectHandler = useCallback( - (userId: User['pk']) => { - return async () => { - onHide(); - - if (!userStore.items[userId]) { - await userStore.updateItem(userId); - } - - const user = userStore.items[userId]; - - onUserSelect(user); - }; - }, - [userStore.items] - ); - - const showUserHasNoNotificationPolicyWarning = useMemo( - () => userAvailability.warnings.some((warning) => warning.error === 'USER_HAS_NO_NOTIFICATION_POLICY'), - [userAvailability] - ); - - const showUserIsNotOncallWarning = useMemo( - () => userAvailability.warnings.some((warning) => warning.error === 'USER_IS_NOT_ON_CALL'), - [userAvailability] - ); - - const userSchedules = useMemo( - () => - userAvailability.warnings.reduce((memo, warning) => { - if (warning.error === 'USER_IS_NOT_ON_CALL') { - const schedules = warning.data.schedules; - const userSchedulesKeys = Object.keys(schedules).filter((key: string) => schedules[key].includes(user.pk)); - memo.push(...userSchedulesKeys); - } - return memo; - }, []), - [userAvailability] - ); - - const recommendedUsers = useMemo( - () => - userAvailability.warnings.reduce((memo, warning) => { - if (warning.error === 'USER_IS_NOT_ON_CALL') { - const users = Object.keys(warning.data.schedules).reduce((memo, key) => { - const users = warning.data.schedules[key]; - memo.push(...users); - - return memo; - }, []); - memo.push(...users); - } - - return memo; - }, []), - [userAvailability] - ); - - return ( - - - {showUserHasNoNotificationPolicyWarning && ( - - - - {user.username} has no notification policy - - - )} - {showUserIsNotOncallWarning && ( - - - - - {user.username} (Local time {dayjs().tz(user.timezone).format('HH:mm:ss')}) - {' '} - is not currently on-call. - - - )} - {userSchedules.length && ( - - - - {user.username} appears in {userSchedules.join(', ')} - - - )} - {recommendedUsers.length && ( - - - Recommended on-call users: - - )} - {recommendedUsers.length && ( -
      - {recommendedUsers.map((userPk) => ( - - ))} -
    - )} - - - - - Are you sure you want to select {user.username}? - - - - - - - -
    -
    - ); -}; - -const RecommendedUser = ({ pk, onSelect }: { pk: User['pk']; onSelect: () => void }) => { - const store = useStore(); - - const { userStore } = store; - - useEffect(() => { - if (!userStore.items[pk]) { - userStore.updateItem(pk); - } - }, [pk]); - - const user = userStore.items[pk]; - - return ( -
  • - - -
    - {user?.username} - - ({getTzOffsetString(dayjs().tz(user?.timezone))}, {user?.timezone}) - - - - - -
  • - ); -}; - -export default UserWarning; diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts index 5a87160e..48369f4e 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts @@ -40,6 +40,10 @@ export interface GroupedAlert { render_for_web: RenderForWeb; } +export type PagedUser = Pick & { + important: boolean; +}; + export interface Alert { pk: string; title: string; @@ -80,14 +84,12 @@ export interface Alert { short?: boolean; root_alert_group?: Alert; alert_receive_channel: Partial; - paged_users: Array>; + paged_users: PagedUser[]; team: GrafanaTeam['id']; // set by client loading?: boolean; undoAction?: AlertAction; - - has_pormortem?: boolean; // not implemented yet } interface RenderForWeb { diff --git a/grafana-plugin/src/models/direct_paging/direct_paging.test.ts b/grafana-plugin/src/models/direct_paging/direct_paging.test.ts new file mode 100644 index 00000000..3a6a7569 --- /dev/null +++ b/grafana-plugin/src/models/direct_paging/direct_paging.test.ts @@ -0,0 +1,154 @@ +import { makeRequest as makeRequestOriginal } from 'network'; +import { RootStore } from 'state'; + +import { DirectPagingStore } from './direct_paging'; + +const makeRequest = makeRequestOriginal as jest.Mock>; + +jest.mock('network'); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('DirectPagingStore', () => { + const generateStore = () => { + const rootStore = new RootStore(); + return new DirectPagingStore(rootStore); + }; + + test('addUserToSelectedUsers properly updates the state', async () => { + const directPagingStore = generateStore(); + const newUser = { + id: '123', + username: 'test', + }; + + directPagingStore.addUserToSelectedUsers(newUser as any); + + expect(directPagingStore.selectedUserResponders).toEqual([ + { + data: newUser, + important: false, + }, + ]); + }); + + test('resetSelectedUsers properly resets the state', async () => { + const directPagingStore = generateStore(); + const newUser = { + id: '123', + username: 'test', + }; + + directPagingStore.addUserToSelectedUsers(newUser as any); + expect(directPagingStore.selectedUserResponders).toHaveLength(1); + + directPagingStore.resetSelectedUsers(); + expect(directPagingStore.selectedUserResponders).toEqual([]); + }); + + test('updateSelectedTeam properly updates the state', async () => { + const directPagingStore = generateStore(); + const newTeam = { + id: '123', + }; + + expect(directPagingStore.selectedTeamResponder).toBeNull(); + directPagingStore.updateSelectedTeam(newTeam as any); + expect(directPagingStore.selectedTeamResponder).toEqual(newTeam); + }); + + test('resetSelectedTeam properly resets the state', async () => { + const directPagingStore = generateStore(); + const newTeam = { + id: '123', + }; + directPagingStore.updateSelectedTeam(newTeam as any); + expect(directPagingStore.selectedTeamResponder).not.toBeNull(); + + directPagingStore.resetSelectedTeam(); + expect(directPagingStore.selectedTeamResponder).toBeNull(); + }); + + test('removeSelectedUser properly updates the state', async () => { + const directPagingStore = generateStore(); + const newUsers = [ + { + id: '123', + username: 'test', + }, + { + id: '456', + username: 'test2', + }, + ] as any; + directPagingStore.addUserToSelectedUsers(newUsers[0]); + directPagingStore.addUserToSelectedUsers(newUsers[1]); + + expect(directPagingStore.selectedUserResponders).toHaveLength(2); + + directPagingStore.removeSelectedUser(0); + + expect(directPagingStore.selectedUserResponders).toHaveLength(1); + expect(directPagingStore.selectedUserResponders[0].data).toEqual(newUsers[1]); + }); + + test('updateSelectedUserImportantStatus properly updates the state', async () => { + const directPagingStore = generateStore(); + const newUsers = [ + { + id: '123', + username: 'test', + }, + { + id: '456', + username: 'test2', + }, + ] as any; + directPagingStore.addUserToSelectedUsers(newUsers[0]); + directPagingStore.addUserToSelectedUsers(newUsers[1]); + + expect(directPagingStore.selectedUserResponders).toHaveLength(2); + + expect(directPagingStore.selectedUserResponders[1].important).toEqual(false); + directPagingStore.updateSelectedUserImportantStatus(1, true); + expect(directPagingStore.selectedUserResponders[1].important).toEqual(true); + }); + + test('createManualAlertRule makes the proper API call and returns the response', async () => { + const directPagingStore = generateStore(); + const mockedRequest = { team: '12345', users: [{ id: 'asdfadf', important: true }] }; + const mockedResponse = { alert_group_id: '123' }; + + makeRequest.mockResolvedValueOnce(mockedResponse); + + expect(await directPagingStore.createManualAlertRule(mockedRequest)).toEqual(mockedResponse); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith('/direct_paging/', { + method: 'POST', + data: mockedRequest, + }); + }); + + test('updateAlertGroup makes the proper API call and returns the response', async () => { + const directPagingStore = generateStore(); + const alertGroupId = '134'; + const mockedRequest = { team: '12345', users: [{ id: 'asdfadf', important: true }] }; + const mockedResponse = { alert_group_id: alertGroupId }; + + makeRequest.mockResolvedValueOnce(mockedResponse); + + expect(await directPagingStore.updateAlertGroup(alertGroupId, mockedRequest)).toEqual(mockedResponse); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith('/direct_paging/', { + method: 'POST', + data: { + alert_group_id: alertGroupId, + ...mockedRequest, + }, + }); + }); +}); diff --git a/grafana-plugin/src/models/direct_paging/direct_paging.ts b/grafana-plugin/src/models/direct_paging/direct_paging.ts index 98a2e96f..332aa9e8 100644 --- a/grafana-plugin/src/models/direct_paging/direct_paging.ts +++ b/grafana-plugin/src/models/direct_paging/direct_paging.ts @@ -1,26 +1,87 @@ +import { action, observable } from 'mobx'; + +import { UserResponders } from 'containers/AddResponders/AddResponders.types'; import { Alert } from 'models/alertgroup/alertgroup.types'; import BaseStore from 'models/base_store'; +import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; +import { User } from 'models/user/user.types'; import { makeRequest } from 'network'; import { RootStore } from 'state'; import { ManualAlertGroupPayload } from './direct_paging.types'; +type DirectPagingResponse = { + alert_group_id: string; +}; + export class DirectPagingStore extends BaseStore { + @observable + selectedTeamResponder: GrafanaTeam | null = null; + + @observable + selectedUserResponders: UserResponders = []; + constructor(rootStore: RootStore) { super(rootStore); this.path = '/direct_paging/'; } - async createManualAlertRule(data: ManualAlertGroupPayload) { - return await makeRequest(`${this.path}`, { + @action + addUserToSelectedUsers = (user: User) => { + this.selectedUserResponders = [ + ...this.selectedUserResponders, + { + data: user, + important: false, + }, + ]; + }; + + @action + resetSelectedUsers = () => { + this.selectedUserResponders = []; + }; + + @action + updateSelectedTeam = (team: GrafanaTeam) => { + this.selectedTeamResponder = team; + }; + + @action + resetSelectedTeam = () => { + this.selectedTeamResponder = null; + }; + + @action + removeSelectedUser(index: number) { + this.selectedUserResponders = [ + ...this.selectedUserResponders.slice(0, index), + ...this.selectedUserResponders.slice(index + 1), + ]; + } + + @action + updateSelectedUserImportantStatus(index: number, important: boolean) { + this.selectedUserResponders = [ + ...this.selectedUserResponders.slice(0, index), + { + ...this.selectedUserResponders[index], + important, + }, + ...this.selectedUserResponders.slice(index + 1), + ]; + } + + async createManualAlertRule(data: ManualAlertGroupPayload): Promise { + return await makeRequest(this.path, { method: 'POST', data, }).catch(this.onApiError); } - async updateAlertGroup(alertId: Alert['pk'], data: ManualAlertGroupPayload) { - return await makeRequest(`${this.path}`, { + async updateAlertGroup(alertId: Alert['pk'], data: ManualAlertGroupPayload): Promise { + return await makeRequest(this.path, { method: 'POST', data: { alert_group_id: alertId, diff --git a/grafana-plugin/src/models/direct_paging/direct_paging.types.ts b/grafana-plugin/src/models/direct_paging/direct_paging.types.ts index 8ad32563..dcae8fb5 100644 --- a/grafana-plugin/src/models/direct_paging/direct_paging.types.ts +++ b/grafana-plugin/src/models/direct_paging/direct_paging.types.ts @@ -1,7 +1,4 @@ -import { Schedule } from 'models/schedule/schedule.types'; -import { User } from 'models/user/user.types'; - -export interface ManualAlertGroupPayload { - users: Array<{ id: User['pk']; important: boolean }>; - schedules: Array<{ id: Schedule['id']; important: boolean }>; -} +export type ManualAlertGroupPayload = { + team: string | null; + users: Array<{ id: string; important: boolean }>; +}; diff --git a/grafana-plugin/src/models/escalation_policy.ts b/grafana-plugin/src/models/escalation_policy.ts index 286178c6..9df6ecc7 100644 --- a/grafana-plugin/src/models/escalation_policy.ts +++ b/grafana-plugin/src/models/escalation_policy.ts @@ -4,7 +4,7 @@ import { UserGroup } from 'models/user_group/user_group.types'; import { ChannelFilter } from './channel_filter'; import { ScheduleDTO } from './schedule'; -import { UserDTO as User } from './user'; +import { User } from './user/user.types'; export interface EscalationPolicyType { id: string; diff --git a/grafana-plugin/src/models/grafana_team/grafana_team.ts b/grafana-plugin/src/models/grafana_team/grafana_team.ts index 8ca9a7b1..bb0b3cee 100644 --- a/grafana-plugin/src/models/grafana_team/grafana_team.ts +++ b/grafana-plugin/src/models/grafana_team/grafana_team.ts @@ -29,9 +29,13 @@ export class GrafanaTeamStore extends BaseStore { } @action - async updateItems(query = '') { + async updateItems(query = '', includeNoTeam = true, onlyIncludeNotifiableTeams = false) { const result = await makeRequest(`${this.path}`, { - params: { search: query }, + params: { + search: query, + include_no_team: includeNoTeam ? 'true' : 'false', + only_include_notifiable_teams: onlyIncludeNotifiableTeams ? 'true' : 'false', + }, }); this.items = { @@ -53,7 +57,7 @@ export class GrafanaTeamStore extends BaseStore { getSearchResult(query = '') { if (!this.searchResult[query]) { - return undefined; + return []; } return this.searchResult[query].map((teamId: GrafanaTeam['id']) => this.items[teamId]); diff --git a/grafana-plugin/src/models/grafana_team/grafana_team.types.ts b/grafana-plugin/src/models/grafana_team/grafana_team.types.ts index 4f6ffc63..8b0af307 100644 --- a/grafana-plugin/src/models/grafana_team/grafana_team.types.ts +++ b/grafana-plugin/src/models/grafana_team/grafana_team.types.ts @@ -4,4 +4,5 @@ export interface GrafanaTeam { email: string; avatar_url: string; is_sharing_resources_to_all: boolean; + number_of_users_currently_oncall: number; } diff --git a/grafana-plugin/src/models/notification_policy.ts b/grafana-plugin/src/models/notification_policy.ts index ad0e7d71..79f7b6b4 100644 --- a/grafana-plugin/src/models/notification_policy.ts +++ b/grafana-plugin/src/models/notification_policy.ts @@ -1,9 +1,9 @@ -import { UserDTO as User } from './user'; +import { User } from './user/user.types'; export interface NotificationPolicyType { id: string; step: number; - notify_by: User['pk'] | null; + notify_by: number | null; wait_delay: string | null; important: boolean; user: User['pk']; diff --git a/grafana-plugin/src/models/resolution_note/resolution_note.types.ts b/grafana-plugin/src/models/resolution_note/resolution_note.types.ts index 2b5c3779..6672e522 100644 --- a/grafana-plugin/src/models/resolution_note/resolution_note.types.ts +++ b/grafana-plugin/src/models/resolution_note/resolution_note.types.ts @@ -1,5 +1,5 @@ import { Alert } from 'models/alertgroup/alertgroup.types'; -import { UserDTO } from 'models/user'; +import { User } from 'models/user/user.types'; interface ResolutionNoteSource { id: number; // TODO check if string @@ -11,7 +11,7 @@ export interface ResolutionNote { alert_group: Alert['pk']; created_at: string; source: ResolutionNoteSource; - author: Partial; + author: Partial; text: string; } diff --git a/grafana-plugin/src/models/schedule/schedule.ts b/grafana-plugin/src/models/schedule/schedule.ts index bb055209..f4e4a55e 100644 --- a/grafana-plugin/src/models/schedule/schedule.ts +++ b/grafana-plugin/src/models/schedule/schedule.ts @@ -193,9 +193,6 @@ export class ScheduleStore extends BaseStore { } getSearchResult() { - if (!this.searchResult) { - return undefined; - } return { page_size: this.searchResult.page_size, count: this.searchResult.count, diff --git a/grafana-plugin/src/models/user.ts b/grafana-plugin/src/models/user.ts deleted file mode 100644 index 3b267954..00000000 --- a/grafana-plugin/src/models/user.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface UserDTO { - pk: number; - slack_login: string; - email: string; - phone: string; - avatar: string; - name: string; - company: string; - role_in_company: string; - username: string; - slack_id: string; - verified_phone_number?: string; - unverified_phone_number?: string; - phone_verified: boolean; - telegram_configuration: { - telegram_nick_name: string; - telegram_chat_id: number; - }; - slack_user_identity: any; - post_onboarding_entry_allowed: any; - teams: []; - onboarding_conversation_data: { - image_link: string | null; - inviter_name: string | null; - video_conference_link: string | null; - }; - trigger_video_call?: boolean; -} diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index fc3b08f2..dd92f538 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -113,9 +113,9 @@ export class UserStore extends BaseStore { @action async updateItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise { const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility - const { searchTerm: search } = filters; + const { searchTerm: search, ...restFilters } = filters; const response = await makeRequest(this.path, { - params: { search, page }, + params: { search, page, ...restFilters }, }); if (invalidateFn && invalidateFn()) { @@ -423,10 +423,4 @@ export class UserStore extends BaseStore { method: 'DELETE', }); } - - async checkUserAvailability(userPk: User['pk']) { - return await makeRequest(`/users/${userPk}/check_availability/`, { - method: 'GET', - }); - } } diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index a89e0f22..a1bad61b 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -1,3 +1,4 @@ +import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; import { Timezone } from 'models/timezone/timezone.types'; export interface MessagingBackends { @@ -13,9 +14,7 @@ export interface User { avatar_full: string; name: string; display_name: string; - company: string; hide_phone_number: boolean; - role_in_company: string; username: string; slack_id: string; phone_verified: boolean; @@ -36,14 +35,7 @@ export interface User { slack_id: string; slack_login: string; } | null; - post_onboarding_entry_allowed: any; current_team: string | null; - onboarding_conversation_data: { - image_link: string | null; - inviter_name: string | null; - video_conference_link: string | null; - }; - trigger_video_call?: boolean; export_url?: string; status?: number; link?: string; @@ -51,4 +43,6 @@ export interface User { hidden_fields?: boolean; timezone: Timezone; working_hours: { [key: string]: [] }; + is_currently_oncall: boolean; + teams: GrafanaTeam[]; } diff --git a/grafana-plugin/src/pages/incident/Incident.module.scss b/grafana-plugin/src/pages/incident/Incident.module.scss index e40de8a9..d3a6fd04 100644 --- a/grafana-plugin/src/pages/incident/Incident.module.scss +++ b/grafana-plugin/src/pages/incident/Incident.module.scss @@ -69,10 +69,6 @@ padding-left: 24px; } -.column:first-child { - border-right: 1px solid rgba(204, 204, 220, 0.25); -} - .incidents-content > div:not(:last-child) { border-bottom: 1px solid rgba(204, 204, 220, 0.25); padding-bottom: 16px; @@ -199,4 +195,4 @@ .user-badge { vertical-align: middle; -} \ No newline at end of file +} diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 4c24336f..98aca39f 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -34,9 +34,10 @@ import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBri import PluginLink from 'components/PluginLink/PluginLink'; import SourceCode from 'components/SourceCode/SourceCode'; import Text from 'components/Text/Text'; +import AddResponders from 'containers/AddResponders/AddResponders'; +import { prepareForUpdate } from 'containers/AddResponders/AddResponders.helpers'; +import { UserResponder } from 'containers/AddResponders/AddResponders.types'; import AttachIncidentForm from 'containers/AttachIncidentForm/AttachIncidentForm'; -import EscalationVariants from 'containers/EscalationVariants/EscalationVariants'; -import { prepareForEdit, prepareForUpdate } from 'containers/EscalationVariants/EscalationVariants.helpers'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { Alert as AlertType, @@ -59,7 +60,6 @@ import sanitize from 'utils/sanitize'; import { getActionButtons } from './Incident.helpers'; import styles from './Incident.module.scss'; -import PagedUsers from './parts/PagedUsers'; const cx = cn.bind(styles); const INTEGRATION_NAME_LENGTH_LIMIT = 30; @@ -177,11 +177,13 @@ class IncidentPage extends React.Component
    - - + {this.renderTimeline()} @@ -231,17 +233,19 @@ class IncidentPage extends React.Component ); } - handlePagedUserRemove = async (userId: User['pk']) => { - const { - store, - match: { - params: { id: alertId }, - }, - } = this.props; + handlePagedUserRemove = (userId: User['pk']) => { + return async () => { + const { + store, + match: { + params: { id: alertId }, + }, + } = this.props; - await store.alertGroupStore.unpageUser(alertId, userId); + await store.alertGroupStore.unpageUser(alertId, userId); - this.update(); + this.update(); + }; }; renderHeader = () => { @@ -422,31 +426,21 @@ class IncidentPage extends React.Component - - - - - + ); }; - handleAddResponders = async (data) => { + handleAddUserResponder = async (user: Omit) => { const { store, match: { @@ -454,10 +448,7 @@ class IncidentPage extends React.Component }, } = this.props; - await store.directPagingStore.updateAlertGroup( - alertId, - prepareForUpdate(data.userResponders, data.scheduleResponders) - ); + await store.directPagingStore.updateAlertGroup(alertId, prepareForUpdate([user])); this.update(); }; @@ -495,7 +486,7 @@ class IncidentPage extends React.Component const { timelineFilter, resolutionNoteText } = this.state; const isResolutionNoteTextEmpty = resolutionNoteText === ''; return ( -
    + Timeline @@ -555,7 +546,7 @@ class IncidentPage extends React.Component Add resolution note -
    + ); }; diff --git a/grafana-plugin/src/pages/incident/parts/PagedUsers.tsx b/grafana-plugin/src/pages/incident/parts/PagedUsers.tsx deleted file mode 100644 index a0640406..00000000 --- a/grafana-plugin/src/pages/incident/parts/PagedUsers.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; - -import { HorizontalGroup, Icon, IconButton, Tooltip } from '@grafana/ui'; -import cn from 'classnames/bind'; -import { observer } from 'mobx-react'; - -import Avatar from 'components/Avatar/Avatar'; -import PluginLink from 'components/PluginLink/PluginLink'; -import Text from 'components/Text/Text'; -import WithConfirm from 'components/WithConfirm/WithConfirm'; -import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; -import { Alert } from 'models/alertgroup/alertgroup.types'; -import { User } from 'models/user/user.types'; -import { useStore } from 'state/useStore'; -import { UserActions } from 'utils/authorization'; - -import styles from './../Incident.module.scss'; - -const cx = cn.bind(styles); - -interface PagedUsersProps { - pagedUsers: Alert['paged_users']; - disabled: boolean; - - onRemove: (id: User['pk']) => void; -} - -const PagedUsers = observer((props: PagedUsersProps) => { - const { pagedUsers, disabled, onRemove } = props; - - const getPagedUserRemoveHandler = useCallback((id: User['pk']) => { - return () => { - onRemove(id); - }; - }, []); - - const { userStore } = useStore(); - - useEffect(() => { - pagedUsers && - pagedUsers.forEach((user) => { - if (!userStore.items[user.pk]) { - userStore.updateItem(user.pk); - } - }); - }, [pagedUsers]); - - if (!pagedUsers || !pagedUsers.length) { - return null; - } - - return ( -
    - - Additional responders - -
      - {pagedUsers.map((pagedUser) => { - const storeUser = userStore.items[pagedUser.pk]; - - return ( -
    • - - - - {pagedUser.username} - {Boolean( - storeUser && - !storeUser.notification_chain_verbal.default && - !storeUser.notification_chain_verbal.important - ) && ( - - - - )} - - - - - - - - - - - - -
    • - ); - })} -
    -
    - ); -}); - -export default PagedUsers; diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 878bbf78..0b554315 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -118,7 +118,7 @@ class Incidents extends React.Component Alert Groups