From bfcc0b9f290fa3628e941fae1b89a6e7d33ee097 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Wed, 9 Oct 2024 08:55:10 -0400 Subject: [PATCH] update URLs constructed by the backend to support IRM plugin (#5137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What this PR does Introduces a new class, `apps.grafana_plugin.ui_url_builder.UIURLBuilder`, which is responsible for... building UI URLs (😄). The class mainly does two things: - it will decide if the URL should point to `grafana-oncall-app` or `grafana-irm-app` based on the value of `organization.is_grafana_irm_enabled` (**NOTE**: this value isn't yet being set + defaults to `False`; logic for setting this value will be done in a subsequent PR) - Adds `enum`s, `OnCallPage` and `IncidentPage` to DRYify hardcoded UI URLs (in case we decide to change these slightly in the near future) ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- engine/apps/alerts/models/alert_group.py | 13 ++- .../alerts/models/alert_receive_channel.py | 10 +- engine/apps/api/serializers/user.py | 2 +- engine/apps/api/tests/test_auth.py | 13 ++- engine/apps/api/views/auth.py | 21 ++-- .../chatops_proxy/register_oncall_tenant.py | 7 +- engine/apps/chatops_proxy/utils.py | 14 ++- .../tests/test_ui_url_builder.py | 105 ++++++++++++++++++ engine/apps/grafana_plugin/ui_url_builder.py | 58 ++++++++++ .../apps/oss_installation/cloud_heartbeat.py | 15 ++- .../serializers/cloud_user.py | 4 +- engine/apps/oss_installation/utils.py | 10 +- .../views/cloud_connection.py | 2 +- .../oss_installation/views/cloud_heartbeat.py | 2 +- .../oss_installation/views/cloud_users.py | 2 +- .../apps/public_api/tests/test_escalation.py | 3 +- .../apps/schedules/models/on_call_schedule.py | 9 +- .../schedules/models/shift_swap_request.py | 9 -- engine/apps/slack/scenarios/paging.py | 6 +- engine/apps/slack/scenarios/schedules.py | 2 +- .../slack/scenarios/shift_swap_requests.py | 4 +- .../tests/test_interactive_api_endpoint.py | 3 +- .../test_shift_swap_requests.py | 2 +- engine/apps/slack/views.py | 4 +- engine/apps/social_auth/middlewares.py | 26 ++--- engine/apps/social_auth/pipeline/google.py | 9 +- engine/apps/social_auth/pipeline/slack.py | 16 +-- ...023_organization_is_grafana_irm_enabled.py | 18 +++ .../user_management/models/organization.py | 13 +-- engine/common/constants/slack_auth.py | 1 + 30 files changed, 302 insertions(+), 101 deletions(-) create mode 100644 engine/apps/grafana_plugin/tests/test_ui_url_builder.py create mode 100644 engine/apps/grafana_plugin/ui_url_builder.py create mode 100644 engine/apps/user_management/migrations/0023_organization_is_grafana_irm_enabled.py diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 6a9062bd..83e7104c 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -4,7 +4,6 @@ import typing import urllib from collections import namedtuple from functools import partial -from urllib.parse import urljoin from celery import uuid as celery_uuid from django.conf import settings @@ -27,10 +26,10 @@ from apps.alerts.tasks import ( send_alert_group_signal_for_delete, unsilence_task, ) +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.metrics_exporter.tasks import update_metrics_for_alert_group from apps.slack.slack_formatter import SlackFormatter from apps.user_management.models import User -from common.constants.plugin_ids import PluginID from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length from common.utils import clean_markup, str_or_backup @@ -543,17 +542,19 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. @property def web_link(self) -> str: - return urljoin(self.channel.organization.web_link, f"alert-groups/{self.public_primary_key}") + return UIURLBuilder(self.channel.organization).alert_group_detail(self.public_primary_key) @property def declare_incident_link(self) -> str: - """Generate a link for AlertGroup to declare Grafana Incident by click""" - incident_link = urljoin(self.channel.organization.grafana_url, f"a/{PluginID.INCIDENT}/incidents/declare/") + """ + Generate a link for AlertGroup to declare Grafana Incident by click + """ caption = urllib.parse.quote_plus("OnCall Alert Group") title = urllib.parse.quote_plus(self.web_title_cache) if self.web_title_cache else DEFAULT_BACKUP_TITLE title = title[:2000] # set max title length to avoid exceptions with too long declare incident link link = urllib.parse.quote_plus(self.web_link) - return urljoin(incident_link, f"?caption={caption}&url={link}&title={title}") + + return UIURLBuilder(self.channel.organization).declare_incident(f"?caption={caption}&url={link}&title={title}") @property def happened_while_maintenance(self): diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index 7b5b4771..2c45c68d 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -1,7 +1,6 @@ import logging import typing from functools import cached_property -from urllib.parse import urljoin import emoji from celery import uuid as celery_uuid @@ -21,6 +20,7 @@ from apps.alerts.models.maintainable_object import MaintainableObject from apps.alerts.tasks import disable_maintenance, disconnect_integration_from_alerting_contact_points from apps.base.messaging import get_messaging_backend_from_id from apps.base.utils import live_settings +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.integrations.legacy_prefix import remove_legacy_prefix from apps.integrations.metadata import heartbeat from apps.integrations.tasks import create_alert, create_alertmanager_alerts @@ -422,8 +422,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): @property def new_incidents_web_link(self): - return urljoin( - self.organization.web_link, f"?page=incidents&integration={self.public_primary_key}&status=0&p=1" + return UIURLBuilder(self.organization).alert_groups( + f"?integration={self.public_primary_key}&status={AlertGroup.NEW}", ) @property @@ -531,8 +531,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): return f"{self.get_integration_display()} {self.smile_code}" @property - def web_link(self): - return urljoin(self.organization.web_link, f"integrations/{self.public_primary_key}") + def web_link(self) -> str: + return UIURLBuilder(self.organization).integration_detail(self.public_primary_key) @property def integration_url(self) -> str | None: diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index b9280e9b..c6ff0c28 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -181,7 +181,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): connector = self.context.get("connector", None) identities = self.context.get("cloud_identities", {}) identity = identities.get(obj.email, None) - status, _ = cloud_user_identity_status(connector, identity) + status, _ = cloud_user_identity_status(obj.organization, connector, identity) return status return None diff --git a/engine/apps/api/tests/test_auth.py b/engine/apps/api/tests/test_auth.py index 85a15554..4800107e 100644 --- a/engine/apps/api/tests/test_auth.py +++ b/engine/apps/api/tests/test_auth.py @@ -10,15 +10,18 @@ from rest_framework.test import APIClient from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND +from common.constants.plugin_ids import PluginID from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE +GRAFANA_URL = "http://example.com" + @pytest.mark.django_db @pytest.mark.parametrize( "backend_name,expected_url", ( - ("slack-login", "/a/grafana-oncall-app/users/me"), - (SLACK_INSTALLATION_BACKEND, "/a/grafana-oncall-app/chat-ops"), + ("slack-login", f"{GRAFANA_URL}/a/{PluginID.ONCALL}/users/me"), + (SLACK_INSTALLATION_BACKEND, f"{GRAFANA_URL}/a/{PluginID.ONCALL}/chat-ops"), ), ) def test_complete_slack_auth_redirect_ok( @@ -28,7 +31,7 @@ def test_complete_slack_auth_redirect_ok( backend_name, expected_url, ): - organization = make_organization() + organization = make_organization(grafana_url=GRAFANA_URL) admin = make_user_for_organization(organization) _, slack_token = make_slack_token_for_user(admin) @@ -181,7 +184,7 @@ def test_google_complete_auth_redirect_ok( make_user_for_organization, make_google_oauth2_token_for_user, ): - organization = make_organization() + organization = make_organization(grafana_url=GRAFANA_URL) admin = make_user_for_organization(organization) _, google_oauth2_token = make_google_oauth2_token_for_user(admin) @@ -194,7 +197,7 @@ def test_google_complete_auth_redirect_ok( response = client.get(url) assert response.status_code == status.HTTP_302_FOUND - assert response.url == "/a/grafana-oncall-app/users/me" + assert response.url == f"{GRAFANA_URL}/a/{PluginID.ONCALL}/users/me" @pytest.mark.django_db diff --git a/engine/apps/api/views/auth.py b/engine/apps/api/views/auth.py index 5a6e7cbb..63aafc7f 100644 --- a/engine/apps/api/views/auth.py +++ b/engine/apps/api/views/auth.py @@ -1,5 +1,4 @@ import logging -from urllib.parse import urljoin from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME @@ -19,6 +18,7 @@ from apps.chatops_proxy.utils import ( get_installation_link_from_chatops_proxy, get_slack_oauth_response_from_chatops_proxy, ) +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.slack.installation import install_slack_integration from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginSlackOAuth2V2 @@ -73,13 +73,6 @@ def overridden_login_social_auth(request: Request, backend: str) -> Response: @psa("social:complete") def overridden_complete_social_auth(request: Request, backend: str, *args, **kwargs) -> Response: """Authentication complete view""" - if isinstance(request.backend, (LoginSlackOAuth2V2, GoogleOAuth2)): - # if this was a user login/linking account, redirect to profile - redirect_to = "/a/grafana-oncall-app/users/me" - else: - # InstallSlackOAuth2V2 backend - redirect_to = "/a/grafana-oncall-app/chat-ops" - kwargs.update( user=request.user, redirect_name=REDIRECT_FIELD_NAME, @@ -99,8 +92,16 @@ def overridden_complete_social_auth(request: Request, backend: str, *args, **kwa return_to = request.backend.strategy.session.get(REDIRECT_FIELD_NAME) if return_to is None: - # We build the frontend url using org url since multiple stacks could be connected to one backend. - return_to = urljoin(request.user.organization.grafana_url, redirect_to) + url_builder = UIURLBuilder(request.user.organization) + + # if this was a user login/linking account, redirect to profile (ie. users/me) + # otherwise it pertains to the InstallSlackOAuth2V2 backend, and we should redirect to the chat-ops page + return_to = ( + url_builder.user_profile() + if isinstance(request.backend, (LoginSlackOAuth2V2, GoogleOAuth2)) + else url_builder.chatops() + ) + return HttpResponseRedirect(return_to) diff --git a/engine/apps/chatops_proxy/register_oncall_tenant.py b/engine/apps/chatops_proxy/register_oncall_tenant.py index c0daeb56..7362635a 100644 --- a/engine/apps/chatops_proxy/register_oncall_tenant.py +++ b/engine/apps/chatops_proxy/register_oncall_tenant.py @@ -1,10 +1,15 @@ # register_oncall_tenant moved to separate file from engine/apps/chatops_proxy/utils.py to avoid circular imports. +import typing + from django.conf import settings from apps.chatops_proxy.client import APP_TYPE_ONCALL, ChatopsProxyAPIClient +if typing.TYPE_CHECKING: + from apps.user_management.models import Organization -def register_oncall_tenant(org): + +def register_oncall_tenant(org: "Organization") -> None: """ register_oncall_tenant registers oncall organization as a tenant in chatops-proxy. """ diff --git a/engine/apps/chatops_proxy/utils.py b/engine/apps/chatops_proxy/utils.py index 1c242bf6..9c8ed6ea 100644 --- a/engine/apps/chatops_proxy/utils.py +++ b/engine/apps/chatops_proxy/utils.py @@ -3,10 +3,11 @@ Set of utils to handle oncall and chatops-proxy interaction. """ import logging import typing -from urllib.parse import urljoin from django.conf import settings +from apps.grafana_plugin.ui_url_builder import UIURLBuilder + from .client import APP_TYPE_ONCALL, PROVIDER_TYPE_SLACK, ChatopsProxyAPIClient, ChatopsProxyAPIException from .register_oncall_tenant import register_oncall_tenant from .tasks import ( @@ -16,10 +17,13 @@ from .tasks import ( unregister_oncall_tenant_async, ) +if typing.TYPE_CHECKING: + from apps.user_management.models import Organization, User + logger = logging.getLogger(__name__) -def get_installation_link_from_chatops_proxy(user) -> typing.Optional[str]: +def get_installation_link_from_chatops_proxy(user: "User") -> typing.Optional[str]: """ get_installation_link_from_chatops_proxy fetches slack installation link from chatops proxy. If there is no existing slack installation - if returns link, If slack already installed, it returns None. @@ -30,7 +34,7 @@ def get_installation_link_from_chatops_proxy(user) -> typing.Optional[str]: link, _ = client.get_slack_oauth_link( org.stack_id, user.user_id, - urljoin(org.web_link, "settings?tab=ChatOps&chatOpsTab=Slack"), + UIURLBuilder(org).settings("?tab=ChatOps&chatOpsTab=Slack"), APP_TYPE_ONCALL, ) return link @@ -44,13 +48,13 @@ def get_installation_link_from_chatops_proxy(user) -> typing.Optional[str]: raise api_exc -def get_slack_oauth_response_from_chatops_proxy(stack_id) -> dict: +def get_slack_oauth_response_from_chatops_proxy(stack_id: int) -> dict: client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) slack_installation, _ = client.get_oauth_installation(stack_id, PROVIDER_TYPE_SLACK) return slack_installation.oauth_response -def register_oncall_tenant_with_async_fallback(org): +def register_oncall_tenant_with_async_fallback(org: "Organization") -> None: """ register_oncall_tenant tries to register oncall tenant synchronously and fall back to task in case of any exceptions to make sure that tenant is registered. diff --git a/engine/apps/grafana_plugin/tests/test_ui_url_builder.py b/engine/apps/grafana_plugin/tests/test_ui_url_builder.py new file mode 100644 index 00000000..dad96877 --- /dev/null +++ b/engine/apps/grafana_plugin/tests/test_ui_url_builder.py @@ -0,0 +1,105 @@ +import pytest + +from apps.grafana_plugin.ui_url_builder import UIURLBuilder +from common.constants.plugin_ids import PluginID + +GRAFANA_URL = "http://example.com" +ALERT_GROUP_ID = "1234" +INTEGRATION_ID = "5678" +SCHEDULE_ID = "lasdfasdf" +PATH_EXTRA = "/extra?foo=bar" + + +@pytest.fixture +def org_setup(make_organization): + def _org_setup(is_grafana_irm_enabled=False): + return make_organization(grafana_url=GRAFANA_URL, is_grafana_irm_enabled=is_grafana_irm_enabled) + + return _org_setup + + +@pytest.mark.parametrize( + "func,call_kwargs,expected_url", + [ + # oncall pages + ( + "home", + {"path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}{PATH_EXTRA}", + ), + ( + "alert_groups", + {"path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}/alert-groups{PATH_EXTRA}", + ), + ( + "alert_group_detail", + {"id": ALERT_GROUP_ID, "path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}/alert-groups/{ALERT_GROUP_ID}{PATH_EXTRA}", + ), + ( + "integration_detail", + {"id": INTEGRATION_ID, "path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}/integrations/{INTEGRATION_ID}{PATH_EXTRA}", + ), + ( + "schedules", + {"path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}/schedules{PATH_EXTRA}", + ), + ( + "schedule_detail", + {"id": SCHEDULE_ID, "path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}/schedules/{SCHEDULE_ID}{PATH_EXTRA}", + ), + ( + "users", + {"path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}/users{PATH_EXTRA}", + ), + ( + "user_profile", + {"path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}/users/me{PATH_EXTRA}", + ), + ( + "chatops", + {"path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}/chat-ops{PATH_EXTRA}", + ), + ( + "settings", + {"path_extra": PATH_EXTRA}, + f"{GRAFANA_URL}/a/{PluginID.ONCALL}/settings{PATH_EXTRA}", + ), + # incident pages + ( + "declare_incident", + {"path_extra": "?caption=abcd&url=asdf&title=test1234"}, + f"{GRAFANA_URL}/a/{PluginID.INCIDENT}/incidents/declare?caption=abcd&url=asdf&title=test1234", + ), + ], +) +@pytest.mark.django_db +def test_build_page_urls(org_setup, func, call_kwargs, expected_url): + builder = UIURLBuilder(org_setup()) + assert getattr(builder, func)(**call_kwargs) == expected_url + + +@pytest.mark.django_db +def test_build_url_overriden_base_url(org_setup): + overriden_base_url = "http://overriden.com" + builder = UIURLBuilder(org_setup(), base_url=overriden_base_url) + assert builder.chatops() == f"{overriden_base_url}/a/{PluginID.ONCALL}/chat-ops" + + +@pytest.mark.parametrize( + "is_grafana_irm_enabled,expected_url", + [ + (True, f"{GRAFANA_URL}/a/{PluginID.IRM}/alert-groups/{ALERT_GROUP_ID}"), + (False, f"{GRAFANA_URL}/a/{PluginID.ONCALL}/alert-groups/{ALERT_GROUP_ID}"), + ], +) +@pytest.mark.django_db +def test_build_url_works_for_irm_and_oncall_plugins(org_setup, is_grafana_irm_enabled, expected_url): + assert UIURLBuilder(org_setup(is_grafana_irm_enabled)).alert_group_detail(ALERT_GROUP_ID) == expected_url diff --git a/engine/apps/grafana_plugin/ui_url_builder.py b/engine/apps/grafana_plugin/ui_url_builder.py new file mode 100644 index 00000000..e37f8e75 --- /dev/null +++ b/engine/apps/grafana_plugin/ui_url_builder.py @@ -0,0 +1,58 @@ +import typing +from urllib.parse import urljoin + +from common.constants.plugin_ids import PluginID + +if typing.TYPE_CHECKING: + from apps.user_management.models import Organization + + +class UIURLBuilder: + """ + If `base_url` is passed into the constructor, it will override using `organization.grafana_url` + """ + + def __init__(self, organization: "Organization", base_url: typing.Optional[str] = None) -> None: + self.base_url = base_url if base_url else organization.grafana_url + self.is_grafana_irm_enabled = organization.is_grafana_irm_enabled + + def _build_url(self, page: str, path_extra: str = "", plugin_id: typing.Optional[str] = None) -> str: + """ + Constructs an absolute URL to a Grafana plugin page. + """ + if not plugin_id: + plugin_id = PluginID.IRM if self.is_grafana_irm_enabled else PluginID.ONCALL + return urljoin(self.base_url, f"a/{plugin_id}/{page}{path_extra}") + + def home(self, path_extra: str = "") -> str: + return self._build_url("", path_extra) + + def alert_groups(self, path_extra: str = "") -> str: + return self._build_url("alert-groups", path_extra) + + def alert_group_detail(self, id: str, path_extra: str = "") -> str: + return self._build_url(f"alert-groups/{id}", path_extra) + + def integration_detail(self, id: str, path_extra: str = "") -> str: + return self._build_url(f"integrations/{id}", path_extra) + + def schedules(self, path_extra: str = "") -> str: + return self._build_url("schedules", path_extra) + + def schedule_detail(self, id: str, path_extra: str = "") -> str: + return self._build_url(f"schedules/{id}", path_extra) + + def users(self, path_extra: str = "") -> str: + return self._build_url("users", path_extra) + + def user_profile(self, path_extra: str = "") -> str: + return self._build_url("users/me", path_extra) + + def chatops(self, path_extra: str = "") -> str: + return self._build_url("chat-ops", path_extra) + + def settings(self, path_extra: str = "") -> str: + return self._build_url("settings", path_extra) + + def declare_incident(self, path_extra: str = "") -> str: + return self._build_url("incidents/declare", path_extra, plugin_id=PluginID.INCIDENT) diff --git a/engine/apps/oss_installation/cloud_heartbeat.py b/engine/apps/oss_installation/cloud_heartbeat.py index b42fee6b..ec04fe79 100644 --- a/engine/apps/oss_installation/cloud_heartbeat.py +++ b/engine/apps/oss_installation/cloud_heartbeat.py @@ -1,14 +1,19 @@ import logging import random -from urllib.parse import urljoin +import typing import requests from django.conf import settings from rest_framework import status from apps.base.utils import live_settings +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from common.api_helpers.utils import create_engine_url +if typing.TYPE_CHECKING: + from apps.oss_installation.models import CloudConnector, CloudHeartbeat + from apps.user_management.models import Organization + logger = logging.getLogger(__name__) @@ -102,9 +107,13 @@ def send_cloud_heartbeat(): logger.info("Finish send cloud heartbeat") -def get_heartbeat_link(connector, heartbeat): +def get_heartbeat_link( + organization: "Organization", + connector: typing.Optional["CloudConnector"], + heartbeat: typing.Optional["CloudHeartbeat"], +) -> typing.Optional[str]: if connector is None: return None if heartbeat is None: return None - return urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=integrations&id={heartbeat.integration_id}") + return UIURLBuilder(organization, base_url=connector.cloud_url).integration_detail(heartbeat.integration_id) diff --git a/engine/apps/oss_installation/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py index 53ccd808..d8f60db2 100644 --- a/engine/apps/oss_installation/serializers/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -12,9 +12,9 @@ class CloudUserSerializer(serializers.ModelSerializer): model = User fields = ["cloud_data"] - def get_cloud_data(self, obj): + def get_cloud_data(self, obj: User): connector = CloudConnector.objects.filter().first() cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first() - status, link = cloud_user_identity_status(connector, cloud_user_identity) + status, link = cloud_user_identity_status(obj.organization, connector, cloud_user_identity) cloud_data = {"status": status, "link": link} return cloud_data diff --git a/engine/apps/oss_installation/utils.py b/engine/apps/oss_installation/utils.py index f2f11606..da2c7521 100644 --- a/engine/apps/oss_installation/utils.py +++ b/engine/apps/oss_installation/utils.py @@ -1,11 +1,15 @@ import logging -from urllib.parse import urljoin +import typing from django.utils import timezone +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.oss_installation.constants import CloudSyncStatus from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period +if typing.TYPE_CHECKING: + from apps.user_management.models import Organization + logger = logging.getLogger(__name__) @@ -67,7 +71,7 @@ def active_oss_users_count(): return len(unique_active_users) -def cloud_user_identity_status(connector, identity): +def cloud_user_identity_status(org: "Organization", connector, identity): link = None if connector is None: status = CloudSyncStatus.NOT_SYNCED @@ -80,5 +84,5 @@ def cloud_user_identity_status(connector, identity): else: status = CloudSyncStatus.SYNCED_PHONE_NOT_VERIFIED - link = urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={identity.cloud_id}") + link = UIURLBuilder(org, base_url=connector.cloud_url).users(f"?p=1&id={identity.cloud_id}") return status, link diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py index 004837df..07aef176 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -26,7 +26,7 @@ class CloudConnectionView(APIView): "cloud_connection_status": connector is not None, "cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, "cloud_heartbeat_enabled": live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED, - "cloud_heartbeat_link": get_heartbeat_link(connector, heartbeat), + "cloud_heartbeat_link": get_heartbeat_link(self.request.auth.organization, connector, heartbeat), "cloud_heartbeat_status": heartbeat is not None and heartbeat.success, } return Response(response) diff --git a/engine/apps/oss_installation/views/cloud_heartbeat.py b/engine/apps/oss_installation/views/cloud_heartbeat.py index a3a2973e..151dd7f0 100644 --- a/engine/apps/oss_installation/views/cloud_heartbeat.py +++ b/engine/apps/oss_installation/views/cloud_heartbeat.py @@ -24,7 +24,7 @@ class CloudHeartbeatView(APIView): return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Cloud heartbeat already exists"}) except CloudHeartbeat.DoesNotExist: heartbeat = setup_heartbeat_integration() - link = get_heartbeat_link(connector, heartbeat) + link = get_heartbeat_link(self.request.auth.organization, connector, heartbeat) return Response(status=status.HTTP_200_OK, data={"link": link}) else: return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"}) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index 471d3a0c..8261a734 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -57,7 +57,7 @@ class CloudUsersView(CloudUsersPagination, APIView): for user in results: cloud_identity = cloud_identities.get(user.email, None) - status, link = cloud_user_identity_status(connector, cloud_identity) + status, link = cloud_user_identity_status(organization, connector, cloud_identity) data.append( { "id": user.public_primary_key, diff --git a/engine/apps/public_api/tests/test_escalation.py b/engine/apps/public_api/tests/test_escalation.py index 5c4e0e77..f6a7665d 100644 --- a/engine/apps/public_api/tests/test_escalation.py +++ b/engine/apps/public_api/tests/test_escalation.py @@ -7,6 +7,7 @@ from rest_framework.test import APIClient from apps.alerts.models import AlertGroup from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError +from common.constants.plugin_ids import PluginID title = "Custom title" message = "Testing escalation with new alert group" @@ -70,7 +71,7 @@ def test_escalation_new_alert_group( "slack": None, "slack_app": None, "telegram": None, - "web": f"a/grafana-oncall-app/alert-groups/{ag.public_primary_key}", + "web": f"a/{PluginID.ONCALL}/alert-groups/{ag.public_primary_key}", }, "silenced_at": None, "last_alert": { diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index e053a6a5..190a7785 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -18,6 +18,7 @@ from polymorphic.managers import PolymorphicManager from polymorphic.models import PolymorphicModel from polymorphic.query import PolymorphicQuerySet +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.schedules.constants import ( EXPORT_WINDOW_DAYS_AFTER, EXPORT_WINDOW_DAYS_BEFORE, @@ -247,11 +248,15 @@ class OnCallSchedule(PolymorphicModel): @property def web_page_link(self) -> str: - return f"{self.organization.web_link}schedules" + return UIURLBuilder(self.organization).schedules() @property def web_detail_page_link(self) -> str: - return f"{self.web_page_link}/{self.public_primary_key}" + return UIURLBuilder(self.organization).schedule_detail(self.public_primary_key) + + @property + def slack_url(self) -> str: + return f"<{self.web_detail_page_link}|{self.name}>" def get_icalendars(self) -> typing.Tuple[typing.Optional[icalendar.Calendar], typing.Optional[icalendar.Calendar]]: """Returns list of calendars. Primary calendar should always be the first""" diff --git a/engine/apps/schedules/models/shift_swap_request.py b/engine/apps/schedules/models/shift_swap_request.py index a8d5429b..5fc5855f 100644 --- a/engine/apps/schedules/models/shift_swap_request.py +++ b/engine/apps/schedules/models/shift_swap_request.py @@ -171,10 +171,6 @@ class ShiftSwapRequest(models.Model): """ return self.schedule.channel - @property - def schedule_slack_url(self) -> str: - return f"<{self.schedule.web_detail_page_link}|{self.schedule.name}>" - @property def organization(self) -> "Organization": return self.schedule.organization @@ -183,11 +179,6 @@ class ShiftSwapRequest(models.Model): def possible_benefactors(self) -> QuerySet["User"]: return self.schedule.related_users().exclude(pk=self.beneficiary_id) - @property - def web_link(self) -> str: - # TODO: finish this once we know the proper URL we'll need - return f"{self.schedule.web_detail_page_link}" - def delete(self): self.deleted_at = timezone.now() self.save() diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index 29fd0e7b..d944971b 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -2,7 +2,6 @@ import enum import json import logging import typing -from urllib.parse import urljoin from uuid import uuid4 from django.conf import settings @@ -12,6 +11,7 @@ from rest_framework.response import Response from apps.alerts.models import AlertReceiveChannel from apps.alerts.paging import DirectPagingUserTeamValidationError, UserNotifications, direct_paging, user_is_oncall from apps.api.permissions import RBACPermission, user_is_authorized +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules from apps.slack.chatops_proxy_routing import make_private_metadata, make_value from apps.slack.constants import DIVIDER, PRIVATE_METADATA_MAX_LENGTH @@ -146,8 +146,8 @@ class StartDirectPaging(scenario_step.ScenarioStep): if slack_team_identity.needs_reinstall: organizations = _get_available_organizations(slack_team_identity, slack_user_identity) if len(organizations) == 1: - # Provide a link to web if user has access only to one organization - link = urljoin(organizations[0].web_link, "settings?tab=ChatOps&chatOpsTab=Slack") + # Provide a link to web if user has access only to one organization + link = UIURLBuilder(organizations[0]).settings("?tab=ChatOps&chatOpsTab=Slack") else: # Otherwise, provide a link to the documentation link = ( diff --git a/engine/apps/slack/scenarios/schedules.py b/engine/apps/slack/scenarios/schedules.py index 105b8c47..61c6da61 100644 --- a/engine/apps/slack/scenarios/schedules.py +++ b/engine/apps/slack/scenarios/schedules.py @@ -215,7 +215,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep): "type": "section", "text": { "type": "mrkdwn", - "text": f"On-call shifts update for schedule *<{schedule.web_detail_page_link}|{schedule.name}>*", + "text": f"On-call shifts update for schedule *{schedule.slack_url}*", "verbatim": True, }, }, diff --git a/engine/apps/slack/scenarios/shift_swap_requests.py b/engine/apps/slack/scenarios/shift_swap_requests.py index d114f615..0ef6e316 100644 --- a/engine/apps/slack/scenarios/shift_swap_requests.py +++ b/engine/apps/slack/scenarios/shift_swap_requests.py @@ -30,7 +30,7 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep): pk = shift_swap_request.pk main_message_text = ( - f"*New shift swap request for {shift_swap_request.schedule_slack_url}*\n" + f"*New shift swap request for {shift_swap_request.schedule.slack_url}*\n" f"Your teammate {shift_swap_request.beneficiary.get_username_with_slack_verbal(True)} has submitted " "a shift swap request." ) @@ -262,7 +262,7 @@ class ShiftSwapRequestFollowUp(BaseShiftSwapRequestStep): "text": { "type": "mrkdwn", "text": ( - f"⚠️ This shift swap request for {shift_swap_request.schedule_slack_url} is " + f"⚠️ This shift swap request for {shift_swap_request.schedule.slack_url} is " f"still open and will start in {delta}. Jump back into the thread and accept it if " "you're available!" ), diff --git a/engine/apps/slack/tests/test_interactive_api_endpoint.py b/engine/apps/slack/tests/test_interactive_api_endpoint.py index f0d8728b..89822d5a 100644 --- a/engine/apps/slack/tests/test_interactive_api_endpoint.py +++ b/engine/apps/slack/tests/test_interactive_api_endpoint.py @@ -11,6 +11,7 @@ from apps.slack.scenarios.paging import OnPagingTeamChange, StartDirectPaging from apps.slack.scenarios.schedules import EditScheduleShiftNotifyStep from apps.slack.scenarios.shift_swap_requests import AcceptShiftSwapRequestStep from apps.slack.types import PayloadType +from common.constants.plugin_ids import PluginID EVENT_TRIGGER_ID = "5333959822612.4122782784722.4734ff484b2ac4d36a185bb242ee9932" WARNING_TEXT = ( @@ -83,7 +84,7 @@ def test_no_user_in_organization_for_slack_team_identity( mock_open_warning_window_if_needed.assert_called_once_with( event_payload, slack_team_identity, - "Permission denied. Please connect your Slack account to OnCall: https://test.com/a/grafana-oncall-app/users/me/", + f"Permission denied. Please connect your Slack account to OnCall: https://test.com/a/{PluginID.ONCALL}/users/me", ) diff --git a/engine/apps/slack/tests/test_scenario_steps/test_shift_swap_requests.py b/engine/apps/slack/tests/test_scenario_steps/test_shift_swap_requests.py index 9d0f9608..b6a7fd13 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_shift_swap_requests.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_shift_swap_requests.py @@ -42,7 +42,7 @@ class TestBaseShiftSwapRequestStep: blocks = step._generate_blocks(ssr) assert blocks[0]["text"]["text"] == ( - f"*New shift swap request for <{ssr.schedule.web_detail_page_link}|{ssr.schedule.name}>*\n" + f"*New shift swap request for {ssr.schedule.slack_url}*\n" f"Your teammate {beneficiary.get_username_with_slack_verbal()} has submitted a shift swap request." ) diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 5668009e..646b82a0 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -3,7 +3,6 @@ import hmac import json import logging from contextlib import suppress -from urllib.parse import urljoin from django.conf import settings from django.core.exceptions import ObjectDoesNotExist @@ -17,6 +16,7 @@ from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication from apps.base.utils import live_settings from apps.chatops_proxy.utils import uninstall_slack as uninstall_slack_from_chatops_proxy +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.slack.client import SlackClient from apps.slack.errors import SlackAPIError from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING @@ -293,7 +293,7 @@ class SlackEventApiEndpointView(APIView): elif organization: user = slack_user_identity.get_user(organization) if not user: # SlackUserIdentity exists but not connected to any user in this organization - user_settings_url = urljoin(organization.grafana_url, "/a/grafana-oncall-app/users/me/") + user_settings_url = UIURLBuilder(organization).user_profile() self._open_warning_window_if_needed( payload, slack_team_identity, diff --git a/engine/apps/social_auth/middlewares.py b/engine/apps/social_auth/middlewares.py index 1cc269f8..19180ebc 100644 --- a/engine/apps/social_auth/middlewares.py +++ b/engine/apps/social_auth/middlewares.py @@ -1,5 +1,4 @@ import logging -from urllib.parse import urljoin from django.http import HttpResponse from django.shortcuts import redirect @@ -7,9 +6,10 @@ from rest_framework import status from social_core import exceptions from social_django.middleware import SocialAuthExceptionMiddleware +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.social_auth.backends import LoginSlackOAuth2V2 from apps.social_auth.exceptions import InstallMultiRegionSlackException -from common.constants.slack_auth import REDIRECT_AFTER_SLACK_INSTALL, SLACK_AUTH_FAILED +from common.constants.slack_auth import REDIRECT_AFTER_SLACK_INSTALL, SLACK_AUTH_FAILED, SLACK_REGION_ERROR logger = logging.getLogger(__name__) @@ -17,28 +17,24 @@ logger = logging.getLogger(__name__) class SocialAuthAuthCanceledExceptionMiddleware(SocialAuthExceptionMiddleware): def process_exception(self, request, exception): backend = getattr(exception, "backend", None) - redirect_to = "/a/grafana-oncall-app/chat-ops" + url_builder = UIURLBuilder(request.user.organization) + url_builder_function = url_builder.chatops + if backend is not None and isinstance(backend, LoginSlackOAuth2V2): - redirect_to = "/a/grafana-oncall-app/users/me" + url_builder_function = url_builder.user_profile + if exception: logger.warning(f"SocialAuthAuthCanceledExceptionMiddleware.process_exception: {exception}") + if isinstance(exception, exceptions.AuthCanceled): # if user canceled authentication, redirect them to the previous page using the same link # as we used to redirect after auth/install - url_to_redirect = urljoin(request.user.organization.grafana_url, redirect_to) - return redirect(url_to_redirect) + return redirect(url_builder_function()) elif isinstance(exception, exceptions.AuthFailed): # if authentication was failed, redirect user to the plugin page using the same link # as we used to redirect after auth/install with error flag - url_to_redirect = urljoin( - request.user.organization.grafana_url, f"{redirect_to}&slack_error={SLACK_AUTH_FAILED}" - ) - return redirect(url_to_redirect) + return redirect(url_builder_function(f"?slack_error={SLACK_AUTH_FAILED}")) elif isinstance(exception, KeyError) and REDIRECT_AFTER_SLACK_INSTALL in exception.args: return HttpResponse(status=status.HTTP_401_UNAUTHORIZED) elif isinstance(exception, InstallMultiRegionSlackException): - REGION_ERROR = "region_error" - url_to_redirect = urljoin( - request.user.organization.grafana_url, f"{redirect_to}?tab=Slack&slack_error={REGION_ERROR}" - ) - return redirect(url_to_redirect) + return redirect(url_builder_function(f"?tab=Slack&slack_error={SLACK_REGION_ERROR}")) diff --git a/engine/apps/social_auth/pipeline/google.py b/engine/apps/social_auth/pipeline/google.py index d79a4bae..19e68a89 100644 --- a/engine/apps/social_auth/pipeline/google.py +++ b/engine/apps/social_auth/pipeline/google.py @@ -1,6 +1,5 @@ import logging import typing -from urllib.parse import urljoin import requests from django.contrib.auth import REDIRECT_FIELD_NAME @@ -9,6 +8,7 @@ from rest_framework import status from social_core.backends.base import BaseAuth from apps.google.utils import user_granted_all_required_scopes +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.social_auth.exceptions import GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR from apps.social_auth.types import GoogleOauth2Response from apps.user_management.models import Organization, User @@ -32,10 +32,9 @@ def connect_user_to_google( f"granted_scopes={granted_scopes}" ) - base_url_to_redirect = urljoin(organization.grafana_url, "/a/grafana-oncall-app/users/me") - strategy.session[ - REDIRECT_FIELD_NAME - ] = f"{base_url_to_redirect}?google_error={GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR}" + strategy.session[REDIRECT_FIELD_NAME] = UIURLBuilder(organization).user_profile( + f"?google_error={GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR}" + ) return HttpResponse(status=status.HTTP_400_BAD_REQUEST) diff --git a/engine/apps/social_auth/pipeline/slack.py b/engine/apps/social_auth/pipeline/slack.py index 4a1f606b..1564c5a2 100644 --- a/engine/apps/social_auth/pipeline/slack.py +++ b/engine/apps/social_auth/pipeline/slack.py @@ -1,5 +1,4 @@ import logging -from urllib.parse import urljoin from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME @@ -7,6 +6,7 @@ from django.http import HttpResponse from rest_framework import status from apps.chatops_proxy.utils import can_link_slack_team, link_slack_team +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.slack.installation import SlackInstallationExc, install_slack_integration from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND from apps.social_auth.exceptions import InstallMultiRegionSlackException @@ -25,9 +25,7 @@ def connect_user_to_slack(response, backend, strategy, user, organization, *args slack_team_identity = organization.slack_team_identity slack_user_id = response["authed_user"]["id"] - - redirect_to = "/a/grafana-oncall-app/users/me/" - base_url_to_redirect = urljoin(organization.grafana_url, redirect_to) + url_builder = UIURLBuilder(organization) if slack_team_identity is None: # means that organization doesn't have slack integration, so user cannot connect their account to slack @@ -36,14 +34,16 @@ def connect_user_to_slack(response, backend, strategy, user, organization, *args if slack_team_identity.slack_id != response["team"]["id"]: # means that user authed in another slack workspace that is not connected to their organization # change redirect url to show user error message and save it in session param - url = base_url_to_redirect + f"?slack_error={SLACK_AUTH_WRONG_WORKSPACE_ERROR}" - strategy.session[REDIRECT_FIELD_NAME] = url + strategy.session[REDIRECT_FIELD_NAME] = url_builder.user_profile( + f"?slack_error={SLACK_AUTH_WRONG_WORKSPACE_ERROR}", + ) return HttpResponse(status=status.HTTP_400_BAD_REQUEST) if organization.users.filter(slack_user_identity__slack_id=slack_user_id).exists(): # means that slack user has already been connected to another user in current organization - url = base_url_to_redirect + f"?slack_error={SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR}" - strategy.session[REDIRECT_FIELD_NAME] = url + strategy.session[REDIRECT_FIELD_NAME] = url_builder.user_profile( + f"?slack_error={SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR}", + ) return HttpResponse(status=status.HTTP_400_BAD_REQUEST) # at this point everything is correct and we can create the SlackUserIdentity diff --git a/engine/apps/user_management/migrations/0023_organization_is_grafana_irm_enabled.py b/engine/apps/user_management/migrations/0023_organization_is_grafana_irm_enabled.py new file mode 100644 index 00000000..f419b885 --- /dev/null +++ b/engine/apps/user_management/migrations/0023_organization_is_grafana_irm_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-10-08 14:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0022_alter_team_unique_together'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='is_grafana_irm_enabled', + field=models.BooleanField(default=False, null=True), + ), + ] diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 2faa2428..a6068d2e 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -1,7 +1,6 @@ import logging import typing import uuid -from urllib.parse import urljoin from django.conf import settings from django.core.validators import MinLengthValidator @@ -16,6 +15,7 @@ from apps.chatops_proxy.utils import ( unlink_slack_team, unregister_oncall_tenant, ) +from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy from apps.user_management.types import AlertGroupTableColumn from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log @@ -253,6 +253,7 @@ class Organization(MaintainableObject): is_rbac_permissions_enabled = models.BooleanField(default=False) is_grafana_incident_enabled = models.BooleanField(default=False) is_grafana_labels_enabled = models.BooleanField(default=False, null=True) + is_grafana_irm_enabled = models.BooleanField(default=False, null=True) alert_group_table_columns: list[AlertGroupTableColumn] | None = JSONField(default=None, null=True) grafana_incident_backend_url = models.CharField(max_length=300, null=True, default=None) @@ -344,14 +345,12 @@ class Organization(MaintainableObject): .distinct() ) - @property - def web_link(self): - return urljoin(self.grafana_url, "a/grafana-oncall-app/") - @property def web_link_with_uuid(self): - # 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}") + """ + It's a workaround to pass some unique identifier to the oncall gateway while proxying telegram requests + """ + return UIURLBuilder(self).home(f"?oncall-uuid={self.uuid}") @classmethod def __str__(self): diff --git a/engine/common/constants/slack_auth.py b/engine/common/constants/slack_auth.py index b6937113..bb93644f 100644 --- a/engine/common/constants/slack_auth.py +++ b/engine/common/constants/slack_auth.py @@ -3,6 +3,7 @@ REDIRECT_AFTER_SLACK_INSTALL = "redirect_after_slack_install" SLACK_AUTH_WRONG_WORKSPACE_ERROR = "wrong_workspace" SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR = "user_already_connected" SLACK_AUTH_FAILED = "auth_failed" +SLACK_REGION_ERROR = "region_error" # Example of a slack oauth response to be used in tests.