update URLs constructed by the backend to support IRM plugin (#5137)

# 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.
This commit is contained in:
Joey Orlando 2024-10-09 08:55:10 -04:00 committed by GitHub
parent 2545bf8336
commit bfcc0b9f29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 302 additions and 101 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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