diff --git a/engine/apps/alerts/paging.py b/engine/apps/alerts/paging.py index 42a996f7..d8240940 100644 --- a/engine/apps/alerts/paging.py +++ b/engine/apps/alerts/paging.py @@ -197,28 +197,3 @@ def unpage_user(alert_group: AlertGroup, user: User, from_user: User) -> None: def user_is_oncall(user: User) -> bool: schedules_with_oncall_users = get_oncall_users_for_multiple_schedules(OnCallSchedule.objects.related_to_user(user)) return user.pk in {user.pk for _, users in schedules_with_oncall_users.items() for user in users} - - -def integration_is_notifiable(integration: AlertReceiveChannel) -> bool: - """ - Returns true if: - - the integration has more than one channel filter associated with it - - the default channel filter has at least one notification method specified or an escalation chain associated with it - """ - if integration.channel_filters.count() > 1: - return True - - default_channel_filter = integration.default_channel_filter - if not default_channel_filter: - return False - - organization = integration.organization - notify_via_slack = organization.slack_is_configured and default_channel_filter.notify_in_slack - notify_via_telegram = organization.telegram_is_configured and default_channel_filter.notify_in_telegram - - notify_via_chatops = notify_via_slack or notify_via_telegram - custom_messaging_backend_configured = default_channel_filter.notification_backends is not None - - return ( - default_channel_filter.escalation_chain is not None or notify_via_chatops or custom_messaging_backend_configured - ) diff --git a/engine/apps/alerts/tests/test_paging.py b/engine/apps/alerts/tests/test_paging.py index 96d5b746..2c0ae9f8 100644 --- a/engine/apps/alerts/tests/test_paging.py +++ b/engine/apps/alerts/tests/test_paging.py @@ -8,7 +8,6 @@ from apps.alerts.paging import ( DirectPagingUserTeamValidationError, _construct_title, direct_paging, - integration_is_notifiable, unpage_user, user_is_oncall, ) @@ -292,67 +291,3 @@ def test_construct_title(make_organization, make_team, make_user_for_organizatio assert _construct_title(from_user, team, multiple_users) == _title( f"{team.name}, {user1.username}, {user2.username} and {user3.username}" ) - - -@pytest.mark.django_db -def test_integration_is_notifiable( - make_organization, - make_alert_receive_channel, - make_channel_filter, - make_escalation_chain, - make_slack_team_identity, - make_telegram_channel, -): - organization = make_organization() - - # integration has no default channel filter - arc = make_alert_receive_channel(organization) - make_channel_filter(arc, is_default=False) - assert integration_is_notifiable(arc) is False - - # integration has more than one channel filter - arc = make_alert_receive_channel(organization) - make_channel_filter(arc, is_default=False) - make_channel_filter(arc, is_default=False) - assert integration_is_notifiable(arc) is True - - # integration's default channel filter is setup to notify via slack but Slack is not configured for the org - arc = make_alert_receive_channel(organization) - make_channel_filter(arc, is_default=True, notify_in_slack=True) - assert integration_is_notifiable(arc) is False - - # integration's default channel filter is setup to notify via slack and Slack is configured for the org - arc = make_alert_receive_channel(organization) - slack_team_identity = make_slack_team_identity() - organization.slack_team_identity = slack_team_identity - organization.save() - - make_channel_filter(arc, is_default=True, notify_in_slack=True) - assert integration_is_notifiable(arc) is True - - # integration's default channel filter is setup to notify via telegram but Telegram is not configured for the org - arc = make_alert_receive_channel(organization) - make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True) - assert integration_is_notifiable(arc) is False - - # integration's default channel filter is setup to notify via telegram and Telegram is configured for the org - arc = make_alert_receive_channel(organization) - make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True) - make_telegram_channel(organization) - assert integration_is_notifiable(arc) is True - - # integration's default channel filter is contactable via a custom messaging backend - arc = make_alert_receive_channel(organization) - make_channel_filter( - arc, - is_default=True, - notify_in_slack=False, - notification_backends={"MSTEAMS": {"channel": "test", "enabled": True}}, - ) - assert integration_is_notifiable(arc) is True - - # integration's default channel filter has an escalation chain attached to it - arc = make_alert_receive_channel(organization) - escalation_chain = make_escalation_chain(organization) - make_channel_filter(arc, is_default=True, notify_in_slack=False, escalation_chain=escalation_chain) - assert integration_is_notifiable(arc) is True diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index a0332dc7..2fd9fd91 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -270,14 +270,19 @@ class UserShortSerializer(serializers.ModelSerializer): ] -class UserLongSerializer(UserSerializer): +class UserIsCurrentlyOnCallSerializer(UserShortSerializer, EagerLoadingMixin): context: UserSerializerContext teams = FastTeamSerializer(read_only=True, many=True) is_currently_oncall = serializers.SerializerMethodField() - class Meta(UserSerializer.Meta): - fields = UserSerializer.Meta.fields + [ + SELECT_RELATED = ["organization"] + PREFETCH_RELATED = ["teams"] + + class Meta(UserShortSerializer.Meta): + fields = UserShortSerializer.Meta.fields + [ + "name", + "timezone", "teams", "is_currently_oncall", ] diff --git a/engine/apps/api/tests/test_team.py b/engine/apps/api/tests/test_team.py index 69a8afbe..f66b4436 100644 --- a/engine/apps/api/tests/test_team.py +++ b/engine/apps/api/tests/test_team.py @@ -28,6 +28,25 @@ def get_payload_from_team(team, long=False): return payload +@pytest.mark.django_db +def test_get_team(make_organization_and_user_with_plugin_token, make_team, make_user_auth_headers): + organization, user, token = make_organization_and_user_with_plugin_token() + team = make_team(organization) + + client = APIClient() + + # team exists + url = reverse("api-internal:team-detail", kwargs={"pk": team.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == get_payload_from_team(team) + + # 404 scenario + url = reverse("api-internal:team-detail", kwargs={"pk": "asdfasdflkjlkajsdf"}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db def test_list_teams( make_organization, @@ -100,7 +119,20 @@ def test_list_teams_only_include_notifiable_teams( 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): + def mock_get_notifiable_direct_paging_integrations(): + class MockRelatedManager: + def filter(self, *args, **kwargs): + return self + + def values_list(self, *args, **kwargs): + return [arc1.team.pk] + + return MockRelatedManager() + + with patch( + "apps.user_management.models.Organization.get_notifiable_direct_paging_integrations", + side_effect=mock_get_notifiable_direct_paging_integrations, + ): response = client.get( f"{url}?only_include_notifiable_teams=true&include_no_team=false", format="json", diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index e918762c..8c15db34 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -1935,7 +1935,7 @@ def test_users_is_currently_oncall_attribute_works_properly( schedule.refresh_ical_final_schedule() client = APIClient() - url = f"{reverse('api-internal:user-list')}?short=false" + url = f"{reverse('api-internal:user-list')}?is_currently_oncall=all" response = client.get(url, format="json", **make_user_auth_headers(user1, token)) oncall_statuses = { diff --git a/engine/apps/api/views/team.py b/engine/apps/api/views/team.py index 47c10e11..72c10770 100644 --- a/engine/apps/api/views/team.py +++ b/engine/apps/api/views/team.py @@ -4,7 +4,6 @@ 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 TeamLongSerializer, TeamSerializer from apps.auth_token.auth import PluginAuthentication @@ -14,7 +13,13 @@ from apps.user_management.models import Team from common.api_helpers.mixins import PublicPrimaryKeyMixin -class TeamViewSet(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet): +class TeamViewSet( + PublicPrimaryKeyMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): authentication_classes = ( MobileAppAuthTokenAuthentication, PluginAuthentication, @@ -61,14 +66,11 @@ class TeamViewSet(PublicPrimaryKeyMixin, mixins.ListModelMixin, mixins.UpdateMod 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) + queryset = queryset.filter( + pk__in=self.request.user.organization.get_notifiable_direct_paging_integrations() + .filter(team__isnull=False) + .values_list("team__pk", flat=True) + ) queryset = queryset.order_by("name") diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index c6a51fc7..06076e44 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -29,7 +29,7 @@ from apps.api.serializers.user import ( CurrentUserSerializer, FilterUserSerializer, UserHiddenFieldsSerializer, - UserLongSerializer, + UserIsCurrentlyOnCallSerializer, UserSerializer, ) from apps.api.throttlers import ( @@ -238,20 +238,14 @@ class UserView( return self.request.query_params.get("is_currently_oncall", "").lower() def _is_currently_oncall_request(self) -> bool: - return self._get_is_currently_oncall_query_param() in ["true", "false"] - - def _is_long_request(self) -> bool: - return self.request.query_params.get("short", "true").lower() == "false" - - def _is_currently_oncall_or_long_request(self) -> bool: - return self._is_currently_oncall_request() or self._is_long_request() + return self._get_is_currently_oncall_query_param() in ["true", "false", "all"] def get_serializer_context(self): context = super().get_serializer_context() context.update( { "schedules_with_oncall_users": self.schedules_with_oncall_users - if self._is_currently_oncall_or_long_request() + if self._is_currently_oncall_request() else {} } ) @@ -268,8 +262,8 @@ class UserView( if is_list_request and is_filters_request: return self.get_filter_serializer_class() - elif is_list_request and self._is_currently_oncall_or_long_request(): - return UserLongSerializer + elif is_list_request and self._is_currently_oncall_request(): + return UserIsCurrentlyOnCallSerializer 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]) @@ -296,8 +290,7 @@ class UserView( def _get_oncall_user_ids(): return {user.pk for _, users in self.schedules_with_oncall_users.items() for user in users} - is_currently_oncall_query_param = self._get_is_currently_oncall_query_param() - if is_currently_oncall_query_param == "true": + if (is_currently_oncall_query_param := self._get_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": diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index dd40ac62..9bd54498 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -534,26 +534,54 @@ def _get_team_select_blocks( slack_user_identity: "SlackUserIdentity", organization: "Organization", is_selected: bool, - value: "Team", + value: typing.Optional["Team"], input_id_prefix: str, ) -> Block.AnyBlocks: + blocks: Block.AnyBlocks = [] user = slack_user_identity.get_user(organization) # TODO: handle None - teams = user.available_teams + teams = ( + user.organization.get_notifiable_direct_paging_integrations() + .filter(team__isnull=False) + .values_list("team__pk", "team__name") + ) + + direct_paging_info_msg = { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ( + "*Note*: You can only page teams which have a Direct Paging integration that is configured. " + "" + ), + }, + ], + } + + if not teams: + direct_paging_info_msg["elements"][0][ + "text" + ] += ". There are currently no teams which have a Direct Paging integration that is configured." + blocks.append(direct_paging_info_msg) + return blocks team_options: typing.List[CompositionObjectOption] = [] initial_option_idx = 0 for idx, team in enumerate(teams): - if team == value: + team_pk, team_name = team + team_pk_str = str(team_pk) + + if value == team_pk_str: initial_option_idx = idx team_options.append( { "text": { "type": "plain_text", - "text": f"{team.name}", + "text": team_name, "emoji": True, }, - "value": f"{team.pk}", + "value": team_pk_str, } ) @@ -574,10 +602,11 @@ def _get_team_select_blocks( "optional": True, } - blocks: Block.AnyBlocks = [team_select] + blocks.append(team_select) # No context block if no team selected if not is_selected: + blocks.append(direct_paging_info_msg) return blocks team_select["element"]["initial_option"] = team_options[initial_option_idx] diff --git a/engine/apps/slack/tests/test_scenario_steps/test_paging.py b/engine/apps/slack/tests/test_scenario_steps/test_paging.py index 91e6cf50..2821b8e6 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_paging.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_paging.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from django.utils import timezone +from apps.alerts.models import AlertReceiveChannel from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb from apps.slack.scenarios.paging import ( DIRECT_PAGING_MESSAGE_INPUT_ID, @@ -19,6 +20,7 @@ from apps.slack.scenarios.paging import ( Policy, StartDirectPaging, _get_organization_select, + _get_team_select_blocks, ) from apps.user_management.models import Organization @@ -243,6 +245,20 @@ def test_trigger_paging_additional_responders(make_organization_and_user_with_sl mock_direct_paging.called_once_with(organization, user, "The Message", team, [(user, True)]) +@pytest.mark.django_db +def test_page_team(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) + + 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) + + mock_direct_paging.called_once_with(organization, user, "The Message", team) + + @pytest.mark.django_db def test_get_organization_select(make_organization): organization = make_organization(org_title="Organization", stack_slug="stack_slug") @@ -251,3 +267,75 @@ def test_get_organization_select(make_organization): assert len(select["element"]["options"]) == 1 assert select["element"]["options"][0]["value"] == str(organization.pk) assert select["element"]["options"][0]["text"]["text"] == "Organization (stack_slug)" + + +@pytest.mark.django_db +def test_get_team_select_blocks( + make_organization_and_user_with_slack_identities, + make_team, + make_alert_receive_channel, + make_escalation_chain, + make_channel_filter, +): + info_msg = ( + "*Note*: You can only page teams which have a Direct Paging integration that is configured. " + "" + ) + + input_id_prefix = "nmxcnvmnxv" + + # no team selected - no team direct paging integrations available + organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities() + blocks = _get_team_select_blocks(slack_user_identity, organization, False, None, input_id_prefix) + + assert len(blocks) == 1 + + context_block = blocks[0] + assert context_block["type"] == "context" + assert ( + context_block["elements"][0]["text"] + == info_msg + ". There are currently no teams which have a Direct Paging integration that is configured." + ) + + # no team selected - 1 team direct paging integration available + organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities() + team = make_team(organization) + arc = make_alert_receive_channel(organization, team=team, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) + escalation_chain = make_escalation_chain(organization) + make_channel_filter(arc, is_default=True, escalation_chain=escalation_chain) + + blocks = _get_team_select_blocks(slack_user_identity, organization, False, None, input_id_prefix) + + assert len(blocks) == 2 + input_block, context_block = blocks + + team_option = {"text": {"emoji": True, "text": team.name, "type": "plain_text"}, "value": str(team.pk)} + + assert input_block["type"] == "input" + assert len(input_block["element"]["options"]) == 1 + assert input_block["element"]["options"] == [team_option] + assert context_block["elements"][0]["text"] == info_msg + + # team selected + organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities() + team = make_team(organization) + arc = make_alert_receive_channel(organization, team=team, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) + escalation_chain = make_escalation_chain(organization) + make_channel_filter(arc, is_default=True, escalation_chain=escalation_chain) + + blocks = _get_team_select_blocks(slack_user_identity, organization, True, team.pk, input_id_prefix) + + assert len(blocks) == 2 + input_block, context_block = blocks + + team_option = {"text": {"emoji": True, "text": team.name, "type": "plain_text"}, "value": str(team.pk)} + + assert input_block["type"] == "input" + assert len(input_block["element"]["options"]) == 1 + assert input_block["element"]["options"] == [team_option] + assert input_block["element"]["initial_option"] == team_option + + assert ( + context_block["elements"][0]["text"] + == f"Integration <{arc.web_link}|{arc.verbal_name}> will be used for notification." + ) diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 5a74531a..70fcf8e0 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -6,6 +6,7 @@ from urllib.parse import urljoin from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models +from django.db.models import Count, Q from django.utils import timezone from mirage import fields as mirage_fields @@ -298,10 +299,32 @@ class Organization(MaintainableObject): new_channel=channel_name, ) - def get_direct_paging_integrations(self) -> "RelatedManager['AlertReceiveChannel']": + def get_notifiable_direct_paging_integrations(self) -> "RelatedManager['AlertReceiveChannel']": + """ + in layman's terms, this filters down an organization's integrations to ones which meet the following criterias: + - the integration is a direct paging integration + + AND at-least one of the following conditions are true for the integration: + - have more than one channel filter associated with it + - OR the organization has either Slack or Telegram configured (as the direct paging integration + would automatically be configured to be notified via these channel(s)) + - OR the default channel filter associated with the integration has an escalation chain associated with it + - OR the default channel filter associated with the integration is contactable via a custom + messaging backend + """ from apps.alerts.models import AlertReceiveChannel - return self.alert_receive_channels.filter(integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING) + return self.alert_receive_channels.annotate( + num_channel_filters=Count("channel_filters"), + # used to determine if the organization has telegram configured + num_org_telegram_channels=Count("organization__telegram_channel"), + ).filter( + Q(num_channel_filters__gt=1) + | (Q(organization__slack_team_identity__isnull=False) | Q(num_org_telegram_channels__gt=0)) + | Q(channel_filters__is_default=True, channel_filters__escalation_chain__isnull=False) + | Q(channel_filters__is_default=True, channel_filters__notification_backends__isnull=False), + integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, + ) @property def web_link(self): @@ -312,14 +335,6 @@ class Organization(MaintainableObject): # It's a workaround to pass some unique identifier to the oncall gateway while proxying telegram requests return urljoin(self.grafana_url, f"a/grafana-oncall-app/?oncall-uuid={self.uuid}") - @property - def slack_is_configured(self) -> bool: - return self.slack_team_identity is not None - - @property - def telegram_is_configured(self) -> bool: - return self.telegram_channel.count() > 0 - @classmethod def __str__(self): return f"{self.pk}: {self.org_title}" diff --git a/engine/apps/user_management/tests/test_organization.py b/engine/apps/user_management/tests/test_organization.py index 692eae90..91915500 100644 --- a/engine/apps/user_management/tests/test_organization.py +++ b/engine/apps/user_management/tests/test_organization.py @@ -198,47 +198,74 @@ def test_organization_hard_delete( @pytest.mark.django_db -def test_slack_is_configured(make_organization, make_slack_team_identity): - organization = make_organization() +def test_get_notifiable_direct_paging_integrations( + make_organization, + make_alert_receive_channel, + make_channel_filter, + make_escalation_chain, + make_slack_team_identity, + make_telegram_channel, +): + def _make_org_and_arc(**arc_kwargs): + org = make_organization() + arc = make_alert_receive_channel(org, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, **arc_kwargs) + return org, arc - assert organization.slack_is_configured is False + def _assert(org, arc, should_be_returned=True): + notifiable_direct_paging_integrations = org.get_notifiable_direct_paging_integrations() + if should_be_returned: + assert arc in notifiable_direct_paging_integrations + else: + assert arc not in notifiable_direct_paging_integrations + + # integration has no default channel filter + org, arc = _make_org_and_arc() + make_channel_filter(arc, is_default=False) + _assert(org, arc, should_be_returned=False) + + # integration has more than one channel filter + org, arc = _make_org_and_arc() + make_channel_filter(arc, is_default=False) + make_channel_filter(arc, is_default=False) + _assert(org, arc) + + # integration's default channel filter is setup to notify via slack but Slack is not configured for the org + org, arc = _make_org_and_arc() + make_channel_filter(arc, is_default=True, notify_in_slack=True) + _assert(org, arc, should_be_returned=False) + + # integration's default channel filter is setup to notify via slack and Slack is configured for the org + org, arc = _make_org_and_arc() slack_team_identity = make_slack_team_identity() - organization.slack_team_identity = slack_team_identity - organization.save() - assert organization.slack_is_configured is True + org.slack_team_identity = slack_team_identity + org.save() + make_channel_filter(arc, is_default=True, notify_in_slack=True) + _assert(org, arc) -@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 + # integration's default channel filter is setup to notify via telegram but Telegram is not configured for the org + org, arc = _make_org_and_arc() + make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True) + _assert(org, arc, should_be_returned=False) + # integration's default channel filter is setup to notify via telegram and Telegram is configured for the org + org, arc = _make_org_and_arc() + make_channel_filter(arc, is_default=True, notify_in_slack=False, notify_in_telegram=True) + make_telegram_channel(org) + _assert(org, arc) -@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 + # integration's default channel filter is contactable via a custom messaging backend + org, arc = _make_org_and_arc() + make_channel_filter( + arc, + is_default=True, + notify_in_slack=False, + notification_backends={"MSTEAMS": {"channel": "test", "enabled": True}}, ) + _assert(org, arc) - 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 + # integration's default channel filter has an escalation chain attached to it + org, arc = _make_org_and_arc() + escalation_chain = make_escalation_chain(org) + make_channel_filter(arc, is_default=True, notify_in_slack=False, escalation_chain=escalation_chain) + _assert(org, arc) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index c86a9a74..ff2d1c6d 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -15,6 +15,7 @@ Including another URLconf """ from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin from django.urls import URLPattern, URLResolver, include, path from .views import HealthCheckView, MaintenanceModeStatusView, ReadinessCheckView, StartupProbeView @@ -76,7 +77,11 @@ if settings.DEBUG: ] + urlpatterns if settings.SILK_PROFILER_ENABLED: - urlpatterns += [path(settings.SILK_PATH, include("silk.urls", namespace="silk"))] + urlpatterns += [ + # need django admin enabled to be able to access silk + path(settings.ONCALL_DJANGO_ADMIN_PATH, admin.site.urls), + path(settings.SILK_PATH, include("silk.urls", namespace="silk")), + ] if settings.DRF_SPECTACULAR_ENABLED: from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView diff --git a/engine/settings/base.py b/engine/settings/base.py index 100ff125..cd58cb26 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -11,7 +11,6 @@ from common.utils import getenv_boolean, getenv_integer, getenv_list VERSION = "dev-oss" SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True) -ADMIN_ENABLED = False # disable django admin panel # License is OpenSource or Cloud OPEN_SOURCE_LICENSE_NAME = "OpenSource" @@ -590,6 +589,10 @@ SELF_IP = os.environ.get("SELF_IP") SILK_PROFILER_ENABLED = getenv_boolean("SILK_PROFILER_ENABLED", default=False) and not IS_IN_MAINTENANCE_MODE +# django admin panel is required to auth with django silk. Otherwise if silk isn't enabled, we don't need it. +ONCALL_DJANGO_ADMIN_PATH = os.environ.get("ONCALL_DJANGO_ADMIN_PATH", "django-admin") + "/" +ADMIN_ENABLED = SILK_PROFILER_ENABLED + if SILK_PROFILER_ENABLED: SILK_PATH = os.environ.get("SILK_PATH", "silk/") SILKY_INTERCEPT_PERCENT = getenv_integer("SILKY_INTERCEPT_PERCENT", 100) diff --git a/grafana-plugin/src/containers/AddResponders/AddResponders.tsx b/grafana-plugin/src/containers/AddResponders/AddResponders.tsx index b7249d4e..515bbe7a 100644 --- a/grafana-plugin/src/containers/AddResponders/AddResponders.tsx +++ b/grafana-plugin/src/containers/AddResponders/AddResponders.tsx @@ -11,7 +11,7 @@ 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 { UserCurrentlyOnCall } from 'models/user/user.types'; import { useStore } from 'state/useStore'; import { UserActions } from 'utils/authorization'; @@ -62,7 +62,7 @@ const AddResponders = observer( const currentMoment = useMemo(() => dayjs(), []); const isCreateMode = mode === 'create'; - const [currentlyConsideredUser, setCurrentlyConsideredUser] = useState(null); + const [currentlyConsideredUser, setCurrentlyConsideredUser] = useState(null); const [currentlyConsideredUserNotificationPolicy, setCurrentlyConsideredUserNotificationPolicy] = useState(NotificationPolicyValue.Default); @@ -141,7 +141,7 @@ const AddResponders = observer( disableNotificationPolicySelect handleDelete={generateRemovePreviouslyPagedUserCallback(user.pk)} important={user.important} - data={user as unknown as User} + data={user as unknown as UserCurrentlyOnCall} /> ))} {selectedUserResponders.map((responder, index) => ( diff --git a/grafana-plugin/src/containers/AddResponders/AddResponders.types.ts b/grafana-plugin/src/containers/AddResponders/AddResponders.types.ts index a53fbf5d..9fe481cf 100644 --- a/grafana-plugin/src/containers/AddResponders/AddResponders.types.ts +++ b/grafana-plugin/src/containers/AddResponders/AddResponders.types.ts @@ -1,7 +1,7 @@ import { SelectableValue } from '@grafana/data'; import { ActionMeta } from '@grafana/ui'; -import { User } from 'models/user/user.types'; +import { UserCurrentlyOnCall } from 'models/user/user.types'; export enum NotificationPolicyValue { Default = 0, @@ -9,7 +9,7 @@ export enum NotificationPolicyValue { } export type UserResponder = { - data: User; + data: UserCurrentlyOnCall; important: boolean; }; export type UserResponders = UserResponder[]; diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss index fbbd2163..7b5f57ac 100644 --- a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.module.scss @@ -30,6 +30,10 @@ margin: 8px; } +.loading-placeholder { + margin: 8px; +} + .table { max-height: 150px; overflow: auto; diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.test.tsx b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.test.tsx index af0ec1c9..37128060 100644 --- a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.test.tsx +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.test.tsx @@ -21,7 +21,7 @@ describe('AddRespondersPopup', () => { }, ]; - test('it renders teams properly', () => { + test('it shows a loading message initially', () => { const mockStoreValue = { directPagingStore: { selectedTeamResponder: null, @@ -30,36 +30,7 @@ describe('AddRespondersPopup', () => { getSearchResult: jest.fn().mockReturnValue(teams), }, userStore: { - getSearchResult: jest.fn().mockReturnValue({ results: [] }), - }, - }; - - const component = render( - - - - ); - - expect(component.container).toMatchSnapshot(); - }); - - test('if a team is selected it shows an info alert', () => { - const mockStoreValue = { - directPagingStore: { - selectedTeamResponder: teams[0], - selectedUserResponders: [], - }, - grafanaTeamStore: { - getSearchResult: jest.fn().mockReturnValue(teams), - }, - userStore: { - getSearchResult: jest.fn().mockReturnValue({ results: [] }), + search: jest.fn().mockReturnValue({ results: [] }), }, }; diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx index 9be2456d..7a500076 100644 --- a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/AddRespondersPopup.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useEffect, useRef, FC } from 'react'; -import { Alert, HorizontalGroup, Icon, Input, RadioButtonGroup } from '@grafana/ui'; +import { Alert, HorizontalGroup, Icon, Input, LoadingPlaceholder, RadioButtonGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import { ColumnsType } from 'rc-table/lib/interface'; @@ -10,7 +10,7 @@ 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 { UserCurrentlyOnCall } from 'models/user/user.types'; import { useStore } from 'state/useStore'; import { useDebouncedCallback, useOnClickOutside } from 'utils/hooks'; @@ -21,7 +21,7 @@ type Props = { visible: boolean; setVisible: (value: boolean) => void; - setCurrentlyConsideredUser: (user: User) => void; + setCurrentlyConsideredUser: (user: UserCurrentlyOnCall) => void; setShowUserConfirmationModal: (value: boolean) => void; existingPagedUsers?: AlertType['paged_users']; @@ -34,13 +34,6 @@ enum TabOptions { 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, @@ -55,23 +48,13 @@ const AddRespondersPopup = observer( const isCreateMode = mode === 'create'; + const [searchLoading, setSearchLoading] = useState(true); const [activeOption, setActiveOption] = useState(isCreateMode ? TabOptions.Teams : TabOptions.Users); + const [teamSearchResults, setTeamSearchResults] = useState([]); + const [userSearchResults, setUserSearchResults] = useState([]); 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); @@ -87,7 +70,7 @@ const AddRespondersPopup = observer( ); const onClickUser = useCallback( - async (user: User) => { + async (user: UserCurrentlyOnCall) => { if (isCreateMode && user.is_currently_oncall) { directPagingStore.addUserToSelectedUsers(user); } else { @@ -113,18 +96,86 @@ const AddRespondersPopup = observer( [setVisible, directPagingStore, setActiveOption] ); - const handleSearchTermChange = useDebouncedCallback(() => { + const searchForUsers = useCallback(async () => { + const userResults = await userStore.search({ searchTerm, is_currently_oncall: 'all' }); + setUserSearchResults(userResults.results); + }, [searchTerm]); + + const searchForTeams = useCallback(async () => { + await grafanaTeamStore.updateItems(searchTerm, false, true, false); + setTeamSearchResults(grafanaTeamStore.getSearchResult()); + }, [searchTerm]); + + const handleSearchTermChange = useDebouncedCallback(async () => { + setSearchLoading(true); + if (isCreateMode && activeOption === TabOptions.Teams) { - grafanaTeamStore.updateItems(searchTerm, false, true, false); + await searchForTeams(); } else { - userStore.updateItems({ searchTerm, short: 'false' }); + await searchForUsers(); } + + setSearchLoading(false); }, 500); - useEffect(handleSearchTermChange, [searchTerm, activeOption]); + const onChangeTab = useCallback( + async (tab: TabOptions) => { + /** + * there's no need to trigger a new search request when the user changes tabs if they don't have a + * search term + */ + if (searchTerm) { + setSearchLoading(true); + + if (activeOption === TabOptions.Teams) { + await searchForTeams(); + } else { + await searchForUsers(); + } + + setSearchLoading(false); + } + + setActiveOption(tab); + }, + [searchTerm] + ); + + useEffect(handleSearchTermChange, [searchTerm]); + + /** + * 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 + */ + useEffect(() => { + if (existingPagedUsers.length > 0) { + const existingPagedUserIds = existingPagedUsers.map(({ pk }) => pk); + setUserSearchResults((userSearchResults) => + userSearchResults.filter(({ pk }) => !existingPagedUserIds.includes(pk)) + ); + } + }, [existingPagedUsers]); + + /** + * pre-populate the users and teams search results so that when the user opens AddRespondersPopup it is already + * populated with data (nicer UX) + */ + useEffect(() => { + (async () => { + /** + * teams are not relevant when the component is rendered in "update" mode so we skip fetching teams here + */ + if (isCreateMode) { + await searchForTeams(); + } + + await searchForUsers(); + setSearchLoading(false); + })(); + }, []); const userIsSelected = useCallback( - (user: User) => selectedUserResponders.some((userResponder) => userResponder.data.pk === user.pk), + (user: UserCurrentlyOnCall) => selectedUserResponders.some((userResponder) => userResponder.data.pk === user.pk), [selectedUserResponders] ); @@ -155,11 +206,11 @@ const AddRespondersPopup = observer( }, ]; - const userColumns: ColumnsType = [ + const userColumns: ColumnsType = [ // TODO: how to make the rows span full width properly? { width: 300, - render: (user: User) => { + render: (user: UserCurrentlyOnCall) => { const { avatar, name, username, teams } = user; const disabled = userIsSelected(user); @@ -170,6 +221,7 @@ const AddRespondersPopup = observer( {name || username} + {/* TODO: we should add an elippsis and/or tooltip in the event that the user has a ton of teams */} {teams?.length > 0 && {teams.map(({ name }) => name).join(', ')}} @@ -179,18 +231,18 @@ const AddRespondersPopup = observer( }, { width: 40, - render: (user: User) => (userIsSelected(user) ? : null), + render: (user: UserCurrentlyOnCall) => (userIsSelected(user) ? : null), key: 'Checked', }, ]; - const UserResultsSection: FC<{ header: string; users: User[] }> = ({ header, users }) => + const UserResultsSection: FC<{ header: string; users: UserCurrentlyOnCall[] }> = ({ header, users }) => users.length > 0 && ( <> {header} - + emptyText={users ? 'No users found' : 'Loading...'} rowKey="pk" columns={userColumns} @@ -223,11 +275,12 @@ const AddRespondersPopup = observer( ]} className={cx('radio-buttons')} value={activeOption} - onChange={setActiveOption} + onChange={onChangeTab} fullWidth /> )} - {activeOption === TabOptions.Teams && ( + {searchLoading && } + {!searchLoading && activeOption === TabOptions.Teams && ( <> {selectedTeamResponder ? ( )} - {activeOption === TabOptions.Users && ( + {!searchLoading && activeOption === TabOptions.Users && ( <> diff --git a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/__snapshots__/AddRespondersPopup.test.tsx.snap b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/__snapshots__/AddRespondersPopup.test.tsx.snap index 16a3c3c9..ba89c566 100644 --- a/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/__snapshots__/AddRespondersPopup.test.tsx.snap +++ b/grafana-plugin/src/containers/AddResponders/parts/AddRespondersPopup/__snapshots__/AddRespondersPopup.test.tsx.snap @@ -1,119 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AddRespondersPopup if a team is selected it shows an info alert 1`] = ` -
-
-
-
- -
-
- - - -
-
-
-
-
- - - - -
-
-
-
- - - -
-
-
-
- You can add only one team per escalation. Please remove the existing team before adding a new one. -
-
-
-
-
-`; - -exports[`AddRespondersPopup it renders teams properly 1`] = ` +exports[`AddRespondersPopup it shows a loading message initially 1`] = `
+ Loading... +
-
- - - -
-
-
-
- - You can only page teams which have a Direct Paging integration that is configured. - - - -
-
- Learn more -
-
-
- - - -
-
-
-
-
-
-
-
-
-
-
-
-
- - - - - - - - - - - - -
-
-
-
-
-
- -
-
- - my test team - -
-
-
-
- - 1 - user - - on-call - -
-
-
-
-
-
-
-
-
- -
-
- - my test team 2 - -
-
-
-
-
-
-
-
+
diff --git a/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.test.tsx b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.test.tsx index c8a5e239..67707240 100644 --- a/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.test.tsx +++ b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/UserResponder.test.tsx @@ -3,7 +3,7 @@ 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 { UserCurrentlyOnCall } from 'models/user/user.types'; import UserResponder from './UserResponder'; @@ -11,7 +11,7 @@ describe('UserResponder', () => { const user = { avatar: 'http://avatar.com/', username: 'johnsmith', - } as User; + } as UserCurrentlyOnCall; test('it renders data properly', () => { const component = render( diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts index 32ccdedc..2a707d73 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts @@ -1,7 +1,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { Channel } from 'models/channel'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; -import { User } from 'models/user/user.types'; +import { PagedUser, User } from 'models/user/user.types'; export enum IncidentStatus { 'Firing', @@ -40,10 +40,6 @@ export interface GroupedAlert { render_for_web: RenderForWeb; } -export type PagedUser = Pick & { - important: boolean; -}; - export interface Alert { pk: string; title: string; diff --git a/grafana-plugin/src/models/direct_paging/direct_paging.ts b/grafana-plugin/src/models/direct_paging/direct_paging.ts index 332aa9e8..19057689 100644 --- a/grafana-plugin/src/models/direct_paging/direct_paging.ts +++ b/grafana-plugin/src/models/direct_paging/direct_paging.ts @@ -4,7 +4,7 @@ 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 { UserCurrentlyOnCall } from 'models/user/user.types'; import { makeRequest } from 'network'; import { RootStore } from 'state'; @@ -28,7 +28,7 @@ export class DirectPagingStore extends BaseStore { } @action - addUserToSelectedUsers = (user: User) => { + addUserToSelectedUsers = (user: UserCurrentlyOnCall) => { this.selectedUserResponders = [ ...this.selectedUserResponders, { diff --git a/grafana-plugin/src/models/user/user.helpers.tsx b/grafana-plugin/src/models/user/user.helpers.tsx index 0911b4e9..347cdcd4 100644 --- a/grafana-plugin/src/models/user/user.helpers.tsx +++ b/grafana-plugin/src/models/user/user.helpers.tsx @@ -4,7 +4,7 @@ import { pick } from 'lodash-es'; import { User } from './user.types'; -export const getTimezone = (user: User) => { +export const getTimezone = (user: Pick) => { return user.timezone || 'UTC'; }; diff --git a/grafana-plugin/src/models/user/user.ts b/grafana-plugin/src/models/user/user.ts index 1fd01a03..eba18cad 100644 --- a/grafana-plugin/src/models/user/user.ts +++ b/grafana-plugin/src/models/user/user.ts @@ -15,6 +15,12 @@ import { isUserActionAllowed, UserActions } from 'utils/authorization'; import { getTimezone, prepareForUpdate } from './user.helpers'; import { User } from './user.types'; +type PaginatedUsersResponse = { + count: number; + page_size: number; + results: UT[]; +}; + export class UserStore extends BaseStore { @observable.shallow searchResult: { count?: number; results?: Array; page_size?: number } = {}; @@ -110,13 +116,17 @@ export class UserStore extends BaseStore { delete this.itemsCurrentlyUpdating[userPk]; } - @action - async updateItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise { + async search(f: any = { searchTerm: '' }, page = 1): Promise> { const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility const { searchTerm: search, ...restFilters } = filters; - const response = await makeRequest(this.path, { + return makeRequest>(this.path, { params: { search, page, ...restFilters }, }); + } + + @action + async updateItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise { + const response = await this.search(f, page); if (invalidateFn && invalidateFn()) { return; diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index 11d6247b..6d3caae3 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -5,17 +5,20 @@ export interface MessagingBackends { [key: string]: any; } -export interface User { +interface BaseUser { pk: string; + name: string; + username: string; + avatar: string; + avatar_full: string; +} + +export interface User extends BaseUser { slack_login: string; email: string; phone: string; - avatar: string; - avatar_full: string; - name: string; display_name: string; hide_phone_number: boolean; - username: string; slack_id: string; phone_verified: boolean; telegram_configuration: { @@ -43,6 +46,14 @@ export interface User { hidden_fields?: boolean; timezone: Timezone; working_hours: { [key: string]: [] }; - is_currently_oncall?: boolean; - teams?: GrafanaTeam[]; +} + +export interface PagedUser extends BaseUser { + important: boolean; +} + +export interface UserCurrentlyOnCall extends BaseUser { + timezone: Timezone; + is_currently_oncall: boolean; + teams: GrafanaTeam[]; }