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 <team.name (if team is specified> <comma separated list of user.usernames> 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)
This commit is contained in:
parent
11259de8e0
commit
697248dc75
80 changed files with 4278 additions and 2675 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %}}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ features:
|
|||
should_escape: false
|
||||
- command: /escalate
|
||||
url: <ONCALL_ENGINE_PUBLIC_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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"] == []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. "
|
||||
"<https://grafana.com/docs/oncall/latest/integrations/manual/#learn-the-flow-and-handle-warnings|Learn more.>"
|
||||
)
|
||||
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. "
|
||||
"<https://grafana.com/docs/oncall/latest/integrations/manual/#learn-the-flow-and-handle-warnings|Learn more.>"
|
||||
)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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']"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
await expect(page.getByTestId('incident-title')).toContainText(title);
|
||||
await expect(page.getByTestId('incident-message')).toContainText(message);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<CustomFieldSectionRendererProps>;
|
||||
onFieldRender?: (
|
||||
|
|
@ -190,7 +192,13 @@ class GForm extends React.Component<GFormProps, {}> {
|
|||
? 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<GFormProps, {}> {
|
|||
);
|
||||
}
|
||||
|
||||
onChange = (field: any, value: string) => {
|
||||
onChange = (errors: FormFieldErrors, field: any, value: string) => {
|
||||
this.props.onChange?.(isEmpty(errors));
|
||||
|
||||
field?.onChange(value);
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<RecordType = unknown> extends TableProps<RecordType> {
|
|||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
const GTable: FC<Props> = (props) => {
|
||||
const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props: Props<RT>): ReactElement => {
|
||||
const {
|
||||
columns: columnsProp,
|
||||
data,
|
||||
|
|
@ -139,7 +140,7 @@ const GTable: FC<Props> = (props) => {
|
|||
|
||||
return (
|
||||
<div className={cx('root')} data-testid="test__gTable">
|
||||
<Table
|
||||
<Table<RT>
|
||||
expandable={expandable}
|
||||
rowKey={rowKey}
|
||||
className={cx('filter-table', className)}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<ManualAlertGroupProps> = (props) => {
|
||||
const store = useStore();
|
||||
const [userResponders, setUserResponders] = useState([]);
|
||||
const [scheduleResponders, setScheduleResponders] = useState([]);
|
||||
const { onHide, onCreate, alertReceiveChannelStore } = props;
|
||||
const ManualAlertGroup: FC<ManualAlertGroupProps> = observer(({ onCreate, onHide }) => {
|
||||
const { directPagingStore } = useStore();
|
||||
const { selectedTeamResponder, selectedUserResponders } = directPagingStore;
|
||||
|
||||
const [selectedTeamId, setSelectedTeam] = useState<GrafanaTeam['id']>();
|
||||
const [selectedTeamDirectPaging, setSelectedTeamDirectPaging] = useState<AlertReceiveChannel>();
|
||||
const [directPagingLoading, setdirectPagingLoading] = useState<boolean>();
|
||||
const [formIsValid, setFormIsValid] = useState<boolean>(false);
|
||||
|
||||
const [chatOpsAvailableChannels, setChatopsAvailableChannels] = useState<any>();
|
||||
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<boolean>();
|
||||
|
||||
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 (
|
||||
<VerticalGroup>
|
||||
{selectedTeamId &&
|
||||
(directPagingLoading ? (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
) : selectedTeamDirectPaging ? (
|
||||
<VerticalGroup>
|
||||
<Label>Integration to be used for notification</Label>
|
||||
<ul className={cx('responders-list')}>
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Text>{selectedTeamDirectPaging.verbal_name}</Text>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">Team:</Text>
|
||||
<TeamName team={store.grafanaTeamStore.items[selectedTeamId]} />
|
||||
</HorizontalGroup>
|
||||
{chatOpsAvailableChannels.length && (
|
||||
<HorizontalGroup>
|
||||
{chatOpsAvailableChannels.map(
|
||||
(chatOpsChannel: { name: string; icon: IconName }, chatOpsIndex) => (
|
||||
<div key={`${chatOpsChannel.name}-${chatOpsIndex}`}>
|
||||
{chatOpsChannel.icon && <Icon name={chatOpsChannel.icon} />}
|
||||
<Text type="primary">{chatOpsChannel.name || ''}</Text>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Tooltip content="Alert group will be posted to these ChatOps channels">
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
<PluginLink target="_blank" query={{ page: 'integrations', id: selectedTeamDirectPaging.id }}>
|
||||
<IconButton
|
||||
tooltip="Open integration in new tab"
|
||||
style={{ color: 'var(--always-gray)' }}
|
||||
name="external-link-alt"
|
||||
/>
|
||||
</PluginLink>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
</ul>
|
||||
{!escalationChainsExist && (
|
||||
<Alert severity="warning" title="Direct paging integration not configured">
|
||||
<VerticalGroup>
|
||||
<Text>
|
||||
The direct paging integration for the selected team has no escalation chains configured.
|
||||
<br />
|
||||
If you proceed with the alert group, the team likely will not be notified. <br />
|
||||
<a
|
||||
href={
|
||||
'https://grafana.com/docs/oncall/latest/integrations/manual/#learn-the-flow-and-handle-warnings'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cx('link')}
|
||||
>
|
||||
<Text type="link">Learn more.</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</Alert>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
) : (
|
||||
<Alert severity="warning" title={'Direct paging integration missing'}>
|
||||
<HorizontalGroup>
|
||||
<Text>
|
||||
The selected team doesn't have a direct paging integration configured and will not be notified. <br />
|
||||
If you proceed with the alert group, an empty direct paging integration will be created automatically
|
||||
for the team. <br />
|
||||
<a
|
||||
href={
|
||||
'https://grafana.com/docs/oncall/latest/integrations/manual/#learn-the-flow-and-handle-warnings'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cx('link')}
|
||||
>
|
||||
<Text type="link">Learn more.</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
</Alert>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer scrollableContent title="Create Alert Group" onClose={onHide} closeOnMaskClick={false} width="70%">
|
||||
<Drawer scrollableContent title="New escalation" onClose={onHideDrawer} closeOnMaskClick={false} width="70%">
|
||||
<VerticalGroup>
|
||||
<GForm form={manualAlertFormConfig} data={data} onSubmit={handleFormSubmit} />
|
||||
<Field label="Team to notify">
|
||||
<GrafanaTeamSelect withoutModal onSelect={onUpdateSelectedTeam} />
|
||||
</Field>
|
||||
<DirectPagingIntegrationVariants
|
||||
selectedTeamId={selectedTeamId}
|
||||
selectedTeamDirectPaging={selectedTeamDirectPaging}
|
||||
chatOpsAvailableChannels={chatOpsAvailableChannels}
|
||||
/>
|
||||
<EscalationVariants
|
||||
value={{ userResponders, scheduleResponders }}
|
||||
onUpdateEscalationVariants={onUpdateEscalationVariants}
|
||||
variant={'secondary'}
|
||||
withLabels={true}
|
||||
/>
|
||||
<GForm form={manualAlertFormConfig} data={data} onSubmit={handleFormSubmit} onChange={setFormIsValid} />
|
||||
<AddResponders mode="create" />
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
<Button variant="secondary" onClick={onHideDrawer}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={manualAlertFormConfig.name} disabled={!selectedTeamId}>
|
||||
<Button type="submit" form={manualAlertFormConfig.name} disabled={!formIsSubmittable}>
|
||||
Create
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default ManualAlertGroup;
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: () => <div>AddRespondersPopup</div>,
|
||||
}));
|
||||
|
||||
jest.mock('containers/WithPermissionControl/WithPermissionControlTooltip', () => ({
|
||||
WithPermissionControlTooltip: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
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(
|
||||
<Provider store={mockStoreValue}>
|
||||
<AddResponders
|
||||
mode={mode}
|
||||
generateRemovePreviouslyPagedUserCallback={generateRemovePreviouslyPagedUserCallback}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
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(
|
||||
<Provider store={mockStoreValue}>
|
||||
<AddResponders
|
||||
mode="create"
|
||||
hideAddResponderButton={hideAddResponderButton}
|
||||
generateRemovePreviouslyPagedUserCallback={generateRemovePreviouslyPagedUserCallback}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
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(
|
||||
<Provider store={mockStoreValue}>
|
||||
<AddResponders
|
||||
mode="create"
|
||||
existingPagedUsers={[
|
||||
{
|
||||
pk: 'asdfasdf',
|
||||
avatar: 'https://example.com/user9995.png',
|
||||
username: 'my test user3',
|
||||
} as any,
|
||||
]}
|
||||
generateRemovePreviouslyPagedUserCallback={generateRemovePreviouslyPagedUserCallback}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
229
grafana-plugin/src/containers/AddResponders/AddResponders.tsx
Normal file
229
grafana-plugin/src/containers/AddResponders/AddResponders.tsx
Normal file
|
|
@ -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<void>;
|
||||
generateRemovePreviouslyPagedUserCallback?: (userId: string) => () => Promise<void>;
|
||||
};
|
||||
|
||||
const LearnMoreAboutNotificationPoliciesLink: React.FC = () => (
|
||||
<a
|
||||
className={cx('learn-more-link')}
|
||||
href="https://grafana.com/docs/oncall/latest/notify/#configure-user-notification-policies"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text type="link">
|
||||
<HorizontalGroup spacing="xs">
|
||||
Learn more
|
||||
<Icon name="external-link-alt" />
|
||||
</HorizontalGroup>
|
||||
</Text>
|
||||
</a>
|
||||
);
|
||||
|
||||
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<User>(null);
|
||||
const [currentlyConsideredUserNotificationPolicy, setCurrentlyConsideredUserNotificationPolicy] =
|
||||
useState<NotificationPolicyValue>(NotificationPolicyValue.Default);
|
||||
|
||||
const [popupIsVisible, setPopupIsVisible] = useState(false);
|
||||
const [showUserConfirmationModal, setShowUserConfirmationModal] = useState(false);
|
||||
|
||||
const onChangeCurrentlyConsideredUserNotificationPolicy = useCallback(
|
||||
({ value }: SelectableValue<number>) => {
|
||||
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 (
|
||||
<>
|
||||
<div className={cx('body')}>
|
||||
<Block bordered>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text.Title type="primary" level={4}>
|
||||
Participants
|
||||
</Text.Title>
|
||||
{!hideAddResponderButton && (
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsDirectPaging}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
setPopupIsVisible(true);
|
||||
}}
|
||||
>
|
||||
{isCreateMode ? 'Invite' : 'Add'}
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
{(selectedTeamResponder || existingPagedUsers.length > 0 || selectedUserResponders.length > 0) && (
|
||||
<>
|
||||
<ul className={cx('responders-list')}>
|
||||
{selectedTeamResponder && (
|
||||
<TeamResponder team={selectedTeamResponder} handleDelete={directPagingStore.resetSelectedTeam} />
|
||||
)}
|
||||
{existingPagedUsers.map((user) => (
|
||||
<UserResponder
|
||||
key={user.pk}
|
||||
onImportantChange={() => {}}
|
||||
disableNotificationPolicySelect
|
||||
handleDelete={generateRemovePreviouslyPagedUserCallback(user.pk)}
|
||||
important={user.important}
|
||||
data={user as unknown as User}
|
||||
/>
|
||||
))}
|
||||
{selectedUserResponders.map((responder, index) => (
|
||||
<UserResponder
|
||||
key={responder.data.pk}
|
||||
onImportantChange={({ value: important }) =>
|
||||
directPagingStore.updateSelectedUserImportantStatus(index, Boolean(important))
|
||||
}
|
||||
handleDelete={() => directPagingStore.removeSelectedUser(index)}
|
||||
{...responder}
|
||||
/>
|
||||
))}
|
||||
{selectedUserResponders.length > 0 && (
|
||||
<Alert
|
||||
severity="info"
|
||||
title={
|
||||
(
|
||||
<Text type="primary">
|
||||
<LearnMoreAboutNotificationPoliciesLink /> about Default vs Important user personal
|
||||
notification settings
|
||||
</Text>
|
||||
) as any
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Block>
|
||||
<AddRespondersPopup
|
||||
mode={mode}
|
||||
visible={popupIsVisible}
|
||||
setVisible={setPopupIsVisible}
|
||||
existingPagedUsers={existingPagedUsers}
|
||||
setCurrentlyConsideredUser={setCurrentlyConsideredUser}
|
||||
setShowUserConfirmationModal={setShowUserConfirmationModal}
|
||||
/>
|
||||
</div>
|
||||
{showUserConfirmationModal && (
|
||||
<Modal
|
||||
isOpen
|
||||
title="Confirm Participant Invitation"
|
||||
onDismiss={closeUserConfirmationModal}
|
||||
className={cx('confirm-participant-invitation-modal')}
|
||||
>
|
||||
<VerticalGroup spacing="md">
|
||||
{!isCreateMode && (
|
||||
<div>
|
||||
<Text>
|
||||
<Text strong>{currentlyConsideredUser.name || currentlyConsideredUser.username}</Text> (local time{' '}
|
||||
{currentMoment.tz(getTimezone(currentlyConsideredUser)).format('HH:mm')}) will be notified using
|
||||
</Text>
|
||||
<div className={cx('confirm-participant-invitation-modal-select')}>
|
||||
<NotificationPoliciesSelect
|
||||
important={Boolean(currentlyConsideredUserNotificationPolicy)}
|
||||
onChange={onChangeCurrentlyConsideredUserNotificationPolicy}
|
||||
/>
|
||||
</div>
|
||||
<Text>notification settings. </Text>
|
||||
<LearnMoreAboutNotificationPoliciesLink />
|
||||
</div>
|
||||
)}
|
||||
{!currentlyConsideredUser.is_currently_oncall && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="This user is not currently on-call. We don't recommend to page users outside on-call hours."
|
||||
/>
|
||||
)}
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={closeUserConfirmationModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={confirmCurrentlyConsideredUser}>
|
||||
Confirm
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default AddResponders;
|
||||
|
|
@ -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<number>, actionMeta: ActionMeta) => void | {};
|
||||
handleDelete: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
|
@ -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`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
>
|
||||
<div
|
||||
class="root root_bordered"
|
||||
>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<h4
|
||||
class="title"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
class="css-b2ba3d-button"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-1gebccs"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19,11H13V5a1,1,0,0,0-2,0v6H5a1,1,0,0,0,0,2h6v6a1,1,0,0,0,2,0V13h6a1,1,0,0,0,0-2Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Invite
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
AddRespondersPopup
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddResponders should properly display the add responders button when hideAddResponderButton is true 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
>
|
||||
<div
|
||||
class="root root_bordered"
|
||||
>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<h4
|
||||
class="title"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
AddRespondersPopup
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddResponders should render properly in create mode 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
>
|
||||
<div
|
||||
class="root root_bordered"
|
||||
>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<h4
|
||||
class="title"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
class="css-b2ba3d-button"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-1gebccs"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19,11H13V5a1,1,0,0,0-2,0v6H5a1,1,0,0,0,0,2h6v6a1,1,0,0,0,2,0V13h6a1,1,0,0,0,0-2Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Invite
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
AddRespondersPopup
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddResponders should render properly in update mode 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
>
|
||||
<div
|
||||
class="root root_bordered"
|
||||
>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<h4
|
||||
class="title"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
class="css-b2ba3d-button"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-1gebccs"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19,11H13V5a1,1,0,0,0-2,0v6H5a1,1,0,0,0,0,2h6v6a1,1,0,0,0,2,0V13h6a1,1,0,0,0,0-2Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Add
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
AddRespondersPopup
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddResponders should render selected team and users properly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="body"
|
||||
>
|
||||
<div
|
||||
class="root root_bordered"
|
||||
>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<h4
|
||||
class="title"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
class="css-b2ba3d-button"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-1gebccs"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19,11H13V5a1,1,0,0,0-2,0v6H5a1,1,0,0,0,0,2h6v6a1,1,0,0,0,2,0V13h6a1,1,0,0,0,0-2Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Invite
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
class="responders-list"
|
||||
>
|
||||
<li>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-medium"
|
||||
data-testid="test__avatar"
|
||||
src="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium responder-name"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-label="Remove responder"
|
||||
class="css-x1vujn"
|
||||
data-testid="team-responder-delete-icon"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-hj6vlq"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10,18a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,10,18ZM20,6H16V5a3,3,0,0,0-3-3H11A3,3,0,0,0,8,5V6H4A1,1,0,0,0,4,8H5V19a3,3,0,0,0,3,3h8a3,3,0,0,0,3-3V8h1a1,1,0,0,0,0-2ZM10,5a1,1,0,0,1,1-1h2a1,1,0,0,1,1,1V6H10Zm7,14a1,1,0,0,1-1,1H8a1,1,0,0,1-1-1V8H17Zm-3-1a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,14,18Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background timeline-icon-background--green"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-medium"
|
||||
data-testid="test__avatar"
|
||||
src="https://example.com/user9995.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium responder-name"
|
||||
>
|
||||
my test user3
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-19xfdrs-input-wrapper select css-8k5qe3-SelectContainer"
|
||||
>
|
||||
<span
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
id="react-select-2-live-region"
|
||||
/>
|
||||
<span
|
||||
aria-atomic="false"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
/>
|
||||
<div
|
||||
class="css-1scgfe8"
|
||||
>
|
||||
<div
|
||||
class="css-1kl463j-grafana-select-value-container"
|
||||
>
|
||||
<div
|
||||
class=" css-1yc0yww-placeholder"
|
||||
id="react-select-2-placeholder"
|
||||
>
|
||||
Choose
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-describedby="react-select-2-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-readonly="true"
|
||||
class="css-mohuvp-dummyInput-DummyInput"
|
||||
disabled=""
|
||||
id="react-select-2-input"
|
||||
inputmode="none"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-uvldi-input-suffix"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-label="Remove responder"
|
||||
class="css-x1vujn"
|
||||
data-testid="user-responder-delete-icon"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-hj6vlq"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10,18a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,10,18ZM20,6H16V5a3,3,0,0,0-3-3H11A3,3,0,0,0,8,5V6H4A1,1,0,0,0,4,8H5V19a3,3,0,0,0,3,3h8a3,3,0,0,0,3-3V8h1a1,1,0,0,0,0-2ZM10,5a1,1,0,0,1,1-1h2a1,1,0,0,1,1,1V6H10Zm7,14a1,1,0,0,1-1,1H8a1,1,0,0,1-1-1V8H17Zm-3-1a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,14,18Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background timeline-icon-background--green"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-medium"
|
||||
data-testid="test__avatar"
|
||||
src="https://example.com/user123.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium responder-name"
|
||||
>
|
||||
my test user
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-15fuo2f-input-wrapper select css-8k5qe3-SelectContainer"
|
||||
>
|
||||
<span
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
id="react-select-3-live-region"
|
||||
/>
|
||||
<span
|
||||
aria-atomic="false"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
/>
|
||||
<div
|
||||
class="css-1scgfe8"
|
||||
>
|
||||
<div
|
||||
class="css-1kl463j-grafana-select-value-container"
|
||||
>
|
||||
<div
|
||||
class=" css-1yc0yww-placeholder"
|
||||
id="react-select-3-placeholder"
|
||||
>
|
||||
Choose
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-describedby="react-select-3-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-readonly="true"
|
||||
class="css-mohuvp-dummyInput-DummyInput"
|
||||
id="react-select-3-input"
|
||||
inputmode="none"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-uvldi-input-suffix"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-label="Remove responder"
|
||||
class="css-x1vujn"
|
||||
data-testid="user-responder-delete-icon"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-hj6vlq"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10,18a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,10,18ZM20,6H16V5a3,3,0,0,0-3-3H11A3,3,0,0,0,8,5V6H4A1,1,0,0,0,4,8H5V19a3,3,0,0,0,3,3h8a3,3,0,0,0,3-3V8h1a1,1,0,0,0,0-2ZM10,5a1,1,0,0,1,1-1h2a1,1,0,0,1,1,1V6H10Zm7,14a1,1,0,0,1-1,1H8a1,1,0,0,1-1-1V8H17Zm-3-1a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,14,18Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background timeline-icon-background--green"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-medium"
|
||||
data-testid="test__avatar"
|
||||
src="https://example.com/user456.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium responder-name"
|
||||
>
|
||||
my test user2
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-15fuo2f-input-wrapper select css-8k5qe3-SelectContainer"
|
||||
>
|
||||
<span
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
id="react-select-4-live-region"
|
||||
/>
|
||||
<span
|
||||
aria-atomic="false"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
/>
|
||||
<div
|
||||
class="css-1scgfe8"
|
||||
>
|
||||
<div
|
||||
class="css-1kl463j-grafana-select-value-container"
|
||||
>
|
||||
<div
|
||||
class=" css-1yc0yww-placeholder"
|
||||
id="react-select-4-placeholder"
|
||||
>
|
||||
Choose
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-describedby="react-select-4-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-readonly="true"
|
||||
class="css-mohuvp-dummyInput-DummyInput"
|
||||
id="react-select-4-input"
|
||||
inputmode="none"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-uvldi-input-suffix"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-label="Remove responder"
|
||||
class="css-x1vujn"
|
||||
data-testid="user-responder-delete-icon"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-hj6vlq"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10,18a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,10,18ZM20,6H16V5a3,3,0,0,0-3-3H11A3,3,0,0,0,8,5V6H4A1,1,0,0,0,4,8H5V19a3,3,0,0,0,3,3h8a3,3,0,0,0,3-3V8h1a1,1,0,0,0,0-2ZM10,5a1,1,0,0,1,1-1h2a1,1,0,0,1,1,1V6H10Zm7,14a1,1,0,0,1-1,1H8a1,1,0,0,1-1-1V8H17Zm-3-1a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,14,18Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<div
|
||||
aria-label="[object Object]"
|
||||
class="css-j2xd7x"
|
||||
data-testid="data-testid Alert info"
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
class="css-38nxtd"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
data-name="Layer 1"
|
||||
height="24"
|
||||
id="Layer_1"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12,2A10,10,0,1,0,22,12,10.01114,10.01114,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8.00917,8.00917,0,0,1,12,20Zm0-8.5a1,1,0,0,0-1,1v3a1,1,0,0,0,2,0v-3A1,1,0,0,0,12,11.5Zm0-4a1.25,1.25,0,1,0,1.25,1.25A1.25,1.25,0,0,0,12,7.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-zmuccj"
|
||||
>
|
||||
<div
|
||||
class="css-hui7p1"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
<a
|
||||
class="learn-more-link"
|
||||
href="https://grafana.com/docs/oncall/latest/notify/#configure-user-notification-policies"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-12pko5d-layoutChildrenWrapper"
|
||||
>
|
||||
Learn more
|
||||
</div>
|
||||
<div
|
||||
class="css-12pko5d-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18,10.82a1,1,0,0,0-1,1V19a1,1,0,0,1-1,1H5a1,1,0,0,1-1-1V8A1,1,0,0,1,5,7h7.18a1,1,0,0,0,0-2H5A3,3,0,0,0,2,8V19a3,3,0,0,0,3,3H16a3,3,0,0,0,3-3V11.82A1,1,0,0,0,18,10.82Zm3.92-8.2a1,1,0,0,0-.54-.54A1,1,0,0,0,21,2H15a1,1,0,0,0,0,2h3.59L8.29,14.29a1,1,0,0,0,0,1.42,1,1,0,0,0,1.42,0L20,5.41V9a1,1,0,0,0,2,0V3A1,1,0,0,0,21.92,2.62Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
about Default vs Important user personal notification settings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
AddRespondersPopup
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
<Provider store={mockStoreValue}>
|
||||
<AddRespondersPopup
|
||||
mode="create"
|
||||
visible={true}
|
||||
setVisible={jest.fn()}
|
||||
setCurrentlyConsideredUser={jest.fn()}
|
||||
setShowUserConfirmationModal={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
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(
|
||||
<Provider store={mockStoreValue}>
|
||||
<AddRespondersPopup
|
||||
mode="create"
|
||||
visible={true}
|
||||
setVisible={jest.fn()}
|
||||
setCurrentlyConsideredUser={jest.fn()}
|
||||
setShowUserConfirmationModal={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TabOptions>(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<HTMLInputElement>) => {
|
||||
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<GrafanaTeam> = [
|
||||
// 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 (
|
||||
<div onClick={() => addTeamResponder(team)} className={cx('responder-item')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Avatar size="small" src={avatar_url} />
|
||||
<Text>{name}</Text>
|
||||
</HorizontalGroup>
|
||||
{number_of_users_currently_oncall > 0 && (
|
||||
<Text type="secondary">
|
||||
{number_of_users_currently_oncall} user{number_of_users_currently_oncall > 1 ? 's' : ''} on-call
|
||||
</Text>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
key: 'Title',
|
||||
},
|
||||
];
|
||||
|
||||
const userColumns: ColumnsType<User> = [
|
||||
// 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 (
|
||||
<div onClick={() => (disabled ? undefined : onClickUser(user))} className={cx('responder-item')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Avatar size="small" src={avatar} />
|
||||
<Text type={disabled ? 'disabled' : undefined}>{name || username}</Text>
|
||||
</HorizontalGroup>
|
||||
{teams.length > 0 && <Text type="secondary">{teams.map(({ name }) => name).join(', ')}</Text>}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
width: 40,
|
||||
render: (user: User) => (userIsSelected(user) ? <Icon name="check" /> : null),
|
||||
key: 'Checked',
|
||||
},
|
||||
];
|
||||
|
||||
const UserResultsSection: FC<{ header: string; users: User[] }> = ({ header, users }) =>
|
||||
users.length > 0 && (
|
||||
<>
|
||||
<Text type="secondary" className={cx('user-results-section-header')}>
|
||||
{header}
|
||||
</Text>
|
||||
<GTable<User>
|
||||
emptyText={users ? 'No users found' : 'Loading...'}
|
||||
rowKey="pk"
|
||||
columns={userColumns}
|
||||
data={users}
|
||||
className={cx('table')}
|
||||
showHeader={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
visible && (
|
||||
<div data-testid="add-responders-popup" ref={ref} className={cx('add-responders-dropdown')}>
|
||||
<Input
|
||||
suffix={<Icon name="search" />}
|
||||
key="search"
|
||||
className={cx('responders-filters')}
|
||||
data-testid="add-responders-search-input"
|
||||
value={searchTerm}
|
||||
placeholder="Search"
|
||||
// @ts-ignore
|
||||
width={'unset'}
|
||||
onChange={handleSetSearchTerm}
|
||||
/>
|
||||
{isCreateMode && (
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ value: TabOptions.Teams, label: 'Teams' },
|
||||
{ value: TabOptions.Users, label: 'Users' },
|
||||
]}
|
||||
className={cx('radio-buttons')}
|
||||
value={activeOption}
|
||||
onChange={setActiveOption}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
{activeOption === TabOptions.Teams && (
|
||||
<>
|
||||
{selectedTeamResponder ? (
|
||||
<Alert
|
||||
severity="info"
|
||||
title="You can add only one team per escalation. Please remove the existing team before adding a new one."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
className={cx('team-direct-paging-info-alert')}
|
||||
severity="info"
|
||||
title={
|
||||
(
|
||||
<Text type="primary">
|
||||
You can only page teams which have a Direct Paging integration that is configured.{' '}
|
||||
<a
|
||||
className={cx('learn-more-link')}
|
||||
href="https://grafana.com/docs/oncall/latest/integrations/manual/#set-up-direct-paging-for-a-team"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text type="link">
|
||||
<HorizontalGroup spacing="xs">
|
||||
Learn more
|
||||
<Icon name="external-link-alt" />
|
||||
</HorizontalGroup>
|
||||
</Text>
|
||||
</a>
|
||||
</Text>
|
||||
) as any
|
||||
}
|
||||
/>
|
||||
<GTable<GrafanaTeam>
|
||||
emptyText={teamSearchResults ? 'No teams found' : 'Loading...'}
|
||||
rowKey="id"
|
||||
columns={teamColumns}
|
||||
data={teamSearchResults}
|
||||
className={cx('table')}
|
||||
showHeader={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activeOption === TabOptions.Users && (
|
||||
<>
|
||||
<UserResultsSection header="On-call now" users={usersCurrentlyOnCall} />
|
||||
<UserResultsSection header="Not on-call" users={usersNotCurrentlyOnCall} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default AddRespondersPopup;
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AddRespondersPopup if a team is selected it shows an info alert 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="add-responders-dropdown"
|
||||
data-testid="add-responders-popup"
|
||||
>
|
||||
<div
|
||||
class="css-11uftlx-input-wrapper responders-filters"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-1w5c5dq-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-1mlczho-input-input"
|
||||
data-testid="add-responders-search-input"
|
||||
placeholder="Search"
|
||||
style="padding-right: 12px;"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
class="css-7y3u6k-input-suffix"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21.71,20.29,18,16.61A9,9,0,1,0,16.61,18l3.68,3.68a1,1,0,0,0,1.42,0A1,1,0,0,0,21.71,20.29ZM11,18a7,7,0,1,1,7-7A7,7,0,0,1,11,18Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="radio-buttons css-sv3u8u"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="css-8hl977"
|
||||
id="option-teams-radiogroup-2"
|
||||
name="radiogroup-2"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="css-1tpfx0m"
|
||||
for="option-teams-radiogroup-2"
|
||||
>
|
||||
Teams
|
||||
|
||||
</label>
|
||||
<input
|
||||
class="css-8hl977"
|
||||
id="option-users-radiogroup-2"
|
||||
name="radiogroup-2"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="css-1tpfx0m"
|
||||
for="option-users-radiogroup-2"
|
||||
>
|
||||
Users
|
||||
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
aria-label="You can add only one team per escalation. Please remove the existing team before adding a new one."
|
||||
class="css-j2xd7x"
|
||||
data-testid="data-testid Alert info"
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
class="css-38nxtd"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
data-name="Layer 1"
|
||||
height="24"
|
||||
id="Layer_1"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12,2A10,10,0,1,0,22,12,10.01114,10.01114,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8.00917,8.00917,0,0,1,12,20Zm0-8.5a1,1,0,0,0-1,1v3a1,1,0,0,0,2,0v-3A1,1,0,0,0,12,11.5Zm0-4a1.25,1.25,0,1,0,1.25,1.25A1.25,1.25,0,0,0,12,7.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-zmuccj"
|
||||
>
|
||||
<div
|
||||
class="css-hui7p1"
|
||||
>
|
||||
You can add only one team per escalation. Please remove the existing team before adding a new one.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AddRespondersPopup it renders teams properly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="add-responders-dropdown"
|
||||
data-testid="add-responders-popup"
|
||||
>
|
||||
<div
|
||||
class="css-11uftlx-input-wrapper responders-filters"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-1w5c5dq-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-1mlczho-input-input"
|
||||
data-testid="add-responders-search-input"
|
||||
placeholder="Search"
|
||||
style="padding-right: 12px;"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
class="css-7y3u6k-input-suffix"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21.71,20.29,18,16.61A9,9,0,1,0,16.61,18l3.68,3.68a1,1,0,0,0,1.42,0A1,1,0,0,0,21.71,20.29ZM11,18a7,7,0,1,1,7-7A7,7,0,0,1,11,18Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="radio-buttons css-sv3u8u"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="css-8hl977"
|
||||
id="option-teams-radiogroup-1"
|
||||
name="radiogroup-1"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="css-1tpfx0m"
|
||||
for="option-teams-radiogroup-1"
|
||||
>
|
||||
Teams
|
||||
|
||||
</label>
|
||||
<input
|
||||
class="css-8hl977"
|
||||
id="option-users-radiogroup-1"
|
||||
name="radiogroup-1"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="css-1tpfx0m"
|
||||
for="option-users-radiogroup-1"
|
||||
>
|
||||
Users
|
||||
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
aria-label="[object Object]"
|
||||
class="css-j2xd7x team-direct-paging-info-alert"
|
||||
data-testid="data-testid Alert info"
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
class="css-38nxtd"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
data-name="Layer 1"
|
||||
height="24"
|
||||
id="Layer_1"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12,2A10,10,0,1,0,22,12,10.01114,10.01114,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8.00917,8.00917,0,0,1,12,20Zm0-8.5a1,1,0,0,0-1,1v3a1,1,0,0,0,2,0v-3A1,1,0,0,0,12,11.5Zm0-4a1.25,1.25,0,1,0,1.25,1.25A1.25,1.25,0,0,0,12,7.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-zmuccj"
|
||||
>
|
||||
<div
|
||||
class="css-hui7p1"
|
||||
>
|
||||
<span
|
||||
class="root text text--primary text--medium"
|
||||
>
|
||||
You can only page teams which have a Direct Paging integration that is configured.
|
||||
|
||||
<a
|
||||
class="learn-more-link"
|
||||
href="https://grafana.com/docs/oncall/latest/integrations/manual/#set-up-direct-paging-for-a-team"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-12pko5d-layoutChildrenWrapper"
|
||||
>
|
||||
Learn more
|
||||
</div>
|
||||
<div
|
||||
class="css-12pko5d-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18,10.82a1,1,0,0,0-1,1V19a1,1,0,0,1-1,1H5a1,1,0,0,1-1-1V8A1,1,0,0,1,5,7h7.18a1,1,0,0,0,0-2H5A3,3,0,0,0,2,8V19a3,3,0,0,0,3,3H16a3,3,0,0,0,3-3V11.82A1,1,0,0,0,18,10.82Zm3.92-8.2a1,1,0,0,0-.54-.54A1,1,0,0,0,21,2H15a1,1,0,0,0,0,2h3.59L8.29,14.29a1,1,0,0,0,0,1.42,1,1,0,0,0,1.42,0L20,5.41V9a1,1,0,0,0,2,0V3A1,1,0,0,0,21.92,2.62Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="root"
|
||||
data-testid="test__gTable"
|
||||
>
|
||||
<div
|
||||
class="rc-table filter-table table"
|
||||
>
|
||||
<div
|
||||
class="rc-table-container"
|
||||
>
|
||||
<div
|
||||
class="rc-table-content"
|
||||
>
|
||||
<table
|
||||
style="table-layout: auto;"
|
||||
>
|
||||
<colgroup>
|
||||
<col
|
||||
style="width: 300px;"
|
||||
/>
|
||||
</colgroup>
|
||||
<tbody
|
||||
class="rc-table-tbody"
|
||||
>
|
||||
<tr
|
||||
class="rc-table-row rc-table-row-level-0"
|
||||
>
|
||||
<td
|
||||
class="rc-table-cell"
|
||||
>
|
||||
<div
|
||||
class="responder-item"
|
||||
>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-small"
|
||||
data-testid="test__avatar"
|
||||
src="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
1
|
||||
user
|
||||
|
||||
on-call
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
class="rc-table-row rc-table-row-level-0"
|
||||
>
|
||||
<td
|
||||
class="rc-table-cell"
|
||||
>
|
||||
<div
|
||||
class="responder-item"
|
||||
>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-small"
|
||||
data-testid="test__avatar"
|
||||
src="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
my test team 2
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.select {
|
||||
width: 150px !important;
|
||||
}
|
||||
|
|
@ -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(<NotificationPoliciesSelect important={false} onChange={() => {}} />);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('disabled state', async () => {
|
||||
const component = render(<NotificationPoliciesSelect disabled important={false} onChange={() => {}} />);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<number>, actionMeta: ActionMeta) => void;
|
||||
};
|
||||
|
||||
const NotificationPoliciesSelect: FC<Props> = ({ disabled = false, important, onChange }) => (
|
||||
<Select
|
||||
className={cx('select')}
|
||||
width="auto"
|
||||
isSearchable={false}
|
||||
value={Number(important)}
|
||||
options={[
|
||||
{
|
||||
value: NotificationPolicyValue.Default,
|
||||
label: 'Default',
|
||||
description: 'Use "Default notifications" from users personal settings',
|
||||
},
|
||||
{
|
||||
value: NotificationPolicyValue.Important,
|
||||
label: 'Important',
|
||||
description: 'Use "Important notifications" from users personal settings',
|
||||
},
|
||||
]}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
export default NotificationPoliciesSelect;
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`NotificationPoliciesSelect disabled state 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="css-19xfdrs-input-wrapper select css-8k5qe3-SelectContainer"
|
||||
>
|
||||
<span
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
id="react-select-3-live-region"
|
||||
/>
|
||||
<span
|
||||
aria-atomic="false"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
/>
|
||||
<div
|
||||
class="css-1scgfe8"
|
||||
>
|
||||
<div
|
||||
class="css-1kl463j-grafana-select-value-container"
|
||||
>
|
||||
<div
|
||||
class="css-3hgwt1-singleValue css-upz218-SingleValue"
|
||||
>
|
||||
Default
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-readonly="true"
|
||||
class="css-mohuvp-dummyInput-DummyInput"
|
||||
disabled=""
|
||||
id="react-select-3-input"
|
||||
inputmode="none"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-uvldi-input-suffix"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`NotificationPoliciesSelect it renders properly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="css-15fuo2f-input-wrapper select css-8k5qe3-SelectContainer"
|
||||
>
|
||||
<span
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
id="react-select-2-live-region"
|
||||
/>
|
||||
<span
|
||||
aria-atomic="false"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
/>
|
||||
<div
|
||||
class="css-1scgfe8"
|
||||
>
|
||||
<div
|
||||
class="css-1kl463j-grafana-select-value-container"
|
||||
>
|
||||
<div
|
||||
class="css-144maed-singleValue css-upz218-SingleValue"
|
||||
>
|
||||
Default
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-readonly="true"
|
||||
class="css-mohuvp-dummyInput-DummyInput"
|
||||
id="react-select-2-input"
|
||||
inputmode="none"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-uvldi-input-suffix"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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(<TeamResponder team={team} handleDelete={() => {}} />);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it calls the delete callback', async () => {
|
||||
const handleDelete = jest.fn();
|
||||
|
||||
render(<TeamResponder team={team} handleDelete={handleDelete} />);
|
||||
|
||||
const deleteIcon = await screen.findByTestId('team-responder-delete-icon');
|
||||
await userEvent.click(deleteIcon);
|
||||
|
||||
expect(handleDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const TeamResponder: FC<Props> = ({ team: { avatar_url, name }, handleDelete }) => (
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={cx('timeline-icon-background')}>
|
||||
<Avatar size="medium" src={avatar_url} />
|
||||
</div>
|
||||
<Text className={cx('responder-name')}>{name}</Text>
|
||||
</HorizontalGroup>
|
||||
<IconButton
|
||||
data-testid="team-responder-delete-icon"
|
||||
tooltip="Remove responder"
|
||||
name="trash-alt"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
);
|
||||
|
||||
export default TeamResponder;
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`TeamResponder it renders data properly 1`] = `
|
||||
<div>
|
||||
<li>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-medium"
|
||||
data-testid="test__avatar"
|
||||
src="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium responder-name"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-label="Remove responder"
|
||||
class="css-x1vujn"
|
||||
data-testid="team-responder-delete-icon"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-hj6vlq"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10,18a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,10,18ZM20,6H16V5a3,3,0,0,0-3-3H11A3,3,0,0,0,8,5V6H4A1,1,0,0,0,4,8H5V19a3,3,0,0,0,3,3h8a3,3,0,0,0,3-3V8h1a1,1,0,0,0,0-2ZM10,5a1,1,0,0,1,1-1h2a1,1,0,0,1,1,1V6H10Zm7,14a1,1,0,0,1-1,1H8a1,1,0,0,1-1-1V8H17Zm-3-1a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,14,18Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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(
|
||||
<UserResponder important data={user} onImportantChange={() => {}} handleDelete={() => {}} />
|
||||
);
|
||||
expect(component.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it calls the delete callback', async () => {
|
||||
const handleDelete = jest.fn();
|
||||
|
||||
render(<UserResponder important data={user} onImportantChange={() => {}} handleDelete={handleDelete} />);
|
||||
|
||||
const deleteIcon = await screen.findByTestId('user-responder-delete-icon');
|
||||
await userEvent.click(deleteIcon);
|
||||
|
||||
expect(handleDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<number>, actionMeta: ActionMeta) => void | {};
|
||||
handleDelete: React.MouseEventHandler<HTMLButtonElement>;
|
||||
disableNotificationPolicySelect?: boolean;
|
||||
};
|
||||
|
||||
const UserResponder: FC<Props> = ({
|
||||
important,
|
||||
data: { avatar, username },
|
||||
onImportantChange,
|
||||
handleDelete,
|
||||
disableNotificationPolicySelect = false,
|
||||
}) => (
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={cx('timeline-icon-background', { 'timeline-icon-background--green': true })}>
|
||||
<Avatar size="medium" src={avatar} />
|
||||
</div>
|
||||
<Text className={cx('responder-name')}>{username}</Text>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<NotificationPoliciesSelect
|
||||
disabled={disableNotificationPolicySelect}
|
||||
important={important}
|
||||
onChange={onImportantChange}
|
||||
/>
|
||||
<IconButton
|
||||
data-testid="user-responder-delete-icon"
|
||||
tooltip="Remove responder"
|
||||
name="trash-alt"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
);
|
||||
|
||||
export default UserResponder;
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`UserResponder it renders data properly 1`] = `
|
||||
<div>
|
||||
<li>
|
||||
<div
|
||||
class="css-on8nbh-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="timeline-icon-background timeline-icon-background--green"
|
||||
>
|
||||
<img
|
||||
class="root avatarSize-medium"
|
||||
data-testid="test__avatar"
|
||||
src="http://avatar.com/"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium responder-name"
|
||||
>
|
||||
johnsmith
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-15fuo2f-input-wrapper select css-8k5qe3-SelectContainer"
|
||||
>
|
||||
<span
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
id="react-select-2-live-region"
|
||||
/>
|
||||
<span
|
||||
aria-atomic="false"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
class="css-1f43avz-a11yText-A11yText"
|
||||
/>
|
||||
<div
|
||||
class="css-1scgfe8"
|
||||
>
|
||||
<div
|
||||
class="css-1kl463j-grafana-select-value-container"
|
||||
>
|
||||
<div
|
||||
class="css-144maed-singleValue css-upz218-SingleValue"
|
||||
>
|
||||
Important
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-readonly="true"
|
||||
class="css-mohuvp-dummyInput-DummyInput"
|
||||
id="react-select-2-input"
|
||||
inputmode="none"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-uvldi-input-suffix"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-eyx4do"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17,9.17a1,1,0,0,0-1.41,0L12,12.71,8.46,9.17a1,1,0,0,0-1.41,0,1,1,0,0,0,0,1.42l4.24,4.24a1,1,0,0,0,1.42,0L17,10.59A1,1,0,0,0,17,9.17Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-label="Remove responder"
|
||||
class="css-x1vujn"
|
||||
data-testid="user-responder-delete-icon"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-hj6vlq"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10,18a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,10,18ZM20,6H16V5a3,3,0,0,0-3-3H11A3,3,0,0,0,8,5V6H4A1,1,0,0,0,4,8H5V19a3,3,0,0,0,3,3h8a3,3,0,0,0,3-3V8h1a1,1,0,0,0,0-2ZM10,5a1,1,0,0,1,1-1h2a1,1,0,0,1,1,1V6H10Zm7,14a1,1,0,0,1-1,1H8a1,1,0,0,1-1-1V8H17Zm-3-1a1,1,0,0,0,1-1V11a1,1,0,0,0-2,0v6A1,1,0,0,0,14,18Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<User | undefined>(undefined);
|
||||
const [userAvailability, setUserAvailability] = useState<UserAvailability | undefined>(undefined);
|
||||
|
||||
const onUpdateEscalationVariants = useCallback((newValue) => {
|
||||
const deduplicatedValue = deduplicate(newValue);
|
||||
|
||||
propsOnUpdateEscalationVariants(deduplicatedValue);
|
||||
}, []);
|
||||
|
||||
const getUserResponderImportChangeHandler = (index) => {
|
||||
return ({ value: important }: SelectableValue<number>) => {
|
||||
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<number>) => {
|
||||
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 (
|
||||
<>
|
||||
<div className={cx('body')}>
|
||||
{!hideSelected && Boolean(value.userResponders.length || value.scheduleResponders.length) && (
|
||||
<>
|
||||
<Label>Additional responders will be notified immediately:</Label>
|
||||
<ul className={cx('responders-list')}>
|
||||
{value.userResponders.map((responder, index) => (
|
||||
<UserResponder
|
||||
key={responder.data?.pk}
|
||||
onImportantChange={getUserResponderImportChangeHandler(index)}
|
||||
handleDelete={getUserResponderDeleteHandler(index)}
|
||||
{...responder}
|
||||
/>
|
||||
))}
|
||||
{value.scheduleResponders.map((responder, index) => (
|
||||
<ScheduleResponder
|
||||
onImportantChange={getScheduleResponderImportChangeHandler(index)}
|
||||
handleDelete={getScheduleResponderDeleteHandler(index)}
|
||||
key={responder.data.id}
|
||||
{...responder}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<div className={cx('assign-responders-button')}>
|
||||
{withLabels && <Label>Additional responders (optional)</Label>}
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button
|
||||
icon="users-alt"
|
||||
variant={variant}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setShowEscalationVariants(true);
|
||||
}}
|
||||
>
|
||||
Notify additional responders
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</div>
|
||||
{showEscalationVariants && (
|
||||
<EscalationVariantsPopup
|
||||
value={value}
|
||||
onUpdateEscalationVariants={onUpdateEscalationVariants}
|
||||
setShowEscalationVariants={setShowEscalationVariants}
|
||||
setSelectedUser={setSelectedUser}
|
||||
setShowUserWarningModal={setShowUserWarningModal}
|
||||
setUserAvailability={setUserAvailability}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showUserWarningModal && (
|
||||
<UserWarning
|
||||
user={selectedUser}
|
||||
userAvailability={userAvailability}
|
||||
onHide={() => {
|
||||
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 (
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={cx('timeline-icon-background', { 'timeline-icon-background--green': true })}>
|
||||
<Avatar size="medium" src={data?.avatar} />
|
||||
</div>
|
||||
<Text className={cx('responder-name')}>{data?.username}</Text>
|
||||
{data.notification_chain_verbal.default || data.notification_chain_verbal.important ? (
|
||||
<HorizontalGroup>
|
||||
<Text type="secondary">by</Text>
|
||||
<Select
|
||||
className={cx('select')}
|
||||
width="auto"
|
||||
isSearchable={false}
|
||||
value={Number(important)}
|
||||
options={[
|
||||
{
|
||||
value: 0,
|
||||
label: 'Default',
|
||||
description: 'Use "Default notifications" from user\'s personal settings',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: 'Important',
|
||||
description: 'Use "Important notifications" from user\'s personal settings',
|
||||
},
|
||||
]}
|
||||
// @ts-ignore
|
||||
isOptionDisabled={({ value }) =>
|
||||
(value === 0 && !data.notification_chain_verbal.default) ||
|
||||
(value === 1 && !data.notification_chain_verbal.important)
|
||||
}
|
||||
getOptionLabel={({ value, label }) => {
|
||||
return (
|
||||
<Text
|
||||
type={
|
||||
(value === 0 && !data.notification_chain_verbal.default) ||
|
||||
(value === 1 && !data.notification_chain_verbal.important)
|
||||
? 'disabled'
|
||||
: 'primary'
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
onChange={onImportantChange}
|
||||
/>
|
||||
<Text type="secondary">notification policies</Text>
|
||||
</HorizontalGroup>
|
||||
) : (
|
||||
<HorizontalGroup>
|
||||
<Tooltip content="User doesn't have configured notification policies">
|
||||
<Icon name="exclamation-triangle" style={{ color: 'var(--error-text-color)' }} />
|
||||
</Tooltip>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<PluginLink className={cx('hover-button')} target="_blank" query={{ page: 'users', id: data.pk }}>
|
||||
<IconButton
|
||||
tooltip="Open user profile in new tab"
|
||||
style={{ color: 'var(--always-gray)' }}
|
||||
name="external-link-alt"
|
||||
/>
|
||||
</PluginLink>
|
||||
<IconButton
|
||||
tooltip="Remove responder"
|
||||
className={cx('hover-button')}
|
||||
name="trash-alt"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const ScheduleResponder = ({ important, data, onImportantChange, handleDelete }) => {
|
||||
return (
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<div className={cx('timeline-icon-background')}>
|
||||
<Icon size="lg" name="calendar-alt" />
|
||||
</div>
|
||||
<Text className={cx('responder-name')}>{data.name}</Text>
|
||||
<Text type="secondary">by</Text>
|
||||
<Select
|
||||
className={cx('select')}
|
||||
width="auto"
|
||||
isSearchable={false}
|
||||
value={Number(important)}
|
||||
options={[
|
||||
{
|
||||
value: 0,
|
||||
label: 'Default',
|
||||
description: 'Use "Default notifications" from users personal settings',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: 'Important',
|
||||
description: 'Use "Important notifications" from users personal settings',
|
||||
},
|
||||
]}
|
||||
onChange={onImportantChange}
|
||||
/>
|
||||
<Text type="secondary">notification policies</Text>
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<PluginLink className={cx('hover-button')} target="_blank" query={{ page: 'schedules', id: data.id }}>
|
||||
<IconButton
|
||||
tooltip="Open schedule in new tab"
|
||||
style={{ color: 'var(--always-gray)' }}
|
||||
name="external-link-alt"
|
||||
/>
|
||||
</PluginLink>
|
||||
<IconButton
|
||||
className={cx('hover-button')}
|
||||
tooltip="Remove responder"
|
||||
name="trash-alt"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default EscalationVariants;
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
onClick={() => (disabled ? undefined : addSchedulesResponders(schedule))}
|
||||
className={cx('responder-item')}
|
||||
>
|
||||
<Text type={disabled ? 'disabled' : undefined}>{schedule.name}</Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
key: 'Title',
|
||||
},
|
||||
{
|
||||
width: 40,
|
||||
render: (item: Schedule) =>
|
||||
value.scheduleResponders.some((scheduleResponder) => scheduleResponder.data.id === item.id) ? (
|
||||
<Icon name="check" />
|
||||
) : null,
|
||||
key: 'Checked',
|
||||
},
|
||||
];
|
||||
|
||||
const userColumns = [
|
||||
{
|
||||
width: 300,
|
||||
render: (user: User) => {
|
||||
const disabled = value.userResponders.some((userResponder) => userResponder.data?.pk === user.pk);
|
||||
return (
|
||||
<div onClick={() => (disabled ? undefined : addUserResponders(user))} className={cx('responder-item')}>
|
||||
<Text type={disabled ? 'disabled' : undefined}>
|
||||
{user.username} ({user.timezone})
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
width: 40,
|
||||
render: (item: User) =>
|
||||
value.userResponders.some((userResponder) => userResponder.data?.pk === item.pk) ? <Icon name="check" /> : null,
|
||||
key: 'Checked',
|
||||
},
|
||||
];
|
||||
|
||||
const ref = useRef();
|
||||
|
||||
useOnClickOutside(ref, () => {
|
||||
setShowEscalationVariants(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cx('escalation-variants-dropdown')}>
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ value: 'schedules', label: 'Schedules' },
|
||||
{ value: 'users', label: 'Users' },
|
||||
]}
|
||||
className={cx('radio-buttons')}
|
||||
value={activeOption}
|
||||
onChange={handleOptionChange}
|
||||
fullWidth
|
||||
/>
|
||||
{activeOption === 'schedules' && (
|
||||
<>
|
||||
<Input
|
||||
prefix={<Icon name="search" />}
|
||||
key="schedules search"
|
||||
className={cx('responders-filters')}
|
||||
value={schedulesSearchTerm}
|
||||
placeholder="Search schedules..."
|
||||
// @ts-ignore
|
||||
width={'unset'}
|
||||
onChange={handleSetSchedulesSearchTerm}
|
||||
/>
|
||||
<GTable
|
||||
emptyText={store.scheduleStore.getSearchResult()?.results ? 'No schedules found' : 'Loading...'}
|
||||
rowKey="id"
|
||||
columns={scheduleColumns}
|
||||
data={store.scheduleStore.getSearchResult()?.results}
|
||||
className={cx('table')}
|
||||
showHeader={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{activeOption === 'users' && (
|
||||
<>
|
||||
<Input
|
||||
prefix={<Icon name="search" />}
|
||||
key="users search"
|
||||
// @ts-ignore
|
||||
width={'unset'}
|
||||
className={cx('responders-filters')}
|
||||
placeholder="Search users..."
|
||||
value={usersSearchTerm}
|
||||
onChange={handleSetUsersSearchTerm}
|
||||
/>
|
||||
<GTable
|
||||
emptyText={store.userStore.getSearchResult()?.results ? 'No users found' : 'Loading...'}
|
||||
rowKey="id"
|
||||
columns={userColumns}
|
||||
data={store.userStore.getSearchResult()?.results}
|
||||
className={cx('table')}
|
||||
showHeader={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default EscalationVariantsPopup;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<UserWarningProps> = (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 (
|
||||
<Modal isOpen title="Add responder" onDismiss={onHide} className={cx('modal')}>
|
||||
<VerticalGroup className={cx('user-warning')}>
|
||||
{showUserHasNoNotificationPolicyWarning && (
|
||||
<HorizontalGroup>
|
||||
<Icon name="exclamation-triangle" style={{ color: 'var(--error-text-color)' }} />
|
||||
<Text>
|
||||
<Text strong>{user.username}</Text> has no notification policy
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
{showUserIsNotOncallWarning && (
|
||||
<HorizontalGroup>
|
||||
<Icon name="exclamation-triangle" style={{ color: 'orange' }} />
|
||||
<Text>
|
||||
<Text strong>
|
||||
{user.username} (Local time {dayjs().tz(user.timezone).format('HH:mm:ss')})
|
||||
</Text>{' '}
|
||||
is not currently on-call.
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
{userSchedules.length && (
|
||||
<HorizontalGroup>
|
||||
<Icon name="calendar-alt" />
|
||||
<Text>
|
||||
<Text strong>{user.username}</Text> appears in <Text strong>{userSchedules.join(', ')} </Text>
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
{recommendedUsers.length && (
|
||||
<HorizontalGroup>
|
||||
<Icon name="info-circle" />
|
||||
<Text>Recommended on-call users:</Text>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
{recommendedUsers.length && (
|
||||
<ul className={cx('users')}>
|
||||
{recommendedUsers.map((userPk) => (
|
||||
<RecommendedUser key={userPk} pk={userPk} onSelect={getUserSelectHandler(userPk)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Text>
|
||||
<HorizontalGroup>
|
||||
<Icon name="question-circle" />
|
||||
<Text>
|
||||
Are you sure you want to select <Text strong>{user.username}</Text>?
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
</Text>
|
||||
<HorizontalGroup justify="flex-end">
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={getUserSelectHandler(user.pk)}>
|
||||
Confirm
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<li>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup spacing="sm">
|
||||
<div className={cx('dot')} />
|
||||
<Text strong>{user?.username}</Text>
|
||||
<Text>
|
||||
({getTzOffsetString(dayjs().tz(user?.timezone))}, {user?.timezone})
|
||||
</Text>
|
||||
<Icon name="calendar-alt" />
|
||||
</HorizontalGroup>
|
||||
<Button size="sm" onClick={onSelect}>
|
||||
Select
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserWarning;
|
||||
|
|
@ -40,6 +40,10 @@ export interface GroupedAlert {
|
|||
render_for_web: RenderForWeb;
|
||||
}
|
||||
|
||||
export type PagedUser = Pick<User, 'pk' | 'name' | 'username' | 'avatar' | 'avatar_full'> & {
|
||||
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<AlertReceiveChannel>;
|
||||
paged_users: Array<Pick<User, 'pk' | 'username' | 'avatar'>>;
|
||||
paged_users: PagedUser[];
|
||||
team: GrafanaTeam['id'];
|
||||
|
||||
// set by client
|
||||
loading?: boolean;
|
||||
undoAction?: AlertAction;
|
||||
|
||||
has_pormortem?: boolean; // not implemented yet
|
||||
}
|
||||
|
||||
interface RenderForWeb {
|
||||
|
|
|
|||
154
grafana-plugin/src/models/direct_paging/direct_paging.test.ts
Normal file
154
grafana-plugin/src/models/direct_paging/direct_paging.test.ts
Normal file
|
|
@ -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<ReturnType<typeof makeRequestOriginal>>;
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<DirectPagingResponse | void> {
|
||||
return await makeRequest<DirectPagingResponse>(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<DirectPagingResponse | void> {
|
||||
return await makeRequest<DirectPagingResponse>(this.path, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
alert_group_id: alertId,
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ export interface GrafanaTeam {
|
|||
email: string;
|
||||
avatar_url: string;
|
||||
is_sharing_resources_to_all: boolean;
|
||||
number_of_users_currently_oncall: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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<UserDTO>;
|
||||
author: Partial<User>;
|
||||
text: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -113,9 +113,9 @@ export class UserStore extends BaseStore {
|
|||
@action
|
||||
async updateItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise<any> {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IncidentPageProps, IncidentPageState>
|
|||
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
|
||||
</div>
|
||||
<div className={cx('column')}>
|
||||
<VerticalGroup>
|
||||
<PagedUsers
|
||||
pagedUsers={incident.paged_users}
|
||||
onRemove={this.handlePagedUserRemove}
|
||||
disabled={incident.is_restricted}
|
||||
<VerticalGroup style={{ display: 'block' }}>
|
||||
<AddResponders
|
||||
mode="update"
|
||||
hideAddResponderButton={incident.resolved}
|
||||
existingPagedUsers={incident.paged_users}
|
||||
onAddNewParticipant={this.handleAddUserResponder}
|
||||
generateRemovePreviouslyPagedUserCallback={this.handlePagedUserRemove}
|
||||
/>
|
||||
{this.renderTimeline()}
|
||||
</VerticalGroup>
|
||||
|
|
@ -231,17 +233,19 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
);
|
||||
}
|
||||
|
||||
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<IncidentPageProps, IncidentPageState>
|
|||
</PluginBridge>
|
||||
</HorizontalGroup>
|
||||
|
||||
<HorizontalGroup>
|
||||
<EscalationVariants
|
||||
variant="secondary"
|
||||
hideSelected
|
||||
value={prepareForEdit(incident.paged_users)}
|
||||
disabled={incident.is_restricted}
|
||||
onUpdateEscalationVariants={this.handleAddResponders}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={incident.alert_receive_channel.deleted || incident.is_restricted}
|
||||
variant="secondary"
|
||||
icon="edit"
|
||||
onClick={this.showIntegrationSettings}
|
||||
>
|
||||
Edit templates
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
<Button
|
||||
disabled={incident.alert_receive_channel.deleted || incident.is_restricted}
|
||||
variant="secondary"
|
||||
icon="edit"
|
||||
onClick={this.showIntegrationSettings}
|
||||
>
|
||||
Edit templates
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
handleAddResponders = async (data) => {
|
||||
handleAddUserResponder = async (user: Omit<UserResponder, 'type'>) => {
|
||||
const {
|
||||
store,
|
||||
match: {
|
||||
|
|
@ -454,10 +448,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
},
|
||||
} = 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<IncidentPageProps, IncidentPageState>
|
|||
const { timelineFilter, resolutionNoteText } = this.state;
|
||||
const isResolutionNoteTextEmpty = resolutionNoteText === '';
|
||||
return (
|
||||
<div>
|
||||
<Block bordered>
|
||||
<Text.Title type="primary" level={4} className={cx('timeline-title')}>
|
||||
Timeline
|
||||
</Text.Title>
|
||||
|
|
@ -555,7 +546,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
Add resolution note
|
||||
</ToolbarButton>
|
||||
</WithPermissionControlTooltip>
|
||||
</div>
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={cx('paged-users')}>
|
||||
<Text.Title type="primary" level={4} className={cx('timeline-title')}>
|
||||
Additional responders
|
||||
</Text.Title>
|
||||
<ul className={cx('paged-users-list')}>
|
||||
{pagedUsers.map((pagedUser) => {
|
||||
const storeUser = userStore.items[pagedUser.pk];
|
||||
|
||||
return (
|
||||
<li key={pagedUser.pk}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<HorizontalGroup>
|
||||
<Avatar size="medium" src={pagedUser.avatar} />
|
||||
<Text strong>{pagedUser.username}</Text>
|
||||
{Boolean(
|
||||
storeUser &&
|
||||
!storeUser.notification_chain_verbal.default &&
|
||||
!storeUser.notification_chain_verbal.important
|
||||
) && (
|
||||
<Tooltip content="User doesn't have configured notification chains">
|
||||
<Icon name="exclamation-triangle" style={{ color: 'var(--error-text-color)' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<PluginLink
|
||||
className={cx('hover-button')}
|
||||
target="_blank"
|
||||
query={{ page: 'users', id: pagedUser.pk }}
|
||||
>
|
||||
<IconButton
|
||||
tooltip="Open user profile in new tab"
|
||||
style={{ color: 'var(--always-gray)' }}
|
||||
name="external-link-alt"
|
||||
/>
|
||||
</PluginLink>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<WithConfirm
|
||||
title={`Are you sure to remove "${pagedUser.username}" from responders?`}
|
||||
confirmText="Remove"
|
||||
>
|
||||
<IconButton
|
||||
className={cx('hover-button')}
|
||||
onClick={getPagedUserRemoveHandler(pagedUser.pk)}
|
||||
tooltip="Remove from responders"
|
||||
name="trash-alt"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</WithConfirm>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PagedUsers;
|
||||
|
|
@ -118,7 +118,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
<Text.Title level={3}>Alert Groups</Text.Title>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsDirectPaging}>
|
||||
<Button icon="plus" onClick={this.handleOnClickEscalateTo}>
|
||||
New alert group
|
||||
Escalation
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue