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:
parent
2545bf8336
commit
bfcc0b9f29
30 changed files with 302 additions and 101 deletions
|
|
@ -4,7 +4,6 @@ import typing
|
||||||
import urllib
|
import urllib
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from celery import uuid as celery_uuid
|
from celery import uuid as celery_uuid
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -27,10 +26,10 @@ from apps.alerts.tasks import (
|
||||||
send_alert_group_signal_for_delete,
|
send_alert_group_signal_for_delete,
|
||||||
unsilence_task,
|
unsilence_task,
|
||||||
)
|
)
|
||||||
|
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
|
||||||
from apps.metrics_exporter.tasks import update_metrics_for_alert_group
|
from apps.metrics_exporter.tasks import update_metrics_for_alert_group
|
||||||
from apps.slack.slack_formatter import SlackFormatter
|
from apps.slack.slack_formatter import SlackFormatter
|
||||||
from apps.user_management.models import User
|
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.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||||
from common.utils import clean_markup, str_or_backup
|
from common.utils import clean_markup, str_or_backup
|
||||||
|
|
||||||
|
|
@ -543,17 +542,19 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def web_link(self) -> str:
|
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
|
@property
|
||||||
def declare_incident_link(self) -> str:
|
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")
|
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 = 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
|
title = title[:2000] # set max title length to avoid exceptions with too long declare incident link
|
||||||
link = urllib.parse.quote_plus(self.web_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
|
@property
|
||||||
def happened_while_maintenance(self):
|
def happened_while_maintenance(self):
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import emoji
|
import emoji
|
||||||
from celery import uuid as celery_uuid
|
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.alerts.tasks import disable_maintenance, disconnect_integration_from_alerting_contact_points
|
||||||
from apps.base.messaging import get_messaging_backend_from_id
|
from apps.base.messaging import get_messaging_backend_from_id
|
||||||
from apps.base.utils import live_settings
|
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.legacy_prefix import remove_legacy_prefix
|
||||||
from apps.integrations.metadata import heartbeat
|
from apps.integrations.metadata import heartbeat
|
||||||
from apps.integrations.tasks import create_alert, create_alertmanager_alerts
|
from apps.integrations.tasks import create_alert, create_alertmanager_alerts
|
||||||
|
|
@ -422,8 +422,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def new_incidents_web_link(self):
|
def new_incidents_web_link(self):
|
||||||
return urljoin(
|
return UIURLBuilder(self.organization).alert_groups(
|
||||||
self.organization.web_link, f"?page=incidents&integration={self.public_primary_key}&status=0&p=1"
|
f"?integration={self.public_primary_key}&status={AlertGroup.NEW}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -531,8 +531,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
||||||
return f"{self.get_integration_display()} {self.smile_code}"
|
return f"{self.get_integration_display()} {self.smile_code}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def web_link(self):
|
def web_link(self) -> str:
|
||||||
return urljoin(self.organization.web_link, f"integrations/{self.public_primary_key}")
|
return UIURLBuilder(self.organization).integration_detail(self.public_primary_key)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def integration_url(self) -> str | None:
|
def integration_url(self) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
|
||||||
connector = self.context.get("connector", None)
|
connector = self.context.get("connector", None)
|
||||||
identities = self.context.get("cloud_identities", {})
|
identities = self.context.get("cloud_identities", {})
|
||||||
identity = identities.get(obj.email, None)
|
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 status
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,18 @@ from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME
|
from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME
|
||||||
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND
|
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
|
from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE
|
||||||
|
|
||||||
|
GRAFANA_URL = "http://example.com"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"backend_name,expected_url",
|
"backend_name,expected_url",
|
||||||
(
|
(
|
||||||
("slack-login", "/a/grafana-oncall-app/users/me"),
|
("slack-login", f"{GRAFANA_URL}/a/{PluginID.ONCALL}/users/me"),
|
||||||
(SLACK_INSTALLATION_BACKEND, "/a/grafana-oncall-app/chat-ops"),
|
(SLACK_INSTALLATION_BACKEND, f"{GRAFANA_URL}/a/{PluginID.ONCALL}/chat-ops"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_complete_slack_auth_redirect_ok(
|
def test_complete_slack_auth_redirect_ok(
|
||||||
|
|
@ -28,7 +31,7 @@ def test_complete_slack_auth_redirect_ok(
|
||||||
backend_name,
|
backend_name,
|
||||||
expected_url,
|
expected_url,
|
||||||
):
|
):
|
||||||
organization = make_organization()
|
organization = make_organization(grafana_url=GRAFANA_URL)
|
||||||
admin = make_user_for_organization(organization)
|
admin = make_user_for_organization(organization)
|
||||||
_, slack_token = make_slack_token_for_user(admin)
|
_, slack_token = make_slack_token_for_user(admin)
|
||||||
|
|
||||||
|
|
@ -181,7 +184,7 @@ def test_google_complete_auth_redirect_ok(
|
||||||
make_user_for_organization,
|
make_user_for_organization,
|
||||||
make_google_oauth2_token_for_user,
|
make_google_oauth2_token_for_user,
|
||||||
):
|
):
|
||||||
organization = make_organization()
|
organization = make_organization(grafana_url=GRAFANA_URL)
|
||||||
admin = make_user_for_organization(organization)
|
admin = make_user_for_organization(organization)
|
||||||
_, google_oauth2_token = make_google_oauth2_token_for_user(admin)
|
_, 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)
|
response = client.get(url)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_302_FOUND
|
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
|
@pytest.mark.django_db
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
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_installation_link_from_chatops_proxy,
|
||||||
get_slack_oauth_response_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.slack.installation import install_slack_integration
|
||||||
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginSlackOAuth2V2
|
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")
|
@psa("social:complete")
|
||||||
def overridden_complete_social_auth(request: Request, backend: str, *args, **kwargs) -> Response:
|
def overridden_complete_social_auth(request: Request, backend: str, *args, **kwargs) -> Response:
|
||||||
"""Authentication complete view"""
|
"""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(
|
kwargs.update(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
redirect_name=REDIRECT_FIELD_NAME,
|
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)
|
return_to = request.backend.strategy.session.get(REDIRECT_FIELD_NAME)
|
||||||
|
|
||||||
if return_to is None:
|
if return_to is None:
|
||||||
# We build the frontend url using org url since multiple stacks could be connected to one backend.
|
url_builder = UIURLBuilder(request.user.organization)
|
||||||
return_to = urljoin(request.user.organization.grafana_url, redirect_to)
|
|
||||||
|
# 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)
|
return HttpResponseRedirect(return_to)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
# register_oncall_tenant moved to separate file from engine/apps/chatops_proxy/utils.py to avoid circular imports.
|
# 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 django.conf import settings
|
||||||
|
|
||||||
from apps.chatops_proxy.client import APP_TYPE_ONCALL, ChatopsProxyAPIClient
|
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.
|
register_oncall_tenant registers oncall organization as a tenant in chatops-proxy.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ Set of utils to handle oncall and chatops-proxy interaction.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from django.conf import settings
|
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 .client import APP_TYPE_ONCALL, PROVIDER_TYPE_SLACK, ChatopsProxyAPIClient, ChatopsProxyAPIException
|
||||||
from .register_oncall_tenant import register_oncall_tenant
|
from .register_oncall_tenant import register_oncall_tenant
|
||||||
from .tasks import (
|
from .tasks import (
|
||||||
|
|
@ -16,10 +17,13 @@ from .tasks import (
|
||||||
unregister_oncall_tenant_async,
|
unregister_oncall_tenant_async,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from apps.user_management.models import Organization, User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
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.
|
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(
|
link, _ = client.get_slack_oauth_link(
|
||||||
org.stack_id,
|
org.stack_id,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
urljoin(org.web_link, "settings?tab=ChatOps&chatOpsTab=Slack"),
|
UIURLBuilder(org).settings("?tab=ChatOps&chatOpsTab=Slack"),
|
||||||
APP_TYPE_ONCALL,
|
APP_TYPE_ONCALL,
|
||||||
)
|
)
|
||||||
return link
|
return link
|
||||||
|
|
@ -44,13 +48,13 @@ def get_installation_link_from_chatops_proxy(user) -> typing.Optional[str]:
|
||||||
raise api_exc
|
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)
|
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||||
slack_installation, _ = client.get_oauth_installation(stack_id, PROVIDER_TYPE_SLACK)
|
slack_installation, _ = client.get_oauth_installation(stack_id, PROVIDER_TYPE_SLACK)
|
||||||
return slack_installation.oauth_response
|
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
|
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.
|
to make sure that tenant is registered.
|
||||||
|
|
|
||||||
105
engine/apps/grafana_plugin/tests/test_ui_url_builder.py
Normal file
105
engine/apps/grafana_plugin/tests/test_ui_url_builder.py
Normal 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
|
||||||
58
engine/apps/grafana_plugin/ui_url_builder.py
Normal file
58
engine/apps/grafana_plugin/ui_url_builder.py
Normal 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)
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from urllib.parse import urljoin
|
import typing
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from apps.base.utils import live_settings
|
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
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -102,9 +107,13 @@ def send_cloud_heartbeat():
|
||||||
logger.info("Finish 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:
|
if connector is None:
|
||||||
return None
|
return None
|
||||||
if heartbeat is None:
|
if heartbeat is None:
|
||||||
return 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)
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ class CloudUserSerializer(serializers.ModelSerializer):
|
||||||
model = User
|
model = User
|
||||||
fields = ["cloud_data"]
|
fields = ["cloud_data"]
|
||||||
|
|
||||||
def get_cloud_data(self, obj):
|
def get_cloud_data(self, obj: User):
|
||||||
connector = CloudConnector.objects.filter().first()
|
connector = CloudConnector.objects.filter().first()
|
||||||
cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).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}
|
cloud_data = {"status": status, "link": link}
|
||||||
return cloud_data
|
return cloud_data
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urljoin
|
import typing
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
|
||||||
from apps.oss_installation.constants import CloudSyncStatus
|
from apps.oss_installation.constants import CloudSyncStatus
|
||||||
from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -67,7 +71,7 @@ def active_oss_users_count():
|
||||||
return len(unique_active_users)
|
return len(unique_active_users)
|
||||||
|
|
||||||
|
|
||||||
def cloud_user_identity_status(connector, identity):
|
def cloud_user_identity_status(org: "Organization", connector, identity):
|
||||||
link = None
|
link = None
|
||||||
if connector is None:
|
if connector is None:
|
||||||
status = CloudSyncStatus.NOT_SYNCED
|
status = CloudSyncStatus.NOT_SYNCED
|
||||||
|
|
@ -80,5 +84,5 @@ def cloud_user_identity_status(connector, identity):
|
||||||
else:
|
else:
|
||||||
status = CloudSyncStatus.SYNCED_PHONE_NOT_VERIFIED
|
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
|
return status, link
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ class CloudConnectionView(APIView):
|
||||||
"cloud_connection_status": connector is not None,
|
"cloud_connection_status": connector is not None,
|
||||||
"cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
|
"cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
|
||||||
"cloud_heartbeat_enabled": live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_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,
|
"cloud_heartbeat_status": heartbeat is not None and heartbeat.success,
|
||||||
}
|
}
|
||||||
return Response(response)
|
return Response(response)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class CloudHeartbeatView(APIView):
|
||||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Cloud heartbeat already exists"})
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Cloud heartbeat already exists"})
|
||||||
except CloudHeartbeat.DoesNotExist:
|
except CloudHeartbeat.DoesNotExist:
|
||||||
heartbeat = setup_heartbeat_integration()
|
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})
|
return Response(status=status.HTTP_200_OK, data={"link": link})
|
||||||
else:
|
else:
|
||||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"})
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"})
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ class CloudUsersView(CloudUsersPagination, APIView):
|
||||||
|
|
||||||
for user in results:
|
for user in results:
|
||||||
cloud_identity = cloud_identities.get(user.email, None)
|
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(
|
data.append(
|
||||||
{
|
{
|
||||||
"id": user.public_primary_key,
|
"id": user.public_primary_key,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.alerts.models import AlertGroup
|
from apps.alerts.models import AlertGroup
|
||||||
from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError
|
from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError
|
||||||
|
from common.constants.plugin_ids import PluginID
|
||||||
|
|
||||||
title = "Custom title"
|
title = "Custom title"
|
||||||
message = "Testing escalation with new alert group"
|
message = "Testing escalation with new alert group"
|
||||||
|
|
@ -70,7 +71,7 @@ def test_escalation_new_alert_group(
|
||||||
"slack": None,
|
"slack": None,
|
||||||
"slack_app": None,
|
"slack_app": None,
|
||||||
"telegram": 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,
|
"silenced_at": None,
|
||||||
"last_alert": {
|
"last_alert": {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from polymorphic.managers import PolymorphicManager
|
||||||
from polymorphic.models import PolymorphicModel
|
from polymorphic.models import PolymorphicModel
|
||||||
from polymorphic.query import PolymorphicQuerySet
|
from polymorphic.query import PolymorphicQuerySet
|
||||||
|
|
||||||
|
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
|
||||||
from apps.schedules.constants import (
|
from apps.schedules.constants import (
|
||||||
EXPORT_WINDOW_DAYS_AFTER,
|
EXPORT_WINDOW_DAYS_AFTER,
|
||||||
EXPORT_WINDOW_DAYS_BEFORE,
|
EXPORT_WINDOW_DAYS_BEFORE,
|
||||||
|
|
@ -247,11 +248,15 @@ class OnCallSchedule(PolymorphicModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def web_page_link(self) -> str:
|
def web_page_link(self) -> str:
|
||||||
return f"{self.organization.web_link}schedules"
|
return UIURLBuilder(self.organization).schedules()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def web_detail_page_link(self) -> str:
|
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]]:
|
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"""
|
"""Returns list of calendars. Primary calendar should always be the first"""
|
||||||
|
|
|
||||||
|
|
@ -171,10 +171,6 @@ class ShiftSwapRequest(models.Model):
|
||||||
"""
|
"""
|
||||||
return self.schedule.channel
|
return self.schedule.channel
|
||||||
|
|
||||||
@property
|
|
||||||
def schedule_slack_url(self) -> str:
|
|
||||||
return f"<{self.schedule.web_detail_page_link}|{self.schedule.name}>"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def organization(self) -> "Organization":
|
def organization(self) -> "Organization":
|
||||||
return self.schedule.organization
|
return self.schedule.organization
|
||||||
|
|
@ -183,11 +179,6 @@ class ShiftSwapRequest(models.Model):
|
||||||
def possible_benefactors(self) -> QuerySet["User"]:
|
def possible_benefactors(self) -> QuerySet["User"]:
|
||||||
return self.schedule.related_users().exclude(pk=self.beneficiary_id)
|
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):
|
def delete(self):
|
||||||
self.deleted_at = timezone.now()
|
self.deleted_at = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import enum
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
from urllib.parse import urljoin
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -12,6 +11,7 @@ from rest_framework.response import Response
|
||||||
from apps.alerts.models import AlertReceiveChannel
|
from apps.alerts.models import AlertReceiveChannel
|
||||||
from apps.alerts.paging import DirectPagingUserTeamValidationError, UserNotifications, direct_paging, user_is_oncall
|
from apps.alerts.paging import DirectPagingUserTeamValidationError, UserNotifications, direct_paging, user_is_oncall
|
||||||
from apps.api.permissions import RBACPermission, user_is_authorized
|
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.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.chatops_proxy_routing import make_private_metadata, make_value
|
||||||
from apps.slack.constants import DIVIDER, PRIVATE_METADATA_MAX_LENGTH
|
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:
|
if slack_team_identity.needs_reinstall:
|
||||||
organizations = _get_available_organizations(slack_team_identity, slack_user_identity)
|
organizations = _get_available_organizations(slack_team_identity, slack_user_identity)
|
||||||
if len(organizations) == 1:
|
if len(organizations) == 1:
|
||||||
# Provide a link to web if user has access only to one organization
|
# Provide a link to web if user has access only to one organization
|
||||||
link = urljoin(organizations[0].web_link, "settings?tab=ChatOps&chatOpsTab=Slack")
|
link = UIURLBuilder(organizations[0]).settings("?tab=ChatOps&chatOpsTab=Slack")
|
||||||
else:
|
else:
|
||||||
# Otherwise, provide a link to the documentation
|
# Otherwise, provide a link to the documentation
|
||||||
link = (
|
link = (
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
||||||
"type": "section",
|
"type": "section",
|
||||||
"text": {
|
"text": {
|
||||||
"type": "mrkdwn",
|
"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,
|
"verbatim": True,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
|
||||||
pk = shift_swap_request.pk
|
pk = shift_swap_request.pk
|
||||||
|
|
||||||
main_message_text = (
|
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 "
|
f"Your teammate {shift_swap_request.beneficiary.get_username_with_slack_verbal(True)} has submitted "
|
||||||
"a shift swap request."
|
"a shift swap request."
|
||||||
)
|
)
|
||||||
|
|
@ -262,7 +262,7 @@ class ShiftSwapRequestFollowUp(BaseShiftSwapRequestStep):
|
||||||
"text": {
|
"text": {
|
||||||
"type": "mrkdwn",
|
"type": "mrkdwn",
|
||||||
"text": (
|
"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 "
|
f"still open and will start in {delta}. Jump back into the thread and accept it if "
|
||||||
"you're available!"
|
"you're available!"
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from apps.slack.scenarios.paging import OnPagingTeamChange, StartDirectPaging
|
||||||
from apps.slack.scenarios.schedules import EditScheduleShiftNotifyStep
|
from apps.slack.scenarios.schedules import EditScheduleShiftNotifyStep
|
||||||
from apps.slack.scenarios.shift_swap_requests import AcceptShiftSwapRequestStep
|
from apps.slack.scenarios.shift_swap_requests import AcceptShiftSwapRequestStep
|
||||||
from apps.slack.types import PayloadType
|
from apps.slack.types import PayloadType
|
||||||
|
from common.constants.plugin_ids import PluginID
|
||||||
|
|
||||||
EVENT_TRIGGER_ID = "5333959822612.4122782784722.4734ff484b2ac4d36a185bb242ee9932"
|
EVENT_TRIGGER_ID = "5333959822612.4122782784722.4734ff484b2ac4d36a185bb242ee9932"
|
||||||
WARNING_TEXT = (
|
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(
|
mock_open_warning_window_if_needed.assert_called_once_with(
|
||||||
event_payload,
|
event_payload,
|
||||||
slack_team_identity,
|
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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class TestBaseShiftSwapRequestStep:
|
||||||
blocks = step._generate_blocks(ssr)
|
blocks = step._generate_blocks(ssr)
|
||||||
|
|
||||||
assert blocks[0]["text"]["text"] == (
|
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."
|
f"Your teammate {beneficiary.get_username_with_slack_verbal()} has submitted a shift swap request."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
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.auth_token.auth import PluginAuthentication
|
||||||
from apps.base.utils import live_settings
|
from apps.base.utils import live_settings
|
||||||
from apps.chatops_proxy.utils import uninstall_slack as uninstall_slack_from_chatops_proxy
|
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.client import SlackClient
|
||||||
from apps.slack.errors import SlackAPIError
|
from apps.slack.errors import SlackAPIError
|
||||||
from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING
|
from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING
|
||||||
|
|
@ -293,7 +293,7 @@ class SlackEventApiEndpointView(APIView):
|
||||||
elif organization:
|
elif organization:
|
||||||
user = slack_user_identity.get_user(organization)
|
user = slack_user_identity.get_user(organization)
|
||||||
if not user: # SlackUserIdentity exists but not connected to any user in this 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(
|
self._open_warning_window_if_needed(
|
||||||
payload,
|
payload,
|
||||||
slack_team_identity,
|
slack_team_identity,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
|
|
@ -7,9 +6,10 @@ from rest_framework import status
|
||||||
from social_core import exceptions
|
from social_core import exceptions
|
||||||
from social_django.middleware import SocialAuthExceptionMiddleware
|
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.backends import LoginSlackOAuth2V2
|
||||||
from apps.social_auth.exceptions import InstallMultiRegionSlackException
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -17,28 +17,24 @@ logger = logging.getLogger(__name__)
|
||||||
class SocialAuthAuthCanceledExceptionMiddleware(SocialAuthExceptionMiddleware):
|
class SocialAuthAuthCanceledExceptionMiddleware(SocialAuthExceptionMiddleware):
|
||||||
def process_exception(self, request, exception):
|
def process_exception(self, request, exception):
|
||||||
backend = getattr(exception, "backend", None)
|
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):
|
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:
|
if exception:
|
||||||
logger.warning(f"SocialAuthAuthCanceledExceptionMiddleware.process_exception: {exception}")
|
logger.warning(f"SocialAuthAuthCanceledExceptionMiddleware.process_exception: {exception}")
|
||||||
|
|
||||||
if isinstance(exception, exceptions.AuthCanceled):
|
if isinstance(exception, exceptions.AuthCanceled):
|
||||||
# if user canceled authentication, redirect them to the previous page using the same link
|
# if user canceled authentication, redirect them to the previous page using the same link
|
||||||
# as we used to redirect after auth/install
|
# as we used to redirect after auth/install
|
||||||
url_to_redirect = urljoin(request.user.organization.grafana_url, redirect_to)
|
return redirect(url_builder_function())
|
||||||
return redirect(url_to_redirect)
|
|
||||||
elif isinstance(exception, exceptions.AuthFailed):
|
elif isinstance(exception, exceptions.AuthFailed):
|
||||||
# if authentication was failed, redirect user to the plugin page using the same link
|
# 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
|
# as we used to redirect after auth/install with error flag
|
||||||
url_to_redirect = urljoin(
|
return redirect(url_builder_function(f"?slack_error={SLACK_AUTH_FAILED}"))
|
||||||
request.user.organization.grafana_url, f"{redirect_to}&slack_error={SLACK_AUTH_FAILED}"
|
|
||||||
)
|
|
||||||
return redirect(url_to_redirect)
|
|
||||||
elif isinstance(exception, KeyError) and REDIRECT_AFTER_SLACK_INSTALL in exception.args:
|
elif isinstance(exception, KeyError) and REDIRECT_AFTER_SLACK_INSTALL in exception.args:
|
||||||
return HttpResponse(status=status.HTTP_401_UNAUTHORIZED)
|
return HttpResponse(status=status.HTTP_401_UNAUTHORIZED)
|
||||||
elif isinstance(exception, InstallMultiRegionSlackException):
|
elif isinstance(exception, InstallMultiRegionSlackException):
|
||||||
REGION_ERROR = "region_error"
|
return redirect(url_builder_function(f"?tab=Slack&slack_error={SLACK_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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
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 social_core.backends.base import BaseAuth
|
||||||
|
|
||||||
from apps.google.utils import user_granted_all_required_scopes
|
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.exceptions import GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR
|
||||||
from apps.social_auth.types import GoogleOauth2Response
|
from apps.social_auth.types import GoogleOauth2Response
|
||||||
from apps.user_management.models import Organization, User
|
from apps.user_management.models import Organization, User
|
||||||
|
|
@ -32,10 +32,9 @@ def connect_user_to_google(
|
||||||
f"granted_scopes={granted_scopes}"
|
f"granted_scopes={granted_scopes}"
|
||||||
)
|
)
|
||||||
|
|
||||||
base_url_to_redirect = urljoin(organization.grafana_url, "/a/grafana-oncall-app/users/me")
|
strategy.session[REDIRECT_FIELD_NAME] = UIURLBuilder(organization).user_profile(
|
||||||
strategy.session[
|
f"?google_error={GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR}"
|
||||||
REDIRECT_FIELD_NAME
|
)
|
||||||
] = f"{base_url_to_redirect}?google_error={GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR}"
|
|
||||||
|
|
||||||
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
from django.contrib.auth import REDIRECT_FIELD_NAME
|
||||||
|
|
@ -7,6 +6,7 @@ from django.http import HttpResponse
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from apps.chatops_proxy.utils import can_link_slack_team, link_slack_team
|
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.slack.installation import SlackInstallationExc, install_slack_integration
|
||||||
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND
|
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND
|
||||||
from apps.social_auth.exceptions import InstallMultiRegionSlackException
|
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_team_identity = organization.slack_team_identity
|
||||||
slack_user_id = response["authed_user"]["id"]
|
slack_user_id = response["authed_user"]["id"]
|
||||||
|
url_builder = UIURLBuilder(organization)
|
||||||
redirect_to = "/a/grafana-oncall-app/users/me/"
|
|
||||||
base_url_to_redirect = urljoin(organization.grafana_url, redirect_to)
|
|
||||||
|
|
||||||
if slack_team_identity is None:
|
if slack_team_identity is None:
|
||||||
# means that organization doesn't have slack integration, so user cannot connect their account to slack
|
# 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"]:
|
if slack_team_identity.slack_id != response["team"]["id"]:
|
||||||
# means that user authed in another slack workspace that is not connected to their organization
|
# 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
|
# 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_builder.user_profile(
|
||||||
strategy.session[REDIRECT_FIELD_NAME] = url
|
f"?slack_error={SLACK_AUTH_WRONG_WORKSPACE_ERROR}",
|
||||||
|
)
|
||||||
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
if organization.users.filter(slack_user_identity__slack_id=slack_user_id).exists():
|
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
|
# 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_builder.user_profile(
|
||||||
strategy.session[REDIRECT_FIELD_NAME] = url
|
f"?slack_error={SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR}",
|
||||||
|
)
|
||||||
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# at this point everything is correct and we can create the SlackUserIdentity
|
# at this point everything is correct and we can create the SlackUserIdentity
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
|
|
@ -16,6 +15,7 @@ from apps.chatops_proxy.utils import (
|
||||||
unlink_slack_team,
|
unlink_slack_team,
|
||||||
unregister_oncall_tenant,
|
unregister_oncall_tenant,
|
||||||
)
|
)
|
||||||
|
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
|
||||||
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
|
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
|
||||||
from apps.user_management.types import AlertGroupTableColumn
|
from apps.user_management.types import AlertGroupTableColumn
|
||||||
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
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_rbac_permissions_enabled = models.BooleanField(default=False)
|
||||||
is_grafana_incident_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_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)
|
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)
|
grafana_incident_backend_url = models.CharField(max_length=300, null=True, default=None)
|
||||||
|
|
@ -344,14 +345,12 @@ class Organization(MaintainableObject):
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def web_link(self):
|
|
||||||
return urljoin(self.grafana_url, "a/grafana-oncall-app/")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def web_link_with_uuid(self):
|
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
|
@classmethod
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ REDIRECT_AFTER_SLACK_INSTALL = "redirect_after_slack_install"
|
||||||
SLACK_AUTH_WRONG_WORKSPACE_ERROR = "wrong_workspace"
|
SLACK_AUTH_WRONG_WORKSPACE_ERROR = "wrong_workspace"
|
||||||
SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR = "user_already_connected"
|
SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR = "user_already_connected"
|
||||||
SLACK_AUTH_FAILED = "auth_failed"
|
SLACK_AUTH_FAILED = "auth_failed"
|
||||||
|
SLACK_REGION_ERROR = "region_error"
|
||||||
|
|
||||||
|
|
||||||
# Example of a slack oauth response to be used in tests.
|
# Example of a slack oauth response to be used in tests.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue