From 11259de8e06f126157011ebdfa9dda2b03b4bd49 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 26 Oct 2023 09:55:02 -0300 Subject: [PATCH 1/8] Add settings to allow detaching integrations server (#3203) To run a detached integrations server: 1. Set env var `DETACHED_INTEGRATIONS_SERVER=True` 2. Run engine with the `integrations_urls.py` root url conf (e.g. `ROOT_URLCONF=engine.integrations_urls python manage.py runserver 0.0.0.0:8081`) --- engine/engine/integrations_urls.py | 9 +++++++++ engine/engine/urls.py | 12 ++++++++---- engine/settings/base.py | 4 +++- 3 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 engine/engine/integrations_urls.py diff --git a/engine/engine/integrations_urls.py b/engine/engine/integrations_urls.py new file mode 100644 index 00000000..7ea1bda2 --- /dev/null +++ b/engine/engine/integrations_urls.py @@ -0,0 +1,9 @@ +from django.urls import URLPattern, URLResolver, include, path + +from .urls import paths_to_work_even_when_maintenance_mode_is_active + +urlpatterns: list[URLPattern | URLResolver] = paths_to_work_even_when_maintenance_mode_is_active + +urlpatterns += [ + path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")), +] diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 06faf004..c86a9a74 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -15,20 +15,24 @@ Including another URLconf """ from django.conf import settings from django.conf.urls.static import static -from django.urls import include, path +from django.urls import URLPattern, URLResolver, include, path from .views import HealthCheckView, MaintenanceModeStatusView, ReadinessCheckView, StartupProbeView -paths_to_work_even_when_maintenance_mode_is_active = [ +paths_to_work_even_when_maintenance_mode_is_active: list[URLPattern | URLResolver] = [ path("", HealthCheckView.as_view()), path("health/", HealthCheckView.as_view()), path("ready/", ReadinessCheckView.as_view()), path("startupprobe/", StartupProbeView.as_view()), - path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")), path("api/internal/v1/maintenance-mode-status", MaintenanceModeStatusView.as_view()), ] -urlpatterns = [ +if not settings.DETACHED_INTEGRATIONS_SERVER: + paths_to_work_even_when_maintenance_mode_is_active += [ + path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")), + ] + +urlpatterns: list[URLPattern | URLResolver] = [ *paths_to_work_even_when_maintenance_mode_is_active, path("api/gi/v1/", include("apps.api_for_grafana_incident.urls", namespace="api-gi")), path("api/internal/v1/", include("apps.api.urls", namespace="api-internal")), diff --git a/engine/settings/base.py b/engine/settings/base.py index 339e88eb..260c4f96 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -373,7 +373,7 @@ LOGGING = { }, } -ROOT_URLCONF = "engine.urls" +ROOT_URLCONF = os.environ.get("ROOT_URLCONF", "engine.urls") TEMPLATES = [ { @@ -831,3 +831,5 @@ ZVONOK_POSTBACK_CAMPAIGN_ID = os.getenv("ZVONOK_POSTBACK_CAMPAIGN_ID", "campaign ZVONOK_POSTBACK_STATUS = os.getenv("ZVONOK_POSTBACK_STATUS", "status") ZVONOK_POSTBACK_USER_CHOICE = os.getenv("ZVONOK_POSTBACK_USER_CHOICE", None) ZVONOK_POSTBACK_USER_CHOICE_ACK = os.getenv("ZVONOK_POSTBACK_USER_CHOICE_ACK", None) + +DETACHED_INTEGRATIONS_SERVER = getenv_boolean("DETACHED_INTEGRATIONS_SERVER", default=False) From 697248dc75dc1a2afb73dbd257613fe453019089 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Fri, 27 Oct 2023 12:12:07 -0400 Subject: [PATCH 2/8] 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 From c4fb620328c9dd83646d7509de74ed72d0733dca Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 27 Oct 2023 15:45:00 -0300 Subject: [PATCH 3/8] Upgrade to django 4.2.6 and other deps updates (#3176) --- CHANGELOG.md | 4 + .../amixr_recurring_ical_events_adapter.py | 94 +------------------ .../schedules/models/custom_on_call_shift.py | 23 ++--- .../apps/schedules/models/on_call_schedule.py | 26 ++--- .../tests/test_custom_on_call_shift.py | 2 +- .../live_setting_django_strategy.py | 4 +- engine/apps/user_management/models/user.py | 2 +- engine/requirements.txt | 16 ++-- 8 files changed, 45 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0053ea..046d0810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix iCal imported schedules related users and next shifts per user ([#3178](https://github.com/grafana/oncall/pull/3178)) - Fix references to removed access control functions in Grafana @mderynck ([#3184](https://github.com/grafana/oncall/pull/3184)) +### Changed + +- Upgrade Django to 4.2.6 and update iCal related deps ([#3176](https://github.com/grafana/oncall/pull/3176)) + ## v1.3.45 (2023-10-19) ### Added diff --git a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py index 5f574aba..155a6161 100644 --- a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py +++ b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py @@ -1,10 +1,8 @@ import datetime -import re import typing -from collections import defaultdict from icalendar import Calendar, Event -from recurring_ical_events import UnfoldableCalendar, compare_greater, is_event, time_span_contains_event +from recurring_ical_events import UnfoldableCalendar, time_span_contains_event from apps.schedules.constants import ( ICAL_DATETIME_END, @@ -21,94 +19,6 @@ from apps.schedules.ical_events.proxy.ical_proxy import IcalService EXTRA_LOOKUP_DAYS = 16 -class AmixrUnfoldableCalendar(UnfoldableCalendar): - """ - This is overridden recurring_ical_events.UnfoldableCalendar. - It is overridden because of bug when summary of recurring event stay the same after editing. - In recurring-ical-events==0.1.20b0 this problem is fixed, but all-day events without timezone lead to exception. - So i took part of code from 0.1.20b0 but leave 0.1.16b in requirements. - """ - - class RepeatedEvent(UnfoldableCalendar.RepeatedEvent): - RE_DATETIME_VALUE = re.compile(r"\d+T\d+") - - class Repetition(UnfoldableCalendar.RepeatedEvent.Repetition): - """ - A repetition of an event. Overridden version of - recurring_ical_events.UnfoldableCalendar.RepeatedEvent.Repetition. This is overridden to remove the 'RRULE' - param from ATTRIBUTES_TO_DELETE_ON_COPY, because the 'UNTIL' param must be stored in repetition events to - calculate its end date. - """ - - ATTRIBUTES_TO_DELETE_ON_COPY = ["RDATE", "EXDATE"] - - def create_rule_with_start(self, rule_string, start): - """Override to handle issue with non-UTC UNTIL value including time information.""" - try: - return super().create_rule_with_start(rule_string, start) - except ValueError: - # string: FREQ=WEEKLY;UNTIL=20191023T100000;BYDAY=TH;WKST=SU - # ValueError: RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware - # https://stackoverflow.com/a/49991809 - rule_list = rule_string.split(";UNTIL=") - assert len(rule_list) == 2 - date_end_index = rule_list[1].find(";") - if date_end_index == -1: - date_end_index = len(rule_list[1]) - until_string = rule_list[1][:date_end_index] - if self.RE_DATETIME_VALUE.match(until_string): - rule_string = rule_list[0] + rule_list[1][date_end_index:] + ";UNTIL=" + until_string + "Z" - return super().create_rule_with_start(rule_string, self.start) - # otherwise, keep raising - raise - - def between(self, start, stop): - """Return events at a time between start (inclusive) and end (inclusive)""" - span_start = self.to_datetime(start) - span_stop = self.to_datetime(stop) - events = [] - events_by_id = defaultdict(dict) # UID (str) : RECURRENCE-ID(datetime) : event (Event) - default_uid = object() - - def add_event(event): - """Add an event and check if it was edited.""" - same_events = events_by_id[event.get("UID", default_uid)] - recurrence_id = event.get("RECURRENCE-ID", event["DTSTART"]).dt - # Start of code from 0.1.20b0 - if isinstance(recurrence_id, datetime.datetime): - recurrence_id = recurrence_id.date() - other = same_events.get(recurrence_id, None) - if other: - event_recurrence_id = event.get("RECURRENCE-ID", None) - other_recurrence_id = other.get("RECURRENCE-ID", None) - if event_recurrence_id is not None and other_recurrence_id is None: - events.remove(other) - elif event_recurrence_id is None and other_recurrence_id is not None: - return - else: - event_sequence = event.get("SEQUENCE", None) - other_sequence = other.get("SEQUENCE", None) - if event_sequence is not None and other_sequence is not None: - if event["SEQUENCE"] < other["SEQUENCE"]: - return - events.remove(other) - # End of code from 0.1.20b0 - same_events[recurrence_id] = event - events.append(event) - - for event in self.calendar.walk(): - if not is_event(event): - continue - repetitions = self.RepeatedEvent(event, span_start) - for repetition in repetitions: - if compare_greater(repetition.start, span_stop) or compare_greater(repetition.start, repetition.stop): - # future repetitions could produce invalid events (because of the until rrule) - break - if repetition.is_in_span(span_start, span_stop): - add_event(repetition.as_vevent()) - return events - - class AmixrRecurringIcalEventsAdapter(IcalService): def get_events_from_ical_between( self, calendar: Calendar, start_date: datetime.datetime, end_date: datetime.datetime @@ -123,7 +33,7 @@ class AmixrRecurringIcalEventsAdapter(IcalService): make one more pass for events array to filter out events which are between start_date and end_date. EXTRA_LOOKUP_DAYS is empirical. """ - events = AmixrUnfoldableCalendar(calendar).between( + events = UnfoldableCalendar(calendar).between( start_date - datetime.timedelta(days=EXTRA_LOOKUP_DAYS), end_date + datetime.timedelta(days=EXTRA_LOOKUP_DAYS), ) diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index 3423de96..a68e8d4e 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -7,6 +7,7 @@ from calendar import monthrange from uuid import uuid4 import pytz +import recurring_ical_events from dateutil import relativedelta from django.conf import settings from django.core.validators import MinLengthValidator @@ -16,7 +17,6 @@ from django.forms.models import model_to_dict from django.utils import timezone from django.utils.functional import cached_property from icalendar.cal import Event -from recurring_ical_events import UnfoldableCalendar from apps.schedules.tasks import ( drop_cached_ical_task, @@ -488,13 +488,14 @@ class CustomOnCallShift(models.Model): next_event_start = current_event_start # Calculate the minimum start date for the next event based on rotation frequency. We don't need to do this # for the first rotation, because in this case the min start date will be the same as the current event date. + DAYS_IN_A_WEEK = 7 + DAYS_IN_A_MONTH = monthrange(current_event_start.year, current_event_start.month)[1] if get_next_date: if self.frequency == CustomOnCallShift.FREQUENCY_HOURLY: next_event_start = current_event_start + datetime.timedelta(hours=ONE_HOUR) elif self.frequency == CustomOnCallShift.FREQUENCY_DAILY: next_event_start = current_event_start + datetime.timedelta(days=ONE_DAY) elif self.frequency == CustomOnCallShift.FREQUENCY_WEEKLY: - DAYS_IN_A_WEEK = 7 # count days before the next week starts days_for_next_event = DAYS_IN_A_WEEK - current_event_start.weekday() + self.week_start if days_for_next_event > DAYS_IN_A_WEEK: @@ -504,7 +505,6 @@ class CustomOnCallShift(models.Model): days=days_for_next_event + DAYS_IN_A_WEEK * (interval - 1) ) elif self.frequency == CustomOnCallShift.FREQUENCY_MONTHLY: - DAYS_IN_A_MONTH = monthrange(current_event_start.year, current_event_start.month)[1] # count days before the next month starts days_for_next_event = DAYS_IN_A_MONTH - current_event_start.day + ONE_DAY # count next event start date with respect to event interval @@ -533,10 +533,12 @@ class CustomOnCallShift(models.Model): next_event = None # repetitions generate the next event shift according with the recurrence rules - repetitions = UnfoldableCalendar(current_event).RepeatedEvent( - current_event, next_event_start.replace(microsecond=0) - ) - for event in repetitions.__iter__(): + repeated_event = recurring_ical_events.RepeatedEvent(current_event) + max_date_range = next_event_start + datetime.timedelta(days=DAYS_IN_A_MONTH) + if end_date: + max_date_range = max(end_date, max_date_range) + repetitions = repeated_event.within_days(next_event_start.replace(microsecond=0), max_date_range) + for event in repetitions: if end_date: # end_date exists for long events with frequency weekly and monthly if end_date >= event.start >= next_event_start: if ( @@ -572,10 +574,9 @@ class CustomOnCallShift(models.Model): last_event = None # repetitions generate the next event shift according with the recurrence rules - repetitions = UnfoldableCalendar(initial_event).RepeatedEvent( - initial_event, initial_event_start.replace(microsecond=0) - ) - for event in repetitions.__iter__(): + repeated_event = recurring_ical_events.RepeatedEvent(initial_event) + repetitions = repeated_event.within_days(initial_event_start, date) + for event in repetitions: if event.start > date: break last_event = event diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index 26761bc5..f3bb8342 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -349,18 +349,22 @@ class OnCallSchedule(PolymorphicModel): include_shift_info: bool = False, ) -> ScheduleEvents: """Return filtered events from schedule.""" - shifts = ( - list_of_oncall_shifts_from_ical( - self, - datetime_start, - datetime_end, - with_empty, - with_gap, - filter_by=filter_by, - from_cached_final=from_cached_final, + try: + shifts = ( + list_of_oncall_shifts_from_ical( + self, + datetime_start, + datetime_end, + with_empty, + with_gap, + filter_by=filter_by, + from_cached_final=from_cached_final, + ) + or [] ) - or [] - ) + except ValueError: + # raised when filtering events on a non-saved/deleted schedule + return [] shifts_data = {} if include_shift_info: pks = set(shift["shift_pk"] for shift in shifts) 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 1f904d0e..01c490f6 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -1749,7 +1749,7 @@ def test_week_start_changed_daily_shift( on_call_shift.add_rolling_users(rolling_users) ical_data = on_call_shift.convert_to_ical() - expected_start = "DTSTART;VALUE=DATE-TIME:{}T000000Z".format(last_sunday.strftime("%Y%m%d")) + expected_start = "DTSTART:{}T000000Z".format(last_sunday.strftime("%Y%m%d")) assert expected_start in ical_data diff --git a/engine/apps/social_auth/live_setting_django_strategy.py b/engine/apps/social_auth/live_setting_django_strategy.py index 62f14d6e..6e103bfb 100644 --- a/engine/apps/social_auth/live_setting_django_strategy.py +++ b/engine/apps/social_auth/live_setting_django_strategy.py @@ -2,7 +2,7 @@ import logging from django.conf import settings from django.shortcuts import resolve_url -from django.utils.encoding import force_text +from django.utils.encoding import force_str from django.utils.functional import Promise from social_django.strategy import DjangoStrategy @@ -29,7 +29,7 @@ class LiveSettingDjangoStrategy(DjangoStrategy): # Force text on URL named settings that are instance of Promise if name.endswith("_URL"): if isinstance(value, Promise): - value = force_text(value) + value = force_str(value) value = resolve_url(value) return value diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index e75490bb..92829216 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -308,7 +308,7 @@ class User(models.Model): self._timezone = value def is_in_working_hours(self, dt: datetime.datetime, tz: typing.Optional[str] = None) -> bool: - assert dt.tzinfo == pytz.utc, "dt must be in UTC" + assert dt.tzinfo == datetime.timezone.utc, "dt must be in UTC" # Default to user's timezone if not tz: diff --git a/engine/requirements.txt b/engine/requirements.txt index f39f828e..e6ac5e73 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -1,5 +1,5 @@ -django==3.2.20 -djangorestframework==3.12.4 +django==4.2.6 +djangorestframework==3.14.0 slack_sdk==3.21.3 whitenoise==5.3.0 twilio~=6.37.0 @@ -22,20 +22,20 @@ git+https://github.com/grafana/django-redis-cache.git@bump-redis-version-to-v4.6 hiredis==1.0.0 django-ratelimit==2.0.0 django-filter==2.4.0 -icalendar==4.0.7 -recurring-ical-events==0.1.16b0 +icalendar==5.0.10 +recurring-ical-events==2.1.0 slack-export-viewer==1.1.4 beautifulsoup4==4.12.2 -social-auth-app-django==5.0.0 +social-auth-app-django==5.3.0 cryptography==38.0.4 # version 39.0.0 introduced an issue - https://stackoverflow.com/a/75053968/3902555 factory-boy<3.0 django-log-request-id==1.6.0 -django-polymorphic==3.0.0 -django-rest-polymorphic==0.1.9 +django-polymorphic==3.1.0 +django-rest-polymorphic==0.1.10 https://github.com/grafana/fcm-django/archive/refs/tags/v1.0.12r1.tar.gz django-mirage-field==1.3.0 django-mysql==4.6.0 -PyMySQL==1.0.2 +PyMySQL==1.1.0 psycopg2==2.9.3 emoji==2.4.0 regex==2021.11.2 From d49ccef8cb80d55be81f08af9fb992c7842f70e8 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Fri, 27 Oct 2023 16:47:00 -0400 Subject: [PATCH 4/8] address some minor direct paging backend issues (#3208) # Which issue(s) this PR fixes - Fixes an issue where if the user does not appear in the `UserHasNotification` query, we don't actually unpage the user and therefore they still show up in the `paged_users` array. (unpaging == creating a `AlertGroupLogRecord.TYPE_UNPAGE_USER` log record) - Fixes an issue where if a user is paged multiple times, they would currently show up in `paged_users` > 1 ## 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) --- engine/apps/alerts/models/alert_group.py | 28 +++++++++---------- engine/apps/alerts/paging.py | 22 +++++++++------ engine/apps/alerts/tests/test_alert_group.py | 21 +++++++++++++- engine/apps/api/tests/test_alert_group.py | 29 +++++++++++++++----- 4 files changed, 69 insertions(+), 31 deletions(-) diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 21c0040d..17051911 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -523,7 +523,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. from apps.alerts.models import AlertGroupLogRecord user_ids: typing.Set[str] = set() - users: typing.List[PagedUser] = [] + users: typing.Dict[str, PagedUser] = {} log_records = self.log_records.filter( type__in=(AlertGroupLogRecord.TYPE_DIRECT_PAGING, AlertGroupLogRecord.TYPE_UNPAGE_USER) @@ -553,23 +553,21 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. 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()], - } - ) + users[user_id] = { + "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] + del users[user_id] - return users + return list(users.values()) 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 20885688..694e88ed 100644 --- a/engine/apps/alerts/paging.py +++ b/engine/apps/alerts/paging.py @@ -155,7 +155,13 @@ def direct_paging( def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None: - """Remove user from alert group escalation.""" + """ + Remove user from alert group escalation. + + An IndexError is raised (and caught) if the user had not been notified for some reason. + Regardless of whether or not the user was notified, we will always create an AlertGroupLogRecord of type + TYPE_UNPAGE_USER. + """ try: with transaction.atomic(): user_has_notification = UserHasNotification.objects.filter( @@ -163,15 +169,15 @@ def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None: ).select_for_update()[0] user_has_notification.active_notification_policy_id = None user_has_notification.save(update_fields=["active_notification_policy_id"]) - # add log entry - alert_group.log_records.create( - type=AlertGroupLogRecord.TYPE_UNPAGE_USER, - author=from_user, - reason=f"{from_user.username} unpaged user {user.username}", - step_specific_info={"user": user.public_primary_key}, - ) except IndexError: return + finally: + alert_group.log_records.create( + type=AlertGroupLogRecord.TYPE_UNPAGE_USER, + author=from_user, + reason=f"{from_user.username} unpaged user {user.username}", + step_specific_info={"user": user.public_primary_key}, + ) def user_is_oncall(user: User) -> bool: diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index 927a5c0c..ab32c3ed 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -526,4 +526,23 @@ def test_alert_group_get_paged_users( _make_log_record(alert_group, other_user, AlertGroupLogRecord.TYPE_DIRECT_PAGING) - alert_group.get_paged_users()[0]["pk"] == other_user.public_primary_key + assert alert_group.get_paged_users()[0]["pk"] == other_user.public_primary_key + + # user was paged, unpaged, and then paged again - they should only show up once + 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, user, AlertGroupLogRecord.TYPE_DIRECT_PAGING) + + paged_users = alert_group.get_paged_users() + assert len(paged_users) == 1 + assert alert_group.get_paged_users()[0]["pk"] == user.public_primary_key + + # user was paged and then paged again - they should only show up once + 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_DIRECT_PAGING) + + paged_users = alert_group.get_paged_users() + assert len(paged_users) == 1 + assert alert_group.get_paged_users()[0]["pk"] == user.public_primary_key diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 5ecd4ebb..b293256c 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -11,6 +11,7 @@ from rest_framework.test import APIClient from apps.alerts.constants import ActionSource from apps.alerts.models import AlertGroup, AlertGroupLogRecord, ResolutionNote +from apps.alerts.paging import direct_paging from apps.alerts.tasks import wipe from apps.api.errors import AlertGroupAPIError from apps.api.permissions import LegacyAccessControlRole @@ -1356,17 +1357,31 @@ def test_unpage_user( make_user_auth_headers, ): client = APIClient() - user, token, alert_groups = alert_group_internal_api_setup - user_to_unpage = make_user(organization=user.organization) - _, _, new_alert_group, _ = alert_groups + user, token, _ = alert_group_internal_api_setup + other_user = make_user(organization=user.organization) - url = reverse("api-internal:alertgroup-unpage-user", kwargs={"pk": new_alert_group.public_primary_key}) - response = client.post( - url, data={"user_id": user_to_unpage.public_primary_key}, **make_user_auth_headers(user, token) - ) + alert_group = direct_paging(user.organization, user, "testtesttest", users=[(other_user, False)]) + paged_users = alert_group.get_paged_users() + + assert paged_users[0]["pk"] == other_user.public_primary_key + + url = reverse("api-internal:alertgroup-unpage-user", kwargs={"pk": alert_group.public_primary_key}) + response = client.post(url, data={"user_id": other_user.public_primary_key}, **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK + alert_group.refresh_from_db() + assert alert_group.silenced_until is None + assert alert_group.get_paged_users() == [] + + unpage_user_log_record = alert_group.log_records.get( + type=AlertGroupLogRecord.TYPE_UNPAGE_USER, + author=user, + ) + + assert unpage_user_log_record.reason == f"{user.username} unpaged user {other_user.username}" + assert unpage_user_log_record.step_specific_info == {"user": other_user.public_primary_key} + @pytest.mark.django_db def test_invalid_bulk_action( From c281484f673aa26bf69e8a5e32145bb6f293d21b Mon Sep 17 00:00:00 2001 From: Nelson <93178586+njohnstone2@users.noreply.github.com> Date: Mon, 30 Oct 2023 22:00:39 +1000 Subject: [PATCH 5/8] add support for datetime on final_shifts API parameters (#3103) # What this PR does - Changes the data type from `DateField` to `DateTimeField` on the `final_shifts` API endpoint - Accepts either a date or a datetime for the `start_date` and `end_date` parameters (e.g. 2021-01-01 or 2021-01-01T01:00) - Datetime granularity is in line with what is configurable on the schedule i.e. `YYYY-MM-DD hh:mm` - removes the rounding logic that modifies the query sent on the database ## Which issue(s) this PR fixes #3086 ## 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) --------- Co-authored-by: Joey Orlando --- CHANGELOG.md | 5 +++ .../public_api/serializers/schedules_base.py | 4 +-- .../apps/public_api/tests/test_schedules.py | 35 ++++++++++++++++--- engine/apps/public_api/views/schedules.py | 14 ++------ 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 046d0810..dbb154e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Data type changed from `DateField` to `DateTimeField` on the `final_shifts` API endpoint. Endpoint now accepts either +a date or a datetime ([#3103](https://github.com/grafana/oncall/pull/3103)) + ### Changed - Simplify Direct Paging workflow. Now when using Direct Paging you either simply specify a team, or one or more users diff --git a/engine/apps/public_api/serializers/schedules_base.py b/engine/apps/public_api/serializers/schedules_base.py index 106df5c2..d0b5b192 100644 --- a/engine/apps/public_api/serializers/schedules_base.py +++ b/engine/apps/public_api/serializers/schedules_base.py @@ -75,8 +75,8 @@ class ScheduleBaseSerializer(serializers.ModelSerializer): class FinalShiftQueryParamsSerializer(serializers.Serializer): - start_date = serializers.DateField(required=True) - end_date = serializers.DateField(required=True) + start_date = serializers.DateTimeField(required=True, input_formats=["%Y-%m-%dT%H:%M", "%Y-%m-%d"]) + end_date = serializers.DateTimeField(required=True, input_formats=["%Y-%m-%dT%H:%M", "%Y-%m-%d"]) def validate(self, attrs): if attrs["start_date"] > attrs["end_date"]: diff --git a/engine/apps/public_api/tests/test_schedules.py b/engine/apps/public_api/tests/test_schedules.py index 45a78dd1..2135d2f9 100644 --- a/engine/apps/public_api/tests/test_schedules.py +++ b/engine/apps/public_api/tests/test_schedules.py @@ -876,7 +876,7 @@ def test_oncall_shifts_request_validation( organization, _, token = make_organization_and_user_with_token() web_schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb) - valid_date_msg = "Date has wrong format. Use one of these formats instead: YYYY-MM-DD." + valid_date_msg = "Datetime has wrong format. Use one of these formats instead: YYYY-MM-DDThh:mm, YYYY-MM-DD." client = APIClient() @@ -917,6 +917,23 @@ def test_oncall_shifts_request_validation( ] } + # datetime validation + # invalid request (doesnt match pattern YYYY-MM-DDThh:mm) + response = _make_request(web_schedule, "?start_date=2021-01-01 01:00") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + response.json()["start_date"][0] + == "Datetime has wrong format. Use one of these formats instead: YYYY-MM-DDThh:mm, YYYY-MM-DD." + ) + + # valid request both parameters using datetime + response = _make_request(web_schedule, "?start_date=2021-01-01T01:00&end_date=2021-01-02T01:00") + assert response.status_code == status.HTTP_200_OK + + # valid request combination of date and datetime + response = _make_request(web_schedule, "?start_date=2021-01-01&end_date=2021-01-02T01:00") + assert response.status_code == status.HTTP_200_OK + @pytest.mark.django_db def test_oncall_shifts_export( @@ -958,7 +975,9 @@ def test_oncall_shifts_export( client = APIClient() url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key}) - response = client.get(f"{url}?start_date=2023-01-01&end_date=2023-02-01", format="json", HTTP_AUTHORIZATION=token) + response = client.get( + f"{url}?start_date=2023-01-01T18:00&end_date=2023-02-01", format="json", HTTP_AUTHORIZATION=token + ) assert response.status_code == status.HTTP_200_OK expected_on_call_times = { @@ -1018,7 +1037,9 @@ def test_oncall_shifts_export_from_ical_schedule( client = APIClient() url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key}) - response = client.get(f"{url}?start_date=2023-07-01&end_date=2023-07-31", format="json", HTTP_AUTHORIZATION=token) + response = client.get( + f"{url}?start_date=2023-07-01T09:00&end_date=2023-07-31T21:00", format="json", HTTP_AUTHORIZATION=token + ) assert response.status_code == status.HTTP_200_OK expected_on_call_times = { @@ -1055,7 +1076,9 @@ def test_oncall_shifts_export_from_api_schedule( client = APIClient() url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key}) - response = client.get(f"{url}?start_date=2023-07-01&end_date=2023-07-31", format="json", HTTP_AUTHORIZATION=token) + response = client.get( + f"{url}?start_date=2023-07-01T09:00&end_date=2023-07-31T11:00", format="json", HTTP_AUTHORIZATION=token + ) assert response.status_code == status.HTTP_200_OK expected_on_call_times = { @@ -1098,7 +1121,9 @@ def test_oncall_shifts_export_truncate_events( # request shifts on a Tu (ie. 00:00 - 09:00) url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key}) - response = client.get(f"{url}?start_date=2023-01-03&end_date=2023-01-03", format="json", HTTP_AUTHORIZATION=token) + response = client.get( + f"{url}?start_date=2023-01-03&end_date=2023-01-03T09:00", format="json", HTTP_AUTHORIZATION=token + ) assert response.status_code == status.HTTP_200_OK expected_on_call_times = {user1_public_primary_key: 9} diff --git a/engine/apps/public_api/views/schedules.py b/engine/apps/public_api/views/schedules.py index 88b1fe26..53b414dc 100644 --- a/engine/apps/public_api/views/schedules.py +++ b/engine/apps/public_api/views/schedules.py @@ -1,7 +1,5 @@ -import datetime import logging -import pytz from django_filters import rest_framework as filters from rest_framework import status from rest_framework.decorators import action @@ -141,14 +139,8 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo start_date = serializer.validated_data["start_date"] end_date = serializer.validated_data["end_date"] - days_between_start_and_end = (end_date - start_date).days - datetime_start = datetime.datetime.combine(start_date, datetime.time.min, tzinfo=pytz.UTC) - datetime_end = datetime_start + datetime.timedelta( - days=days_between_start_and_end, hours=23, minutes=59, seconds=59 - ) - - final_schedule_events: ScheduleEvents = schedule.final_events(datetime_start, datetime_end) + final_schedule_events: ScheduleEvents = schedule.final_events(start_date, end_date) logger.info( f"Exporting oncall shifts for schedule {pk} between dates {start_date} and {end_date}. {len(final_schedule_events)} shift events were found." ) @@ -159,8 +151,8 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo "user_email": user["email"], "user_username": user["display_name"], # truncate shift start/end exceeding the requested period - "shift_start": event["start"] if event["start"] >= datetime_start else datetime_start, - "shift_end": event["end"] if event["end"] <= datetime_end else datetime_end, + "shift_start": event["start"] if event["start"] >= start_date else start_date, + "shift_end": event["end"] if event["end"] <= end_date else end_date, } for event in final_schedule_events for user in event["users"] From 6a78ee69835369ef3cb42ccd71bbc7140409e9f9 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Mon, 30 Oct 2023 09:48:54 -0400 Subject: [PATCH 6/8] fix RBAC authz issue for Slack Alert Group actions (#3213) Was able to reproduce locally with OnCall RBAC enabled and as a user with Viewer role: Screenshot 2023-10-30 at 09 30 52 Fixed with the changes introduced in this PR: Screenshot 2023-10-30 at 09 30 16 Screenshot 2023-10-30 at 09 30 11 ## Which issue(s) this PR fixes Closes #3212 ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] 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 | 6 ++++- .../apps/slack/scenarios/distribute_alerts.py | 22 +++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb154e7..277717d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Data type changed from `DateField` to `DateTimeField` on the `final_shifts` API endpoint. Endpoint now accepts either -a date or a datetime ([#3103](https://github.com/grafana/oncall/pull/3103)) + a date or a datetime ([#3103](https://github.com/grafana/oncall/pull/3103)) ### 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)) +### Fixed + +- Fix RBAC authorization bugs related to interacting with Alert Group Slack messages by @joeyorlando ([#3213](https://github.com/grafana/oncall/pull/3213)) + ## v1.3.47 (2023-10-25) ### Fixed diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 3f551025..e6a30191 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -230,7 +230,7 @@ class InviteOtherPersonToIncident(AlertGroupActionsMixin, scenario_step.Scenario Check out apps/slack/scenarios/manage_responders.py for the new version that uses direct paging. """ - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -266,7 +266,7 @@ class InviteOtherPersonToIncident(AlertGroupActionsMixin, scenario_step.Scenario class SilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -293,7 +293,7 @@ class SilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class UnSilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -313,7 +313,7 @@ class UnSilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -509,7 +509,7 @@ class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -534,7 +534,7 @@ class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep): Check out apps/slack/scenarios/manage_responders.py for the new version that uses direct paging. """ - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -561,7 +561,7 @@ class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep): class CustomButtonProcessStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -624,7 +624,7 @@ class CustomButtonProcessStep(AlertGroupActionsMixin, scenario_step.ScenarioStep class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -665,7 +665,7 @@ class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class UnResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -685,7 +685,7 @@ class UnResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class AcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, @@ -705,7 +705,7 @@ class AcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): class UnAcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): - REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] + REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] def process_scenario( self, From 76a0643bc5a9857622ded9727b8cd86183bf3c5a Mon Sep 17 00:00:00 2001 From: Yulya Artyukhina Date: Mon, 30 Oct 2023 14:44:18 +0100 Subject: [PATCH 7/8] "Going oncall" notification settings (#3187) # What this PR does ## Which issue(s) this PR fixes ## 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) --------- Co-authored-by: Joey Orlando --- CHANGELOG.md | 1 + ...ttings_going_oncall_notification_timing.py | 30 +++++++++++++ engine/apps/mobile_app/models.py | 18 +++++--- engine/apps/mobile_app/serializers.py | 15 +++++++ .../tasks/going_oncall_notification.py | 44 +++++++++---------- .../tasks/test_going_oncall_notification.py | 38 +++++++--------- .../mobile_app/tests/test_user_settings.py | 32 +++++++++++--- engine/apps/mobile_app/urls.py | 13 +++++- engine/apps/mobile_app/views.py | 14 +++++- 9 files changed, 146 insertions(+), 59 deletions(-) create mode 100644 engine/apps/mobile_app/migrations/0011_alter_mobileappusersettings_going_oncall_notification_timing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 277717d0..687c0a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) +- Enable timing options for mobile push notifications, allow multi-select by @Ferril ([#3187](https://github.com/grafana/oncall/pull/3187)) ### Fixed diff --git a/engine/apps/mobile_app/migrations/0011_alter_mobileappusersettings_going_oncall_notification_timing.py b/engine/apps/mobile_app/migrations/0011_alter_mobileappusersettings_going_oncall_notification_timing.py new file mode 100644 index 00000000..c758fa8c --- /dev/null +++ b/engine/apps/mobile_app/migrations/0011_alter_mobileappusersettings_going_oncall_notification_timing.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.20 on 2023-10-30 09:25 + +import apps.mobile_app.models +import django_migration_linter as linter + +from django.db import migrations, models +from apps.mobile_app.models import default_notification_timing_options + + +def set_going_oncall_notification_timing_to_default(apps, schema_editor): + MobileAppUserSettings = apps.get_model("mobile_app", "MobileAppUserSettings") + default = default_notification_timing_options() + MobileAppUserSettings.objects.all().update(going_oncall_notification_timing=default) + + +class Migration(migrations.Migration): + + dependencies = [ + ('mobile_app', '0010_mobileappusersettings_time_zone'), + ] + + operations = [ + linter.IgnoreMigration(), + migrations.AlterField( + model_name='mobileappusersettings', + name='going_oncall_notification_timing', + field=models.JSONField(default=apps.mobile_app.models.default_notification_timing_options), + ), + migrations.RunPython(set_going_oncall_notification_timing_to_default, migrations.RunPython.noop), + ] diff --git a/engine/apps/mobile_app/models.py b/engine/apps/mobile_app/models.py index d526f160..cb24a396 100644 --- a/engine/apps/mobile_app/models.py +++ b/engine/apps/mobile_app/models.py @@ -4,6 +4,7 @@ import typing from django.core import validators from django.db import models +from django.db.models import JSONField from django.utils import timezone from fcm_django.models import FCMDevice as BaseFCMDevice @@ -21,6 +22,10 @@ def get_expire_date(): return timezone.now() + timezone.timedelta(seconds=MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS) +def default_notification_timing_options(): + return [MobileAppUserSettings.FIFTEEN_MINUTES_IN_SECONDS] + + class ActiveFCMDeviceQuerySet(models.QuerySet): def filter(self, *args, **kwargs): return super().filter(*args, **kwargs, active=True) @@ -159,19 +164,20 @@ class MobileAppUserSettings(models.Model): # these choices + the below column are used to calculate when to send the "You're Going OnCall soon" # push notification - # ONE_HOUR, TWELVE_HOURS, ONE_DAY, ONE_WEEK = range(4) + FIFTEEN_MINUTES_IN_SECONDS = 15 * 60 + ONE_HOUR_IN_SECONDS = 60 * 60 + SIX_HOURS_IN_SECONDS = 6 * 60 * 60 TWELVE_HOURS_IN_SECONDS = 12 * 60 * 60 ONE_DAY_IN_SECONDS = TWELVE_HOURS_IN_SECONDS * 2 - ONE_WEEK_IN_SECONDS = ONE_DAY_IN_SECONDS * 7 NOTIFICATION_TIMING_CHOICES = ( + (FIFTEEN_MINUTES_IN_SECONDS, "fifteen minutes before"), + (ONE_HOUR_IN_SECONDS, "one hour before"), + (SIX_HOURS_IN_SECONDS, "six hours before"), (TWELVE_HOURS_IN_SECONDS, "twelve hours before"), (ONE_DAY_IN_SECONDS, "one day before"), - (ONE_WEEK_IN_SECONDS, "one week before"), - ) - going_oncall_notification_timing = models.IntegerField( - choices=NOTIFICATION_TIMING_CHOICES, default=TWELVE_HOURS_IN_SECONDS ) + going_oncall_notification_timing = JSONField(default=default_notification_timing_options) locale = models.CharField(max_length=50, null=True) time_zone = models.CharField(max_length=100, default="UTC") diff --git a/engine/apps/mobile_app/serializers.py b/engine/apps/mobile_app/serializers.py index 4551deec..8a321aff 100644 --- a/engine/apps/mobile_app/serializers.py +++ b/engine/apps/mobile_app/serializers.py @@ -1,3 +1,5 @@ +import typing + from rest_framework import serializers from apps.mobile_app.models import MobileAppUserSettings @@ -6,6 +8,7 @@ from common.api_helpers.custom_fields import TimeZoneField class MobileAppUserSettingsSerializer(serializers.ModelSerializer): time_zone = TimeZoneField(required=False, allow_null=False) + going_oncall_notification_timing = serializers.ListField(required=False, allow_null=False) class Meta: model = MobileAppUserSettings @@ -28,3 +31,15 @@ class MobileAppUserSettingsSerializer(serializers.ModelSerializer): "locale", "time_zone", ) + + def validate_going_oncall_notification_timing( + self, going_oncall_notification_timing: typing.Optional[typing.List[int]] + ) -> typing.Optional[typing.List[int]]: + if going_oncall_notification_timing is not None: + if len(going_oncall_notification_timing) == 0: + raise serializers.ValidationError(detail="invalid timing options") + notification_timing_options = [opt[0] for opt in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES] + for option in going_oncall_notification_timing: + if option not in notification_timing_options: + raise serializers.ValidationError(detail="invalid timing options") + return going_oncall_notification_timing diff --git a/engine/apps/mobile_app/tasks/going_oncall_notification.py b/engine/apps/mobile_app/tasks/going_oncall_notification.py index 248778a6..05406e10 100644 --- a/engine/apps/mobile_app/tasks/going_oncall_notification.py +++ b/engine/apps/mobile_app/tasks/going_oncall_notification.py @@ -121,7 +121,6 @@ def _should_we_send_push_notification( an `int` which represents the # of seconds until the oncall shift starts. """ NOTIFICATION_TIMING_BUFFER = 7 * 60 # 7 minutes in seconds - FIFTEEN_MINUTES_IN_SECONDS = 15 * 60 # this _should_ always be positive since final_events is returning only events in the future seconds_until_shift_starts = math.floor((schedule_event["start"] - now).total_seconds()) @@ -134,32 +133,33 @@ def _should_we_send_push_notification( logger.info("not sending going oncall push notification because info_notifications_enabled is false") return None - # 14 minute window where the notification could be sent (7 mins before or 7 mins after) - timing_window_lower = user_notification_timing_preference - NOTIFICATION_TIMING_BUFFER - timing_window_upper = user_notification_timing_preference + NOTIFICATION_TIMING_BUFFER + for timing_preference in user_notification_timing_preference: + # 14 minute window where the notification could be sent (7 mins before or 7 mins after) + timing_window_lower = timing_preference - NOTIFICATION_TIMING_BUFFER + timing_window_upper = timing_preference + NOTIFICATION_TIMING_BUFFER - shift_starts_within_users_notification_timing_preference = _shift_starts_within_range( - timing_window_lower, timing_window_upper, seconds_until_shift_starts - ) - shift_starts_within_fifteen_minutes = _shift_starts_within_range( - 0, FIFTEEN_MINUTES_IN_SECONDS, seconds_until_shift_starts - ) + shift_starts_within_users_notification_timing_preference = _shift_starts_within_range( + timing_window_lower, timing_window_upper, seconds_until_shift_starts + ) - timing_logging_msg = ( + if shift_starts_within_users_notification_timing_preference: + logger.info( + f"timing is right to send going oncall push notification\n" + f"seconds_until_shift_starts: {seconds_until_shift_starts}\n" + f"user_notification_timing_preference: {user_notification_timing_preference}\n" + f"current timing_preference: {timing_preference}\n" + f"timing_window_lower: {timing_window_lower}\n" + f"timing_window_upper: {timing_window_upper}\n" + f"shift_starts_within_users_notification_timing_preference: {shift_starts_within_users_notification_timing_preference}\n" + ) + return seconds_until_shift_starts + + logger.info( + f"timing is not right to send going oncall push notification\n" f"seconds_until_shift_starts: {seconds_until_shift_starts}\n" f"user_notification_timing_preference: {user_notification_timing_preference}\n" - f"timing_window_lower: {timing_window_lower}\n" - f"timing_window_upper: {timing_window_upper}\n" - f"shift_starts_within_users_notification_timing_preference: {shift_starts_within_users_notification_timing_preference}\n" - f"shift_starts_within_fifteen_minutes: {shift_starts_within_fifteen_minutes}" + f"shift_starts_within_users_notification_timing_preference: False\n" ) - - # Temporary remove `shift_starts_within_users_notification_timing_preference` from condition to send notification only 15 minutes before the shift starts - # TODO: Return it once mobile app ready and default value is changed (https://github.com/grafana/oncall/issues/1999) - if shift_starts_within_fifteen_minutes: - logger.info(f"timing is right to send going oncall push notification\n{timing_logging_msg}") - return seconds_until_shift_starts - logger.info(f"timing is not right to send going oncall push notification\n{timing_logging_msg}") return None diff --git a/engine/apps/mobile_app/tests/tasks/test_going_oncall_notification.py b/engine/apps/mobile_app/tests/tasks/test_going_oncall_notification.py index a15067ba..c4f6d799 100644 --- a/engine/apps/mobile_app/tests/tasks/test_going_oncall_notification.py +++ b/engine/apps/mobile_app/tests/tasks/test_going_oncall_notification.py @@ -21,6 +21,7 @@ from apps.mobile_app.types import MessageType, Platform from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb from apps.schedules.models.on_call_schedule import ScheduleEvent +FIFTEEN_MINUTES_IN_SECONDS = 15 * 60 ONE_HOUR_IN_SECONDS = 60 * 60 ONCALL_TIMING_PREFERENCE = ONE_HOUR_IN_SECONDS * 12 @@ -254,7 +255,7 @@ def test_get_fcm_message( ( True, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS], timezone.datetime(2022, 5, 2, 13, 13, 0), None, ), @@ -262,14 +263,14 @@ def test_get_fcm_message( ( True, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS], timezone.datetime(2022, 5, 2, 13, 12, 0), - None, + 67 * 60, ), ( False, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS], timezone.datetime(2022, 5, 2, 13, 12, 0), None, ), @@ -277,14 +278,14 @@ def test_get_fcm_message( ( True, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS], timezone.datetime(2022, 5, 2, 12, 58, 0), - None, + 53 * 60, ), ( False, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS], timezone.datetime(2022, 5, 2, 12, 58, 0), None, ), @@ -292,7 +293,7 @@ def test_get_fcm_message( ( True, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS], timezone.datetime(2022, 5, 2, 12, 57, 0), None, ), @@ -300,37 +301,30 @@ def test_get_fcm_message( ( True, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS], timezone.datetime(2022, 5, 2, 12, 21, 0), None, ), - # shift starts in 15m - send only if info_notifications_enabled is true + # shift starts in 15m, user timing preference is 1h and 15m - send only if info_notifications_enabled is true ( True, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS, FIFTEEN_MINUTES_IN_SECONDS], timezone.datetime(2022, 5, 2, 12, 20, 0), 15 * 60, ), ( False, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS, FIFTEEN_MINUTES_IN_SECONDS], timezone.datetime(2022, 5, 2, 12, 20, 0), None, ), - # shift starts in 0secs - send only if info_notifications_enabled is true - ( - True, - timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, - timezone.datetime(2022, 5, 2, 12, 5, 0), - 0, - ), + # shift starts in 0secs - don't send ( False, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS], timezone.datetime(2022, 5, 2, 12, 5, 0), None, ), @@ -338,7 +332,7 @@ def test_get_fcm_message( ( True, timezone.datetime(2022, 5, 2, 12, 5, 0), - ONE_HOUR_IN_SECONDS, + [ONE_HOUR_IN_SECONDS], timezone.datetime(2022, 5, 2, 12, 4, 55), None, ), diff --git a/engine/apps/mobile_app/tests/test_user_settings.py b/engine/apps/mobile_app/tests/test_user_settings.py index 43ceede9..9a7238c9 100644 --- a/engine/apps/mobile_app/tests/test_user_settings.py +++ b/engine/apps/mobile_app/tests/test_user_settings.py @@ -33,20 +33,42 @@ def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token "important_notification_volume_override": True, "important_notification_override_dnd": True, "info_notifications_enabled": False, - "going_oncall_notification_timing": 43200, + "going_oncall_notification_timing": [900], "locale": None, "time_zone": "UTC", } +@pytest.mark.django_db +def test_user_settings_get_notification_timing_options(make_organization_and_user_with_mobile_app_auth_token): + _, _, auth_token = make_organization_and_user_with_mobile_app_auth_token() + + client = APIClient() + url = reverse("mobile_app:notification_timing_options") + + choices = [ + {"value": item[0], "display_name": item[1]} for item in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES + ] + + response = client.get(url, HTTP_AUTHORIZATION=auth_token) + assert response.status_code == status.HTTP_200_OK + + # Check the default values are correct + assert response.json() == choices + + @pytest.mark.django_db @pytest.mark.parametrize( "going_oncall_notification_timing,expected_status_code", [ - (43200, status.HTTP_200_OK), - (86400, status.HTTP_200_OK), - (604800, status.HTTP_200_OK), - (500, status.HTTP_400_BAD_REQUEST), + ([MobileAppUserSettings.FIFTEEN_MINUTES_IN_SECONDS], status.HTTP_200_OK), + ([MobileAppUserSettings.ONE_HOUR_IN_SECONDS], status.HTTP_200_OK), + ([MobileAppUserSettings.SIX_HOURS_IN_SECONDS], status.HTTP_200_OK), + ([MobileAppUserSettings.TWELVE_HOURS_IN_SECONDS], status.HTTP_200_OK), + ([MobileAppUserSettings.ONE_DAY_IN_SECONDS], status.HTTP_200_OK), + ([MobileAppUserSettings.ONE_DAY_IN_SECONDS, MobileAppUserSettings.ONE_HOUR_IN_SECONDS], status.HTTP_200_OK), + ([123], status.HTTP_400_BAD_REQUEST), + ([], status.HTTP_400_BAD_REQUEST), ], ) def test_user_settings_put( diff --git a/engine/apps/mobile_app/urls.py b/engine/apps/mobile_app/urls.py index 5d4898e9..ed36874c 100644 --- a/engine/apps/mobile_app/urls.py +++ b/engine/apps/mobile_app/urls.py @@ -1,5 +1,5 @@ from apps.mobile_app.fcm_relay import FCMRelayView -from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView, MobileAppUserSettingsAPIView +from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView, MobileAppUserSettingsViewSet from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path app_name = "mobile_app" @@ -10,7 +10,16 @@ router.register("fcm", FCMDeviceAuthorizedViewSet, basename="fcm") urlpatterns = [ *router.urls, optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"), - optional_slash_path("user_settings", MobileAppUserSettingsAPIView.as_view(), name="user_settings"), + optional_slash_path( + "user_settings/notification_timing_options", + MobileAppUserSettingsViewSet.as_view({"get": "notification_timing_options"}), + name="notification_timing_options", + ), + optional_slash_path( + "user_settings", + MobileAppUserSettingsViewSet.as_view({"get": "retrieve", "put": "update", "patch": "partial_update"}), + name="user_settings", + ), ] urlpatterns += [ diff --git a/engine/apps/mobile_app/views.py b/engine/apps/mobile_app/views.py index 035b68fa..8a4fe558 100644 --- a/engine/apps/mobile_app/views.py +++ b/engine/apps/mobile_app/views.py @@ -1,5 +1,5 @@ from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet as BaseFCMDeviceAuthorizedViewSet -from rest_framework import generics, status +from rest_framework import mixins, status, viewsets from rest_framework.exceptions import NotFound from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -54,7 +54,11 @@ class MobileAppAuthTokenAPIView(APIView): return Response(status=status.HTTP_204_NO_CONTENT) -class MobileAppUserSettingsAPIView(generics.RetrieveUpdateAPIView): +class MobileAppUserSettingsViewSet( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): authentication_classes = (MobileAppAuthTokenAuthentication,) permission_classes = (IsAuthenticated,) serializer_class = MobileAppUserSettingsSerializer @@ -62,3 +66,9 @@ class MobileAppUserSettingsAPIView(generics.RetrieveUpdateAPIView): def get_object(self): mobile_app_settings, _ = MobileAppUserSettings.objects.get_or_create(user=self.request.user) return mobile_app_settings + + def notification_timing_options(self, request): + choices = [ + {"value": item[0], "display_name": item[1]} for item in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES + ] + return Response(choices) From c78ab58b4434c7c2788f78c8e507384a9b31937b Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Mon, 30 Oct 2023 09:58:34 -0400 Subject: [PATCH 8/8] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 687c0a07..57f84435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## v1.3.48 (2023-10-30) + ### Added - Data type changed from `DateField` to `DateTimeField` on the `final_shifts` API endpoint. Endpoint now accepts either