This commit is contained in:
Joey Orlando 2023-10-30 10:03:38 -04:00 committed by GitHub
commit 14ca75524c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
100 changed files with 4599 additions and 2909 deletions

View file

@ -5,6 +5,25 @@ 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
## v1.3.48 (2023-10-30)
### Added
- Data type changed from `DateField` to `DateTimeField` on the `final_shifts` API endpoint. Endpoint now accepts either
a date or a datetime ([#3103](https://github.com/grafana/oncall/pull/3103))
### Changed
- Simplify Direct Paging workflow. Now when using Direct Paging you either simply specify a team, or one or more users
to page by @joeyorlando ([#3128](https://github.com/grafana/oncall/pull/3128))
- Enable timing options for mobile push notifications, allow multi-select by @Ferril ([#3187](https://github.com/grafana/oncall/pull/3187))
### Fixed
- Fix RBAC authorization bugs related to interacting with Alert Group Slack messages by @joeyorlando ([#3213](https://github.com/grafana/oncall/pull/3213))
## v1.3.47 (2023-10-25)
### Fixed
@ -24,6 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix iCal imported schedules related users and next shifts per user ([#3178](https://github.com/grafana/oncall/pull/3178))
- Fix references to removed access control functions in Grafana @mderynck ([#3184](https://github.com/grafana/oncall/pull/3184))
### Changed
- Upgrade Django to 4.2.6 and update iCal related deps ([#3176](https://github.com/grafana/oncall/pull/3176))
## v1.3.45 (2023-10-19)
### Added

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,55 @@ 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.Dict[str, 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[user_id] = {
"id": user.pk,
"pk": user.public_primary_key,
"name": user.name,
"username": user.username,
"avatar": user.avatar_url,
"avatar_full": user.avatar_full_url,
"important": important,
"teams": [{"pk": t.public_primary_key, "name": t.name} for t in user.teams.all()],
}
else:
# user was unpaged at some point, remove them
del users[user_id]
return list(users.values())
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,
},
)
@ -246,7 +155,13 @@ def direct_paging(
def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None:
"""Remove user from alert group escalation."""
"""
Remove user from alert group escalation.
An IndexError is raised (and caught) if the user had not been notified for some reason.
Regardless of whether or not the user was notified, we will always create an AlertGroupLogRecord of type
TYPE_UNPAGE_USER.
"""
try:
with transaction.atomic():
user_has_notification = UserHasNotification.objects.filter(
@ -254,12 +169,42 @@ def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None:
).select_for_update()[0]
user_has_notification.active_notification_policy_id = None
user_has_notification.save(update_fields=["active_notification_policy_id"])
# add log entry
alert_group.log_records.create(
type=AlertGroupLogRecord.TYPE_UNPAGE_USER,
author=from_user,
reason=f"{from_user.username} unpaged user {user.username}",
step_specific_info={"user": user.public_primary_key},
)
except IndexError:
return
finally:
alert_group.log_records.create(
type=AlertGroupLogRecord.TYPE_UNPAGE_USER,
author=from_user,
reason=f"{from_user.username} unpaged user {user.username}",
step_specific_info={"user": user.public_primary_key},
)
def user_is_oncall(user: User) -> bool:
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,67 @@ 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)
assert alert_group.get_paged_users()[0]["pk"] == other_user.public_primary_key
# user was paged, unpaged, and then paged again - they should only show up once
alert_group = make_alert_group(alert_receive_channel)
_make_log_record(alert_group, user, AlertGroupLogRecord.TYPE_DIRECT_PAGING)
_make_log_record(alert_group, user, AlertGroupLogRecord.TYPE_UNPAGE_USER)
_make_log_record(alert_group, user, AlertGroupLogRecord.TYPE_DIRECT_PAGING)
paged_users = alert_group.get_paged_users()
assert len(paged_users) == 1
assert alert_group.get_paged_users()[0]["pk"] == user.public_primary_key
# user was paged and then paged again - they should only show up once
alert_group = make_alert_group(alert_receive_channel)
_make_log_record(alert_group, user, AlertGroupLogRecord.TYPE_DIRECT_PAGING)
_make_log_record(alert_group, user, AlertGroupLogRecord.TYPE_DIRECT_PAGING)
paged_users = alert_group.get_paged_users()
assert len(paged_users) == 1
assert alert_group.get_paged_users()[0]["pk"] == user.public_primary_key

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

@ -11,6 +11,7 @@ from rest_framework.test import APIClient
from apps.alerts.constants import ActionSource
from apps.alerts.models import AlertGroup, AlertGroupLogRecord, ResolutionNote
from apps.alerts.paging import direct_paging
from apps.alerts.tasks import wipe
from apps.api.errors import AlertGroupAPIError
from apps.api.permissions import LegacyAccessControlRole
@ -1356,17 +1357,31 @@ def test_unpage_user(
make_user_auth_headers,
):
client = APIClient()
user, token, alert_groups = alert_group_internal_api_setup
user_to_unpage = make_user(organization=user.organization)
_, _, new_alert_group, _ = alert_groups
user, token, _ = alert_group_internal_api_setup
other_user = make_user(organization=user.organization)
url = reverse("api-internal:alertgroup-unpage-user", kwargs={"pk": new_alert_group.public_primary_key})
response = client.post(
url, data={"user_id": user_to_unpage.public_primary_key}, **make_user_auth_headers(user, token)
)
alert_group = direct_paging(user.organization, user, "testtesttest", users=[(other_user, False)])
paged_users = alert_group.get_paged_users()
assert paged_users[0]["pk"] == other_user.public_primary_key
url = reverse("api-internal:alertgroup-unpage-user", kwargs={"pk": alert_group.public_primary_key})
response = client.post(url, data={"user_id": other_user.public_primary_key}, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
alert_group.refresh_from_db()
assert alert_group.silenced_until is None
assert alert_group.get_paged_users() == []
unpage_user_log_record = alert_group.log_records.get(
type=AlertGroupLogRecord.TYPE_UNPAGE_USER,
author=user,
)
assert unpage_user_log_record.reason == f"{user.username} unpaged user {other_user.username}"
assert unpage_user_log_record.step_specific_info == {"user": other_user.public_primary_key}
@pytest.mark.django_db
def test_invalid_bulk_action(
@ -1841,7 +1856,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

@ -0,0 +1,30 @@
# Generated by Django 3.2.20 on 2023-10-30 09:25
import apps.mobile_app.models
import django_migration_linter as linter
from django.db import migrations, models
from apps.mobile_app.models import default_notification_timing_options
def set_going_oncall_notification_timing_to_default(apps, schema_editor):
MobileAppUserSettings = apps.get_model("mobile_app", "MobileAppUserSettings")
default = default_notification_timing_options()
MobileAppUserSettings.objects.all().update(going_oncall_notification_timing=default)
class Migration(migrations.Migration):
dependencies = [
('mobile_app', '0010_mobileappusersettings_time_zone'),
]
operations = [
linter.IgnoreMigration(),
migrations.AlterField(
model_name='mobileappusersettings',
name='going_oncall_notification_timing',
field=models.JSONField(default=apps.mobile_app.models.default_notification_timing_options),
),
migrations.RunPython(set_going_oncall_notification_timing_to_default, migrations.RunPython.noop),
]

View file

@ -4,6 +4,7 @@ import typing
from django.core import validators
from django.db import models
from django.db.models import JSONField
from django.utils import timezone
from fcm_django.models import FCMDevice as BaseFCMDevice
@ -21,6 +22,10 @@ def get_expire_date():
return timezone.now() + timezone.timedelta(seconds=MOBILE_APP_AUTH_VERIFICATION_TOKEN_TIMEOUT_SECONDS)
def default_notification_timing_options():
return [MobileAppUserSettings.FIFTEEN_MINUTES_IN_SECONDS]
class ActiveFCMDeviceQuerySet(models.QuerySet):
def filter(self, *args, **kwargs):
return super().filter(*args, **kwargs, active=True)
@ -159,19 +164,20 @@ class MobileAppUserSettings(models.Model):
# these choices + the below column are used to calculate when to send the "You're Going OnCall soon"
# push notification
# ONE_HOUR, TWELVE_HOURS, ONE_DAY, ONE_WEEK = range(4)
FIFTEEN_MINUTES_IN_SECONDS = 15 * 60
ONE_HOUR_IN_SECONDS = 60 * 60
SIX_HOURS_IN_SECONDS = 6 * 60 * 60
TWELVE_HOURS_IN_SECONDS = 12 * 60 * 60
ONE_DAY_IN_SECONDS = TWELVE_HOURS_IN_SECONDS * 2
ONE_WEEK_IN_SECONDS = ONE_DAY_IN_SECONDS * 7
NOTIFICATION_TIMING_CHOICES = (
(FIFTEEN_MINUTES_IN_SECONDS, "fifteen minutes before"),
(ONE_HOUR_IN_SECONDS, "one hour before"),
(SIX_HOURS_IN_SECONDS, "six hours before"),
(TWELVE_HOURS_IN_SECONDS, "twelve hours before"),
(ONE_DAY_IN_SECONDS, "one day before"),
(ONE_WEEK_IN_SECONDS, "one week before"),
)
going_oncall_notification_timing = models.IntegerField(
choices=NOTIFICATION_TIMING_CHOICES, default=TWELVE_HOURS_IN_SECONDS
)
going_oncall_notification_timing = JSONField(default=default_notification_timing_options)
locale = models.CharField(max_length=50, null=True)
time_zone = models.CharField(max_length=100, default="UTC")

View file

@ -1,3 +1,5 @@
import typing
from rest_framework import serializers
from apps.mobile_app.models import MobileAppUserSettings
@ -6,6 +8,7 @@ from common.api_helpers.custom_fields import TimeZoneField
class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
time_zone = TimeZoneField(required=False, allow_null=False)
going_oncall_notification_timing = serializers.ListField(required=False, allow_null=False)
class Meta:
model = MobileAppUserSettings
@ -28,3 +31,15 @@ class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
"locale",
"time_zone",
)
def validate_going_oncall_notification_timing(
self, going_oncall_notification_timing: typing.Optional[typing.List[int]]
) -> typing.Optional[typing.List[int]]:
if going_oncall_notification_timing is not None:
if len(going_oncall_notification_timing) == 0:
raise serializers.ValidationError(detail="invalid timing options")
notification_timing_options = [opt[0] for opt in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES]
for option in going_oncall_notification_timing:
if option not in notification_timing_options:
raise serializers.ValidationError(detail="invalid timing options")
return going_oncall_notification_timing

View file

@ -121,7 +121,6 @@ def _should_we_send_push_notification(
an `int` which represents the # of seconds until the oncall shift starts.
"""
NOTIFICATION_TIMING_BUFFER = 7 * 60 # 7 minutes in seconds
FIFTEEN_MINUTES_IN_SECONDS = 15 * 60
# this _should_ always be positive since final_events is returning only events in the future
seconds_until_shift_starts = math.floor((schedule_event["start"] - now).total_seconds())
@ -134,32 +133,33 @@ def _should_we_send_push_notification(
logger.info("not sending going oncall push notification because info_notifications_enabled is false")
return None
# 14 minute window where the notification could be sent (7 mins before or 7 mins after)
timing_window_lower = user_notification_timing_preference - NOTIFICATION_TIMING_BUFFER
timing_window_upper = user_notification_timing_preference + NOTIFICATION_TIMING_BUFFER
for timing_preference in user_notification_timing_preference:
# 14 minute window where the notification could be sent (7 mins before or 7 mins after)
timing_window_lower = timing_preference - NOTIFICATION_TIMING_BUFFER
timing_window_upper = timing_preference + NOTIFICATION_TIMING_BUFFER
shift_starts_within_users_notification_timing_preference = _shift_starts_within_range(
timing_window_lower, timing_window_upper, seconds_until_shift_starts
)
shift_starts_within_fifteen_minutes = _shift_starts_within_range(
0, FIFTEEN_MINUTES_IN_SECONDS, seconds_until_shift_starts
)
shift_starts_within_users_notification_timing_preference = _shift_starts_within_range(
timing_window_lower, timing_window_upper, seconds_until_shift_starts
)
timing_logging_msg = (
if shift_starts_within_users_notification_timing_preference:
logger.info(
f"timing is right to send going oncall push notification\n"
f"seconds_until_shift_starts: {seconds_until_shift_starts}\n"
f"user_notification_timing_preference: {user_notification_timing_preference}\n"
f"current timing_preference: {timing_preference}\n"
f"timing_window_lower: {timing_window_lower}\n"
f"timing_window_upper: {timing_window_upper}\n"
f"shift_starts_within_users_notification_timing_preference: {shift_starts_within_users_notification_timing_preference}\n"
)
return seconds_until_shift_starts
logger.info(
f"timing is not right to send going oncall push notification\n"
f"seconds_until_shift_starts: {seconds_until_shift_starts}\n"
f"user_notification_timing_preference: {user_notification_timing_preference}\n"
f"timing_window_lower: {timing_window_lower}\n"
f"timing_window_upper: {timing_window_upper}\n"
f"shift_starts_within_users_notification_timing_preference: {shift_starts_within_users_notification_timing_preference}\n"
f"shift_starts_within_fifteen_minutes: {shift_starts_within_fifteen_minutes}"
f"shift_starts_within_users_notification_timing_preference: False\n"
)
# Temporary remove `shift_starts_within_users_notification_timing_preference` from condition to send notification only 15 minutes before the shift starts
# TODO: Return it once mobile app ready and default value is changed (https://github.com/grafana/oncall/issues/1999)
if shift_starts_within_fifteen_minutes:
logger.info(f"timing is right to send going oncall push notification\n{timing_logging_msg}")
return seconds_until_shift_starts
logger.info(f"timing is not right to send going oncall push notification\n{timing_logging_msg}")
return None

View file

@ -21,6 +21,7 @@ from apps.mobile_app.types import MessageType, Platform
from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb
from apps.schedules.models.on_call_schedule import ScheduleEvent
FIFTEEN_MINUTES_IN_SECONDS = 15 * 60
ONE_HOUR_IN_SECONDS = 60 * 60
ONCALL_TIMING_PREFERENCE = ONE_HOUR_IN_SECONDS * 12
@ -254,7 +255,7 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 13, 13, 0),
None,
),
@ -262,14 +263,14 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 13, 12, 0),
None,
67 * 60,
),
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 13, 12, 0),
None,
),
@ -277,14 +278,14 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 58, 0),
None,
53 * 60,
),
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 58, 0),
None,
),
@ -292,7 +293,7 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 57, 0),
None,
),
@ -300,37 +301,30 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 21, 0),
None,
),
# shift starts in 15m - send only if info_notifications_enabled is true
# shift starts in 15m, user timing preference is 1h and 15m - send only if info_notifications_enabled is true
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS, FIFTEEN_MINUTES_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 20, 0),
15 * 60,
),
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS, FIFTEEN_MINUTES_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 20, 0),
None,
),
# shift starts in 0secs - send only if info_notifications_enabled is true
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
timezone.datetime(2022, 5, 2, 12, 5, 0),
0,
),
# shift starts in 0secs - don't send
(
False,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 5, 0),
None,
),
@ -338,7 +332,7 @@ def test_get_fcm_message(
(
True,
timezone.datetime(2022, 5, 2, 12, 5, 0),
ONE_HOUR_IN_SECONDS,
[ONE_HOUR_IN_SECONDS],
timezone.datetime(2022, 5, 2, 12, 4, 55),
None,
),

View file

@ -33,20 +33,42 @@ def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token
"important_notification_volume_override": True,
"important_notification_override_dnd": True,
"info_notifications_enabled": False,
"going_oncall_notification_timing": 43200,
"going_oncall_notification_timing": [900],
"locale": None,
"time_zone": "UTC",
}
@pytest.mark.django_db
def test_user_settings_get_notification_timing_options(make_organization_and_user_with_mobile_app_auth_token):
_, _, auth_token = make_organization_and_user_with_mobile_app_auth_token()
client = APIClient()
url = reverse("mobile_app:notification_timing_options")
choices = [
{"value": item[0], "display_name": item[1]} for item in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES
]
response = client.get(url, HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_200_OK
# Check the default values are correct
assert response.json() == choices
@pytest.mark.django_db
@pytest.mark.parametrize(
"going_oncall_notification_timing,expected_status_code",
[
(43200, status.HTTP_200_OK),
(86400, status.HTTP_200_OK),
(604800, status.HTTP_200_OK),
(500, status.HTTP_400_BAD_REQUEST),
([MobileAppUserSettings.FIFTEEN_MINUTES_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.ONE_HOUR_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.SIX_HOURS_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.TWELVE_HOURS_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.ONE_DAY_IN_SECONDS], status.HTTP_200_OK),
([MobileAppUserSettings.ONE_DAY_IN_SECONDS, MobileAppUserSettings.ONE_HOUR_IN_SECONDS], status.HTTP_200_OK),
([123], status.HTTP_400_BAD_REQUEST),
([], status.HTTP_400_BAD_REQUEST),
],
)
def test_user_settings_put(

View file

@ -1,5 +1,5 @@
from apps.mobile_app.fcm_relay import FCMRelayView
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView, MobileAppUserSettingsAPIView
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView, MobileAppUserSettingsViewSet
from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
app_name = "mobile_app"
@ -10,7 +10,16 @@ router.register("fcm", FCMDeviceAuthorizedViewSet, basename="fcm")
urlpatterns = [
*router.urls,
optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"),
optional_slash_path("user_settings", MobileAppUserSettingsAPIView.as_view(), name="user_settings"),
optional_slash_path(
"user_settings/notification_timing_options",
MobileAppUserSettingsViewSet.as_view({"get": "notification_timing_options"}),
name="notification_timing_options",
),
optional_slash_path(
"user_settings",
MobileAppUserSettingsViewSet.as_view({"get": "retrieve", "put": "update", "patch": "partial_update"}),
name="user_settings",
),
]
urlpatterns += [

View file

@ -1,5 +1,5 @@
from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet as BaseFCMDeviceAuthorizedViewSet
from rest_framework import generics, status
from rest_framework import mixins, status, viewsets
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@ -54,7 +54,11 @@ class MobileAppAuthTokenAPIView(APIView):
return Response(status=status.HTTP_204_NO_CONTENT)
class MobileAppUserSettingsAPIView(generics.RetrieveUpdateAPIView):
class MobileAppUserSettingsViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
authentication_classes = (MobileAppAuthTokenAuthentication,)
permission_classes = (IsAuthenticated,)
serializer_class = MobileAppUserSettingsSerializer
@ -62,3 +66,9 @@ class MobileAppUserSettingsAPIView(generics.RetrieveUpdateAPIView):
def get_object(self):
mobile_app_settings, _ = MobileAppUserSettings.objects.get_or_create(user=self.request.user)
return mobile_app_settings
def notification_timing_options(self, request):
choices = [
{"value": item[0], "display_name": item[1]} for item in MobileAppUserSettings.NOTIFICATION_TIMING_CHOICES
]
return Response(choices)

View file

@ -75,8 +75,8 @@ class ScheduleBaseSerializer(serializers.ModelSerializer):
class FinalShiftQueryParamsSerializer(serializers.Serializer):
start_date = serializers.DateField(required=True)
end_date = serializers.DateField(required=True)
start_date = serializers.DateTimeField(required=True, input_formats=["%Y-%m-%dT%H:%M", "%Y-%m-%d"])
end_date = serializers.DateTimeField(required=True, input_formats=["%Y-%m-%dT%H:%M", "%Y-%m-%d"])
def validate(self, attrs):
if attrs["start_date"] > attrs["end_date"]:

View file

@ -876,7 +876,7 @@ def test_oncall_shifts_request_validation(
organization, _, token = make_organization_and_user_with_token()
web_schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
valid_date_msg = "Date has wrong format. Use one of these formats instead: YYYY-MM-DD."
valid_date_msg = "Datetime has wrong format. Use one of these formats instead: YYYY-MM-DDThh:mm, YYYY-MM-DD."
client = APIClient()
@ -917,6 +917,23 @@ def test_oncall_shifts_request_validation(
]
}
# datetime validation
# invalid request (doesnt match pattern YYYY-MM-DDThh:mm)
response = _make_request(web_schedule, "?start_date=2021-01-01 01:00")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert (
response.json()["start_date"][0]
== "Datetime has wrong format. Use one of these formats instead: YYYY-MM-DDThh:mm, YYYY-MM-DD."
)
# valid request both parameters using datetime
response = _make_request(web_schedule, "?start_date=2021-01-01T01:00&end_date=2021-01-02T01:00")
assert response.status_code == status.HTTP_200_OK
# valid request combination of date and datetime
response = _make_request(web_schedule, "?start_date=2021-01-01&end_date=2021-01-02T01:00")
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db
def test_oncall_shifts_export(
@ -958,7 +975,9 @@ def test_oncall_shifts_export(
client = APIClient()
url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key})
response = client.get(f"{url}?start_date=2023-01-01&end_date=2023-02-01", format="json", HTTP_AUTHORIZATION=token)
response = client.get(
f"{url}?start_date=2023-01-01T18:00&end_date=2023-02-01", format="json", HTTP_AUTHORIZATION=token
)
assert response.status_code == status.HTTP_200_OK
expected_on_call_times = {
@ -1018,7 +1037,9 @@ def test_oncall_shifts_export_from_ical_schedule(
client = APIClient()
url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key})
response = client.get(f"{url}?start_date=2023-07-01&end_date=2023-07-31", format="json", HTTP_AUTHORIZATION=token)
response = client.get(
f"{url}?start_date=2023-07-01T09:00&end_date=2023-07-31T21:00", format="json", HTTP_AUTHORIZATION=token
)
assert response.status_code == status.HTTP_200_OK
expected_on_call_times = {
@ -1055,7 +1076,9 @@ def test_oncall_shifts_export_from_api_schedule(
client = APIClient()
url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key})
response = client.get(f"{url}?start_date=2023-07-01&end_date=2023-07-31", format="json", HTTP_AUTHORIZATION=token)
response = client.get(
f"{url}?start_date=2023-07-01T09:00&end_date=2023-07-31T11:00", format="json", HTTP_AUTHORIZATION=token
)
assert response.status_code == status.HTTP_200_OK
expected_on_call_times = {
@ -1098,7 +1121,9 @@ def test_oncall_shifts_export_truncate_events(
# request shifts on a Tu (ie. 00:00 - 09:00)
url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key})
response = client.get(f"{url}?start_date=2023-01-03&end_date=2023-01-03", format="json", HTTP_AUTHORIZATION=token)
response = client.get(
f"{url}?start_date=2023-01-03&end_date=2023-01-03T09:00", format="json", HTTP_AUTHORIZATION=token
)
assert response.status_code == status.HTTP_200_OK
expected_on_call_times = {user1_public_primary_key: 9}

View file

@ -1,7 +1,5 @@
import datetime
import logging
import pytz
from django_filters import rest_framework as filters
from rest_framework import status
from rest_framework.decorators import action
@ -141,14 +139,8 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo
start_date = serializer.validated_data["start_date"]
end_date = serializer.validated_data["end_date"]
days_between_start_and_end = (end_date - start_date).days
datetime_start = datetime.datetime.combine(start_date, datetime.time.min, tzinfo=pytz.UTC)
datetime_end = datetime_start + datetime.timedelta(
days=days_between_start_and_end, hours=23, minutes=59, seconds=59
)
final_schedule_events: ScheduleEvents = schedule.final_events(datetime_start, datetime_end)
final_schedule_events: ScheduleEvents = schedule.final_events(start_date, end_date)
logger.info(
f"Exporting oncall shifts for schedule {pk} between dates {start_date} and {end_date}. {len(final_schedule_events)} shift events were found."
)
@ -159,8 +151,8 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo
"user_email": user["email"],
"user_username": user["display_name"],
# truncate shift start/end exceeding the requested period
"shift_start": event["start"] if event["start"] >= datetime_start else datetime_start,
"shift_end": event["end"] if event["end"] <= datetime_end else datetime_end,
"shift_start": event["start"] if event["start"] >= start_date else start_date,
"shift_end": event["end"] if event["end"] <= end_date else end_date,
}
for event in final_schedule_events
for user in event["users"]

View file

@ -1,10 +1,8 @@
import datetime
import re
import typing
from collections import defaultdict
from icalendar import Calendar, Event
from recurring_ical_events import UnfoldableCalendar, compare_greater, is_event, time_span_contains_event
from recurring_ical_events import UnfoldableCalendar, time_span_contains_event
from apps.schedules.constants import (
ICAL_DATETIME_END,
@ -21,94 +19,6 @@ from apps.schedules.ical_events.proxy.ical_proxy import IcalService
EXTRA_LOOKUP_DAYS = 16
class AmixrUnfoldableCalendar(UnfoldableCalendar):
"""
This is overridden recurring_ical_events.UnfoldableCalendar.
It is overridden because of bug when summary of recurring event stay the same after editing.
In recurring-ical-events==0.1.20b0 this problem is fixed, but all-day events without timezone lead to exception.
So i took part of code from 0.1.20b0 but leave 0.1.16b in requirements.
"""
class RepeatedEvent(UnfoldableCalendar.RepeatedEvent):
RE_DATETIME_VALUE = re.compile(r"\d+T\d+")
class Repetition(UnfoldableCalendar.RepeatedEvent.Repetition):
"""
A repetition of an event. Overridden version of
recurring_ical_events.UnfoldableCalendar.RepeatedEvent.Repetition. This is overridden to remove the 'RRULE'
param from ATTRIBUTES_TO_DELETE_ON_COPY, because the 'UNTIL' param must be stored in repetition events to
calculate its end date.
"""
ATTRIBUTES_TO_DELETE_ON_COPY = ["RDATE", "EXDATE"]
def create_rule_with_start(self, rule_string, start):
"""Override to handle issue with non-UTC UNTIL value including time information."""
try:
return super().create_rule_with_start(rule_string, start)
except ValueError:
# string: FREQ=WEEKLY;UNTIL=20191023T100000;BYDAY=TH;WKST=SU
# ValueError: RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware
# https://stackoverflow.com/a/49991809
rule_list = rule_string.split(";UNTIL=")
assert len(rule_list) == 2
date_end_index = rule_list[1].find(";")
if date_end_index == -1:
date_end_index = len(rule_list[1])
until_string = rule_list[1][:date_end_index]
if self.RE_DATETIME_VALUE.match(until_string):
rule_string = rule_list[0] + rule_list[1][date_end_index:] + ";UNTIL=" + until_string + "Z"
return super().create_rule_with_start(rule_string, self.start)
# otherwise, keep raising
raise
def between(self, start, stop):
"""Return events at a time between start (inclusive) and end (inclusive)"""
span_start = self.to_datetime(start)
span_stop = self.to_datetime(stop)
events = []
events_by_id = defaultdict(dict) # UID (str) : RECURRENCE-ID(datetime) : event (Event)
default_uid = object()
def add_event(event):
"""Add an event and check if it was edited."""
same_events = events_by_id[event.get("UID", default_uid)]
recurrence_id = event.get("RECURRENCE-ID", event["DTSTART"]).dt
# Start of code from 0.1.20b0
if isinstance(recurrence_id, datetime.datetime):
recurrence_id = recurrence_id.date()
other = same_events.get(recurrence_id, None)
if other:
event_recurrence_id = event.get("RECURRENCE-ID", None)
other_recurrence_id = other.get("RECURRENCE-ID", None)
if event_recurrence_id is not None and other_recurrence_id is None:
events.remove(other)
elif event_recurrence_id is None and other_recurrence_id is not None:
return
else:
event_sequence = event.get("SEQUENCE", None)
other_sequence = other.get("SEQUENCE", None)
if event_sequence is not None and other_sequence is not None:
if event["SEQUENCE"] < other["SEQUENCE"]:
return
events.remove(other)
# End of code from 0.1.20b0
same_events[recurrence_id] = event
events.append(event)
for event in self.calendar.walk():
if not is_event(event):
continue
repetitions = self.RepeatedEvent(event, span_start)
for repetition in repetitions:
if compare_greater(repetition.start, span_stop) or compare_greater(repetition.start, repetition.stop):
# future repetitions could produce invalid events (because of the until rrule)
break
if repetition.is_in_span(span_start, span_stop):
add_event(repetition.as_vevent())
return events
class AmixrRecurringIcalEventsAdapter(IcalService):
def get_events_from_ical_between(
self, calendar: Calendar, start_date: datetime.datetime, end_date: datetime.datetime
@ -123,7 +33,7 @@ class AmixrRecurringIcalEventsAdapter(IcalService):
make one more pass for events array to filter out events which are between start_date and end_date.
EXTRA_LOOKUP_DAYS is empirical.
"""
events = AmixrUnfoldableCalendar(calendar).between(
events = UnfoldableCalendar(calendar).between(
start_date - datetime.timedelta(days=EXTRA_LOOKUP_DAYS),
end_date + datetime.timedelta(days=EXTRA_LOOKUP_DAYS),
)

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

@ -7,6 +7,7 @@ from calendar import monthrange
from uuid import uuid4
import pytz
import recurring_ical_events
from dateutil import relativedelta
from django.conf import settings
from django.core.validators import MinLengthValidator
@ -16,7 +17,6 @@ from django.forms.models import model_to_dict
from django.utils import timezone
from django.utils.functional import cached_property
from icalendar.cal import Event
from recurring_ical_events import UnfoldableCalendar
from apps.schedules.tasks import (
drop_cached_ical_task,
@ -488,13 +488,14 @@ class CustomOnCallShift(models.Model):
next_event_start = current_event_start
# Calculate the minimum start date for the next event based on rotation frequency. We don't need to do this
# for the first rotation, because in this case the min start date will be the same as the current event date.
DAYS_IN_A_WEEK = 7
DAYS_IN_A_MONTH = monthrange(current_event_start.year, current_event_start.month)[1]
if get_next_date:
if self.frequency == CustomOnCallShift.FREQUENCY_HOURLY:
next_event_start = current_event_start + datetime.timedelta(hours=ONE_HOUR)
elif self.frequency == CustomOnCallShift.FREQUENCY_DAILY:
next_event_start = current_event_start + datetime.timedelta(days=ONE_DAY)
elif self.frequency == CustomOnCallShift.FREQUENCY_WEEKLY:
DAYS_IN_A_WEEK = 7
# count days before the next week starts
days_for_next_event = DAYS_IN_A_WEEK - current_event_start.weekday() + self.week_start
if days_for_next_event > DAYS_IN_A_WEEK:
@ -504,7 +505,6 @@ class CustomOnCallShift(models.Model):
days=days_for_next_event + DAYS_IN_A_WEEK * (interval - 1)
)
elif self.frequency == CustomOnCallShift.FREQUENCY_MONTHLY:
DAYS_IN_A_MONTH = monthrange(current_event_start.year, current_event_start.month)[1]
# count days before the next month starts
days_for_next_event = DAYS_IN_A_MONTH - current_event_start.day + ONE_DAY
# count next event start date with respect to event interval
@ -533,10 +533,12 @@ class CustomOnCallShift(models.Model):
next_event = None
# repetitions generate the next event shift according with the recurrence rules
repetitions = UnfoldableCalendar(current_event).RepeatedEvent(
current_event, next_event_start.replace(microsecond=0)
)
for event in repetitions.__iter__():
repeated_event = recurring_ical_events.RepeatedEvent(current_event)
max_date_range = next_event_start + datetime.timedelta(days=DAYS_IN_A_MONTH)
if end_date:
max_date_range = max(end_date, max_date_range)
repetitions = repeated_event.within_days(next_event_start.replace(microsecond=0), max_date_range)
for event in repetitions:
if end_date: # end_date exists for long events with frequency weekly and monthly
if end_date >= event.start >= next_event_start:
if (
@ -572,10 +574,9 @@ class CustomOnCallShift(models.Model):
last_event = None
# repetitions generate the next event shift according with the recurrence rules
repetitions = UnfoldableCalendar(initial_event).RepeatedEvent(
initial_event, initial_event_start.replace(microsecond=0)
)
for event in repetitions.__iter__():
repeated_event = recurring_ical_events.RepeatedEvent(initial_event)
repetitions = repeated_event.within_days(initial_event_start, date)
for event in repetitions:
if event.start > date:
break
last_event = event

View file

@ -349,18 +349,22 @@ class OnCallSchedule(PolymorphicModel):
include_shift_info: bool = False,
) -> ScheduleEvents:
"""Return filtered events from schedule."""
shifts = (
list_of_oncall_shifts_from_ical(
self,
datetime_start,
datetime_end,
with_empty,
with_gap,
filter_by=filter_by,
from_cached_final=from_cached_final,
try:
shifts = (
list_of_oncall_shifts_from_ical(
self,
datetime_start,
datetime_end,
with_empty,
with_gap,
filter_by=filter_by,
from_cached_final=from_cached_final,
)
or []
)
or []
)
except ValueError:
# raised when filtering events on a non-saved/deleted schedule
return []
shifts_data = {}
if include_shift_info:
pks = set(shift["shift_pk"] for shift in shifts)

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
@ -1749,7 +1749,7 @@ def test_week_start_changed_daily_shift(
on_call_shift.add_rolling_users(rolling_users)
ical_data = on_call_shift.convert_to_ical()
expected_start = "DTSTART;VALUE=DATE-TIME:{}T000000Z".format(last_sunday.strftime("%Y%m%d"))
expected_start = "DTSTART:{}T000000Z".format(last_sunday.strftime("%Y%m%d"))
assert expected_start in ical_data

View file

@ -230,7 +230,7 @@ class InviteOtherPersonToIncident(AlertGroupActionsMixin, scenario_step.Scenario
Check out apps/slack/scenarios/manage_responders.py for the new version that uses direct paging.
"""
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -266,7 +266,7 @@ class InviteOtherPersonToIncident(AlertGroupActionsMixin, scenario_step.Scenario
class SilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -293,7 +293,7 @@ class SilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
class UnSilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -313,7 +313,7 @@ class UnSilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -509,7 +509,7 @@ class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -534,7 +534,7 @@ class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep):
Check out apps/slack/scenarios/manage_responders.py for the new version that uses direct paging.
"""
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -561,7 +561,7 @@ class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep):
class CustomButtonProcessStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -624,7 +624,7 @@ class CustomButtonProcessStep(AlertGroupActionsMixin, scenario_step.ScenarioStep
class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -665,7 +665,7 @@ class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
class UnResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -685,7 +685,7 @@ class UnResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
class AcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,
@ -705,7 +705,7 @@ class AcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
class UnAcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
def process_scenario(
self,

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

@ -2,7 +2,7 @@ import logging
from django.conf import settings
from django.shortcuts import resolve_url
from django.utils.encoding import force_text
from django.utils.encoding import force_str
from django.utils.functional import Promise
from social_django.strategy import DjangoStrategy
@ -29,7 +29,7 @@ class LiveSettingDjangoStrategy(DjangoStrategy):
# Force text on URL named settings that are instance of Promise
if name.endswith("_URL"):
if isinstance(value, Promise):
value = force_text(value)
value = force_str(value)
value = resolve_url(value)
return value

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']"
@ -307,7 +308,7 @@ class User(models.Model):
self._timezone = value
def is_in_working_hours(self, dt: datetime.datetime, tz: typing.Optional[str] = None) -> bool:
assert dt.tzinfo == pytz.utc, "dt must be in UTC"
assert dt.tzinfo == datetime.timezone.utc, "dt must be in UTC"
# Default to user's timezone
if not tz:

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

@ -0,0 +1,9 @@
from django.urls import URLPattern, URLResolver, include, path
from .urls import paths_to_work_even_when_maintenance_mode_is_active
urlpatterns: list[URLPattern | URLResolver] = paths_to_work_even_when_maintenance_mode_is_active
urlpatterns += [
path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")),
]

View file

@ -15,20 +15,24 @@ Including another URLconf
"""
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path
from django.urls import URLPattern, URLResolver, include, path
from .views import HealthCheckView, MaintenanceModeStatusView, ReadinessCheckView, StartupProbeView
paths_to_work_even_when_maintenance_mode_is_active = [
paths_to_work_even_when_maintenance_mode_is_active: list[URLPattern | URLResolver] = [
path("", HealthCheckView.as_view()),
path("health/", HealthCheckView.as_view()),
path("ready/", ReadinessCheckView.as_view()),
path("startupprobe/", StartupProbeView.as_view()),
path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")),
path("api/internal/v1/maintenance-mode-status", MaintenanceModeStatusView.as_view()),
]
urlpatterns = [
if not settings.DETACHED_INTEGRATIONS_SERVER:
paths_to_work_even_when_maintenance_mode_is_active += [
path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")),
]
urlpatterns: list[URLPattern | URLResolver] = [
*paths_to_work_even_when_maintenance_mode_is_active,
path("api/gi/v1/", include("apps.api_for_grafana_incident.urls", namespace="api-gi")),
path("api/internal/v1/", include("apps.api.urls", namespace="api-internal")),

View file

@ -1,5 +1,5 @@
django==3.2.20
djangorestframework==3.12.4
django==4.2.6
djangorestframework==3.14.0
slack_sdk==3.21.3
whitenoise==5.3.0
twilio~=6.37.0
@ -22,20 +22,20 @@ git+https://github.com/grafana/django-redis-cache.git@bump-redis-version-to-v4.6
hiredis==1.0.0
django-ratelimit==2.0.0
django-filter==2.4.0
icalendar==4.0.7
recurring-ical-events==0.1.16b0
icalendar==5.0.10
recurring-ical-events==2.1.0
slack-export-viewer==1.1.4
beautifulsoup4==4.12.2
social-auth-app-django==5.0.0
social-auth-app-django==5.3.0
cryptography==38.0.4 # version 39.0.0 introduced an issue - https://stackoverflow.com/a/75053968/3902555
factory-boy<3.0
django-log-request-id==1.6.0
django-polymorphic==3.0.0
django-rest-polymorphic==0.1.9
django-polymorphic==3.1.0
django-rest-polymorphic==0.1.10
https://github.com/grafana/fcm-django/archive/refs/tags/v1.0.12r1.tar.gz
django-mirage-field==1.3.0
django-mysql==4.6.0
PyMySQL==1.0.2
PyMySQL==1.1.0
psycopg2==2.9.3
emoji==2.4.0
regex==2021.11.2

View file

@ -373,7 +373,7 @@ LOGGING = {
},
}
ROOT_URLCONF = "engine.urls"
ROOT_URLCONF = os.environ.get("ROOT_URLCONF", "engine.urls")
TEMPLATES = [
{
@ -831,3 +831,5 @@ ZVONOK_POSTBACK_CAMPAIGN_ID = os.getenv("ZVONOK_POSTBACK_CAMPAIGN_ID", "campaign
ZVONOK_POSTBACK_STATUS = os.getenv("ZVONOK_POSTBACK_STATUS", "status")
ZVONOK_POSTBACK_USER_CHOICE = os.getenv("ZVONOK_POSTBACK_USER_CHOICE", None)
ZVONOK_POSTBACK_USER_CHOICE_ACK = os.getenv("ZVONOK_POSTBACK_USER_CHOICE_ACK", None)
DETACHED_INTEGRATIONS_SERVER = getenv_boolean("DETACHED_INTEGRATIONS_SERVER", default=False)

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>