Prepare OnCall for Unified Slack App (#4232)
This PR does a bunch of changes to prepare OnCall for Unified Slack App: 1. Install Slack via Chatops-Proxy. This change contains two parts: getting a Slack install link from chatops-proxy ([code](https://github.com/grafana/oncall/pull/4232/files#diff-437a77d49fc04b92d315651b3df5991000b1ab74cf60aabb21aa77cb2823bf52R46)) and receiving a "slack installed" event from chatops-proxy ([code](https://github.com/grafana/oncall/pull/4232/files#diff-976d106f0962be5c1de5e35582193f68435ed0c17f2defd6bd2857bf6e27f65d)). Also it means that OnCall doesn't need to register slack_links anymore when slack is connected/disconnected. These changes are behind UNIFIED_SLACK_APP_ENABLED flag and should be no-op if flag is not enabled. 2. Get rid of Multiregionatily restrictions - instrument all slack interactions with a ProxyMeta - json data telling chatops-proxy where to route the interaction. Note, that it doesn't apply for "Add to resolution notes" message action - it will be handled differently in following PR. 3. Move all chatops-proxy related stuff from common/oncall-gateway to apps/chatops-proxy Minor changes: 1. Remove usage of **CHATOPS_V3** flag. Chatops v3 is already released (It's a refactoring from previous quarter) --------- Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com> Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
parent
2aa8639e2a
commit
17f448c506
48 changed files with 1286 additions and 770 deletions
|
|
@ -6,4 +6,4 @@ registry: ctlptl-registry
|
|||
kindV1Alpha4Cluster:
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: kindest/node:v1.24.7
|
||||
image: kindest/node:v1.27.3
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from django.utils.text import Truncator
|
|||
|
||||
from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
|
||||
from apps.alerts.incident_appearance.templaters import AlertSlackTemplater
|
||||
from apps.slack.chatops_proxy_routing import make_value
|
||||
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE
|
||||
from apps.slack.scenarios.scenario_step import ScenarioStep
|
||||
from apps.slack.types import Block
|
||||
|
|
@ -436,9 +437,8 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer):
|
|||
|
||||
data = {
|
||||
"organization_id": self.alert_group.channel.organization_id,
|
||||
"alert_group_pk": self.alert_group.pk,
|
||||
# eventually replace using alert_group_pk with alert_group_public_pk in slack payload
|
||||
"alert_group_ppk": self.alert_group.public_primary_key,
|
||||
**kwargs,
|
||||
}
|
||||
return json.dumps(data) # Slack block elements allow to pass value as string only (max 2000 chars)
|
||||
|
||||
return make_value(data, self.alert_group.channel.organization)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
||||
|
|
@ -9,6 +9,8 @@ from rest_framework import status
|
|||
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.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -16,7 +18,7 @@ from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME
|
|||
"backend_name,expected_url",
|
||||
(
|
||||
("slack-login", "/a/grafana-oncall-app/users/me"),
|
||||
("slack-install-free", "/a/grafana-oncall-app/chat-ops"),
|
||||
(SLACK_INSTALLATION_BACKEND, "/a/grafana-oncall-app/chat-ops"),
|
||||
),
|
||||
)
|
||||
def test_complete_slack_auth_redirect_ok(
|
||||
|
|
@ -71,6 +73,77 @@ def test_complete_slack_auth_redirect_error(
|
|||
assert response.url == "some-url"
|
||||
|
||||
|
||||
@override_settings(UNIFIED_SLACK_APP_ENABLED=False)
|
||||
@pytest.mark.django_db
|
||||
def test_start_slack_ok(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
"""
|
||||
Covers the case when user starts Slack integration installation via Grafana OnCall
|
||||
"""
|
||||
_, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND})
|
||||
|
||||
mock_do_auth_return = Mock()
|
||||
mock_do_auth_return.url = "https://slack_oauth_redirect.com"
|
||||
with patch("apps.api.views.auth.do_auth", return_value=mock_do_auth_return) as mock_do_auth:
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert mock_do_auth.called
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == "https://slack_oauth_redirect.com"
|
||||
|
||||
|
||||
@override_settings(UNIFIED_SLACK_APP_ENABLED=True)
|
||||
@pytest.mark.django_db
|
||||
def test_start_unified_slack_ok(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
"""
|
||||
Covers the case when user starts Unified Slack integration installation
|
||||
"""
|
||||
_, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND})
|
||||
|
||||
with patch(
|
||||
"apps.api.views.auth.get_installation_link_from_chatops_proxy", return_value="https://slack_oauth_redirect.com"
|
||||
):
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == "https://slack_oauth_redirect.com"
|
||||
|
||||
|
||||
@override_settings(UNIFIED_SLACK_APP_ENABLED=True)
|
||||
@patch("apps.api.views.auth.get_installation_link_from_chatops_proxy", return_value=None)
|
||||
@patch("apps.api.views.auth.get_slack_oauth_response_from_chatops_proxy", return_value=SLACK_OAUTH_ACCESS_RESPONSE)
|
||||
@patch("apps.api.views.auth.install_slack_integration", return_value=None)
|
||||
@pytest.mark.django_db
|
||||
def test_start_slack_ok_via_chatops_proxy_when_already_installed(
|
||||
mock_install_slack_integration,
|
||||
mock_get_slack_oauth_response_from_chatops_proxy,
|
||||
mock_get_installation_link_from_chatops_proxy,
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
"""
|
||||
Covers the case when user starts Unified Slack integration installation, but it's already installed.
|
||||
It might happen if integration was installed from Incident side, but OnCall missed the corresponding event
|
||||
"""
|
||||
org, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND})
|
||||
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert mock_install_slack_integration.call_args.args == (org, user, SLACK_OAUTH_ACCESS_RESPONSE)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("apps.social_auth.backends.GoogleOAuth2.get_redirect_uri")
|
||||
@patch("apps.social_auth.backends.GoogleOAuth2Token.create_auth_token", return_value=("something", "token_string"))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ from social_django.utils import psa
|
|||
from social_django.views import _do_login
|
||||
|
||||
from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication
|
||||
from apps.social_auth.backends import LoginSlackOAuth2V2
|
||||
from apps.chatops_proxy.utils import (
|
||||
get_installation_link_from_chatops_proxy,
|
||||
get_slack_oauth_response_from_chatops_proxy,
|
||||
)
|
||||
from apps.slack.installation import install_slack_integration
|
||||
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginSlackOAuth2V2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -25,6 +30,10 @@ logger = logging.getLogger(__name__)
|
|||
@never_cache
|
||||
@psa("social:complete")
|
||||
def overridden_login_social_auth(request: Request, backend: str) -> Response:
|
||||
"""
|
||||
overridden_login_social_auth starts the installation of integration which uses OAuth flow.
|
||||
"""
|
||||
|
||||
# We can't just redirect frontend here because we need to make a API call and pass tokens to this view from JS.
|
||||
# So frontend can't follow our redirect.
|
||||
# So wrapping and returning URL to redirect as a string.
|
||||
|
|
@ -34,7 +43,26 @@ def overridden_login_social_auth(request: Request, backend: str) -> Response:
|
|||
status=400,
|
||||
)
|
||||
|
||||
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
|
||||
if backend == SLACK_INSTALLATION_BACKEND and settings.UNIFIED_SLACK_APP_ENABLED:
|
||||
"""
|
||||
Install unified slack integration via chatops-proxy.
|
||||
1. Get installation link from chatops-proxy
|
||||
2. If link is not None – slack installation already exists on Chatops-Proxy - install using it's oauth response.
|
||||
"""
|
||||
try:
|
||||
link = get_installation_link_from_chatops_proxy(request.user)
|
||||
if link is not None:
|
||||
return Response(link, 200)
|
||||
else:
|
||||
slack_oauth_response = get_slack_oauth_response_from_chatops_proxy(request.user.organization.stack_id)
|
||||
install_slack_integration(request.user.organization, request.user, slack_oauth_response)
|
||||
return Response("slack integration installed", 201)
|
||||
except Exception as e:
|
||||
logger.exception("overridden_login_social_auth: Failed to install slack via chatops-proxy: %s", e)
|
||||
return Response({"error": "something went wrong, try again later"}, 500)
|
||||
else:
|
||||
# Otherwise use social-auth.
|
||||
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
|
||||
return Response(url_to_redirect_to, 200)
|
||||
|
||||
|
||||
|
|
|
|||
0
engine/apps/chatops_proxy/__init__.py
Normal file
0
engine/apps/chatops_proxy/__init__.py
Normal file
|
|
@ -7,6 +7,7 @@ import requests
|
|||
from django.conf import settings
|
||||
|
||||
SERVICE_TYPE_ONCALL = "oncall"
|
||||
PROVIDER_TYPE_SLACK = "slack"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -32,6 +33,15 @@ class Tenant:
|
|||
msteams_links: List[MSTeamsLink] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthInstallation:
|
||||
id: str
|
||||
oauth_response: dict
|
||||
stack_id: int
|
||||
provider_type: str
|
||||
provider_id: str
|
||||
|
||||
|
||||
class ChatopsProxyAPIException(Exception):
|
||||
"""A generic 400 or 500 level exception from the Chatops Proxy API"""
|
||||
|
||||
|
|
@ -55,7 +65,7 @@ class ChatopsProxyAPIClient:
|
|||
|
||||
# OnCall Tenant
|
||||
def register_tenant(
|
||||
self, service_tenant_id: str, cluster_slug: str, service_type: str
|
||||
self, service_tenant_id: str, cluster_slug: str, service_type: str, stack_id: int
|
||||
) -> tuple[Tenant, requests.models.Response]:
|
||||
url = f"{self.api_base_url}/tenants/register"
|
||||
d = {
|
||||
|
|
@ -63,6 +73,7 @@ class ChatopsProxyAPIClient:
|
|||
"service_tenant_id": service_tenant_id,
|
||||
"cluster_slug": cluster_slug,
|
||||
"service_type": service_type,
|
||||
"stack_id": stack_id,
|
||||
}
|
||||
}
|
||||
response = requests.post(url=url, json=d, headers=self._headers)
|
||||
|
|
@ -131,6 +142,34 @@ class ChatopsProxyAPIClient:
|
|||
self._check_response(response)
|
||||
return response.json()["removed"], response
|
||||
|
||||
def get_slack_oauth_link(
|
||||
self, stack_id: int, grafana_user_id: int, app_redirect: str, app_type: str
|
||||
) -> tuple[str, requests.models.Response]:
|
||||
url = f"{self.api_base_url}/oauth2/start"
|
||||
d = {
|
||||
"stack_id": stack_id,
|
||||
"grafana_user_id": grafana_user_id,
|
||||
"app_redirect": app_redirect,
|
||||
"app_type": app_type,
|
||||
}
|
||||
response = requests.post(url=url, json=d, headers=self._headers)
|
||||
self._check_response(response)
|
||||
return response.json()["install_link"], response
|
||||
|
||||
def get_oauth_installation(
|
||||
self,
|
||||
stack_id: int,
|
||||
provider_type: str,
|
||||
) -> tuple[OAuthInstallation, requests.models.Response]:
|
||||
url = f"{self.api_base_url}/oauth_installations/get"
|
||||
d = {
|
||||
"stack_id": stack_id,
|
||||
"provider_type": provider_type,
|
||||
}
|
||||
response = requests.post(url=url, json=d, headers=self._headers)
|
||||
self._check_response(response)
|
||||
return OAuthInstallation(**response.json()["oauth_installation"]), response
|
||||
|
||||
def _check_response(self, response: requests.models.Response):
|
||||
"""
|
||||
Wraps an exceptional response to ChatopsProxyAPIException
|
||||
1
engine/apps/chatops_proxy/events/__init__.py
Normal file
1
engine/apps/chatops_proxy/events/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .root_handler import ChatopsEventsHandler # noqa
|
||||
50
engine/apps/chatops_proxy/events/handlers.py
Normal file
50
engine/apps/chatops_proxy/events/handlers.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import logging
|
||||
import typing
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from apps.chatops_proxy.client import PROVIDER_TYPE_SLACK
|
||||
from apps.slack.installation import SlackInstallationExc, install_slack_integration
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
from .types import INTEGRATION_INSTALLED_EVENT_TYPE, Event, IntegrationInstalledData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Handler(ABC):
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def match(cls, event: Event) -> bool:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def handle(cls, event_data: dict) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class SlackInstallationHandler(Handler):
|
||||
@classmethod
|
||||
def match(cls, event: Event) -> bool:
|
||||
return (
|
||||
event.get("event_type") == INTEGRATION_INSTALLED_EVENT_TYPE
|
||||
and event.get("data", {}).get("provider_type") == PROVIDER_TYPE_SLACK
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def handle(cls, data: dict) -> None:
|
||||
data = typing.cast(IntegrationInstalledData, data)
|
||||
|
||||
stack_id = data.get("stack_id")
|
||||
user_id = data.get("grafana_user_id")
|
||||
payload = data.get("payload")
|
||||
|
||||
organization = Organization.objects.get(stack_id=stack_id)
|
||||
user = organization.users.get(user_id=user_id)
|
||||
try:
|
||||
install_slack_integration(organization, user, payload)
|
||||
except SlackInstallationExc as e:
|
||||
logger.exception(
|
||||
f'msg="SlackInstallationHandler: Failed to install Slack integration: %s" org_id={organization.id} stack_id={stack_id}',
|
||||
e,
|
||||
)
|
||||
37
engine/apps/chatops_proxy/events/root_handler.py
Normal file
37
engine/apps/chatops_proxy/events/root_handler.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import logging
|
||||
import typing
|
||||
|
||||
from .handlers import Handler, SlackInstallationHandler
|
||||
from .types import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatopsEventsHandler:
|
||||
"""
|
||||
ChatopsEventsHandler is a root handler which receives event from Chatops-Proxy and chooses the handler to process it.
|
||||
"""
|
||||
|
||||
HANDLERS: typing.List[typing.Type[Handler]] = [SlackInstallationHandler]
|
||||
|
||||
def handle(self, event_data: Event) -> bool:
|
||||
"""
|
||||
handle iterates over all handlers and chooses the first one that matches the event.
|
||||
Returns True if a handler was found and False otherwise.
|
||||
"""
|
||||
logger.info(f"msg=\"ChatopsEventsHandler: Handling\" event_type={event_data.get('event_type')}")
|
||||
for h in self.HANDLERS:
|
||||
if h.match(event_data):
|
||||
logger.info(
|
||||
f"msg=\"ChatopsEventsHandler: Found matching handler {h.__name__}\" event_type={event_data.get('event_type')}"
|
||||
)
|
||||
self._exec(h.handle, event_data.get("data", {}))
|
||||
return True
|
||||
logger.error(f"msg=\"ChatopsEventsHandler: No handler found\" event_type={event_data.get('event_type')}")
|
||||
return False
|
||||
|
||||
def _exec(self, handlefunc: typing.Callable[[dict], None], data: dict):
|
||||
"""
|
||||
_exec is a helper method to execute a handler's handle method.
|
||||
"""
|
||||
return handlefunc(data)
|
||||
36
engine/apps/chatops_proxy/events/signature.py
Normal file
36
engine/apps/chatops_proxy/events/signature.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
|
||||
def hash(data):
|
||||
hasher = hashlib.sha256()
|
||||
hasher.update(data)
|
||||
return base64.b64encode(hasher.digest()).decode()
|
||||
|
||||
|
||||
def generate_signature(data, secret):
|
||||
h = hmac.new(secret.encode(), data.encode(), hashlib.sha256)
|
||||
return binascii.hexlify(h.digest()).decode()
|
||||
|
||||
|
||||
def verify_signature(request, secret) -> bool:
|
||||
header = request.META.get("HTTP_X_CHATOPS_SIGNATURE")
|
||||
if not header:
|
||||
return False
|
||||
|
||||
signatures = header.split(",")
|
||||
s = dict(pair.split("=") for pair in signatures)
|
||||
t = s.get("t")
|
||||
v1 = s.get("v1")
|
||||
|
||||
payload = request.body
|
||||
body_hash = hash(payload)
|
||||
string_to_sign = f"{body_hash}:{t}:v1"
|
||||
expected = generate_signature(string_to_sign, secret)
|
||||
|
||||
if expected != v1:
|
||||
return False
|
||||
|
||||
return True
|
||||
17
engine/apps/chatops_proxy/events/types.py
Normal file
17
engine/apps/chatops_proxy/events/types.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import typing
|
||||
|
||||
INTEGRATION_INSTALLED_EVENT_TYPE = "integration_installed"
|
||||
INTEGRATION_UNINSTALLED_EVENT_TYPE = "integration_uninstalled"
|
||||
|
||||
|
||||
class Event(typing.TypedDict):
|
||||
event_type: str
|
||||
data: dict
|
||||
|
||||
|
||||
class IntegrationInstalledData(typing.TypedDict):
|
||||
oauth_installation_id: int
|
||||
provider_type: str
|
||||
stack_id: int
|
||||
grafana_user_id: int
|
||||
payload: dict
|
||||
122
engine/apps/chatops_proxy/tasks.py
Normal file
122
engine/apps/chatops_proxy/tasks.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
||||
from .client import ChatopsProxyAPIClient, ChatopsProxyAPIException
|
||||
|
||||
task_logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=100,
|
||||
)
|
||||
def register_oncall_tenant_async(**kwargs):
|
||||
service_tenant_id = kwargs.get("service_tenant_id")
|
||||
cluster_slug = kwargs.get("cluster_slug")
|
||||
service_type = kwargs.get("service_type")
|
||||
stack_id = kwargs.get("stack_id")
|
||||
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.register_tenant(service_tenant_id, cluster_slug, service_type, stack_id)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
task_logger.error(
|
||||
f'msg="Failed to register OnCall tenant: {api_exc.msg}" service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}'
|
||||
)
|
||||
if api_exc.status == 409:
|
||||
# 409 Indicates that it's impossible to register tenant, because tenant already registered.
|
||||
# Not retrying in this case, because manual conflict-resolution needed.
|
||||
return
|
||||
else:
|
||||
# Otherwise keep retrying task
|
||||
raise api_exc
|
||||
except Exception as e:
|
||||
# Keep retrying task for any other exceptions too
|
||||
task_logger.error(
|
||||
f"Failed to register OnCall tenant: {e} service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}"
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=100,
|
||||
)
|
||||
def unregister_oncall_tenant_async(**kwargs):
|
||||
service_tenant_id = kwargs.get("service_tenant_id")
|
||||
cluster_slug = kwargs.get("cluster_slug")
|
||||
service_type = kwargs.get("service_type")
|
||||
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.unregister_tenant(service_tenant_id, cluster_slug, service_type)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
if api_exc.status == 400:
|
||||
# 400 Indicates that tenant is already deleted
|
||||
return
|
||||
else:
|
||||
# Otherwise keep retrying task
|
||||
raise api_exc
|
||||
except Exception as e:
|
||||
task_logger.error(f"Failed to delete OnCallTenant: {e} service_tenant_id={service_tenant_id}")
|
||||
raise e
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=100,
|
||||
)
|
||||
def link_slack_team_async(**kwargs):
|
||||
service_tenant_id = kwargs.get("service_tenant_id")
|
||||
service_type = kwargs.get("service_type")
|
||||
slack_team_id = kwargs.get("slack_team_id")
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.link_slack_team(service_tenant_id, slack_team_id, service_type)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
task_logger.error(
|
||||
f'msg="Failed to link slack team: {api_exc.msg}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
|
||||
)
|
||||
if api_exc.status == 409:
|
||||
# Impossible to register tenant, slack workspace already connected to another cluster.
|
||||
# Not retrying in this case, because manual conflict-resolution needed.
|
||||
return
|
||||
else:
|
||||
raise api_exc
|
||||
except Exception as e:
|
||||
task_logger.error(
|
||||
f'msg="Failed to link slack team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=100,
|
||||
)
|
||||
def unlink_slack_team_async(**kwargs):
|
||||
service_tenant_id = kwargs.get("service_tenant_id")
|
||||
service_type = kwargs.get("service_type")
|
||||
slack_team_id = kwargs.get("slack_team_id")
|
||||
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.unlink_slack_team(service_tenant_id, slack_team_id, service_type)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
if api_exc.status == 400:
|
||||
# 400 Indicates that tenant is already deleted
|
||||
return
|
||||
else:
|
||||
# Otherwise keep retrying task
|
||||
raise api_exc
|
||||
except Exception as e:
|
||||
task_logger.error(
|
||||
f'msg="Failed to unlink slack_team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
|
||||
)
|
||||
raise e
|
||||
0
engine/apps/chatops_proxy/tests/__init__.py
Normal file
0
engine/apps/chatops_proxy/tests/__init__.py
Normal file
66
engine/apps/chatops_proxy/tests/test_events.py
Normal file
66
engine/apps/chatops_proxy/tests/test_events.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.chatops_proxy.events import ChatopsEventsHandler
|
||||
from apps.chatops_proxy.events.handlers import SlackInstallationHandler
|
||||
from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE
|
||||
|
||||
installation_event = {
|
||||
"event_type": "integration_installed",
|
||||
"data": {
|
||||
"provider_type": "slack",
|
||||
"stack_id": "stack_id",
|
||||
"grafana_user_id": "grafana_user_id",
|
||||
"payload": SLACK_OAUTH_ACCESS_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
unknown_event = {
|
||||
"event_type": "unknown_event",
|
||||
"data": {
|
||||
"provider_type": "slack",
|
||||
"stack_id": "stack_id",
|
||||
"grafana_user_id": "grafana_user_id",
|
||||
"payload": {},
|
||||
},
|
||||
}
|
||||
|
||||
invalid_schema_event = {
|
||||
"a": "b",
|
||||
"c": "d",
|
||||
}
|
||||
|
||||
|
||||
@patch.object(ChatopsEventsHandler, "_exec", return_value=None)
|
||||
@pytest.mark.parametrize(
|
||||
"payload,is_handled",
|
||||
[
|
||||
(installation_event, True),
|
||||
(unknown_event, False),
|
||||
(invalid_schema_event, False),
|
||||
],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
@override_settings(UNIFIED_SLACK_APP_ENABLED=True)
|
||||
def test_root_event_handler(mock_exec, payload, is_handled):
|
||||
h = ChatopsEventsHandler()
|
||||
assert h.handle(payload) is is_handled
|
||||
|
||||
|
||||
@patch("apps.chatops_proxy.events.handlers.install_slack_integration", return_value=None)
|
||||
@pytest.mark.django_db
|
||||
def test_slack_installation_handler(mock_install_slack_integration, make_organization_and_user):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
installation_event["data"].update({"stack_id": organization.stack_id, "grafana_user_id": user.user_id})
|
||||
|
||||
h = SlackInstallationHandler()
|
||||
|
||||
assert h.match(unknown_event) is False
|
||||
assert h.match(invalid_schema_event) is False
|
||||
|
||||
assert h.match(installation_event) is True
|
||||
h.handle(installation_event["data"])
|
||||
assert mock_install_slack_integration.call_args.args == (organization, user, installation_event["data"]["payload"])
|
||||
9
engine/apps/chatops_proxy/urls.py
Normal file
9
engine/apps/chatops_proxy/urls.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from common.api_helpers.optional_slash_router import optional_slash_path
|
||||
|
||||
from .views import ChatopsEventsView
|
||||
|
||||
app_name = "chatops-proxy"
|
||||
|
||||
urlpatterns = [
|
||||
optional_slash_path("events", ChatopsEventsView.as_view(), name="events"),
|
||||
]
|
||||
143
engine/apps/chatops_proxy/utils.py
Normal file
143
engine/apps/chatops_proxy/utils.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""
|
||||
Set of utils to handle oncall and chatops-proxy interaction.
|
||||
"""
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .client import PROVIDER_TYPE_SLACK, SERVICE_TYPE_ONCALL, ChatopsProxyAPIClient, ChatopsProxyAPIException
|
||||
from .tasks import (
|
||||
link_slack_team_async,
|
||||
register_oncall_tenant_async,
|
||||
unlink_slack_team_async,
|
||||
unregister_oncall_tenant_async,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_installation_link_from_chatops_proxy(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.
|
||||
"""
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
org = user.organization
|
||||
try:
|
||||
link, _ = client.get_slack_oauth_link(
|
||||
org.stack_id,
|
||||
user.user_id,
|
||||
org.web_link,
|
||||
SERVICE_TYPE_ONCALL,
|
||||
)
|
||||
return link
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
if api_exc.status == 409:
|
||||
return None
|
||||
logger.exception(
|
||||
"Error while getting installation link from chatops proxy: " "error=%s",
|
||||
api_exc,
|
||||
)
|
||||
raise api_exc
|
||||
|
||||
|
||||
def get_slack_oauth_response_from_chatops_proxy(stack_id) -> 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(service_tenant_id: str, cluster_slug: str, stack_id: int):
|
||||
"""
|
||||
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.
|
||||
First attempt is synchronous to register tenant ASAP to not miss any chatops requests.
|
||||
"""
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.register_tenant(
|
||||
service_tenant_id,
|
||||
cluster_slug,
|
||||
SERVICE_TYPE_ONCALL,
|
||||
stack_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"create_oncall_connector: failed "
|
||||
f"oncall_org_id={service_tenant_id} backend={cluster_slug} stack_id={stack_id} exc={e}"
|
||||
)
|
||||
register_oncall_tenant_async.apply_async(
|
||||
kwargs={
|
||||
"service_tenant_id": service_tenant_id,
|
||||
"cluster_slug": cluster_slug,
|
||||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
"stack_id": stack_id,
|
||||
},
|
||||
countdown=2,
|
||||
)
|
||||
|
||||
|
||||
def unregister_oncall_tenant(service_tenant_id: str, cluster_slug: str):
|
||||
"""
|
||||
unregister_oncall_tenant unregisters tenant asynchronously.
|
||||
"""
|
||||
unregister_oncall_tenant_async.apply_async(
|
||||
kwargs={
|
||||
"service_tenant_id": service_tenant_id,
|
||||
"cluster_slug": cluster_slug,
|
||||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
},
|
||||
countdown=2,
|
||||
)
|
||||
|
||||
|
||||
def can_link_slack_team(
|
||||
service_tenant_id: str,
|
||||
slack_team_id: str,
|
||||
cluster_slug: str,
|
||||
) -> bool:
|
||||
"""
|
||||
can_link_slack_team checks if it's possible to link slack workspace to oncall tenant located in cluster.
|
||||
All oncall tenants linked to same slack team should have same cluster.
|
||||
"""
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
response = client.can_slack_link(service_tenant_id, cluster_slug, slack_team_id, SERVICE_TYPE_ONCALL)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"can_link_slack_team: slack installation impossible: {e} "
|
||||
f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id} cluster_slug={cluster_slug}"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def link_slack_team(service_tenant_id: str, slack_team_id: str):
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.link_slack_team(service_tenant_id, slack_team_id, SERVICE_TYPE_ONCALL)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'msg="Failed to link slack team: {e}"'
|
||||
f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}"
|
||||
)
|
||||
link_slack_team_async.apply_async(
|
||||
kwargs={
|
||||
"service_tenant_id": service_tenant_id,
|
||||
"slack_team_id": slack_team_id,
|
||||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
},
|
||||
countdown=2,
|
||||
)
|
||||
|
||||
|
||||
def unlink_slack_team(service_tenant_id: str, slack_team_id: str):
|
||||
unlink_slack_team_async.apply_async(
|
||||
kwargs={
|
||||
"service_tenant_id": service_tenant_id,
|
||||
"slack_team_id": slack_team_id,
|
||||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
}
|
||||
)
|
||||
24
engine/apps/chatops_proxy/views.py
Normal file
24
engine/apps/chatops_proxy/views.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.chatops_proxy.events import ChatopsEventsHandler
|
||||
from apps.chatops_proxy.events.signature import verify_signature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
handler = ChatopsEventsHandler()
|
||||
|
||||
|
||||
class ChatopsEventsView(APIView):
|
||||
def post(self, request):
|
||||
verified = verify_signature(request, settings.CHATOPS_SIGNING_SECRET)
|
||||
if not verified:
|
||||
logger.error("ChatopsEventsView: Invalid signature")
|
||||
return Response(status=401)
|
||||
found = handler.handle(request.data)
|
||||
if not found:
|
||||
return Response(status=400)
|
||||
return Response(status=200)
|
||||
18
engine/apps/slack/chatops_proxy_routing.py
Normal file
18
engine/apps/slack/chatops_proxy_routing.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import json
|
||||
import typing
|
||||
|
||||
|
||||
# ProxyMeta is a data injected into various Slack payloads to route them to the correct cluster via Chatops-Proxy
|
||||
# Short keys are used to satisfy slack limit for 155 chars in values
|
||||
class ProxyMeta(typing.TypedDict):
|
||||
s: str # s is a service name
|
||||
tid: str # tid is a tenant_id
|
||||
|
||||
|
||||
def make_value(data: dict, organization) -> str:
|
||||
# Slack block elements allow to pass value as string only (max 2000 chars)
|
||||
return json.dumps({**data, "s": "oncall", "tid": str(organization.uuid)})
|
||||
|
||||
|
||||
def make_private_metadata(data: dict, organization) -> str:
|
||||
return json.dumps({**data, "s": "oncall", "tid": str(organization.uuid)})
|
||||
74
engine/apps/slack/installation.py
Normal file
74
engine/apps/slack/installation.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.chatops_proxy.utils import unlink_slack_team
|
||||
from apps.slack.tasks import (
|
||||
clean_slack_integration_leftovers,
|
||||
populate_slack_channels_for_team,
|
||||
populate_slack_usergroups_for_team,
|
||||
unpopulate_slack_user_identities,
|
||||
)
|
||||
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SlackInstallationExc(Exception):
|
||||
"""
|
||||
SlackInstallationExc represents some exception happened while managing Slack integration.
|
||||
"""
|
||||
|
||||
def __init__(self, error_message=None):
|
||||
# error message is a user-visible error message
|
||||
self.error_message = error_message
|
||||
|
||||
|
||||
def install_slack_integration(organization, user, oauth_response):
|
||||
"""
|
||||
Installs Slack integration for the organization.
|
||||
Raises:
|
||||
SlackInstallationExc if organization already has Slack integration.
|
||||
"""
|
||||
from apps.slack.models import SlackTeamIdentity
|
||||
|
||||
if organization.slack_team_identity is not None:
|
||||
raise SlackInstallationExc("Organization already has Slack integration")
|
||||
|
||||
slack_team_id = oauth_response["team"]["id"]
|
||||
slack_team_identity, is_slack_team_identity_created = SlackTeamIdentity.objects.get_or_create(
|
||||
slack_id=slack_team_id,
|
||||
)
|
||||
# update slack oauth fields by data from response
|
||||
slack_team_identity.update_oauth_fields(user, organization, oauth_response)
|
||||
write_chatops_insight_log(
|
||||
author=user, event_name=ChatOpsEvent.WORKSPACE_CONNECTED, chatops_type=ChatOpsTypePlug.SLACK.value
|
||||
)
|
||||
populate_slack_channels_for_team.apply_async((slack_team_identity.pk,))
|
||||
user.slack_user_identity.update_profile_info()
|
||||
# todo slack: do we need update info for all existing slack users in slack team?
|
||||
# 24.03.2024 - this todo here for a while. populate_slack_user_identities automatically links users to slack.
|
||||
# Should be useful if we want to unify with Incident.
|
||||
# populate_slack_user_identities.apply_async((organization.pk,))
|
||||
populate_slack_usergroups_for_team.apply_async((slack_team_identity.pk,), countdown=10)
|
||||
|
||||
|
||||
def uninstall_slack_integration(organization, user):
|
||||
"""
|
||||
Uninstalls Slack integration for the organization.
|
||||
Raises:
|
||||
SlackInstallationExc if organization has no Slack integration.
|
||||
"""
|
||||
slack_team_identity = organization.slack_team_identity
|
||||
if slack_team_identity is not None:
|
||||
clean_slack_integration_leftovers.apply_async((organization.pk,))
|
||||
if settings.FEATURE_MULTIREGION_ENABLED and not settings.UNIFIED_SLACK_APP_ENABLED:
|
||||
unlink_slack_team(str(organization.uuid), slack_team_identity.slack_id)
|
||||
write_chatops_insight_log(
|
||||
author=user,
|
||||
event_name=ChatOpsEvent.WORKSPACE_DISCONNECTED,
|
||||
chatops_type=ChatOpsTypePlug.SLACK.value,
|
||||
)
|
||||
unpopulate_slack_user_identities(organization.pk, True)
|
||||
else:
|
||||
raise SlackInstallationExc("Organization has no Slack integration.")
|
||||
|
|
@ -14,7 +14,6 @@ from apps.slack.errors import (
|
|||
SlackAPITokenError,
|
||||
)
|
||||
from apps.user_management.models.user import User
|
||||
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models.manager import RelatedManager
|
||||
|
|
@ -55,30 +54,27 @@ class SlackTeamIdentity(models.Model):
|
|||
def __str__(self):
|
||||
return f"{self.pk}: {self.name}"
|
||||
|
||||
def update_oauth_fields(self, user, organization, reinstall_data):
|
||||
def update_oauth_fields(self, user, organization, oauth_response):
|
||||
logger.info(f"updated oauth_fields for sti {self.pk}")
|
||||
from apps.slack.models import SlackUserIdentity
|
||||
|
||||
organization.slack_team_identity = self
|
||||
organization.save(update_fields=["slack_team_identity"])
|
||||
slack_user_identity, _ = SlackUserIdentity.objects.get_or_create(
|
||||
slack_id=reinstall_data["authed_user"]["id"],
|
||||
slack_id=oauth_response["authed_user"]["id"],
|
||||
slack_team_identity=self,
|
||||
)
|
||||
user.slack_user_identity = slack_user_identity
|
||||
user.save(update_fields=["slack_user_identity"])
|
||||
self.bot_access_token = reinstall_data["access_token"]
|
||||
self.bot_user_id = reinstall_data["bot_user_id"]
|
||||
self.oauth_scope = reinstall_data["scope"]
|
||||
self.cached_name = reinstall_data["team"]["name"]
|
||||
self.access_token = reinstall_data["authed_user"]["access_token"]
|
||||
self.bot_access_token = oauth_response["access_token"]
|
||||
self.bot_user_id = oauth_response["bot_user_id"]
|
||||
self.oauth_scope = oauth_response["scope"]
|
||||
self.cached_name = oauth_response["team"]["name"]
|
||||
self.access_token = oauth_response["authed_user"]["access_token"]
|
||||
self.installed_by = slack_user_identity
|
||||
self.cached_reinstall_data = None
|
||||
self.installed_via_granular_permissions = True
|
||||
self.save()
|
||||
write_chatops_insight_log(
|
||||
author=user, event_name=ChatOpsEvent.WORKSPACE_CONNECTED, chatops_type=ChatOpsTypePlug.SLACK.value
|
||||
)
|
||||
|
||||
def get_cached_channels(self, search_term=None, slack_id=None):
|
||||
queryset = self.cached_channels
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import json
|
|||
import typing
|
||||
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.slack.chatops_proxy_routing import make_private_metadata
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.types import (
|
||||
Block,
|
||||
|
|
@ -63,7 +64,7 @@ class OpenAlertAppearanceDialogStep(AlertGroupActionsMixin, scenario_step.Scenar
|
|||
"type": "plain_text",
|
||||
"text": "Refresh alert group",
|
||||
},
|
||||
"private_metadata": json.dumps(private_metadata),
|
||||
"private_metadata": make_private_metadata(private_metadata, alert_receive_channel.organization),
|
||||
}
|
||||
|
||||
self._slack_client.views_open(trigger_id=payload["trigger_id"], view=view)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from apps.alerts.incident_appearance.renderers.constants import DEFAULT_BACKUP_T
|
|||
from apps.alerts.incident_appearance.renderers.slack_renderer import AlertSlackRenderer
|
||||
from apps.alerts.models import Alert, AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, Invitation
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
|
||||
from apps.slack.constants import CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME
|
||||
from apps.slack.errors import (
|
||||
SlackAPICantUpdateMessageError,
|
||||
|
|
@ -339,11 +340,12 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
|
|||
"type": "plain_text",
|
||||
"text": "Attach to Alert Group",
|
||||
},
|
||||
"private_metadata": json.dumps(
|
||||
"private_metadata": make_private_metadata(
|
||||
{
|
||||
"organization_id": self.organization.pk if self.organization else alert_group.organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
}
|
||||
},
|
||||
self.organization,
|
||||
),
|
||||
"close": {"type": "plain_text", "text": "Cancel", "emoji": True},
|
||||
}
|
||||
|
|
@ -398,9 +400,8 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
|
|||
collected_options: typing.List[CompositionObjectOption] = []
|
||||
blocks: Block.AnyBlocks = []
|
||||
|
||||
alert_receive_channel_ids = AlertReceiveChannel.objects.filter(
|
||||
organization=alert_group.channel.organization
|
||||
).values_list("id", flat=True)
|
||||
org = alert_group.channel.organization
|
||||
alert_receive_channel_ids = AlertReceiveChannel.objects.filter(organization=org).values_list("id", flat=True)
|
||||
|
||||
alert_groups_queryset = (
|
||||
AlertGroup.objects.prefetch_related(
|
||||
|
|
@ -412,6 +413,7 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
|
|||
.order_by("-pk")
|
||||
)
|
||||
|
||||
sf = SlackFormatter(org)
|
||||
for alert_group_to_attach in alert_groups_queryset[:ATTACH_TO_ALERT_GROUPS_LIMIT]:
|
||||
# long_verbose_name_without_formatting was removed from here because it increases queries count due to
|
||||
# alerts.first().
|
||||
|
|
@ -419,7 +421,6 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
|
|||
# prefetch_related.
|
||||
first_alert = alert_group_to_attach.alerts.all()[0]
|
||||
templated_alert = AlertSlackRenderer(first_alert).templated_alert
|
||||
sf = SlackFormatter(alert_group_to_attach.channel.organization)
|
||||
if is_string_with_visible_characters(templated_alert.title):
|
||||
alert_name = templated_alert.title
|
||||
alert_name = sf.format(alert_name)
|
||||
|
|
@ -434,7 +435,7 @@ class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
|
|||
collected_options.append(
|
||||
{
|
||||
"text": {"type": "plain_text", "text": f"{alert_name}", "emoji": True},
|
||||
"value": str(alert_group_to_attach.pk),
|
||||
"value": make_value({root_ag_id_value_key: alert_group_to_attach.pk}, org),
|
||||
}
|
||||
)
|
||||
if len(collected_options) > 0:
|
||||
|
|
@ -495,16 +496,22 @@ class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
|
|||
if payload["type"] == PayloadType.VIEW_SUBMISSION:
|
||||
alert_group_pk = json.loads(payload["view"]["private_metadata"])["alert_group_pk"]
|
||||
alert_group = AlertGroup.objects.get(pk=alert_group_pk)
|
||||
root_alert_group_pk = payload["view"]["state"]["values"][SelectAttachGroupStep.routing_uid()][
|
||||
AttachGroupStep.routing_uid()
|
||||
]["selected_option"]["value"]
|
||||
root_alert_group_pk = _get_root_alert_group_id_from_value(
|
||||
payload["view"]["state"]["values"][SelectAttachGroupStep.routing_uid()][AttachGroupStep.routing_uid()][
|
||||
"selected_option"
|
||||
]["value"]
|
||||
)
|
||||
root_alert_group = AlertGroup.objects.get(pk=root_alert_group_pk)
|
||||
# old version of attach selection by dropdown
|
||||
else:
|
||||
try:
|
||||
root_alert_group_pk = int(payload["actions"][0]["selected_options"][0]["value"])
|
||||
root_alert_group_pk = int(
|
||||
_get_root_alert_group_id_from_value(payload["actions"][0]["selected_options"][0]["value"])
|
||||
)
|
||||
except KeyError:
|
||||
root_alert_group_pk = int(payload["actions"][0]["selected_option"]["value"])
|
||||
root_alert_group_pk = int(
|
||||
_get_root_alert_group_id_from_value(payload["actions"][0]["selected_option"]["value"])
|
||||
)
|
||||
|
||||
root_alert_group = AlertGroup.objects.get(pk=root_alert_group_pk)
|
||||
alert_group = self.get_alert_group(slack_team_identity, payload)
|
||||
|
|
@ -512,6 +519,22 @@ class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
|
|||
alert_group.attach_by_user(self.user, root_alert_group, action_source=ActionSource.SLACK)
|
||||
|
||||
|
||||
root_ag_id_value_key = "ag_id"
|
||||
|
||||
|
||||
def _get_root_alert_group_id_from_value(value: str) -> str:
|
||||
"""
|
||||
Extract ag ID from value string.
|
||||
It might be either JSON-encoded object or just a user ID.
|
||||
Json encoded object introduced for Chatops-Proxy routing, plain string with user ID is legacy.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(value)
|
||||
return data[root_ag_id_value_key]
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep):
|
||||
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE]
|
||||
|
||||
|
|
@ -717,8 +740,14 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep):
|
|||
) -> None:
|
||||
from apps.alerts.models import AlertGroup
|
||||
|
||||
alert_group_id = payload["actions"][0]["value"].split("_")[1]
|
||||
alert_group = AlertGroup.objects.get(pk=alert_group_id)
|
||||
value = payload["actions"][0]["value"]
|
||||
try:
|
||||
alert_group_pk = json.loads(value)["alert_group_pk"]
|
||||
except json.JSONDecodeError:
|
||||
# Deprecated and kept for backward compatibility (so older Slack messages can still be processed)
|
||||
alert_group_pk = value.split("_")[1]
|
||||
|
||||
alert_group = AlertGroup.objects.get(pk=alert_group_pk)
|
||||
channel = payload["channel"]["id"]
|
||||
message_ts = payload["message_ts"]
|
||||
|
||||
|
|
@ -776,10 +805,7 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep):
|
|||
"text": "Confirm",
|
||||
"type": "button",
|
||||
"style": "primary",
|
||||
"value": scenario_step.ScenarioStep.get_step(
|
||||
"distribute_alerts", "AcknowledgeConfirmationStep"
|
||||
).routing_uid()
|
||||
+ ("_" + str(alert_group.pk)),
|
||||
"value": make_value({"alert_group_pk": alert_group.pk}, alert_group.channel.organization),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import json
|
|||
import typing
|
||||
|
||||
from apps.alerts.paging import DirectPagingAlertGroupResolvedError, direct_paging, unpage_user, user_is_oncall
|
||||
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
|
||||
from apps.slack.constants import DIVIDER
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.scenarios.paging import (
|
||||
|
|
@ -61,7 +62,9 @@ class ManageRespondersUserChange(scenario_step.ScenarioStep):
|
|||
# check if user is on-call
|
||||
if not user_is_oncall(selected_user):
|
||||
# display additional confirmation modal
|
||||
private_metadata = json.dumps({USER_DATA_KEY: selected_user.id, ALERT_GROUP_DATA_KEY: alert_group.pk})
|
||||
private_metadata = make_private_metadata(
|
||||
{USER_DATA_KEY: selected_user.id, ALERT_GROUP_DATA_KEY: alert_group.pk}, organization
|
||||
)
|
||||
view = _display_confirm_participant_invitation_view(
|
||||
ManageRespondersConfirmUserChange.routing_uid(), private_metadata
|
||||
)
|
||||
|
|
@ -163,7 +166,7 @@ def render_dialog(alert_group: "AlertGroup", alert_group_resolved_warning=False)
|
|||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Remove", "emoji": True},
|
||||
"action_id": ManageRespondersRemoveUser.routing_uid(),
|
||||
"value": str(user["id"]),
|
||||
"value": make_value({"id": str(user["id"])}, alert_group.channel.organization),
|
||||
},
|
||||
},
|
||||
),
|
||||
|
|
@ -205,7 +208,7 @@ def _get_selected_user_from_payload(payload: EventPayload) -> "User":
|
|||
from apps.user_management.models import User
|
||||
|
||||
try:
|
||||
selected_user_id = payload["actions"][0]["value"] # "remove" button
|
||||
selected_user_id = json.loads(payload["actions"][0]["value"])["id"] # "remove" button
|
||||
except KeyError:
|
||||
try:
|
||||
# "confirm" button on availability warnings modal
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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.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
|
||||
from apps.slack.errors import SlackAPIChannelNotFoundError
|
||||
from apps.slack.scenarios import scenario_step
|
||||
|
|
@ -292,15 +293,16 @@ class OnPagingUserChange(scenario_step.ScenarioStep):
|
|||
# check if user is on-call
|
||||
if not user_is_oncall(selected_user):
|
||||
# display additional confirmation modal
|
||||
metadata = metadata = json.loads(payload["view"]["private_metadata"])
|
||||
private_metadata = json.dumps(
|
||||
metadata = json.loads(payload["view"]["private_metadata"])
|
||||
private_metadata = make_private_metadata(
|
||||
{
|
||||
"state": payload["view"]["state"],
|
||||
"input_id_prefix": metadata["input_id_prefix"],
|
||||
"channel_id": metadata["channel_id"],
|
||||
"submit_routing_uid": metadata["submit_routing_uid"],
|
||||
DataKey.USERS: metadata[DataKey.USERS],
|
||||
}
|
||||
},
|
||||
selected_user.organization,
|
||||
)
|
||||
|
||||
view = _display_confirm_participant_invitation_view(
|
||||
|
|
@ -326,9 +328,9 @@ class OnPagingUserChange(scenario_step.ScenarioStep):
|
|||
class OnPagingItemActionChange(scenario_step.ScenarioStep):
|
||||
"""Reload form with updated user details."""
|
||||
|
||||
def _parse_action(self, payload: EventPayload) -> typing.Tuple[Policy, str, str]:
|
||||
value = payload["actions"][0]["selected_option"]["value"]
|
||||
return value.split("|")
|
||||
def _parse_action(self, payload: EventPayload) -> typing.Tuple[Policy, DataKey, str]:
|
||||
value = json.loads(payload["actions"][0]["selected_option"]["value"])
|
||||
return value["action"], value["key"], value["id"]
|
||||
|
||||
def process_scenario(
|
||||
self,
|
||||
|
|
@ -464,7 +466,9 @@ def render_dialog(
|
|||
}
|
||||
)
|
||||
|
||||
return _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata))
|
||||
return _get_form_view(
|
||||
submit_routing_uid, blocks, make_private_metadata(new_private_metadata, selected_organization)
|
||||
)
|
||||
|
||||
|
||||
def _get_unauthorized_warning(error=False):
|
||||
|
|
@ -518,7 +522,7 @@ def _get_organization_select(
|
|||
"text": f"{org.org_title} ({org.stack_slug})",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{org.pk}",
|
||||
"value": make_value({"id": org.pk}, org),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -547,7 +551,7 @@ def _get_select_field_value(payload: EventPayload, prefix_id: str, routing_uid:
|
|||
field = payload["view"]["state"]["values"][prefix_id + field_id][routing_uid]["selected_option"]
|
||||
except KeyError:
|
||||
return None
|
||||
return field["value"] if field else None
|
||||
return json.loads(field["value"])["id"] if field else None
|
||||
|
||||
|
||||
def _get_selected_org_from_payload(
|
||||
|
|
@ -616,7 +620,7 @@ def _get_team_select_blocks(
|
|||
"text": team_name,
|
||||
"emoji": True,
|
||||
},
|
||||
"value": str(team_pk),
|
||||
"value": make_value({"id": team_pk}, organization),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -668,7 +672,7 @@ def _get_team_select_blocks(
|
|||
|
||||
|
||||
def _create_user_option_groups(
|
||||
users: "RelatedManager['User']", max_options_per_group: int, option_group_label_text_prefix: str
|
||||
organization, users: "RelatedManager['User']", max_options_per_group: int, option_group_label_text_prefix: str
|
||||
) -> typing.List[CompositionObjectOptionGroup]:
|
||||
user_options: typing.List[CompositionObjectOption] = [
|
||||
{
|
||||
|
|
@ -677,7 +681,7 @@ def _create_user_option_groups(
|
|||
"text": f"{user.name or user.username}",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{user.pk}",
|
||||
"value": make_value({"id": user.pk}, organization),
|
||||
}
|
||||
for user in users
|
||||
]
|
||||
|
|
@ -733,7 +737,7 @@ def _get_user_select_blocks(
|
|||
# selected items
|
||||
if selected_users := get_current_items(payload, DataKey.USERS, organization.users):
|
||||
blocks += [DIVIDER]
|
||||
blocks += _get_selected_entries_list(input_id_prefix, DataKey.USERS, selected_users)
|
||||
blocks += _get_selected_entries_list(organization, input_id_prefix, DataKey.USERS, selected_users)
|
||||
blocks += [DIVIDER]
|
||||
|
||||
return blocks
|
||||
|
|
@ -746,10 +750,10 @@ def _get_users_select(
|
|||
oncall_user_pks = {user.pk for _, users in schedules.items() for user in users}
|
||||
|
||||
oncall_user_option_groups = _create_user_option_groups(
|
||||
organization.users.filter(pk__in=oncall_user_pks), max_options_per_group, "On-call now"
|
||||
organization, organization.users.filter(pk__in=oncall_user_pks), max_options_per_group, "On-call now"
|
||||
)
|
||||
not_oncall_user_option_groups = _create_user_option_groups(
|
||||
organization.users.exclude(pk__in=oncall_user_pks), max_options_per_group, "Not on-call"
|
||||
organization, organization.users.exclude(pk__in=oncall_user_pks), max_options_per_group, "Not on-call"
|
||||
)
|
||||
|
||||
if not oncall_user_option_groups and not not_oncall_user_option_groups:
|
||||
|
|
@ -773,7 +777,7 @@ def _get_users_select(
|
|||
|
||||
|
||||
def _get_selected_entries_list(
|
||||
input_id_prefix: str, key: DataKey, entries: typing.List[typing.Tuple[Model, Policy]]
|
||||
organization: "Organization", input_id_prefix: str, key: DataKey, entries: typing.List[typing.Tuple[Model, Policy]]
|
||||
) -> typing.List[Block.Section]:
|
||||
current_entries: typing.List[Block.Section] = []
|
||||
for entry, policy in entries:
|
||||
|
|
@ -793,7 +797,10 @@ def _get_selected_entries_list(
|
|||
"accessory": {
|
||||
"type": "overflow",
|
||||
"options": [
|
||||
{"text": {"type": "plain_text", "text": f"{label}"}, "value": f"{action}|{key}|{entry.pk}"}
|
||||
{
|
||||
"text": {"type": "plain_text", "text": f"{label}"},
|
||||
"value": make_value({"action": action, "key": key, "id": str(entry.pk)}, organization),
|
||||
}
|
||||
for (action, label) in ITEM_ACTIONS
|
||||
],
|
||||
"action_id": OnPagingItemActionChange.routing_uid(),
|
||||
|
|
@ -900,7 +907,7 @@ def _get_available_organizations(
|
|||
|
||||
def _generate_input_id_prefix() -> str:
|
||||
"""
|
||||
returns unique string to not to preserve input's values between view update
|
||||
returns unique string to not preserve input's values between view update
|
||||
|
||||
https://api.slack.com/methods/views.update#markdown
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.db.models import Q
|
|||
from django.utils.text import Truncator
|
||||
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.slack.chatops_proxy_routing import make_value
|
||||
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE, DIVIDER
|
||||
from apps.slack.errors import (
|
||||
SlackAPICantUpdateMessageError,
|
||||
|
|
@ -483,14 +484,15 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep
|
|||
"emoji": True,
|
||||
},
|
||||
"action_id": AddRemoveThreadMessageStep.routing_uid(),
|
||||
"value": json.dumps(
|
||||
"value": make_value(
|
||||
{
|
||||
"resolution_note_window_action": "edit",
|
||||
"msg_value": "add" if not message.added_to_resolution_note else "remove",
|
||||
"message_pk": message.pk,
|
||||
"resolution_note_pk": None,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
}
|
||||
},
|
||||
alert_group.channel.organization,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
@ -542,7 +544,7 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep
|
|||
"emoji": True,
|
||||
},
|
||||
"action_id": AddRemoveThreadMessageStep.routing_uid(),
|
||||
"value": json.dumps(
|
||||
"value": make_value(
|
||||
{
|
||||
"resolution_note_window_action": "edit",
|
||||
"msg_value": "remove",
|
||||
|
|
@ -551,7 +553,8 @@ class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep
|
|||
else resolution_note_slack_message.pk,
|
||||
"resolution_note_pk": resolution_note.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
}
|
||||
},
|
||||
alert_group.channel.organization,
|
||||
),
|
||||
"confirm": {
|
||||
"title": {"type": "plain_text", "text": "Are you sure?"},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import typing
|
|||
import pytz
|
||||
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
from apps.slack.chatops_proxy_routing import make_value
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.types import (
|
||||
Block,
|
||||
|
|
@ -33,22 +34,26 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
slack_team_identity: "SlackTeamIdentity",
|
||||
payload: EventPayload,
|
||||
) -> None:
|
||||
if payload["actions"][0].get("value", None) and payload["actions"][0]["value"].startswith("edit"):
|
||||
action_type = payload["actions"][0]["type"]
|
||||
if action_type == BlockActionType.BUTTON:
|
||||
self.open_settings_modal(payload)
|
||||
elif payload["actions"][0].get("type", None) and payload["actions"][0]["type"] == "static_select":
|
||||
elif action_type == BlockActionType.STATIC_SELECT:
|
||||
self.set_selected_value(slack_user_identity, payload)
|
||||
|
||||
def open_settings_modal(self, payload: EventPayload) -> None:
|
||||
schedule_id = payload["actions"][0]["value"].split("_")[1]
|
||||
value = payload["actions"][0]["value"]
|
||||
try:
|
||||
_ = OnCallSchedule.objects.get(pk=schedule_id) # noqa
|
||||
schedule_id = json.loads(value)["schedule_id"]
|
||||
except json.JSONDecodeError:
|
||||
# Deprecated and kept for backward compatibility (so older Slack messages can still be processed)
|
||||
schedule_id = value.split("_")[1]
|
||||
|
||||
try:
|
||||
schedule = OnCallSchedule.objects.get(pk=schedule_id) # noqa
|
||||
except OnCallSchedule.DoesNotExist:
|
||||
blocks = [{"type": "section", "text": {"type": "plain_text", "text": "Schedule was removed"}}]
|
||||
else:
|
||||
blocks = self.get_modal_blocks(schedule_id)
|
||||
|
||||
private_metadata = {}
|
||||
private_metadata["schedule_id"] = schedule_id
|
||||
blocks = self.get_modal_blocks(schedule)
|
||||
|
||||
view: ModalView = {
|
||||
"callback_id": EditScheduleShiftNotifyStep.routing_uid(),
|
||||
|
|
@ -58,7 +63,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
"type": "plain_text",
|
||||
"text": "Notification preferences",
|
||||
},
|
||||
"private_metadata": json.dumps(private_metadata),
|
||||
"private_metadata": json.dumps({"schedule_id": schedule_id}),
|
||||
}
|
||||
|
||||
self._slack_client.views_open(trigger_id=payload["trigger_id"], view=view)
|
||||
|
|
@ -69,7 +74,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
schedule_id = private_metadata["schedule_id"]
|
||||
schedule = OnCallSchedule.objects.get(pk=schedule_id)
|
||||
prev_state = schedule.insight_logs_serialized
|
||||
setattr(schedule, action["block_id"], int(action["selected_option"]["value"]))
|
||||
setattr(schedule, action["block_id"], json.loads(action["selected_option"]["value"])["option"])
|
||||
schedule.save()
|
||||
new_state = schedule.insight_logs_serialized
|
||||
write_resource_insight_log(
|
||||
|
|
@ -80,7 +85,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
new_state=new_state,
|
||||
)
|
||||
|
||||
def get_modal_blocks(self, schedule_id: str) -> typing.List[Block.Section]:
|
||||
def get_modal_blocks(self, schedule: OnCallSchedule) -> typing.List[Block.Section]:
|
||||
blocks: typing.List[Block.Section] = [
|
||||
{
|
||||
"type": "section",
|
||||
|
|
@ -90,8 +95,8 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "----"},
|
||||
"action_id": EditScheduleShiftNotifyStep.routing_uid(),
|
||||
"options": self.get_options("notify_oncall_shift_freq"),
|
||||
"initial_option": self.get_initial_option(schedule_id, "notify_oncall_shift_freq"),
|
||||
"options": self.get_options(schedule, "notify_oncall_shift_freq"),
|
||||
"initial_option": self.get_initial_option(schedule, "notify_oncall_shift_freq"),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -102,8 +107,8 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "----"},
|
||||
"action_id": EditScheduleShiftNotifyStep.routing_uid(),
|
||||
"options": self.get_options("mention_oncall_start"),
|
||||
"initial_option": self.get_initial_option(schedule_id, "mention_oncall_start"),
|
||||
"options": self.get_options(schedule, "mention_oncall_start"),
|
||||
"initial_option": self.get_initial_option(schedule, "mention_oncall_start"),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -114,8 +119,8 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "----"},
|
||||
"action_id": EditScheduleShiftNotifyStep.routing_uid(),
|
||||
"options": self.get_options("mention_oncall_next"),
|
||||
"initial_option": self.get_initial_option(schedule_id, "mention_oncall_next"),
|
||||
"options": self.get_options(schedule, "mention_oncall_next"),
|
||||
"initial_option": self.get_initial_option(schedule, "mention_oncall_next"),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -126,24 +131,25 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "----"},
|
||||
"action_id": EditScheduleShiftNotifyStep.routing_uid(),
|
||||
"options": self.get_options("notify_empty_oncall"),
|
||||
"initial_option": self.get_initial_option(schedule_id, "notify_empty_oncall"),
|
||||
"options": self.get_options(schedule, "notify_empty_oncall"),
|
||||
"initial_option": self.get_initial_option(schedule, "notify_empty_oncall"),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return blocks
|
||||
|
||||
def get_options(self, select_name: str) -> typing.List[CompositionObjectOption]:
|
||||
def get_options(self, schedule: OnCallSchedule, select_name: str) -> typing.List[CompositionObjectOption]:
|
||||
select_options = getattr(self, f"{select_name}_options")
|
||||
return [
|
||||
{"text": {"type": "plain_text", "text": select_options[option]}, "value": str(option)}
|
||||
{
|
||||
"text": {"type": "plain_text", "text": select_options[option]},
|
||||
"value": make_value({"option": option}, schedule.organization),
|
||||
}
|
||||
for option in select_options
|
||||
]
|
||||
|
||||
def get_initial_option(self, schedule_id: str, select_name: str) -> CompositionObjectOption:
|
||||
schedule = OnCallSchedule.objects.get(pk=schedule_id)
|
||||
|
||||
def get_initial_option(self, schedule: OnCallSchedule, select_name: str) -> CompositionObjectOption:
|
||||
current_value = getattr(schedule, select_name)
|
||||
text = getattr(self, f"{select_name}_options")[current_value]
|
||||
|
||||
|
|
@ -152,7 +158,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
"type": "plain_text",
|
||||
"text": f"{text}",
|
||||
},
|
||||
"value": str(int(current_value)),
|
||||
"value": make_value({"option": int(current_value)}, schedule.organization),
|
||||
}
|
||||
|
||||
return initial_option
|
||||
|
|
@ -243,7 +249,7 @@ class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
|
|||
"type": "button",
|
||||
"action_id": f"{cls.routing_uid()}",
|
||||
"text": {"type": "plain_text", "text": ":gear:", "emoji": True},
|
||||
"value": f"edit_{schedule.pk}",
|
||||
"value": make_value({"schedule_id": schedule.pk}, schedule.organization),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import humanize
|
|||
from django.utils import timezone
|
||||
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.slack.chatops_proxy_routing import make_value
|
||||
from apps.slack.models import SlackMessage
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.types import Block, BlockActionType, EventPayload, PayloadType, ScenarioRoute
|
||||
|
|
@ -143,7 +144,7 @@ class BaseShiftSwapRequestStep(scenario_step.ScenarioStep):
|
|||
"text": "Accept",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": json.dumps(value),
|
||||
"value": make_value(value, shift_swap_request.organization),
|
||||
"action_id": AcceptShiftSwapRequestStep.routing_uid(),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
135
engine/apps/slack/tests/test_installation.py
Normal file
135
engine/apps/slack/tests/test_installation.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Response example from Slack docs https://api.slack.com/methods/oauth.v2.access#examples
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.slack.client import SlackClient
|
||||
from apps.slack.installation import SlackInstallationExc, install_slack_integration, uninstall_slack_integration
|
||||
from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE
|
||||
|
||||
users_profile_get_response = {
|
||||
"ok": True,
|
||||
"user": {
|
||||
"id": "W012A3CDE",
|
||||
"team_id": "T012AB3C4",
|
||||
"name": "spengler",
|
||||
"deleted": False,
|
||||
"color": "9f69e7",
|
||||
"real_name": "Egon Spengler",
|
||||
"tz": "America/Los_Angeles",
|
||||
"tz_label": "Pacific Daylight Time",
|
||||
"tz_offset": -25200,
|
||||
"profile": {
|
||||
"avatar_hash": "ge3b51ca72de",
|
||||
"status_text": "Print is dead",
|
||||
"status_emoji": ":books:",
|
||||
"real_name": "Egon Spengler",
|
||||
"display_name": "spengler",
|
||||
"real_name_normalized": "Egon Spengler",
|
||||
"display_name_normalized": "spengler",
|
||||
"email": "spengler@ghostbusters.example.com",
|
||||
"image_original": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
|
||||
"image_24": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
|
||||
"image_32": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
|
||||
"image_48": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
|
||||
"image_72": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
|
||||
"image_192": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
|
||||
"image_512": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg",
|
||||
"team": "T012AB3C4",
|
||||
},
|
||||
"is_admin": True,
|
||||
"is_owner": False,
|
||||
"is_primary_owner": False,
|
||||
"is_restricted": False,
|
||||
"is_ultra_restricted": False,
|
||||
"is_bot": False,
|
||||
"updated": 1502138686,
|
||||
"is_app_user": False,
|
||||
"has_2fa": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@patch("apps.slack.tasks.populate_slack_channels_for_team.apply_async", return_value=None)
|
||||
@patch("apps.slack.tasks.populate_slack_usergroups_for_team.apply_async", return_value=None)
|
||||
@patch.object(SlackClient, "users_info", return_value=users_profile_get_response)
|
||||
@pytest.mark.django_db
|
||||
def test_install_slack_integration(
|
||||
mock_populate_slack_channels_for_team,
|
||||
mock_populate_slack_usergroups_for_team,
|
||||
mock_users_info,
|
||||
make_organization_and_user,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
install_slack_integration(organization, user, SLACK_OAUTH_ACCESS_RESPONSE)
|
||||
|
||||
assert organization.slack_team_identity is not None
|
||||
# test that two most important fields are set: id of slack workspace and api acess token
|
||||
assert organization.slack_team_identity.slack_id == SLACK_OAUTH_ACCESS_RESPONSE["team"]["id"]
|
||||
assert organization.slack_team_identity.bot_access_token == SLACK_OAUTH_ACCESS_RESPONSE["access_token"]
|
||||
|
||||
# install_slack_integration links instgallers's slack profile to OnCall
|
||||
assert user.slack_user_identity is not None
|
||||
|
||||
# assert that installer slack profile is linked to OnCall user
|
||||
assert user.slack_user_identity.slack_id == SLACK_OAUTH_ACCESS_RESPONSE["authed_user"]["id"]
|
||||
|
||||
# assert that we populated user's profile info
|
||||
assert user.slack_user_identity.cached_slack_login == users_profile_get_response["user"]["name"]
|
||||
|
||||
# assert that we ran task for fetching data from slack
|
||||
assert mock_populate_slack_channels_for_team.called
|
||||
assert mock_populate_slack_usergroups_for_team.called
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_install_slack_integration_raises_exception_for_existing_integration(
|
||||
make_organization_and_user, make_slack_team_identity
|
||||
):
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
organization, user = make_organization_and_user()
|
||||
organization.slack_team_identity = slack_team_identity
|
||||
organization.save()
|
||||
|
||||
with pytest.raises(SlackInstallationExc):
|
||||
install_slack_integration(organization, user, SLACK_OAUTH_ACCESS_RESPONSE)
|
||||
|
||||
|
||||
@patch("apps.slack.tasks.clean_slack_integration_leftovers.apply_async", return_value=None)
|
||||
@pytest.mark.django_db
|
||||
def test_uninstall_slack_integration(
|
||||
mock_clean_slack_integration_leftovers,
|
||||
make_organization_and_user,
|
||||
make_slack_team_identity,
|
||||
make_slack_user_identity,
|
||||
):
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
organization, user = make_organization_and_user()
|
||||
organization.slack_team_identity = slack_team_identity
|
||||
organization.save()
|
||||
organization.refresh_from_db()
|
||||
|
||||
slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity)
|
||||
user.slack_user_identity = slack_user_identity
|
||||
user.save()
|
||||
user.refresh_from_db()
|
||||
|
||||
uninstall_slack_integration(organization, user)
|
||||
|
||||
organization.refresh_from_db()
|
||||
user.refresh_from_db()
|
||||
assert organization.slack_team_identity is None
|
||||
assert user.slack_user_identity is None
|
||||
|
||||
# assert that we ran task for fetching data from slack
|
||||
assert mock_clean_slack_integration_leftovers.called
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_uninstall_slack_integration_raises_exception_for_non_existing_integration(
|
||||
make_organization_and_user,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
with pytest.raises(SlackInstallationExc):
|
||||
uninstall_slack_integration(organization, user)
|
||||
|
|
@ -6,6 +6,7 @@ from django.utils import timezone
|
|||
|
||||
from apps.base.models import UserNotificationPolicy
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
|
||||
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
|
||||
from apps.slack.scenarios.manage_responders import (
|
||||
ALERT_GROUP_DATA_KEY,
|
||||
DIRECT_PAGING_USER_SELECT_ID,
|
||||
|
|
@ -23,17 +24,19 @@ CHANNEL_ID = "123"
|
|||
MESSAGE_TS = "67"
|
||||
|
||||
|
||||
def make_slack_payload(user=None, actions=None):
|
||||
def make_slack_payload(organization, user=None, actions=None):
|
||||
payload = {
|
||||
"trigger_id": TRIGGER_ID,
|
||||
"view": {
|
||||
"id": "view-id",
|
||||
"private_metadata": json.dumps({"input_id_prefix": "", ALERT_GROUP_DATA_KEY: ALERT_GROUP_ID}),
|
||||
"private_metadata": make_private_metadata(
|
||||
{"input_id_prefix": "", ALERT_GROUP_DATA_KEY: ALERT_GROUP_ID}, organization
|
||||
),
|
||||
"state": {
|
||||
"values": {
|
||||
DIRECT_PAGING_USER_SELECT_ID: {
|
||||
ManageRespondersUserChange.routing_uid(): {
|
||||
"selected_option": {"value": user.pk} if user else None
|
||||
"selected_option": {"value": make_value({"id": user.pk}, organization)} if user else None
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -118,21 +121,23 @@ def test_add_user_no_warning(manage_responders_setup, make_schedule, make_on_cal
|
|||
notify_by=UserNotificationPolicy.NotificationChannel.SMS,
|
||||
)
|
||||
|
||||
payload = make_slack_payload(user=user)
|
||||
payload = make_slack_payload(organization, user=user)
|
||||
|
||||
step = ManageRespondersUserChange(slack_team_identity, organization, user)
|
||||
with patch.object(step._slack_client, "views_update") as mock_slack_api_call:
|
||||
step.process_scenario(slack_user_identity, slack_team_identity, payload)
|
||||
|
||||
# check there's a delete button for the user
|
||||
assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["value"] == str(user.pk)
|
||||
assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["value"] == make_value(
|
||||
{"id": str(user.pk)}, organization
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_add_user_raise_warning(manage_responders_setup):
|
||||
organization, user, slack_team_identity, slack_user_identity = manage_responders_setup
|
||||
# user is not on call
|
||||
payload = make_slack_payload(user=user)
|
||||
payload = make_slack_payload(organization, user=user)
|
||||
|
||||
step = ManageRespondersUserChange(slack_team_identity, organization, user)
|
||||
with patch.object(step._slack_client, "views_push") as mock_slack_api_call:
|
||||
|
|
@ -154,7 +159,7 @@ def test_add_user_raise_warning(manage_responders_setup):
|
|||
def test_remove_user(manage_responders_setup):
|
||||
organization, user, slack_team_identity, slack_user_identity = manage_responders_setup
|
||||
|
||||
payload = make_slack_payload(actions=[{"value": user.pk}])
|
||||
payload = make_slack_payload(organization, actions=[{"value": make_value({"id": user.pk}, organization)}])
|
||||
step = ManageRespondersRemoveUser(slack_team_identity, organization, user)
|
||||
with patch.object(step._slack_client, "views_update") as mock_slack_api_call:
|
||||
step.process_scenario(slack_user_identity, slack_team_identity, payload)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.utils import timezone
|
|||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
|
||||
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
|
||||
from apps.slack.scenarios.paging import (
|
||||
DIRECT_PAGING_MESSAGE_INPUT_ID,
|
||||
DIRECT_PAGING_ORG_SELECT_ID,
|
||||
|
|
@ -32,24 +33,31 @@ def make_slack_payload(organization, team=None, user=None, current_users=None, a
|
|||
"trigger_id": "111",
|
||||
"view": {
|
||||
"id": "view-id",
|
||||
"private_metadata": json.dumps(
|
||||
"private_metadata": make_private_metadata(
|
||||
{
|
||||
"input_id_prefix": "",
|
||||
"channel_id": "123",
|
||||
"submit_routing_uid": "FinishStepUID",
|
||||
DataKey.USERS: current_users or {},
|
||||
}
|
||||
},
|
||||
organization,
|
||||
),
|
||||
"state": {
|
||||
"values": {
|
||||
DIRECT_PAGING_ORG_SELECT_ID: {
|
||||
OnPagingOrgChange.routing_uid(): {"selected_option": {"value": organization.pk}}
|
||||
OnPagingOrgChange.routing_uid(): {
|
||||
"selected_option": {"value": make_value({"id": organization.pk}, organization)}
|
||||
}
|
||||
},
|
||||
DIRECT_PAGING_TEAM_SELECT_ID: {
|
||||
OnPagingTeamChange.routing_uid(): {"selected_option": {"value": team.pk if team else None}}
|
||||
OnPagingTeamChange.routing_uid(): {
|
||||
"selected_option": {"value": make_value({"id": team.pk if team else None}, organization)}
|
||||
}
|
||||
},
|
||||
DIRECT_PAGING_USER_SELECT_ID: {
|
||||
OnPagingUserChange.routing_uid(): {"selected_option": {"value": user.pk} if user else None}
|
||||
OnPagingUserChange.routing_uid(): {
|
||||
"selected_option": {"value": make_value({"id": user.pk}, organization)} if user else None
|
||||
}
|
||||
},
|
||||
DIRECT_PAGING_MESSAGE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Message"}},
|
||||
}
|
||||
|
|
@ -203,7 +211,13 @@ def test_change_user_policy(make_organization_and_user_with_slack_identities):
|
|||
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
payload = make_slack_payload(
|
||||
organization=organization,
|
||||
actions=[{"selected_option": {"value": f"{Policy.IMPORTANT}|{DataKey.USERS}|{user.pk}"}}],
|
||||
actions=[
|
||||
{
|
||||
"selected_option": {
|
||||
"value": make_value({"action": Policy.IMPORTANT, "key": DataKey.USERS, "id": user.pk}, organization)
|
||||
}
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
step = OnPagingItemActionChange(slack_team_identity)
|
||||
|
|
@ -219,7 +233,15 @@ def test_remove_user(make_organization_and_user_with_slack_identities):
|
|||
organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
payload = make_slack_payload(
|
||||
organization=organization,
|
||||
actions=[{"selected_option": {"value": f"{Policy.REMOVE_ACTION}|{DataKey.USERS}|{user.pk}"}}],
|
||||
actions=[
|
||||
{
|
||||
"selected_option": {
|
||||
"value": make_value(
|
||||
{"action": Policy.REMOVE_ACTION, "key": DataKey.USERS, "id": user.pk}, organization
|
||||
)
|
||||
}
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
step = OnPagingItemActionChange(slack_team_identity)
|
||||
|
|
@ -302,7 +324,9 @@ def test_get_organization_select(make_organization):
|
|||
select = _get_organization_select(Organization.objects.filter(pk=organization.pk), organization, "test")
|
||||
|
||||
assert len(select["element"]["options"]) == 1
|
||||
assert select["element"]["options"][0]["value"] == str(organization.pk)
|
||||
assert json.loads(select["element"]["options"][0]["value"]) == json.loads(
|
||||
make_value({"id": organization.pk}, organization)
|
||||
)
|
||||
assert select["element"]["options"][0]["text"]["text"] == "Organization (stack_slug)"
|
||||
|
||||
|
||||
|
|
@ -322,7 +346,10 @@ def test_get_team_select_blocks(
|
|||
input_id_prefix = "nmxcnvmnxv"
|
||||
|
||||
def _contstruct_team_option(team):
|
||||
return {"text": {"emoji": True, "text": team.name, "type": "plain_text"}, "value": str(team.pk)}
|
||||
return {
|
||||
"text": {"emoji": True, "text": team.name, "type": "plain_text"},
|
||||
"value": make_value({"id": team.pk}, organization),
|
||||
}
|
||||
|
||||
# no team selected - no team direct paging integrations available
|
||||
organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities()
|
||||
|
|
@ -408,5 +435,7 @@ def test_get_team_select_blocks(
|
|||
|
||||
assert input_block["type"] == "input"
|
||||
assert len(input_block["element"]["options"]) == 1
|
||||
assert input_block["element"]["options"] == [_contstruct_team_option(team)]
|
||||
assert json.loads(input_block["element"]["options"][0]["value"]) == json.loads(
|
||||
_contstruct_team_option(team)["value"]
|
||||
)
|
||||
assert context_block["elements"][0]["text"] == info_msg
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
|
||||
from apps.slack.chatops_proxy_routing import make_value
|
||||
from apps.slack.client import SlackClient
|
||||
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE
|
||||
from apps.slack.errors import SlackAPIViewNotFoundError
|
||||
|
|
@ -91,14 +92,15 @@ def test_get_resolution_notes_blocks_non_empty(
|
|||
"emoji": True,
|
||||
},
|
||||
"action_id": "AddRemoveThreadMessageStep",
|
||||
"value": json.dumps(
|
||||
"value": make_value(
|
||||
{
|
||||
"resolution_note_window_action": "edit",
|
||||
"msg_value": "add",
|
||||
"message_pk": resolution_note.pk,
|
||||
"resolution_note_pk": None,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
}
|
||||
},
|
||||
organization,
|
||||
),
|
||||
},
|
||||
},
|
||||
|
|
@ -281,14 +283,15 @@ def test_get_resolution_notes_blocks_latest_limit(
|
|||
"emoji": True,
|
||||
},
|
||||
"action_id": "AddRemoveThreadMessageStep",
|
||||
"value": json.dumps(
|
||||
"value": make_value(
|
||||
{
|
||||
"resolution_note_window_action": "edit",
|
||||
"msg_value": "add",
|
||||
"message_pk": m.pk,
|
||||
"resolution_note_pk": None,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
}
|
||||
},
|
||||
organization,
|
||||
),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import pytest
|
|||
|
||||
from apps.alerts.incident_appearance.renderers.slack_renderer import AlertGroupSlackRenderer
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.slack.chatops_proxy_routing import make_value
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -17,11 +18,15 @@ def test_slack_renderer_acknowledge_button(make_organization, make_alert_receive
|
|||
|
||||
button = elements[0]
|
||||
assert button["text"]["text"] == "Acknowledge"
|
||||
assert json.loads(button["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
}
|
||||
assert json.loads(button["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -37,11 +42,15 @@ def test_slack_renderer_unacknowledge_button(
|
|||
|
||||
button = elements[0]
|
||||
assert button["text"]["text"] == "Unacknowledge"
|
||||
assert json.loads(button["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
}
|
||||
assert json.loads(button["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -55,11 +64,15 @@ def test_slack_renderer_resolve_button(make_organization, make_alert_receive_cha
|
|||
|
||||
button = elements[1]
|
||||
assert button["text"]["text"] == "Resolve"
|
||||
assert json.loads(button["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
}
|
||||
assert json.loads(button["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -73,11 +86,15 @@ def test_slack_renderer_unresolve_button(make_organization, make_alert_receive_c
|
|||
|
||||
button = elements[0]
|
||||
assert button["text"]["text"] == "Unresolve"
|
||||
assert json.loads(button["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
}
|
||||
assert json.loads(button["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -109,12 +126,16 @@ def test_slack_renderer_stop_invite_button(
|
|||
action = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[1]["actions"][0]
|
||||
|
||||
assert action["text"] == f"Stop inviting {user.username}"
|
||||
assert json.loads(action["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
"invitation_id": invitation.pk,
|
||||
}
|
||||
assert json.loads(action["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
"invitation_id": invitation.pk,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -131,12 +152,16 @@ def test_slack_renderer_silence_button(make_organization, make_alert_receive_cha
|
|||
|
||||
values = [json.loads(option["value"]) for option in button["options"]]
|
||||
assert values == [
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
"delay": delay,
|
||||
}
|
||||
json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
"delay": delay,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
for delay, _ in AlertGroup.SILENCE_DELAY_OPTIONS
|
||||
]
|
||||
|
||||
|
|
@ -152,11 +177,15 @@ def test_slack_renderer_unsilence_button(make_organization, make_alert_receive_c
|
|||
button = elements[2]
|
||||
|
||||
assert button["text"]["text"] == "Unsilence"
|
||||
assert json.loads(button["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
}
|
||||
assert json.loads(button["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -170,11 +199,15 @@ def test_slack_renderer_attach_button(make_organization, make_alert_receive_chan
|
|||
button = elements[4]
|
||||
|
||||
assert button["text"]["text"] == "Attach to ..."
|
||||
assert json.loads(button["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
}
|
||||
assert json.loads(button["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -191,11 +224,15 @@ def test_slack_renderer_unattach_button(make_organization, make_alert_receive_ch
|
|||
action = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["actions"][0]
|
||||
|
||||
assert action["text"] == "Unattach"
|
||||
assert json.loads(action["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
}
|
||||
assert json.loads(action["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -211,11 +248,15 @@ def test_slack_renderer_format_alert_button(
|
|||
|
||||
button = elements[5]
|
||||
assert button["text"]["text"] == ":mag: Format Alert"
|
||||
assert json.loads(button["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
}
|
||||
assert json.loads(button["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -231,9 +272,13 @@ def test_slack_renderer_resolution_notes_button(
|
|||
|
||||
button = elements[6]
|
||||
assert button["text"]["text"] == "Add Resolution notes"
|
||||
assert json.loads(button["value"]) == {
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
"resolution_note_window_action": "edit",
|
||||
}
|
||||
assert json.loads(button["value"]) == json.loads(
|
||||
make_value(
|
||||
{
|
||||
"organization_id": organization.pk,
|
||||
"alert_group_ppk": alert_group.public_primary_key,
|
||||
"resolution_note_window_action": "edit",
|
||||
},
|
||||
organization,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,17 +9,19 @@ from .views import (
|
|||
)
|
||||
|
||||
urlpatterns = [
|
||||
# Old urls for handling slack events and interactive messages. Currently used in OSS
|
||||
path("event_api_endpoint/", SlackEventApiEndpointView.as_view()),
|
||||
path("interactive_api_endpoint/", SlackEventApiEndpointView.as_view()),
|
||||
# New urls used in cloud via chatops-proxy v3.
|
||||
path("events/", SlackEventApiEndpointView.as_view()),
|
||||
path("interactive/", SlackEventApiEndpointView.as_view()),
|
||||
# Trailing / is missing here on purpose. QA the feature if you want to add it. No idea why doesn't it work with it.
|
||||
path("reset_slack", ResetSlackView.as_view(), name="reset-slack"),
|
||||
# Deprecated.
|
||||
path("oauth/", OAuthSlackView.as_view()),
|
||||
path("oauth/<str:subscription>/<str:utm>/", OAuthSlackView.as_view()),
|
||||
path("install_redirect/", InstallLinkRedirectView.as_view()),
|
||||
path("install_redirect/<str:subscription>/<str:utm>/", InstallLinkRedirectView.as_view()),
|
||||
path("signup_redirect/", SignupRedirectView.as_view()),
|
||||
path("signup_redirect/<str:subscription>/<str:utm>/", SignupRedirectView.as_view()),
|
||||
# Trailing / is missing here on purpose. QA the feature if you want to add it. No idea why doesn't it work with it.
|
||||
path("reset_slack", ResetSlackView.as_view(), name="reset-slack"),
|
||||
# urls for chatops-proxy v3. Currently, they are experimental.
|
||||
path("events/", SlackEventApiEndpointView.as_view()),
|
||||
path("interactive/", SlackEventApiEndpointView.as_view()),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -35,13 +35,11 @@ from apps.slack.scenarios.shift_swap_requests import STEPS_ROUTING as SHIFT_SWAP
|
|||
from apps.slack.scenarios.slack_channel import STEPS_ROUTING as CHANNEL_ROUTING
|
||||
from apps.slack.scenarios.slack_channel_integration import STEPS_ROUTING as SLACK_CHANNEL_INTEGRATION_ROUTING
|
||||
from apps.slack.scenarios.slack_usergroup import STEPS_ROUTING as SLACK_USERGROUP_UPDATE_ROUTING
|
||||
from apps.slack.tasks import clean_slack_integration_leftovers, unpopulate_slack_user_identities
|
||||
from apps.slack.types import EventPayload, EventType, MessageEventSubtype, PayloadType, ScenarioRoute
|
||||
from apps.user_management.models import Organization
|
||||
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
||||
from common.oncall_gateway import unlink_slack_team_wrapper
|
||||
|
||||
from .errors import SlackAPITokenError
|
||||
from .installation import SlackInstallationExc, uninstall_slack_integration
|
||||
from .models import SlackMessage, SlackTeamIdentity, SlackUserIdentity
|
||||
|
||||
SCENARIOS_ROUTES: ScenarioRoute.RoutingSteps = []
|
||||
|
|
@ -565,25 +563,14 @@ class ResetSlackView(APIView):
|
|||
}
|
||||
|
||||
def post(self, request):
|
||||
# TODO: this check should be removed once Unified Slack App is release
|
||||
if settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED:
|
||||
response = Response(
|
||||
return Response(
|
||||
"Grafana OnCall is temporary unable to connect your slack account or install OnCall to your slack workspace",
|
||||
status=400,
|
||||
)
|
||||
else:
|
||||
organization = request.auth.organization
|
||||
slack_team_identity = organization.slack_team_identity
|
||||
if slack_team_identity is not None:
|
||||
clean_slack_integration_leftovers.apply_async((organization.pk,))
|
||||
if settings.FEATURE_MULTIREGION_ENABLED:
|
||||
unlink_slack_team_wrapper(str(organization.uuid), slack_team_identity.slack_id)
|
||||
write_chatops_insight_log(
|
||||
author=request.user,
|
||||
event_name=ChatOpsEvent.WORKSPACE_DISCONNECTED,
|
||||
chatops_type=ChatOpsTypePlug.SLACK.value,
|
||||
)
|
||||
unpopulate_slack_user_identities(organization.pk, True)
|
||||
response = Response(status=200)
|
||||
else:
|
||||
response = Response(status=400)
|
||||
return response
|
||||
try:
|
||||
uninstall_slack_integration(request.user.organization, request.user)
|
||||
except SlackInstallationExc as e:
|
||||
return Response({"error": e.error_message}, status=400)
|
||||
return Response(status=200)
|
||||
|
|
|
|||
|
|
@ -91,7 +91,10 @@ class SlackOAuth2V2(SlackOAuth2):
|
|||
|
||||
@handle_http_errors
|
||||
def auth_complete(self, *args, **kwargs):
|
||||
"""Completes login process, must return user instance"""
|
||||
"""
|
||||
Override original method to include auth token in redirect uri and adjust response shape to slack Oauth2.0 V2.
|
||||
Access token is in the ["authed_user"]["access_token"] field, not in the root of the response.
|
||||
"""
|
||||
self.process_error(self.data)
|
||||
state = self.validate_state()
|
||||
# add auth token to redirect uri, because it must be the same in all slack auth requests
|
||||
|
|
@ -113,6 +116,7 @@ class SlackOAuth2V2(SlackOAuth2):
|
|||
method=self.ACCESS_TOKEN_METHOD,
|
||||
)
|
||||
self.process_error(response)
|
||||
# Take access token from the authed_user field, not from the root
|
||||
access_token = response["authed_user"]["access_token"]
|
||||
kwargs.update(response=response)
|
||||
return self.do_auth(access_token, *args, **kwargs)
|
||||
|
|
@ -187,8 +191,13 @@ class LoginSlackOAuth2V2(SlackOAuth2V2):
|
|||
return {"user_scope": USER_SCOPE}
|
||||
|
||||
|
||||
# it's named slack-install-free because it was used to install free version of Slack App.
|
||||
# There is no free/paid version of Slack App anymore, so it's just a name.
|
||||
SLACK_INSTALLATION_BACKEND = "slack-install-free"
|
||||
|
||||
|
||||
class InstallSlackOAuth2V2(SlackOAuth2V2):
|
||||
name = "slack-install-free"
|
||||
name = SLACK_INSTALLATION_BACKEND
|
||||
|
||||
def get_scope(self):
|
||||
return {"user_scope": USER_SCOPE, "scope": BOT_SCOPE}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ from django.contrib.auth import REDIRECT_FIELD_NAME
|
|||
from django.http import HttpResponse
|
||||
from rest_framework import status
|
||||
|
||||
from apps.slack.tasks import populate_slack_channels_for_team, populate_slack_usergroups_for_team
|
||||
from apps.chatops_proxy.utils import can_link_slack_team, link_slack_team
|
||||
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
|
||||
from common.constants.slack_auth import SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR, SLACK_AUTH_WRONG_WORKSPACE_ERROR
|
||||
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
||||
from common.oncall_gateway import can_link_slack_team_wrapper, link_slack_team_wrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -72,34 +73,21 @@ def connect_user_to_slack(response, backend, strategy, user, organization, *args
|
|||
|
||||
|
||||
def populate_slack_identities(response, backend, user, organization, **kwargs):
|
||||
from apps.slack.models import SlackTeamIdentity
|
||||
|
||||
# Continue pipeline step only if it was installation
|
||||
if backend.name != "slack-install-free":
|
||||
if backend.name != SLACK_INSTALLATION_BACKEND:
|
||||
return
|
||||
|
||||
if organization.slack_team_identity is not None:
|
||||
# means that organization already has Slack integration
|
||||
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
slack_team_id = response["team"]["id"]
|
||||
if settings.LICENSE == settings.CLOUD_LICENSE_NAME:
|
||||
can_link = can_link_slack_team_wrapper(str(organization.uuid), slack_team_id, settings.ONCALL_BACKEND_REGION)
|
||||
if settings.FEATURE_MULTIREGION_ENABLED and not can_link:
|
||||
if settings.FEATURE_MULTIREGION_ENABLED and not settings.UNIFIED_SLACK_APP_ENABLED:
|
||||
can_link = can_link_slack_team(str(organization.uuid), slack_team_id, settings.ONCALL_BACKEND_REGION)
|
||||
if not can_link:
|
||||
raise InstallMultiRegionSlackException
|
||||
|
||||
slack_team_identity, is_slack_team_identity_created = SlackTeamIdentity.objects.get_or_create(
|
||||
slack_id=slack_team_id,
|
||||
)
|
||||
# update slack oauth fields by data from response
|
||||
slack_team_identity.update_oauth_fields(user, organization, response)
|
||||
if settings.FEATURE_MULTIREGION_ENABLED:
|
||||
link_slack_team_wrapper(str(organization.uuid), slack_team_id)
|
||||
populate_slack_channels_for_team.apply_async((slack_team_identity.pk,))
|
||||
user.slack_user_identity.update_profile_info()
|
||||
# todo slack: do we need update info for all existing slack users in slack team?
|
||||
# populate_slack_user_identities.apply_async((organization.pk,))
|
||||
populate_slack_usergroups_for_team.apply_async((slack_team_identity.pk,), countdown=10)
|
||||
try:
|
||||
install_slack_integration(organization, user, response)
|
||||
except SlackInstallationExc:
|
||||
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
||||
if settings.FEATURE_MULTIREGION_ENABLED and not settings.UNIFIED_SLACK_APP_ENABLED:
|
||||
link_slack_team(str(organization.uuid), slack_team_id)
|
||||
|
||||
|
||||
def delete_slack_auth_token(strategy, *args, **kwargs):
|
||||
|
|
|
|||
|
|
@ -39,14 +39,13 @@ class TelegramClient:
|
|||
return False
|
||||
|
||||
def register_webhook(self, webhook_url: Optional[str] = None) -> None:
|
||||
# Hack to test chatops-proxy v3, remove once v3 is release.
|
||||
if settings.CHATOPS_V3:
|
||||
if settings.IS_OPEN_SOURCE:
|
||||
webhook_url = webhook_url or create_engine_url(
|
||||
"api/v3/webhook/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST
|
||||
"/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST
|
||||
)
|
||||
else:
|
||||
webhook_url = webhook_url or create_engine_url(
|
||||
"/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST
|
||||
"api/v3/webhook/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST
|
||||
)
|
||||
# avoid unnecessary set_webhook calls to make sure Telegram rate limits are not exceeded
|
||||
webhook_info = self.api_client.get_webhook_info()
|
||||
|
|
|
|||
|
|
@ -11,14 +11,10 @@ from django.utils import timezone
|
|||
from mirage import fields as mirage_fields
|
||||
|
||||
from apps.alerts.models import MaintainableObject
|
||||
from apps.chatops_proxy.utils import register_oncall_tenant, unlink_slack_team, unregister_oncall_tenant
|
||||
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
|
||||
from common.oncall_gateway import (
|
||||
register_oncall_tenant_wrapper,
|
||||
unlink_slack_team_wrapper,
|
||||
unregister_oncall_tenant_wrapper,
|
||||
)
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
|
|
@ -65,7 +61,7 @@ class OrganizationQuerySet(models.QuerySet):
|
|||
def create(self, **kwargs):
|
||||
instance = super().create(**kwargs)
|
||||
if settings.FEATURE_MULTIREGION_ENABLED:
|
||||
register_oncall_tenant_wrapper(str(instance.uuid), settings.ONCALL_BACKEND_REGION)
|
||||
register_oncall_tenant(str(instance.uuid), settings.ONCALL_BACKEND_REGION, instance.stack_id)
|
||||
return instance
|
||||
|
||||
def delete(self):
|
||||
|
|
@ -108,9 +104,9 @@ class Organization(MaintainableObject):
|
|||
|
||||
def delete(self):
|
||||
if settings.FEATURE_MULTIREGION_ENABLED:
|
||||
unregister_oncall_tenant_wrapper(str(self.uuid), settings.ONCALL_BACKEND_REGION)
|
||||
if self.slack_team_identity:
|
||||
unlink_slack_team_wrapper(str(self.uuid), self.slack_team_identity.slack_id)
|
||||
unregister_oncall_tenant(str(self.uuid), settings.ONCALL_BACKEND_REGION)
|
||||
if self.slack_team_identity and not settings.UNIFIED_SLACK_APP_ENABLED:
|
||||
unlink_slack_team(str(self.uuid), self.slack_team_identity.slack_id)
|
||||
self.deleted_at = timezone.now()
|
||||
self.save(update_fields=["deleted_at"])
|
||||
|
||||
|
|
|
|||
|
|
@ -3,3 +3,19 @@ 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"
|
||||
|
||||
|
||||
# Example of a slack oauth response to be used in tests.
|
||||
# It contains NO actual tokens, got it from slack docs.
|
||||
# https://api.slack.com/authentication/oauth-v2
|
||||
SLACK_OAUTH_ACCESS_RESPONSE = {
|
||||
"ok": True,
|
||||
"access_token": "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy",
|
||||
"token_type": "bot",
|
||||
"scope": "commands,incoming-webhook",
|
||||
"bot_user_id": "U0KRQLJ9H",
|
||||
"app_id": "A0KRD7HC3",
|
||||
"team": {"name": "Slack Softball Team", "id": "T9TK3CUKW"},
|
||||
"enterprise": {"name": "slack-sports", "id": "E12345678"},
|
||||
"authed_user": {"id": "U1234", "scope": "chat:write", "access_token": "xoxp-1234", "token_type": "user"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
"""
|
||||
This package is for interaction with OnCall-Gateway, service to provide multiregional chatops.
|
||||
"""
|
||||
|
||||
from .utils import ( # noqa: F401
|
||||
can_link_slack_team_wrapper,
|
||||
link_slack_team_wrapper,
|
||||
register_oncall_tenant_wrapper,
|
||||
unlink_slack_team_wrapper,
|
||||
unregister_oncall_tenant_wrapper,
|
||||
)
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnCallConnector:
|
||||
"""
|
||||
OnCallConnector represents connection between oncall org and oncall-gateway
|
||||
"""
|
||||
|
||||
oncall_org_id: str
|
||||
backend: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackConnector:
|
||||
"""
|
||||
SlackConnector represents connection between slack team with installed oncall app and oncall-gateway
|
||||
"""
|
||||
|
||||
oncall_org_id: str
|
||||
slack_team_id: str
|
||||
backend: str
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT = 5
|
||||
|
||||
|
||||
class OnCallGatewayAPIClient:
|
||||
"""
|
||||
It's a legacy api client, which should be removed after chatops proxy v3 release.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, token: str):
|
||||
self.base_url = url
|
||||
self.api_base_url = urljoin(self.base_url, "api/v1/")
|
||||
self.api_token = token
|
||||
|
||||
# OnCall Connector
|
||||
@property
|
||||
def _oncall_connectors_url(self) -> str:
|
||||
return urljoin(self.api_base_url, "oncall_org_connectors")
|
||||
|
||||
def post_oncall_connector(
|
||||
self, oncall_org_id: str, backend: str
|
||||
) -> tuple[OnCallConnector, requests.models.Response]:
|
||||
d = {"oncall_org_id": oncall_org_id, "backend": backend}
|
||||
response = self._post(url=self._oncall_connectors_url, json=d)
|
||||
response_data = response.json()
|
||||
|
||||
return OnCallConnector(oncall_org_id=response_data["oncall_org_id"], backend=response_data["backend"]), response
|
||||
|
||||
def delete_oncall_connector(self, oncall_org_id: str) -> requests.models.Response:
|
||||
url = urljoin(f"{self._oncall_connectors_url}/", oncall_org_id)
|
||||
response = self._delete(url=url)
|
||||
return response
|
||||
|
||||
# Slack Connector
|
||||
@property
|
||||
def _slack_connectors_url(self) -> str:
|
||||
return urljoin(self.api_base_url, "slack_team_connectors")
|
||||
|
||||
def post_slack_connector(
|
||||
self, oncall_org_id: str, slack_id: str, backend: str
|
||||
) -> tuple[SlackConnector, requests.models.Response]:
|
||||
d = {"oncall_org_id": oncall_org_id, "slack_team_id": slack_id, "backend": backend}
|
||||
response = self._post(url=self._slack_connectors_url, json=d)
|
||||
response_data = response.json()
|
||||
return (
|
||||
SlackConnector(
|
||||
response_data["oncall_org_id"],
|
||||
response_data["slack_team_id"],
|
||||
response_data["backend"],
|
||||
),
|
||||
response,
|
||||
)
|
||||
|
||||
def delete_slack_connector(self, oncall_org_id: str) -> requests.models.Response:
|
||||
url = urljoin(f"{self._slack_connectors_url}/", oncall_org_id)
|
||||
response = self._delete(url=url)
|
||||
return response
|
||||
|
||||
def check_slack_installation_possible(self, oncall_org_id, backend, slack_id: str) -> requests.models.Response:
|
||||
url = urljoin(f"{self._slack_connectors_url}/", "check_installation_possible")
|
||||
url += f"?slack_team_id={slack_id}&oncall_org_id={oncall_org_id}&backend={backend}"
|
||||
return self._get(url=url)
|
||||
|
||||
def _get(self, url, params=None, **kwargs) -> requests.models.Response:
|
||||
kwargs["params"] = params
|
||||
response = self._call_api(method=requests.get, url=url, **kwargs)
|
||||
return response
|
||||
|
||||
def _post(self, url, data=None, json=None, **kwargs) -> requests.models.Response:
|
||||
kwargs["data"] = data
|
||||
kwargs["json"] = json
|
||||
response = self._call_api(method=requests.post, url=url, **kwargs)
|
||||
return response
|
||||
|
||||
def _delete(self, url, **kwargs) -> requests.models.Response:
|
||||
response = self._call_api(method=requests.delete, url=url, **kwargs)
|
||||
return response
|
||||
|
||||
def _call_api(self, method, url, **kwargs) -> requests.models.Response:
|
||||
kwargs["headers"] = self._headers | kwargs.get("headers", {})
|
||||
response = method(url, **kwargs)
|
||||
self._check_response(response)
|
||||
return response
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"User-Agent": settings.GRAFANA_COM_USER_AGENT,
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _check_response(cls, response: requests.models.Response):
|
||||
if response.status_code not in [200, 201, 202, 204]:
|
||||
err_msg = cls._get_error_msg_from_response(response)
|
||||
if 400 <= response.status_code < 500:
|
||||
err_msg = "%s Client Error: %s for url: %s" % (response.status_code, err_msg, response.url)
|
||||
elif 500 <= response.status_code < 600:
|
||||
err_msg = "%s Server Error: %s for url: %s" % (response.status_code, err_msg, response.url)
|
||||
raise requests.exceptions.HTTPError(err_msg, response=response)
|
||||
|
||||
@classmethod
|
||||
def _get_error_msg_from_response(cls, response: requests.models.Response) -> str:
|
||||
error_msg = ""
|
||||
try:
|
||||
error_msg = response.json()["message"]
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
error_msg = response.text if response.text else response.reason
|
||||
return error_msg
|
||||
|
|
@ -1,137 +1,16 @@
|
|||
import requests
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
|
||||
from apps.chatops_proxy import tasks as new_tasks
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
||||
from .client import ChatopsProxyAPIClient, ChatopsProxyAPIException
|
||||
from .legacy_client import OnCallGatewayAPIClient
|
||||
|
||||
task_logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=100,
|
||||
)
|
||||
def create_oncall_connector_async(oncall_org_id, backend):
|
||||
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.post_oncall_connector(oncall_org_id, backend)
|
||||
except requests.exceptions.HTTPError as http_exc:
|
||||
if http_exc.response.status_code == 409:
|
||||
# 409 Indicates that it's impossible to create such connector.
|
||||
# More likely because it already exists.
|
||||
task_logger.error(
|
||||
f"Failed to create OnCallConnector oncall_org_id={oncall_org_id} backend={backend} exc={http_exc}"
|
||||
)
|
||||
else:
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
task_logger.error(f"Failed to create OnCallConnector oncall_org_id={oncall_org_id} backend={backend} exc={e}")
|
||||
raise e
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=100,
|
||||
)
|
||||
def delete_oncall_connector_async(oncall_org_id):
|
||||
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.delete_oncall_connector(oncall_org_id)
|
||||
except requests.exceptions.HTTPError as http_exc:
|
||||
if http_exc.response.status_code == 404:
|
||||
# 404 indicates that connector was deleted already
|
||||
return
|
||||
else:
|
||||
task_logger.error(f"Failed to delete OnCallConnector oncall_org_id={oncall_org_id} exc={http_exc}")
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
task_logger.error(f"Failed to delete OnCallConnector oncall_org_id={oncall_org_id} exc={e}")
|
||||
raise e
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=100,
|
||||
)
|
||||
def create_slack_connector_async_v2(**kwargs):
|
||||
oncall_org_id = kwargs.get("oncall_org_id")
|
||||
slack_team_id = kwargs.get("slack_team_id")
|
||||
backend = kwargs.get("backend")
|
||||
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.post_slack_connector(oncall_org_id, slack_team_id, backend)
|
||||
except requests.exceptions.HTTPError as http_exc:
|
||||
if http_exc.response.status_code == 409:
|
||||
# 409 Indicates that it's impossible to create such connector.
|
||||
# More likely because it already exists.
|
||||
task_logger.error(
|
||||
f"Failed to create SlackConnector oncall_org_id={oncall_org_id} backend={backend} exc={http_exc}"
|
||||
)
|
||||
else:
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
task_logger.error(f"Failed to create SlackConnector slack_id={oncall_org_id} backend={backend} exc={e}")
|
||||
raise e
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=100,
|
||||
)
|
||||
def delete_slack_connector_async_v2(**kwargs):
|
||||
oncall_org_id = kwargs.get("oncall_org_id")
|
||||
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.delete_slack_connector(oncall_org_id)
|
||||
except requests.exceptions.HTTPError as http_exc:
|
||||
if http_exc.response.status_code == 404:
|
||||
# 404 indicates that connector was deleted already
|
||||
return
|
||||
else:
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
task_logger.error(f"Failed to delete SlackConnectorV2 oncall_org_id={oncall_org_id} exc={e}")
|
||||
raise e
|
||||
|
||||
|
||||
# New tasks to use once chatops v3 is landed
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
max_retries=100,
|
||||
)
|
||||
def register_oncall_tenant_async(**kwargs):
|
||||
service_tenant_id = kwargs.get("service_tenant_id")
|
||||
cluster_slug = kwargs.get("cluster_slug")
|
||||
service_type = kwargs.get("service_type")
|
||||
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.register_tenant(service_tenant_id, cluster_slug, service_type)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
task_logger.error(
|
||||
f'msg="Failed to register OnCall tenant: {api_exc.msg}" service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}'
|
||||
)
|
||||
if api_exc.status == 409:
|
||||
# 409 Indicates that it's impossible to register tenant, because tenant already registered.
|
||||
# Not retrying in this case, because manual conflict-resolution needed.
|
||||
return
|
||||
else:
|
||||
# Otherwise keep retrying task
|
||||
raise api_exc
|
||||
except Exception as e:
|
||||
# Keep retrying task for any other exceptions too
|
||||
task_logger.error(
|
||||
f"Failed to register OnCall tenant: {e} service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}"
|
||||
)
|
||||
raise e
|
||||
new_tasks.register_oncall_tenant_async.apply_async(
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
|
|
@ -140,23 +19,9 @@ def register_oncall_tenant_async(**kwargs):
|
|||
max_retries=100,
|
||||
)
|
||||
def unregister_oncall_tenant_async(**kwargs):
|
||||
service_tenant_id = kwargs.get("service_tenant_id")
|
||||
cluster_slug = kwargs.get("cluster_slug")
|
||||
service_type = kwargs.get("service_type")
|
||||
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.unregister_tenant(service_tenant_id, cluster_slug, service_type)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
if api_exc.status == 400:
|
||||
# 400 Indicates that tenant is already deleted
|
||||
return
|
||||
else:
|
||||
# Otherwise keep retrying task
|
||||
raise api_exc
|
||||
except Exception as e:
|
||||
task_logger.error(f"Failed to delete OnCallTenant: {e} service_tenant_id={service_tenant_id}")
|
||||
raise e
|
||||
new_tasks.unregister_oncall_tenant_async.apply_async(
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
|
|
@ -165,27 +30,9 @@ def unregister_oncall_tenant_async(**kwargs):
|
|||
max_retries=100,
|
||||
)
|
||||
def link_slack_team_async(**kwargs):
|
||||
service_tenant_id = kwargs.get("service_tenant_id")
|
||||
service_type = kwargs.get("service_type")
|
||||
slack_team_id = kwargs.get("slack_team_id")
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.link_slack_team(service_tenant_id, slack_team_id, service_type)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
task_logger.error(
|
||||
f'msg="Failed to link slack team: {api_exc.msg}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
|
||||
)
|
||||
if api_exc.status == 409:
|
||||
# Impossible to register tenant, slack workspace already connected to another cluster.
|
||||
# Not retrying in this case, because manual conflict-resolution needed.
|
||||
return
|
||||
else:
|
||||
raise api_exc
|
||||
except Exception as e:
|
||||
task_logger.error(
|
||||
f'msg="Failed to link slack team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
|
||||
)
|
||||
raise e
|
||||
new_tasks.link_slack_team_async.apply_async(
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
|
|
@ -194,22 +41,6 @@ def link_slack_team_async(**kwargs):
|
|||
max_retries=100,
|
||||
)
|
||||
def unlink_slack_team_async(**kwargs):
|
||||
service_tenant_id = kwargs.get("service_tenant_id")
|
||||
service_type = kwargs.get("service_type")
|
||||
slack_team_id = kwargs.get("slack_team_id")
|
||||
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.unlink_slack_team(service_tenant_id, slack_team_id, service_type)
|
||||
except ChatopsProxyAPIException as api_exc:
|
||||
if api_exc.status == 400:
|
||||
# 400 Indicates that tenant is already deleted
|
||||
return
|
||||
else:
|
||||
# Otherwise keep retrying task
|
||||
raise api_exc
|
||||
except Exception as e:
|
||||
task_logger.error(
|
||||
f'msg="Failed to unlink slack_team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}'
|
||||
)
|
||||
raise e
|
||||
new_tasks.unlink_slack_team_async.apply_async(
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,196 +0,0 @@
|
|||
"""
|
||||
Set of utils to handle oncall and chatops-proxy interaction.
|
||||
TODO: Once chatops v3 will be released, remove legacy and wrapper functions
|
||||
"""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from .client import SERVICE_TYPE_ONCALL, ChatopsProxyAPIClient
|
||||
from .legacy_client import OnCallGatewayAPIClient
|
||||
from .tasks import (
|
||||
create_oncall_connector_async,
|
||||
create_slack_connector_async_v2,
|
||||
delete_oncall_connector_async,
|
||||
delete_slack_connector_async_v2,
|
||||
link_slack_team_async,
|
||||
register_oncall_tenant_async,
|
||||
unlink_slack_team_async,
|
||||
unregister_oncall_tenant_async,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Legacy to work with chatops-proxy v1.
|
||||
def create_oncall_connector(oncall_org_id: str, backend: str):
|
||||
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.post_oncall_connector(oncall_org_id, backend)
|
||||
except Exception as e:
|
||||
logger.error(f"create_oncall_connector: failed " f"oncall_org_id={oncall_org_id} backend={backend} exc={e}")
|
||||
create_oncall_connector_async.apply_async((oncall_org_id, backend), countdown=2)
|
||||
|
||||
|
||||
def delete_oncall_connector(oncall_org_id: str):
|
||||
delete_oncall_connector_async.delay(oncall_org_id)
|
||||
|
||||
|
||||
def check_slack_installation_possible(oncall_org_id: str, slack_id: str, backend: str) -> bool:
|
||||
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
response = client.check_slack_installation_possible(
|
||||
oncall_org_id=oncall_org_id, slack_id=slack_id, backend=backend
|
||||
)
|
||||
return response.status_code == 200
|
||||
except requests.exceptions.HTTPError as http_exc:
|
||||
logger.error(
|
||||
f"check_slack_installation_backend: slack installation impossible "
|
||||
f"oncall_org_id={oncall_org_id} slack_id={slack_id} backend={backend} exc={http_exc}"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def create_slack_connector(oncall_org_id: str, slack_id: str, backend: str):
|
||||
client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.post_slack_connector(oncall_org_id, slack_id, backend)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"create_slack_connector: failed "
|
||||
f"oncall_org_id={oncall_org_id} slack_id={slack_id} backend={backend} exc={e}"
|
||||
)
|
||||
create_slack_connector_async_v2.apply_async(
|
||||
kwargs={"oncall_org_id": oncall_org_id, "slack_id": slack_id, "backend": backend}, countdown=2
|
||||
)
|
||||
|
||||
|
||||
def delete_slack_connector(oncall_org_id: str):
|
||||
delete_slack_connector_async_v2.delay(oncall_org_id=oncall_org_id)
|
||||
|
||||
|
||||
# utils to work with v3 version
|
||||
def register_oncall_tenant(service_tenant_id: str, cluster_slug: str):
|
||||
"""
|
||||
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.
|
||||
First attempt is synchronous to register tenant ASAP to not miss any chatops requests.
|
||||
"""
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.register_tenant(service_tenant_id, cluster_slug, SERVICE_TYPE_ONCALL)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"create_oncall_connector: failed " f"oncall_org_id={service_tenant_id} backend={cluster_slug} exc={e}"
|
||||
)
|
||||
register_oncall_tenant_async.apply_async(
|
||||
kwargs={
|
||||
"service_tenant_id": service_tenant_id,
|
||||
"cluster_slug": cluster_slug,
|
||||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
},
|
||||
countdown=2,
|
||||
)
|
||||
|
||||
|
||||
def unregister_oncall_tenant(service_tenant_id: str, cluster_slug: str):
|
||||
"""
|
||||
unregister_oncall_tenant unregisters tenant asynchronously.
|
||||
"""
|
||||
unregister_oncall_tenant_async.apply_async(
|
||||
kwargs={
|
||||
"service_tenant_id": service_tenant_id,
|
||||
"cluster_slug": cluster_slug,
|
||||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
},
|
||||
countdown=2,
|
||||
)
|
||||
|
||||
|
||||
def can_link_slack_team(
|
||||
service_tenant_id: str,
|
||||
slack_team_id: str,
|
||||
cluster_slug: str,
|
||||
) -> bool:
|
||||
"""
|
||||
can_link_slack_team checks if it's possible to link slack workspace to oncall tenant located in cluster.
|
||||
All oncall tenants linked to same slack team should have same cluster.
|
||||
"""
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
response = client.can_slack_link(service_tenant_id, cluster_slug, slack_team_id, SERVICE_TYPE_ONCALL)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"can_link_slack_team: slack installation impossible: {e} "
|
||||
f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id} cluster_slug={cluster_slug}"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def link_slack_team(service_tenant_id: str, slack_team_id: str):
|
||||
client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN)
|
||||
try:
|
||||
client.link_slack_team(service_tenant_id, slack_team_id, SERVICE_TYPE_ONCALL)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'msg="Failed to link slack team: {e}"'
|
||||
f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}"
|
||||
)
|
||||
link_slack_team_async.apply_async(
|
||||
kwargs={
|
||||
"service_tenant_id": service_tenant_id,
|
||||
"slack_team_id": slack_team_id,
|
||||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
},
|
||||
countdown=2,
|
||||
)
|
||||
|
||||
|
||||
def unlink_slack_team(service_tenant_id: str, slack_team_id: str):
|
||||
unlink_slack_team_async.apply_async(
|
||||
kwargs={
|
||||
"service_tenant_id": service_tenant_id,
|
||||
"slack_team_id": slack_team_id,
|
||||
"service_type": SERVICE_TYPE_ONCALL,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Wrappers to choose whether legacy or v3 function should be call, depending on CHATOPS_V3 env var.
|
||||
def register_oncall_tenant_wrapper(service_tenant_id: str, cluster_slug: str):
|
||||
if settings.CHATOPS_V3:
|
||||
register_oncall_tenant(service_tenant_id, cluster_slug)
|
||||
else:
|
||||
create_oncall_connector(service_tenant_id, cluster_slug)
|
||||
|
||||
|
||||
def unregister_oncall_tenant_wrapper(service_tenant_id: str, cluster_slug: str):
|
||||
if settings.CHATOPS_V3:
|
||||
unregister_oncall_tenant(service_tenant_id, cluster_slug)
|
||||
else:
|
||||
delete_oncall_connector(service_tenant_id)
|
||||
|
||||
|
||||
def can_link_slack_team_wrapper(service_tenant_id: str, slack_team_id, cluster_slug: str) -> bool:
|
||||
if settings.CHATOPS_V3:
|
||||
return can_link_slack_team(service_tenant_id, slack_team_id, cluster_slug)
|
||||
else:
|
||||
return check_slack_installation_possible(service_tenant_id, slack_team_id, cluster_slug)
|
||||
|
||||
|
||||
def link_slack_team_wrapper(service_tenant_id: str, slack_team_id: str):
|
||||
if settings.CHATOPS_V3:
|
||||
link_slack_team(service_tenant_id, slack_team_id)
|
||||
else:
|
||||
create_slack_connector(service_tenant_id, slack_team_id, settings.ONCALL_BACKEND_REGION)
|
||||
|
||||
|
||||
def unlink_slack_team_wrapper(service_tenant_id: str, slack_team_id: str):
|
||||
if settings.CHATOPS_V3:
|
||||
unlink_slack_team(service_tenant_id, slack_team_id)
|
||||
else:
|
||||
delete_slack_connector(service_tenant_id)
|
||||
|
|
@ -58,9 +58,11 @@ if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
|
|||
if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
|
||||
urlpatterns += [
|
||||
path("api/internal/v1/slack/", include("apps.slack.urls")),
|
||||
path("api/v3/webhook/slack/", include("apps.slack.urls")),
|
||||
path("slack/", include("apps.slack.urls")),
|
||||
]
|
||||
|
||||
|
||||
if settings.IS_OPEN_SOURCE:
|
||||
urlpatterns += [
|
||||
path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")),
|
||||
|
|
@ -88,3 +90,8 @@ if settings.DRF_SPECTACULAR_ENABLED:
|
|||
path("internal/schema/", SpectacularYAMLAPIView.as_view(api_version="internal/v1"), name="schema"),
|
||||
path("internal/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
||||
]
|
||||
|
||||
if settings.UNIFIED_SLACK_APP_ENABLED:
|
||||
urlpatterns += [
|
||||
path("api/chatops/", include("apps.chatops_proxy.urls")),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -98,7 +98,9 @@ WEBHOOK_RESPONSE_LIMIT = 50000
|
|||
ONCALL_GATEWAY_URL = os.environ.get("ONCALL_GATEWAY_URL", "")
|
||||
ONCALL_GATEWAY_API_TOKEN = os.environ.get("ONCALL_GATEWAY_API_TOKEN", "")
|
||||
ONCALL_BACKEND_REGION = os.environ.get("ONCALL_BACKEND_REGION")
|
||||
CHATOPS_V3 = getenv_boolean("CHATOPS_V3", False)
|
||||
UNIFIED_SLACK_APP_ENABLED = getenv_boolean("UNIFIED_SLACK_APP_ENABLED", default=False)
|
||||
# secret to verify the incoming requests from the chatops-proxy
|
||||
CHATOPS_SIGNING_SECRET = os.environ.get("CHATOPS_SIGNING_SECRET", None)
|
||||
|
||||
# Prometheus exporter metrics endpoint auth
|
||||
PROMETHEUS_EXPORTER_SECRET = os.environ.get("PROMETHEUS_EXPORTER_SECRET")
|
||||
|
|
@ -281,6 +283,7 @@ INSTALLED_APPS = [
|
|||
"apps.phone_notifications",
|
||||
"drf_spectacular",
|
||||
"apps.google",
|
||||
"apps.chatops_proxy",
|
||||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
|
|
@ -779,7 +782,7 @@ GRAFANA_CLOUD_AUTH_API_URL = os.environ.get("GRAFANA_CLOUD_AUTH_API_URL", None)
|
|||
GRAFANA_CLOUD_AUTH_API_SYSTEM_TOKEN = os.environ.get("GRAFANA_CLOUD_AUTH_API_SYSTEM_TOKEN", None)
|
||||
|
||||
SELF_HOSTED_SETTINGS = {
|
||||
"STACK_ID": 5,
|
||||
"STACK_ID": getenv_integer("SELF_HOSTED_STACK_ID", 5),
|
||||
"STACK_SLUG": os.environ.get("SELF_HOSTED_STACK_SLUG", "self_hosted_stack"),
|
||||
"ORG_ID": 100,
|
||||
"ORG_SLUG": os.environ.get("SELF_HOSTED_ORG_SLUG", "self_hosted_org"),
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ CELERY_TASK_ROUTES = {
|
|||
"common.oncall_gateway.tasks.unlink_slack_team_async": {"queue": "default"},
|
||||
"common.oncall_gateway.tasks.register_oncall_tenant_async": {"queue": "default"},
|
||||
"common.oncall_gateway.tasks.unregister_oncall_tenant_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.link_slack_team_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.unlink_slack_team_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.register_oncall_tenant_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.unregister_oncall_tenant_async": {"queue": "default"},
|
||||
# CRITICAL
|
||||
"apps.alerts.tasks.acknowledge_reminder.acknowledge_reminder_task": {"queue": "critical"},
|
||||
"apps.alerts.tasks.acknowledge_reminder.unacknowledge_timeout_task": {"queue": "critical"},
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { action, observable, makeObservable, runInAction } from 'mobx';
|
|||
|
||||
import { BaseStore } from 'models/base_store';
|
||||
import { SlackChannel } from 'models/slack_channel/slack_channel.types';
|
||||
import { makeRequest } from 'network/network';
|
||||
import { makeRequest, makeRequestRaw } from 'network/network';
|
||||
import { RootStore } from 'state/rootStore';
|
||||
import { GENERIC_ERROR } from 'utils/consts';
|
||||
import { openErrorNotification } from 'utils/utils';
|
||||
|
||||
import { SlackSettings } from './slack.types';
|
||||
|
||||
|
|
@ -81,8 +83,19 @@ export class SlackStore extends BaseStore {
|
|||
}
|
||||
|
||||
async installSlackIntegration() {
|
||||
const url_for_redirect = await makeRequest('/login/slack-install-free/', {});
|
||||
window.location = url_for_redirect;
|
||||
try {
|
||||
const response = await makeRequestRaw('/login/slack-install-free/', {});
|
||||
|
||||
if (response.status === 201) {
|
||||
this.rootStore.organizationStore.loadCurrentOrganization();
|
||||
} else if (response.status === 200) {
|
||||
window.location = response.data;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (ex.response?.status === 500) {
|
||||
openErrorNotification(GENERIC_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async removeSlackIntegration() {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ interface RequestConfig {
|
|||
|
||||
export const isNetworkError = axios.isAxiosError;
|
||||
|
||||
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
|
||||
export const makeRequestRaw = async (path: string, config: RequestConfig) => {
|
||||
const { method = 'GET', params, data, validateStatus, headers } = config;
|
||||
|
||||
const url = `${API_PROXY_PREFIX}${API_PATH_PREFIX}${path}`;
|
||||
|
|
@ -56,7 +56,7 @@ export const makeRequest = async <RT = any>(path: string, config: RequestConfig)
|
|||
});
|
||||
|
||||
FaroHelper.pushAxiosNetworkResponseEvent({ name: 'Request succeeded', res: response });
|
||||
return response.data as RT;
|
||||
return response;
|
||||
} catch (ex) {
|
||||
const error = ex as AxiosError;
|
||||
FaroHelper.pushAxiosNetworkResponseEvent({ name: 'Request failed', res: error.response });
|
||||
|
|
@ -64,3 +64,12 @@ export const makeRequest = async <RT = any>(path: string, config: RequestConfig)
|
|||
throw ex;
|
||||
}
|
||||
};
|
||||
|
||||
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
|
||||
try {
|
||||
const result = await makeRequestRaw(path, config);
|
||||
return result.data as RT;
|
||||
} catch (ex) {
|
||||
throw ex;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue