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:
Joey Orlando 2023-10-27 12:12:07 -04:00 committed by GitHub
parent 11259de8e0
commit 697248dc75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 4278 additions and 2675 deletions

View file

@ -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

View file

@ -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 %}}

View file

@ -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:

View file

@ -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."""

View file

@ -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
)

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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",
]

View file

@ -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

View file

@ -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]

View file

@ -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))

View file

@ -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

View file

@ -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"] == []

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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)"

View file

@ -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

View file

@ -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}"

View file

@ -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']"

View file

@ -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

View file

@ -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);
});

View file

@ -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);
};

View file

@ -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();
};

View file

@ -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)}

View file

@ -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 },
},
],
};

View file

@ -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;
}

View file

@ -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;

View file

@ -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 })),
});

View file

@ -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;
}

View file

@ -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();
});
});

View 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;

View file

@ -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>;
};

View file

@ -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>
`;

View file

@ -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;
}

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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>
`;

View file

@ -0,0 +1,3 @@
.select {
width: 150px !important;
}

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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>
`;

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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>
`;

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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>
`;

View file

@ -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: [],
};
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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 {

View 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,
},
});
});
});

View file

@ -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,

View file

@ -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 }>;
};

View file

@ -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;

View file

@ -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]);

View file

@ -4,4 +4,5 @@ export interface GrafanaTeam {
email: string;
avatar_url: string;
is_sharing_resources_to_all: boolean;
number_of_users_currently_oncall: number;
}

View file

@ -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'];

View file

@ -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;
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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',
});
}
}

View file

@ -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[];
}

View file

@ -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;
}
}

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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>