feat: add support for mattermost chatops (#5321)
Related to https://github.com/grafana/oncall/issues/96 --------- Co-authored-by: Ravishankar <ravishankar.gnanaprakasam@gmail.com>
This commit is contained in:
parent
df086e686b
commit
dcae98b02a
94 changed files with 4429 additions and 37 deletions
|
|
@ -13,12 +13,20 @@ TWILIO_VERIFY_SERVICE_SID=
|
|||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_NUMBER=
|
||||
|
||||
MATTERMOST_CLIENT_OAUTH_ID=
|
||||
MATTERMOST_CLIENT_OAUTH_SECRET=
|
||||
MATTERMOST_HOST=
|
||||
MATTERMOST_BOT_TOKEN=
|
||||
MATTERMOST_LOGIN_RETURN_REDIRECT_HOST=http://localhost:8080
|
||||
MATTERMOST_SIGNING_SECRET=
|
||||
|
||||
DJANGO_SETTINGS_MODULE=settings.dev
|
||||
SECRET_KEY=jyRnfRIeMjYfKdoFa9dKXcNaEGGc8GH1TChmYoWW
|
||||
BASE_URL=http://localhost:8080
|
||||
|
||||
FEATURE_TELEGRAM_INTEGRATION_ENABLED=True
|
||||
FEATURE_SLACK_INTEGRATION_ENABLED=True
|
||||
FEATURE_MATTERMOST_INTEGRATION_ENABLED=True
|
||||
|
||||
SLACK_INSTALL_RETURN_REDIRECT_HOST=http://localhost:8080
|
||||
SOCIAL_AUTH_REDIRECT_IS_HTTPS=False
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: Mattermost
|
||||
menuTitle: Mattermost
|
||||
description: Explains that a Mattermost integration is not implemented yet.
|
||||
description: How to connect Mattermost for alert group notifications.
|
||||
weight: 900
|
||||
keywords:
|
||||
- OnCall
|
||||
|
|
@ -15,9 +15,26 @@ aliases:
|
|||
- ../../chat-options/configure-mattermost
|
||||
---
|
||||
|
||||
# Mattermost
|
||||
# Mattermost integration for Grafana OnCall
|
||||
|
||||
Mattermost support is not implemented yet.
|
||||
The Mattermost integration for Grafana OnCall allows connecting a Mattermost channel directly
|
||||
into your incident response workflow to help your team focus on alert resolution with less friction.
|
||||
|
||||
Please join [GitHub Issue](https://github.com/grafana/oncall/issues/96) or
|
||||
check [PR](https://github.com/grafana/oncall/pull/606).
|
||||
At the moment, this integration is only available for OSS installations.
|
||||
|
||||
## Before you begin
|
||||
|
||||
To install the Mattermost integration, you must have Admin Permissions in your Grafana setup
|
||||
as well as in the Mattermost instance that you'd like to integrate with.
|
||||
|
||||
Follow the steps in our [documentation](https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup).
|
||||
|
||||
## Connect to a Mattermost Channel
|
||||
|
||||
1. Go to the Mattermost channel you want to connect to, check its information and copy the channel id.
|
||||
2. In Grafana OnCall, in the Settings section, click on the **ChatOps** tab and select Mattermost in the side menu.
|
||||
3. Click the **Add Mattermost channel** button, paste the channel id from step (1) and click **Create**.
|
||||
4. Set a default channel for the alerts.
|
||||
|
||||
(Note: Make sure the bot in your setup is member of the team the channel belongs to and
|
||||
has `read_channel` privileges [Ref](https://api.mattermost.com/#tag/channels/operation/GetChannelByNameForTeamName))
|
||||
|
|
|
|||
|
|
@ -211,11 +211,40 @@ Refer to the following steps to configure the Telegram integration:
|
|||
Alternatively, in case you want to connect Telegram channels to your Grafana OnCall environment, navigate
|
||||
to the **ChatOps** tab.
|
||||
|
||||
## Grafana OSS-Cloud Setup
|
||||
## Mattermost Setup
|
||||
|
||||
The benefits of connecting to Grafana Cloud OnCall include:
|
||||
The Mattermost integration of the Grafana OnCall is designed for collobarative team work and improved incident response.
|
||||
|
||||
Refer to the following steps to configure the Mattermost integration:
|
||||
|
||||
1. Ensure your Grafana OnCall environment is up and running.
|
||||
2. Set `FEATURE_MATTERMOST_INTEGRATION_ENABLED` as "True".
|
||||
3. Create a Mattermost bot account [Ref](https://developers.mattermost.com/integrate/reference/bot-accounts/#bot-account-creation)
|
||||
and save the token generated (NOTE: you may need to give the bot admin permissions
|
||||
to make it possible for it to update alert group notifications).
|
||||
4. Add the bot to the Mattermost team(s) owning the channels you want to connect to
|
||||
(via Mattermost System console -> User Management -> Teams).
|
||||
5. Set the token generated in the `MATTERMOST_BOT_TOKEN` variable on the **ENV Variables** page
|
||||
of your Grafana OnCall instance.
|
||||
6. [Create OAuth 2.0 Application In Mattermost](https://developers.mattermost.com/integrate/apps/authentication/oauth2/#register-an-oauth-20-application).
|
||||
The callback url for the OAuth application should be,
|
||||
|
||||
```text
|
||||
https://<ONCALL_ENGINE_PUBLIC_URL>/api/internal/v1/complete/mattermost-login/
|
||||
```
|
||||
|
||||
This will allow users to connect their OnCall accounts with Mattermost. The OAuth credentials will be needed later.
|
||||
7. Generate a JWT secret for authenticating the incoming event messages from Mattermost
|
||||
and set it as the `MATTERMOST_SIGNING_SECRET` variable on the **ENV Variables** page of your Grafana OnCall instance.
|
||||
8. Set the following environment variables too:
|
||||
|
||||
```text
|
||||
MATTERMOST_CLIENT_OAUTH_ID = << Integrations -> OAuth 2.0 Applications -> Client ID >>
|
||||
MATTERMOST_CLIENT_OAUTH_SECRET = << Integrations -> OAuth 2.0 Applications -> Client Secret >>
|
||||
MATTERMOST_HOST = << Mattermost server URL >>
|
||||
MATTERMOST_LOGIN_RETURN_REDIRECT_HOST = << OnCall external URL >>
|
||||
```
|
||||
|
||||
- Grafana Cloud OnCall could monitor OSS OnCall uptime using heartbeat
|
||||
- SMS for user notifications
|
||||
- Phone calls for user notifications.
|
||||
|
||||
|
|
@ -354,3 +383,4 @@ To configure this feature as such:
|
|||
|
||||
Additionally, if you prefer to disable this feature, you can set the `ESCALATION_AUDITOR_ENABLED` environment variable
|
||||
to `False`.
|
||||
e`.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ class ActionSource(IntegerChoices):
|
|||
TELEGRAM = 3, "Telegram"
|
||||
API = 4, "API"
|
||||
BACKSYNC = 5, "Backsync"
|
||||
MATTERMOST = 6, "Mattermost"
|
||||
|
||||
|
||||
TASK_DELAY_SECONDS = 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2.15 on 2024-12-03 13:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0074_alter_escalationpolicy_step'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='alertgrouplogrecord',
|
||||
name='action_source',
|
||||
field=models.SmallIntegerField(default=None, null=True, verbose_name=[(0, 'Slack'), (1, 'Web'), (2, 'Phone'), (3, 'Telegram'), (4, 'API'), (5, 'Backsync'), (6, 'Mattermost')]),
|
||||
),
|
||||
]
|
||||
|
|
@ -84,9 +84,13 @@ class CurrentOrganizationSerializer(OrganizationSerializer):
|
|||
|
||||
telegram_configured = not LiveSetting.objects.filter(name__startswith="TELEGRAM", error__isnull=False).exists()
|
||||
phone_provider_config = get_phone_provider().flags
|
||||
mattermost_configured = not LiveSetting.objects.filter(
|
||||
name__startswith="MATTERMOST", error__isnull=False
|
||||
).exists()
|
||||
return {
|
||||
"telegram_configured": telegram_configured,
|
||||
"phone_provider": asdict(phone_provider_config),
|
||||
"mattermost_configured": mattermost_configured,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ from django.urls import reverse
|
|||
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 apps.auth_token.constants import MATTERMOST_AUTH_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
|
||||
from apps.social_auth.backends import MATTERMOST_LOGIN_BACKEND, SLACK_INSTALLATION_BACKEND
|
||||
from common.constants.plugin_ids import PluginID
|
||||
from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE
|
||||
|
||||
|
|
@ -99,6 +99,85 @@ def test_start_slack_ok(
|
|||
assert response.json() == "https://slack_oauth_redirect.com"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"backend_name,expected_url",
|
||||
((MATTERMOST_LOGIN_BACKEND, "a/grafana-oncall-app/users/me"),),
|
||||
)
|
||||
def test_complete_mattermost_auth_redirect_ok(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_mattermost_token_for_user,
|
||||
backend_name,
|
||||
expected_url,
|
||||
):
|
||||
organization = make_organization()
|
||||
admin = make_user_for_organization(organization)
|
||||
_, mattermost_token = make_mattermost_token_for_user(admin)
|
||||
|
||||
client = APIClient()
|
||||
url = (
|
||||
reverse("api-internal:complete-social-auth", kwargs={"backend": backend_name})
|
||||
+ f"?{MATTERMOST_AUTH_TOKEN_NAME}={mattermost_token}"
|
||||
)
|
||||
|
||||
with patch("apps.api.views.auth.do_complete") as mock_do_complete:
|
||||
mock_do_complete.return_value = None
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
assert response.url == expected_url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_complete_mattermost_auth_redirect_error(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_mattermost_token_for_user,
|
||||
):
|
||||
organization = make_organization()
|
||||
admin = make_user_for_organization(organization)
|
||||
_, mattermost_token = make_mattermost_token_for_user(admin)
|
||||
|
||||
client = APIClient()
|
||||
url = (
|
||||
reverse("api-internal:complete-social-auth", kwargs={"backend": MATTERMOST_LOGIN_BACKEND})
|
||||
+ f"?{MATTERMOST_AUTH_TOKEN_NAME}={mattermost_token}"
|
||||
)
|
||||
|
||||
def _custom_do_complete(backend, *args, **kwargs):
|
||||
backend.strategy.session[REDIRECT_FIELD_NAME] = "some-url"
|
||||
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with patch("apps.api.views.auth.do_complete", side_effect=_custom_do_complete):
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
assert response.url == "some-url"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_start_mattermost_ok(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
"""
|
||||
Covers the case when user starts Mattermost integration installation via Grafana OnCall
|
||||
"""
|
||||
_, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:social-auth", kwargs={"backend": MATTERMOST_LOGIN_BACKEND})
|
||||
|
||||
mock_do_auth_return = Mock()
|
||||
mock_do_auth_return.url = "https://mattermost_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://mattermost_oauth_redirect.com"
|
||||
|
||||
|
||||
@override_settings(UNIFIED_SLACK_APP_ENABLED=True)
|
||||
@pytest.mark.django_db
|
||||
def test_start_unified_slack_ok(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ mock_env_status = {
|
|||
"verification_call": False,
|
||||
"verification_sms": False,
|
||||
},
|
||||
"mattermost_configured": False,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,14 +13,19 @@ from social_core.backends.google import GoogleOAuth2
|
|||
from social_django.utils import psa
|
||||
from social_django.views import _do_login
|
||||
|
||||
from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication
|
||||
from apps.auth_token.auth import (
|
||||
GoogleTokenAuthentication,
|
||||
MattermostTokenAuthentication,
|
||||
PluginAuthentication,
|
||||
SlackTokenAuthentication,
|
||||
)
|
||||
from apps.chatops_proxy.utils import (
|
||||
get_installation_link_from_chatops_proxy,
|
||||
get_slack_oauth_response_from_chatops_proxy,
|
||||
)
|
||||
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
|
||||
from apps.slack.installation import install_slack_integration
|
||||
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginSlackOAuth2V2
|
||||
from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginMattermostOAuth2, LoginSlackOAuth2V2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -67,7 +72,7 @@ def overridden_login_social_auth(request: Request, backend: str) -> Response:
|
|||
|
||||
|
||||
@api_view(["GET"])
|
||||
@authentication_classes([GoogleTokenAuthentication, SlackTokenAuthentication])
|
||||
@authentication_classes([GoogleTokenAuthentication, SlackTokenAuthentication, MattermostTokenAuthentication])
|
||||
@never_cache
|
||||
@csrf_exempt
|
||||
@psa("social:complete")
|
||||
|
|
@ -98,7 +103,7 @@ def overridden_complete_social_auth(request: Request, backend: str, *args, **kwa
|
|||
# otherwise it pertains to the InstallSlackOAuth2V2 backend, and we should redirect to the chat-ops page
|
||||
return_to = (
|
||||
url_builder.user_profile()
|
||||
if isinstance(request.backend, (LoginSlackOAuth2V2, GoogleOAuth2))
|
||||
if isinstance(request.backend, (LoginMattermostOAuth2, LoginSlackOAuth2V2, GoogleOAuth2))
|
||||
else url_builder.chatops()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class Feature(enum.StrEnum):
|
|||
GOOGLE_OAUTH2 = "google_oauth2"
|
||||
SERVICE_DEPENDENCIES = "service_dependencies"
|
||||
PERSONAL_WEBHOOK = "personal_webhook"
|
||||
MATTERMOST = "mattermost"
|
||||
|
||||
|
||||
class FeaturesAPIView(APIView):
|
||||
|
|
@ -80,4 +81,7 @@ class FeaturesAPIView(APIView):
|
|||
if settings.FEATURE_PERSONAL_WEBHOOK_ENABLED:
|
||||
enabled_features.append(Feature.PERSONAL_WEBHOOK)
|
||||
|
||||
if settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED:
|
||||
enabled_features.append(Feature.MATTERMOST)
|
||||
|
||||
return enabled_features
|
||||
|
|
|
|||
|
|
@ -19,12 +19,18 @@ from apps.user_management.sync import get_or_create_user
|
|||
from common.utils import validate_url
|
||||
from settings.base import SELF_HOSTED_SETTINGS
|
||||
|
||||
from .constants import GOOGLE_OAUTH2_AUTH_TOKEN_NAME, SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
|
||||
from .constants import (
|
||||
GOOGLE_OAUTH2_AUTH_TOKEN_NAME,
|
||||
MATTERMOST_AUTH_TOKEN_NAME,
|
||||
SCHEDULE_EXPORT_TOKEN_NAME,
|
||||
SLACK_AUTH_TOKEN_NAME,
|
||||
)
|
||||
from .exceptions import InvalidToken
|
||||
from .models import (
|
||||
ApiAuthToken,
|
||||
GoogleOAuth2Token,
|
||||
IntegrationBacksyncAuthToken,
|
||||
MattermostAuthToken,
|
||||
PluginAuthToken,
|
||||
ScheduleExportAuthToken,
|
||||
ServiceAccountToken,
|
||||
|
|
@ -272,6 +278,11 @@ class SlackTokenAuthentication(_SocialAuthTokenAuthentication[SlackAuthToken]):
|
|||
model = SlackAuthToken
|
||||
|
||||
|
||||
class MattermostTokenAuthentication(_SocialAuthTokenAuthentication[MattermostAuthToken]):
|
||||
token_query_param_name = MATTERMOST_AUTH_TOKEN_NAME
|
||||
model = MattermostAuthToken
|
||||
|
||||
|
||||
class GoogleTokenAuthentication(_SocialAuthTokenAuthentication[GoogleOAuth2Token]):
|
||||
token_query_param_name = GOOGLE_OAUTH2_AUTH_TOKEN_NAME
|
||||
model = GoogleOAuth2Token
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ DIGEST_LENGTH = 128
|
|||
MAX_PUBLIC_API_TOKENS_PER_USER = 5
|
||||
|
||||
SLACK_AUTH_TOKEN_NAME = "slack_login_token"
|
||||
MATTERMOST_AUTH_TOKEN_NAME = "state"
|
||||
GOOGLE_OAUTH2_AUTH_TOKEN_NAME = "state"
|
||||
"""
|
||||
We must use the `state` query param, otherwise Google returns a 400 error.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 4.2.15 on 2024-12-03 13:13
|
||||
|
||||
import apps.auth_token.models.mattermost_auth_token
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('user_management', '0029_remove_organization_general_log_channel_id_db'),
|
||||
('auth_token', '0007_serviceaccounttoken'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MattermostAuthToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token_key', models.CharField(db_index=True, max_length=8)),
|
||||
('digest', models.CharField(max_length=128)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('revoked_at', models.DateTimeField(null=True)),
|
||||
('expire_date', models.DateTimeField(default=apps.auth_token.models.mattermost_auth_token.get_expire_date)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_auth_token_set', to='user_management.organization')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_auth_token', to='user_management.user')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -2,6 +2,7 @@ from .api_auth_token import ApiAuthToken # noqa: F401
|
|||
from .base_auth_token import BaseAuthToken # noqa: F401
|
||||
from .google_oauth2_token import GoogleOAuth2Token # noqa: F401
|
||||
from .integration_backsync_auth_token import IntegrationBacksyncAuthToken # noqa: F401
|
||||
from .mattermost_auth_token import MattermostAuthToken # noqa: F401
|
||||
from .plugin_auth_token import PluginAuthToken # noqa: F401
|
||||
from .schedule_export_auth_token import ScheduleExportAuthToken # noqa: F401
|
||||
from .service_account_token import ServiceAccountToken # noqa: F401
|
||||
|
|
|
|||
38
engine/apps/auth_token/models/mattermost_auth_token.py
Normal file
38
engine/apps/auth_token/models/mattermost_auth_token.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from typing import Tuple
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.auth_token import constants, crypto
|
||||
from apps.auth_token.models import BaseAuthToken
|
||||
from apps.user_management.models import Organization, User
|
||||
from settings.base import AUTH_TOKEN_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def get_expire_date():
|
||||
return timezone.now() + timezone.timedelta(seconds=AUTH_TOKEN_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
class MattermostAuthToken(BaseAuthToken):
|
||||
user = models.OneToOneField("user_management.User", related_name="mattermost_auth_token", on_delete=models.CASCADE)
|
||||
organization = models.ForeignKey(
|
||||
"user_management.Organization", related_name="mattermost_auth_token_set", on_delete=models.CASCADE
|
||||
)
|
||||
expire_date = models.DateTimeField(default=get_expire_date)
|
||||
|
||||
@classmethod
|
||||
def create_auth_token(cls, user: User, organization: Organization) -> Tuple["MattermostAuthToken", str]:
|
||||
old_token = cls.objects_with_deleted.filter(user=user)
|
||||
if old_token.exists():
|
||||
old_token.delete()
|
||||
|
||||
token_string = crypto.generate_token_string()
|
||||
digest = crypto.hash_token_string(token_string)
|
||||
|
||||
instance = cls.objects.create(
|
||||
token_key=token_string[: constants.TOKEN_KEY_LENGTH],
|
||||
digest=digest,
|
||||
user=user,
|
||||
organization=organization,
|
||||
)
|
||||
return instance, token_string
|
||||
|
|
@ -80,6 +80,12 @@ class LiveSetting(models.Model):
|
|||
"EXOTEL_SMS_SENDER_ID",
|
||||
"EXOTEL_SMS_VERIFICATION_TEMPLATE",
|
||||
"EXOTEL_SMS_DLT_ENTITY_ID",
|
||||
"MATTERMOST_CLIENT_OAUTH_ID",
|
||||
"MATTERMOST_CLIENT_OAUTH_SECRET",
|
||||
"MATTERMOST_HOST",
|
||||
"MATTERMOST_BOT_TOKEN",
|
||||
"MATTERMOST_LOGIN_RETURN_REDIRECT_HOST",
|
||||
"MATTERMOST_SIGNING_SECRET",
|
||||
)
|
||||
|
||||
DESCRIPTIONS = {
|
||||
|
|
@ -187,6 +193,36 @@ class LiveSetting(models.Model):
|
|||
"EXOTEL_SMS_SENDER_ID": "Exotel SMS Sender ID to use for verification SMS",
|
||||
"EXOTEL_SMS_VERIFICATION_TEMPLATE": "SMS text template to be used for sending SMS, add $verification_code as a placeholder for the verification code",
|
||||
"EXOTEL_SMS_DLT_ENTITY_ID": "DLT Entity ID registered with TRAI.",
|
||||
"MATTERMOST_CLIENT_OAUTH_ID": (
|
||||
"Check <a href='"
|
||||
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
|
||||
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
|
||||
),
|
||||
"MATTERMOST_CLIENT_OAUTH_SECRET": (
|
||||
"Check <a href='"
|
||||
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
|
||||
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
|
||||
),
|
||||
"MATTERMOST_HOST": (
|
||||
"Check <a href='"
|
||||
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
|
||||
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
|
||||
),
|
||||
"MATTERMOST_BOT_TOKEN": (
|
||||
"Check <a href='"
|
||||
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
|
||||
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
|
||||
),
|
||||
"MATTERMOST_LOGIN_RETURN_REDIRECT_HOST": (
|
||||
"Check <a href='"
|
||||
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
|
||||
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
|
||||
),
|
||||
"MATTERMOST_SIGNING_SECRET": (
|
||||
"Check <a href='"
|
||||
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
|
||||
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
|
||||
),
|
||||
}
|
||||
|
||||
SECRET_SETTING_NAMES = (
|
||||
|
|
@ -205,6 +241,9 @@ class LiveSetting(models.Model):
|
|||
"ZVONOK_API_KEY",
|
||||
"EXOTEL_ACCOUNT_SID",
|
||||
"EXOTEL_API_TOKEN",
|
||||
"MATTERMOST_CLIENT_OAUTH_ID",
|
||||
"MATTERMOST_CLIENT_OAUTH_SECRET",
|
||||
"MATTERMOST_BOT_TOKEN",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
|
|
|||
|
|
@ -107,7 +107,11 @@ class UserNotificationPolicyLogRecord(models.Model):
|
|||
ERROR_NOTIFICATION_MOBILE_USER_HAS_NO_ACTIVE_DEVICE,
|
||||
ERROR_NOTIFICATION_FORMATTING_ERROR,
|
||||
ERROR_NOTIFICATION_IN_TELEGRAM_RATELIMIT,
|
||||
) = range(30)
|
||||
ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST,
|
||||
ERROR_NOTIFICATION_IN_MATTERMOST_ALERT_GROUP_MESSAGE_NOT_FOUND,
|
||||
ERROR_NOTIFICATION_IN_MATTERMOST_API_TOKEN_INVALID,
|
||||
ERROR_NOTIFICATION_IN_MATTERMOST_API_UNAUTHORIZED,
|
||||
) = range(34)
|
||||
|
||||
# for this errors we want to send message to general log channel
|
||||
ERRORS_TO_SEND_IN_SLACK_CHANNEL = [
|
||||
|
|
@ -323,6 +327,11 @@ class UserNotificationPolicyLogRecord(models.Model):
|
|||
result += f"failed to send push notification to {user_verbal} because user has no device set up"
|
||||
elif self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORMATTING_ERROR:
|
||||
result += f"failed to send message to {user_verbal} due to a formatting error"
|
||||
elif (
|
||||
self.notification_error_code
|
||||
== UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST
|
||||
):
|
||||
result += f"failed to notify {user_verbal} in Mattermost, because {user_verbal} is not in Mattermost"
|
||||
else:
|
||||
# TODO: handle specific backend errors
|
||||
try:
|
||||
|
|
|
|||
0
engine/apps/mattermost/__init__.py
Normal file
0
engine/apps/mattermost/__init__.py
Normal file
87
engine/apps/mattermost/alert_group_representative.py
Normal file
87
engine/apps/mattermost/alert_group_representative.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import logging
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.alerts.representative import AlertGroupAbstractRepresentative
|
||||
from apps.mattermost.alert_rendering import MattermostMessageRenderer
|
||||
from apps.mattermost.client import MattermostClient
|
||||
from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid
|
||||
from apps.mattermost.tasks import on_alert_group_action_triggered_async, on_create_alert_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class AlertGroupMattermostRepresentative(AlertGroupAbstractRepresentative):
|
||||
def __init__(self, log_record) -> None:
|
||||
self.log_record = log_record
|
||||
|
||||
def is_applicable(self):
|
||||
from apps.mattermost.models import MattermostChannel
|
||||
|
||||
organization = self.log_record.alert_group.channel.organization
|
||||
handler_exists = self.log_record.type in self.get_handler_map().keys()
|
||||
|
||||
mattermost_channels = MattermostChannel.objects.filter(organization=organization)
|
||||
return handler_exists and mattermost_channels.exists()
|
||||
|
||||
@staticmethod
|
||||
def get_handler_map():
|
||||
from apps.alerts.models import AlertGroupLogRecord
|
||||
|
||||
return {
|
||||
AlertGroupLogRecord.TYPE_ACK: "alert_group_action",
|
||||
AlertGroupLogRecord.TYPE_UN_ACK: "alert_group_action",
|
||||
AlertGroupLogRecord.TYPE_AUTO_UN_ACK: "alert_group_action",
|
||||
AlertGroupLogRecord.TYPE_RESOLVED: "alert_group_action",
|
||||
AlertGroupLogRecord.TYPE_UN_RESOLVED: "alert_group_action",
|
||||
AlertGroupLogRecord.TYPE_ACK_REMINDER_TRIGGERED: "alert_group_action",
|
||||
AlertGroupLogRecord.TYPE_SILENCE: "alert_group_action",
|
||||
AlertGroupLogRecord.TYPE_UN_SILENCE: "alert_group_action",
|
||||
AlertGroupLogRecord.TYPE_ATTACHED: "alert_group_action",
|
||||
AlertGroupLogRecord.TYPE_UNATTACHED: "alert_group_action",
|
||||
}
|
||||
|
||||
def on_alert_group_action(self, alert_group: AlertGroup):
|
||||
logger.info(f"Update mattermost message for alert_group {alert_group.pk}")
|
||||
payload = MattermostMessageRenderer(alert_group).render_alert_group_message()
|
||||
mattermost_message = alert_group.mattermost_messages.order_by("created_at").first()
|
||||
try:
|
||||
client = MattermostClient()
|
||||
client.update_post(post_id=mattermost_message.post_id, data=payload)
|
||||
except MattermostAPITokenInvalid:
|
||||
logger.error(f"Mattermost API token is invalid could not create post for alert {alert_group.pk}")
|
||||
except MattermostAPIException as ex:
|
||||
logger.error(f"Mattermost API error {ex}")
|
||||
if ex.status not in [status.HTTP_401_UNAUTHORIZED]:
|
||||
raise ex
|
||||
|
||||
@staticmethod
|
||||
def on_create_alert(**kwargs):
|
||||
alert_pk = kwargs["alert"]
|
||||
on_create_alert_async.apply_async((alert_pk,))
|
||||
|
||||
@staticmethod
|
||||
def on_alert_group_action_triggered(**kwargs):
|
||||
from apps.alerts.models import AlertGroupLogRecord
|
||||
|
||||
log_record = kwargs["log_record"]
|
||||
if isinstance(log_record, AlertGroupLogRecord):
|
||||
log_record_id = log_record.pk
|
||||
else:
|
||||
log_record_id = log_record
|
||||
on_alert_group_action_triggered_async.apply_async((log_record_id,))
|
||||
|
||||
def get_handler(self):
|
||||
handler_name = self.get_handler_name()
|
||||
logger.info(f"Using '{handler_name}' handler to process alert action in mattermost")
|
||||
if hasattr(self, handler_name):
|
||||
handler = getattr(self, handler_name)
|
||||
else:
|
||||
handler = None
|
||||
|
||||
return handler
|
||||
|
||||
def get_handler_name(self):
|
||||
return self.HANDLER_PREFIX + self.get_handler_map()[self.log_record.type]
|
||||
130
engine/apps/mattermost/alert_rendering.py
Normal file
130
engine/apps/mattermost/alert_rendering.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
|
||||
from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
|
||||
from apps.alerts.models import Alert, AlertGroup
|
||||
from apps.mattermost.events.types import EventAction
|
||||
from apps.mattermost.utils import MattermostEventAuthenticator
|
||||
from common.api_helpers.utils import create_engine_url
|
||||
from common.utils import is_string_with_visible_characters, str_or_backup
|
||||
|
||||
|
||||
class MattermostMessageRenderer:
|
||||
def __init__(self, alert_group: AlertGroup):
|
||||
self.alert_group = alert_group
|
||||
|
||||
def render_alert_group_message(self):
|
||||
attachments = AlertGroupMattermostRenderer(self.alert_group).render_alert_group_attachments()
|
||||
return {"props": {"attachments": attachments}}
|
||||
|
||||
|
||||
class AlertMattermostTemplater(AlertTemplater):
|
||||
RENDER_FOR_MATTERMOST = "mattermost"
|
||||
|
||||
def _render_for(self) -> str:
|
||||
return self.RENDER_FOR_MATTERMOST
|
||||
|
||||
|
||||
class AlertMattermostRenderer(AlertBaseRenderer):
|
||||
def __init__(self, alert: Alert):
|
||||
super().__init__(alert)
|
||||
self.channel = alert.group.channel
|
||||
|
||||
@property
|
||||
def templater_class(self):
|
||||
return AlertMattermostTemplater
|
||||
|
||||
def render_alert_attachments(self):
|
||||
attachments = []
|
||||
title = str_or_backup(self.templated_alert.title, "Alert")
|
||||
message = ""
|
||||
if is_string_with_visible_characters(self.templated_alert.message):
|
||||
message = self.templated_alert.message
|
||||
attachments.append(
|
||||
{
|
||||
"fallback": "{}: {}".format(self.channel.get_integration_display(), self.alert.title),
|
||||
"title": title,
|
||||
"title_link": self.templated_alert.source_link,
|
||||
"text": message,
|
||||
"image_url": self.templated_alert.image_url,
|
||||
}
|
||||
)
|
||||
return attachments
|
||||
|
||||
|
||||
class AlertGroupMattermostRenderer(AlertGroupBaseRenderer):
|
||||
def __init__(self, alert_group: AlertGroup):
|
||||
super().__init__(alert_group)
|
||||
|
||||
self.alert_renderer = self.alert_renderer_class(self.alert_group.alerts.last())
|
||||
|
||||
@property
|
||||
def alert_renderer_class(self):
|
||||
return AlertMattermostRenderer
|
||||
|
||||
def render_alert_group_attachments(self):
|
||||
attachments = self.alert_renderer.render_alert_attachments()
|
||||
alert_group = self.alert_group
|
||||
|
||||
if alert_group.resolved:
|
||||
attachments.append(
|
||||
{
|
||||
"fallback": "Resolved...",
|
||||
"text": alert_group.get_resolve_text(),
|
||||
}
|
||||
)
|
||||
elif alert_group.acknowledged:
|
||||
attachments.append(
|
||||
{
|
||||
"fallback": "Acknowledged...",
|
||||
"text": alert_group.get_acknowledge_text(),
|
||||
}
|
||||
)
|
||||
|
||||
# append buttons to the initial attachment
|
||||
attachments[0]["actions"] = self._get_buttons_attachments()
|
||||
|
||||
return self._set_attachments_color(attachments)
|
||||
|
||||
def _get_buttons_attachments(self):
|
||||
actions = []
|
||||
|
||||
def _make_actions(id, name, token):
|
||||
return {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"integration": {
|
||||
"url": create_engine_url("api/internal/v1/mattermost/event/"),
|
||||
"context": {
|
||||
"action": id,
|
||||
"alert": self.alert_group.public_primary_key,
|
||||
"token": token,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
token = MattermostEventAuthenticator.create_token(organization=self.alert_group.channel.organization)
|
||||
if not self.alert_group.resolved:
|
||||
if self.alert_group.acknowledged:
|
||||
actions.append(_make_actions(EventAction.UNACKNOWLEDGE.value, "Unacknowledge", token))
|
||||
else:
|
||||
actions.append(_make_actions(EventAction.ACKNOWLEDGE.value, "Acknowledge", token))
|
||||
|
||||
if self.alert_group.resolved:
|
||||
actions.append(_make_actions(EventAction.UNRESOLVE.value, "Unresolve", token))
|
||||
else:
|
||||
actions.append(_make_actions(EventAction.RESOLVE.value, "Resolve", token))
|
||||
|
||||
return actions
|
||||
|
||||
def _set_attachments_color(self, attachments):
|
||||
color = "#a30200" # danger
|
||||
if self.alert_group.silenced:
|
||||
color = "#dddddd" # slack-grey
|
||||
if self.alert_group.acknowledged:
|
||||
color = "#daa038" # warning
|
||||
if self.alert_group.resolved:
|
||||
color = "#2eb886" # good
|
||||
|
||||
for attachment in attachments:
|
||||
attachment["color"] = color
|
||||
|
||||
return attachments
|
||||
8
engine/apps/mattermost/apps.py
Normal file
8
engine/apps/mattermost/apps.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MattermostConfig(AppConfig):
|
||||
name = "apps.mattermost"
|
||||
|
||||
def ready(self) -> None:
|
||||
import apps.mattermost.signals # noqa: F401
|
||||
29
engine/apps/mattermost/auth.py
Normal file
29
engine/apps/mattermost/auth.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import logging
|
||||
import typing
|
||||
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
|
||||
from apps.mattermost.models import MattermostUser
|
||||
from apps.mattermost.utils import MattermostEventAuthenticator, MattermostEventTokenInvalid
|
||||
from apps.user_management.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class MattermostEventAuthentication(BaseAuthentication):
|
||||
def authenticate(self, request) -> typing.Tuple[User, None]:
|
||||
if "context" not in request.data or "token" not in request.data["context"]:
|
||||
raise exceptions.AuthenticationFailed("Auth token is missing")
|
||||
|
||||
auth = request.data["context"]["token"]
|
||||
try:
|
||||
MattermostEventAuthenticator.verify(auth)
|
||||
mattermost_user = MattermostUser.objects.get(mattermost_user_id=request.data["user_id"])
|
||||
except MattermostEventTokenInvalid:
|
||||
raise exceptions.AuthenticationFailed("Invalid auth token")
|
||||
except MattermostUser.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed("Mattermost user not integrated")
|
||||
|
||||
return mattermost_user.user, None
|
||||
65
engine/apps/mattermost/backend.py
Normal file
65
engine/apps/mattermost/backend.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from apps.base.messaging import BaseMessagingBackend
|
||||
from apps.mattermost.models import MattermostChannel
|
||||
from apps.mattermost.tasks import notify_user_about_alert_async
|
||||
|
||||
|
||||
class MattermostBackend(BaseMessagingBackend):
|
||||
backend_id = "MATTERMOST"
|
||||
label = "Mattermost"
|
||||
short_label = "Mattermost"
|
||||
available_for_use = True
|
||||
templater = "apps.mattermost.alert_rendering.AlertMattermostTemplater"
|
||||
|
||||
def unlink_user(self, user):
|
||||
from apps.mattermost.models import MattermostUser
|
||||
|
||||
mattermost_user = MattermostUser.objects.get(user=user)
|
||||
mattermost_user.delete()
|
||||
|
||||
def serialize_user(self, user):
|
||||
mattermost_user = getattr(user, "mattermost_user_identity", None)
|
||||
if not mattermost_user:
|
||||
return None
|
||||
return {
|
||||
"mattermost_user_id": mattermost_user.mattermost_user_id,
|
||||
"username": mattermost_user.username,
|
||||
}
|
||||
|
||||
def notify_user(self, user, alert_group, notification_policy):
|
||||
notify_user_about_alert_async.delay(
|
||||
user_pk=user.pk,
|
||||
alert_group_pk=alert_group.pk,
|
||||
notification_policy_pk=notification_policy.pk,
|
||||
)
|
||||
|
||||
def validate_channel_filter_data(self, organization, data):
|
||||
notification_data = {}
|
||||
|
||||
if not data:
|
||||
return notification_data
|
||||
|
||||
if "enabled" in data:
|
||||
notification_data["enabled"] = bool(data["enabled"])
|
||||
|
||||
if "channel" not in data:
|
||||
return notification_data
|
||||
|
||||
# We need to treat "channel" key and "enabled" key separately
|
||||
# This condition is to handle the case when channel is cleared but the flag is enabled.
|
||||
# payload example: {"channel": nil}
|
||||
if not data["channel"]:
|
||||
notification_data["channel"] = data["channel"]
|
||||
return notification_data
|
||||
|
||||
channel = MattermostChannel.objects.filter(
|
||||
organization=organization, public_primary_key=data["channel"]
|
||||
).first()
|
||||
|
||||
if not channel:
|
||||
raise serializers.ValidationError(["Invalid mattermost channel id"])
|
||||
|
||||
notification_data["channel"] = channel.public_primary_key
|
||||
|
||||
return notification_data
|
||||
108
engine/apps/mattermost/client.py
Normal file
108
engine/apps/mattermost/client.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from requests.auth import AuthBase
|
||||
from requests.models import PreparedRequest
|
||||
|
||||
from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid
|
||||
|
||||
|
||||
class TokenAuth(AuthBase):
|
||||
def __init__(self, token: str) -> None:
|
||||
self.token = token
|
||||
|
||||
def __call__(self, request: PreparedRequest) -> PreparedRequest:
|
||||
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||
return request
|
||||
|
||||
|
||||
@dataclass
|
||||
class MattermostUser:
|
||||
user_id: str
|
||||
username: str
|
||||
nickname: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MattermostChannel:
|
||||
channel_id: str
|
||||
team_id: str
|
||||
channel_name: str
|
||||
display_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MattermostPost:
|
||||
post_id: str
|
||||
channel_id: str
|
||||
user_id: str
|
||||
|
||||
|
||||
class MattermostClient:
|
||||
def __init__(self, token: Optional[str] = None) -> None:
|
||||
self.token = token or settings.MATTERMOST_BOT_TOKEN
|
||||
self.base_url = f"{settings.MATTERMOST_HOST}/api/v4"
|
||||
self.timeout: int = 10
|
||||
|
||||
if self.token is None:
|
||||
raise MattermostAPITokenInvalid
|
||||
|
||||
def _check_response(self, response: requests.models.Response):
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as ex:
|
||||
raise MattermostAPIException(
|
||||
status=ex.response.status_code,
|
||||
url=ex.response.request.url,
|
||||
msg=ex.response.json()["message"],
|
||||
method=ex.response.request.method,
|
||||
)
|
||||
except requests.Timeout as ex:
|
||||
raise MattermostAPIException(
|
||||
status=ex.response.status_code,
|
||||
url=ex.response.request.url,
|
||||
msg="Mattermost api call gateway timedout",
|
||||
method=ex.response.request.method,
|
||||
)
|
||||
except requests.exceptions.RequestException as ex:
|
||||
raise MattermostAPIException(
|
||||
status=ex.response.status_code,
|
||||
url=ex.response.request.url,
|
||||
msg="Unexpected error from mattermost server",
|
||||
method=ex.response.request.method,
|
||||
)
|
||||
|
||||
def get_channel_by_id(self, channel_id: str) -> MattermostChannel:
|
||||
url = f"{self.base_url}/channels/{channel_id}"
|
||||
response = requests.get(url=url, timeout=self.timeout, auth=TokenAuth(self.token))
|
||||
self._check_response(response)
|
||||
data = response.json()
|
||||
return MattermostChannel(
|
||||
channel_id=data["id"], team_id=data["team_id"], channel_name=data["name"], display_name=data["display_name"]
|
||||
)
|
||||
|
||||
def get_user(self, user_id: str = "me"):
|
||||
url = f"{self.base_url}/users/{user_id}"
|
||||
response = requests.get(url=url, timeout=self.timeout, auth=TokenAuth(self.token))
|
||||
self._check_response(response)
|
||||
data = response.json()
|
||||
return MattermostUser(user_id=data["id"], username=data["username"], nickname=data["nickname"])
|
||||
|
||||
def create_post(self, channel_id: str, data: dict):
|
||||
url = f"{self.base_url}/posts"
|
||||
data.update({"channel_id": channel_id})
|
||||
response = requests.post(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token))
|
||||
self._check_response(response)
|
||||
data = response.json()
|
||||
return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"])
|
||||
|
||||
def update_post(self, post_id: str, data: dict):
|
||||
url = f"{self.base_url}/posts/{post_id}"
|
||||
data.update({"id": post_id})
|
||||
response = requests.put(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token))
|
||||
self._check_response(response)
|
||||
data = response.json()
|
||||
return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"])
|
||||
1
engine/apps/mattermost/events/__init__.py
Normal file
1
engine/apps/mattermost/events/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .alert_group_actions_handler import AlertGroupActionHandler # noqa: F401
|
||||
81
engine/apps/mattermost/events/alert_group_actions_handler.py
Normal file
81
engine/apps/mattermost/events/alert_group_actions_handler.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import logging
|
||||
import typing
|
||||
|
||||
from apps.alerts.constants import ActionSource
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.mattermost.events.event_handler import MattermostEventHandler
|
||||
from apps.mattermost.events.types import EventAction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlertGroupActionHandler(MattermostEventHandler):
|
||||
"""
|
||||
Handles the alert group actions from the mattermost message buttons
|
||||
"""
|
||||
|
||||
def is_match(self):
|
||||
action = self._get_action()
|
||||
return action and action in [
|
||||
EventAction.ACKNOWLEDGE,
|
||||
EventAction.UNACKNOWLEDGE,
|
||||
EventAction.RESOLVE,
|
||||
EventAction.UNRESOLVE,
|
||||
]
|
||||
|
||||
def process(self):
|
||||
alert_group = self._get_alert_group()
|
||||
action = self._get_action()
|
||||
|
||||
if not alert_group or not action:
|
||||
return
|
||||
|
||||
action_fn, fn_kwargs = self._get_action_function(alert_group, action)
|
||||
action_fn(user=self.user, action_source=ActionSource.MATTERMOST, **fn_kwargs)
|
||||
|
||||
def _get_action(self) -> typing.Optional[EventAction]:
|
||||
if "context" not in self.event or "action" not in self.event["context"]:
|
||||
return
|
||||
|
||||
try:
|
||||
action = self.event["context"]["action"]
|
||||
return EventAction(action)
|
||||
except ValueError:
|
||||
logger.info(f"Mattermost event action not found {action}")
|
||||
return
|
||||
|
||||
def _get_alert_group(self) -> typing.Optional[AlertGroup]:
|
||||
return self._get_alert_group_from_event()
|
||||
|
||||
def _get_alert_group_from_event(self) -> typing.Optional[AlertGroup]:
|
||||
if "context" not in self.event or "alert" not in self.event["context"]:
|
||||
return
|
||||
|
||||
try:
|
||||
alert_group = AlertGroup.objects.get(public_primary_key=self.event["context"]["alert"])
|
||||
except AlertGroup.DoesNotExist:
|
||||
return
|
||||
|
||||
return alert_group
|
||||
|
||||
def _get_action_function(self, alert_group: AlertGroup, action: EventAction) -> typing.Tuple[typing.Callable, dict]:
|
||||
action_to_fn = {
|
||||
EventAction.ACKNOWLEDGE: {
|
||||
"fn_name": "acknowledge_by_user_or_backsync",
|
||||
"kwargs": {},
|
||||
},
|
||||
EventAction.UNACKNOWLEDGE: {
|
||||
"fn_name": "un_acknowledge_by_user_or_backsync",
|
||||
"kwargs": {},
|
||||
},
|
||||
EventAction.RESOLVE: {
|
||||
"fn_name": "resolve_by_user_or_backsync",
|
||||
"kwargs": {},
|
||||
},
|
||||
EventAction.UNRESOLVE: {"fn_name": "un_resolve_by_user_or_backsync", "kwargs": {}},
|
||||
}
|
||||
|
||||
fn_info = action_to_fn[action]
|
||||
fn = getattr(alert_group, fn_info["fn_name"])
|
||||
|
||||
return fn, fn_info["kwargs"]
|
||||
18
engine/apps/mattermost/events/event_handler.py
Normal file
18
engine/apps/mattermost/events/event_handler.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
from apps.mattermost.events.types import MattermostEvent
|
||||
from apps.user_management.models import User
|
||||
|
||||
|
||||
class MattermostEventHandler(ABC):
|
||||
def __init__(self, event: MattermostEvent, user: User):
|
||||
self.event: MattermostEvent = event
|
||||
self.user: User = user
|
||||
|
||||
@abstractmethod
|
||||
def is_match(self) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def process(self) -> None:
|
||||
pass
|
||||
37
engine/apps/mattermost/events/event_manager.py
Normal file
37
engine/apps/mattermost/events/event_manager.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import logging
|
||||
import typing
|
||||
|
||||
from rest_framework.request import Request
|
||||
|
||||
from apps.mattermost.events.event_handler import MattermostEventHandler
|
||||
from apps.mattermost.events.types import MattermostEvent
|
||||
from apps.user_management.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventManager:
|
||||
"""
|
||||
Manager for mattermost events
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def process_request(cls, request: Request):
|
||||
user = request.user
|
||||
event = request.data
|
||||
handler = cls.select_event_handler(user=user, event=event)
|
||||
if handler is None:
|
||||
logger.info("No event handler found")
|
||||
return
|
||||
|
||||
logger.info(f"Processing mattermost event with handler: {handler.__class__.__name__}")
|
||||
handler.process()
|
||||
|
||||
@staticmethod
|
||||
def select_event_handler(user: User, event: MattermostEvent) -> typing.Optional[MattermostEventHandler]:
|
||||
handler_classes = MattermostEventHandler.__subclasses__()
|
||||
for handler_class in handler_classes:
|
||||
handler = handler_class(user=user, event=event)
|
||||
if handler.is_match():
|
||||
return handler
|
||||
return None
|
||||
29
engine/apps/mattermost/events/types.py
Normal file
29
engine/apps/mattermost/events/types.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import enum
|
||||
import typing
|
||||
|
||||
|
||||
class MattermostAlertGroupContext(typing.TypedDict):
|
||||
action: str
|
||||
token: str
|
||||
alert: str
|
||||
|
||||
|
||||
class MattermostEvent(typing.TypedDict):
|
||||
user_id: str
|
||||
user_name: str
|
||||
channel_id: str
|
||||
channel_name: str
|
||||
team_id: str
|
||||
team_domain: str
|
||||
post_id: str
|
||||
trigger_id: str
|
||||
type: str
|
||||
data_source: str
|
||||
context: MattermostAlertGroupContext
|
||||
|
||||
|
||||
class EventAction(enum.StrEnum):
|
||||
ACKNOWLEDGE = "acknowledge"
|
||||
UNACKNOWLEDGE = "unacknowledge"
|
||||
RESOLVE = "resolve"
|
||||
UNRESOLVE = "unresolve"
|
||||
21
engine/apps/mattermost/exceptions.py
Normal file
21
engine/apps/mattermost/exceptions.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
class MattermostAPITokenInvalid(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MattermostAPIException(Exception):
|
||||
def __init__(self, status, url, msg="", method="GET"):
|
||||
self.url = url
|
||||
self.status = status
|
||||
self.method = method
|
||||
self.msg = msg
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"MattermostAPIException: status={self.status} url={self.url} method={self.method} error={self.msg}"
|
||||
|
||||
|
||||
class MattermostEventTokenInvalid(Exception):
|
||||
def __init__(self, msg=""):
|
||||
self.msg = msg
|
||||
|
||||
def __str__(self):
|
||||
return f"MattermostEventTokenInvalid message={self.msg}"
|
||||
68
engine/apps/mattermost/migrations/0001_initial.py
Normal file
68
engine/apps/mattermost/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Generated by Django 4.2.16 on 2024-11-30 16:34
|
||||
|
||||
import apps.mattermost.models.channel
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('user_management', '0026_auto_20241017_1919'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MattermostChannel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('public_primary_key', models.CharField(default=apps.mattermost.models.channel.generate_public_primary_key_for_mattermost_channel, max_length=20, unique=True, validators=[django.core.validators.MinLengthValidator(13)])),
|
||||
('mattermost_team_id', models.CharField(max_length=100)),
|
||||
('channel_id', models.CharField(max_length=100)),
|
||||
('channel_name', models.CharField(default=None, max_length=100)),
|
||||
('display_name', models.CharField(default=None, max_length=100)),
|
||||
('is_default_channel', models.BooleanField(default=False, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_channels', to='user_management.organization')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MattermostUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('mattermost_user_id', models.CharField(max_length=100)),
|
||||
('username', models.CharField(max_length=100)),
|
||||
('nickname', models.CharField(blank=True, default=None, max_length=100, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_user_identity', to='user_management.user')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['mattermost_user_id'], name='mattermost__matterm_55d2a0_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MattermostMessage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('post_id', models.CharField(max_length=100)),
|
||||
('channel_id', models.CharField(max_length=100)),
|
||||
('message_type', models.IntegerField(choices=[(0, 'Alert group message'), (1, 'Log message')])),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_messages', to='alerts.alertgroup')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['channel_id', 'post_id'], name='mattermost__channel_1fbf8b_idx')],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='mattermostmessage',
|
||||
constraint=models.UniqueConstraint(fields=('alert_group', 'message_type', 'channel_id'), name='unique_alert_group_message_type_channel_id'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='mattermostchannel',
|
||||
unique_together={('organization', 'channel_id')},
|
||||
),
|
||||
]
|
||||
0
engine/apps/mattermost/migrations/__init__.py
Normal file
0
engine/apps/mattermost/migrations/__init__.py
Normal file
3
engine/apps/mattermost/models/__init__.py
Normal file
3
engine/apps/mattermost/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .channel import MattermostChannel # noqa: F401
|
||||
from .message import MattermostMessage # noqa F401
|
||||
from .user import MattermostUser # noqa F401
|
||||
102
engine/apps/mattermost/models/channel.py
Normal file
102
engine/apps/mattermost/models/channel.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import typing
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models, transaction
|
||||
|
||||
from apps.alerts.models import AlertGroup
|
||||
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
||||
|
||||
def generate_public_primary_key_for_mattermost_channel():
|
||||
prefix = "MT"
|
||||
new_public_primary_key = generate_public_primary_key(prefix)
|
||||
|
||||
failure_counter = 0
|
||||
while MattermostChannel.objects.filter(public_primary_key=new_public_primary_key).exists():
|
||||
new_public_primary_key = increase_public_primary_key_length(
|
||||
failure_counter=failure_counter, prefix=prefix, model_name="MattermostChannel"
|
||||
)
|
||||
failure_counter += 1
|
||||
|
||||
return new_public_primary_key
|
||||
|
||||
|
||||
class MattermostChannel(models.Model):
|
||||
organization = models.ForeignKey(
|
||||
"user_management.Organization",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="mattermost_channels",
|
||||
)
|
||||
|
||||
public_primary_key = models.CharField(
|
||||
max_length=20,
|
||||
validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)],
|
||||
unique=True,
|
||||
default=generate_public_primary_key_for_mattermost_channel,
|
||||
)
|
||||
|
||||
mattermost_team_id = models.CharField(max_length=100)
|
||||
channel_id = models.CharField(max_length=100)
|
||||
channel_name = models.CharField(max_length=100, default=None)
|
||||
display_name = models.CharField(max_length=100, default=None)
|
||||
is_default_channel = models.BooleanField(null=True, default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("organization", "channel_id")
|
||||
|
||||
@classmethod
|
||||
def get_channel_for_alert_group(cls, alert_group: AlertGroup) -> typing.Optional["MattermostChannel"]:
|
||||
from apps.mattermost.backend import MattermostBackend # To avoid circular import
|
||||
|
||||
default_channel = cls.objects.filter(
|
||||
organization=alert_group.channel.organization, is_default_channel=True
|
||||
).first()
|
||||
|
||||
if (
|
||||
alert_group.channel_filter is None
|
||||
or not alert_group.channel_filter.notification_backends
|
||||
or not alert_group.channel_filter.notification_backends.get(MattermostBackend.backend_id)
|
||||
):
|
||||
return default_channel
|
||||
|
||||
channel_id = alert_group.channel_filter.notification_backends[MattermostBackend.backend_id].get("channel")
|
||||
enabled = alert_group.channel_filter.notification_backends[MattermostBackend.backend_id].get("enabled")
|
||||
|
||||
if not enabled:
|
||||
return None
|
||||
|
||||
if not channel_id:
|
||||
return default_channel
|
||||
|
||||
channel = cls.objects.filter(
|
||||
organization=alert_group.channel.organization, public_primary_key=channel_id
|
||||
).first()
|
||||
|
||||
if not channel:
|
||||
return default_channel
|
||||
|
||||
return channel
|
||||
|
||||
def make_channel_default(self, author):
|
||||
try:
|
||||
old_default_channel = MattermostChannel.objects.get(organization=self.organization, is_default_channel=True)
|
||||
old_default_channel.is_default_channel = False
|
||||
except MattermostChannel.DoesNotExist:
|
||||
old_default_channel = None
|
||||
self.is_default_channel = True
|
||||
self.save(update_fields=["is_default_channel"])
|
||||
else:
|
||||
self.is_default_channel = True
|
||||
with transaction.atomic():
|
||||
old_default_channel.save(update_fields=["is_default_channel"])
|
||||
self.save(update_fields=["is_default_channel"])
|
||||
write_chatops_insight_log(
|
||||
author=author,
|
||||
event_name=ChatOpsEvent.DEFAULT_CHANNEL_CHANGED,
|
||||
chatops_type=ChatOpsTypePlug.MATTERMOST.value,
|
||||
prev_channel=old_default_channel.channel_name if old_default_channel else None,
|
||||
new_channel=self.channel_name,
|
||||
)
|
||||
48
engine/apps/mattermost/models/message.py
Normal file
48
engine/apps/mattermost/models/message.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from django.db import models
|
||||
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.mattermost.client import MattermostPost
|
||||
|
||||
|
||||
class MattermostMessage(models.Model):
|
||||
(
|
||||
ALERT_GROUP_MESSAGE,
|
||||
LOG_MESSAGE,
|
||||
) = range(2)
|
||||
|
||||
MATTERMOST_MESSAGE_CHOICES = (
|
||||
(ALERT_GROUP_MESSAGE, "Alert group message"),
|
||||
(LOG_MESSAGE, "Log message"),
|
||||
)
|
||||
|
||||
post_id = models.CharField(max_length=100)
|
||||
|
||||
channel_id = models.CharField(max_length=100)
|
||||
|
||||
message_type = models.IntegerField(choices=MATTERMOST_MESSAGE_CHOICES)
|
||||
|
||||
alert_group = models.ForeignKey(
|
||||
"alerts.AlertGroup",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="mattermost_messages",
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["alert_group", "message_type", "channel_id"],
|
||||
name="unique_alert_group_message_type_channel_id",
|
||||
)
|
||||
]
|
||||
|
||||
indexes = [
|
||||
models.Index(fields=["channel_id", "post_id"]),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def create_message(alert_group: AlertGroup, post: MattermostPost, message_type: int):
|
||||
return MattermostMessage.objects.create(
|
||||
alert_group=alert_group, post_id=post.post_id, channel_id=post.channel_id, message_type=message_type
|
||||
)
|
||||
20
engine/apps/mattermost/models/user.py
Normal file
20
engine/apps/mattermost/models/user.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from django.db import models
|
||||
|
||||
|
||||
class MattermostUser(models.Model):
|
||||
user = models.OneToOneField(
|
||||
"user_management.User", on_delete=models.CASCADE, related_name="mattermost_user_identity"
|
||||
)
|
||||
mattermost_user_id = models.CharField(max_length=100)
|
||||
username = models.CharField(max_length=100)
|
||||
nickname = models.CharField(max_length=100, null=True, blank=True, default=None)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["mattermost_user_id"]),
|
||||
]
|
||||
|
||||
@property
|
||||
def mention_username(self):
|
||||
return f"@{self.username}"
|
||||
53
engine/apps/mattermost/serializers.py
Normal file
53
engine/apps/mattermost/serializers.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from apps.mattermost.client import MattermostClient
|
||||
from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid
|
||||
from apps.mattermost.models import MattermostChannel
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.utils import CurrentOrganizationDefault
|
||||
|
||||
|
||||
class MattermostChannelSerializer(serializers.ModelSerializer):
|
||||
id = serializers.CharField(read_only=True, source="public_primary_key")
|
||||
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
|
||||
|
||||
class Meta:
|
||||
model = MattermostChannel
|
||||
fields = [
|
||||
"id",
|
||||
"organization",
|
||||
"mattermost_team_id",
|
||||
"channel_id",
|
||||
"channel_name",
|
||||
"display_name",
|
||||
"is_default_channel",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"mattermost_team_id": {"required": True, "write_only": True},
|
||||
"channel_id": {"required": True},
|
||||
}
|
||||
|
||||
def create(self, validated_data):
|
||||
return MattermostChannel.objects.create(**validated_data)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
channel_id = data.get("channel_id")
|
||||
|
||||
if not channel_id:
|
||||
raise serializers.ValidationError({"channel_id": "This field is required."})
|
||||
|
||||
try:
|
||||
response = MattermostClient().get_channel_by_id(channel_id=channel_id)
|
||||
except MattermostAPIException as ex:
|
||||
raise BadRequest(detail=ex.msg)
|
||||
except MattermostAPITokenInvalid:
|
||||
raise BadRequest(detail="Mattermost API token is invalid.")
|
||||
|
||||
return super().to_internal_value(
|
||||
{
|
||||
"channel_id": response.channel_id,
|
||||
"mattermost_team_id": response.team_id,
|
||||
"channel_name": response.channel_name,
|
||||
"display_name": response.display_name,
|
||||
}
|
||||
)
|
||||
5
engine/apps/mattermost/signals.py
Normal file
5
engine/apps/mattermost/signals.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from apps.alerts.signals import alert_create_signal, alert_group_action_triggered_signal
|
||||
from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative
|
||||
|
||||
alert_create_signal.connect(AlertGroupMattermostRepresentative.on_create_alert)
|
||||
alert_group_action_triggered_signal.connect(AlertGroupMattermostRepresentative.on_alert_group_action_triggered)
|
||||
182
engine/apps/mattermost/tasks.py
Normal file
182
engine/apps/mattermost/tasks.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import logging
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.conf import settings
|
||||
from rest_framework import status
|
||||
|
||||
from apps.alerts.models import Alert, AlertGroup
|
||||
from apps.mattermost.alert_rendering import AlertGroupMattermostRenderer, MattermostMessageRenderer
|
||||
from apps.mattermost.client import MattermostClient
|
||||
from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid
|
||||
from apps.mattermost.models import MattermostChannel, MattermostMessage
|
||||
from apps.user_management.models import User
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
from common.utils import OkToRetry
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
bind=True, autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
|
||||
)
|
||||
def on_create_alert_async(self, alert_pk):
|
||||
"""
|
||||
It's async in order to prevent Mattermost downtime or formatting issues causing delay with SMS and other destinations.
|
||||
"""
|
||||
try:
|
||||
alert = Alert.objects.get(pk=alert_pk)
|
||||
except Alert.DoesNotExist as e:
|
||||
if on_create_alert_async.request.retries >= 10:
|
||||
logger.error(f"Alert {alert_pk} was not found. Probably it was deleted. Stop retrying")
|
||||
return
|
||||
else:
|
||||
raise e
|
||||
|
||||
alert_group = alert.group
|
||||
mattermost_channel = MattermostChannel.get_channel_for_alert_group(alert_group=alert_group)
|
||||
if not mattermost_channel:
|
||||
logger.error(f"Mattermost channel not found for alert {alert_pk}. Probably it was deleted. Stop retrying")
|
||||
return
|
||||
|
||||
message = alert_group.mattermost_messages.filter(message_type=MattermostMessage.ALERT_GROUP_MESSAGE).first()
|
||||
if message:
|
||||
logger.error(f"Mattermost message exist with post id {message.post_id} hence skipping")
|
||||
return
|
||||
|
||||
payload = MattermostMessageRenderer(alert_group).render_alert_group_message()
|
||||
|
||||
with OkToRetry(task=self, exc=(MattermostAPIException,), num_retries=3):
|
||||
try:
|
||||
client = MattermostClient()
|
||||
mattermost_post = client.create_post(channel_id=mattermost_channel.channel_id, data=payload)
|
||||
except MattermostAPITokenInvalid:
|
||||
logger.error(f"Mattermost API token is invalid could not create post for alert {alert_pk}")
|
||||
except MattermostAPIException as ex:
|
||||
logger.error(f"Mattermost API error {ex}")
|
||||
if ex.status not in [status.HTTP_401_UNAUTHORIZED]:
|
||||
raise ex
|
||||
else:
|
||||
MattermostMessage.create_message(
|
||||
alert_group=alert_group, post=mattermost_post, message_type=MattermostMessage.ALERT_GROUP_MESSAGE
|
||||
)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
|
||||
)
|
||||
def on_alert_group_action_triggered_async(log_record_id):
|
||||
from apps.alerts.models import AlertGroupLogRecord
|
||||
from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative
|
||||
|
||||
try:
|
||||
log_record = AlertGroupLogRecord.objects.get(pk=log_record_id)
|
||||
except AlertGroupLogRecord.DoesNotExist as e:
|
||||
logger.warning(f"Mattermost representative: log record {log_record_id} never created or has been deleted")
|
||||
raise e
|
||||
|
||||
alert_group_id = log_record.alert_group_id
|
||||
|
||||
try:
|
||||
log_record.alert_group.mattermost_messages.get(message_type=MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
except MattermostMessage.DoesNotExist as e:
|
||||
if on_alert_group_action_triggered_async.request.retries >= 10:
|
||||
logger.error(f"Mattermost message not created for {alert_group_id}. Stop retrying")
|
||||
return
|
||||
else:
|
||||
raise e
|
||||
|
||||
logger.info(
|
||||
f"Start mattermost on_alert_group_action_triggered for alert_group {alert_group_id}, log record {log_record_id}"
|
||||
)
|
||||
representative = AlertGroupMattermostRepresentative(log_record)
|
||||
if representative.is_applicable():
|
||||
handler = representative.get_handler()
|
||||
handler(log_record.alert_group)
|
||||
|
||||
|
||||
@shared_dedicated_queue_retry_task(
|
||||
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
|
||||
)
|
||||
def notify_user_about_alert_async(user_pk, alert_group_pk, notification_policy_pk):
|
||||
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
|
||||
|
||||
def _create_error_log_record(notification_error_code=None):
|
||||
UserNotificationPolicyLogRecord.objects.create(
|
||||
author=user,
|
||||
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
|
||||
notification_policy=notification_policy,
|
||||
alert_group=alert_group,
|
||||
reason="Error during mattermost notification",
|
||||
notification_step=notification_policy.step,
|
||||
notification_channel=notification_policy.notify_by,
|
||||
notification_error_code=notification_error_code,
|
||||
)
|
||||
|
||||
try:
|
||||
user = User.objects.get(pk=user_pk)
|
||||
alert_group = AlertGroup.objects.get(pk=alert_group_pk)
|
||||
notification_policy = UserNotificationPolicy.objects.get(pk=notification_policy_pk)
|
||||
mattermost_messsage = alert_group.mattermost_messages.get(message_type=MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
except User.DoesNotExist:
|
||||
logger.warning(f"User {user_pk} is not found")
|
||||
return
|
||||
except AlertGroup.DoesNotExist:
|
||||
logger.warning(f"Alert group {alert_group_pk} is not found")
|
||||
return
|
||||
except UserNotificationPolicy.DoesNotExist:
|
||||
logger.warning(f"UserNotificationPolicy {notification_policy_pk} is not found")
|
||||
return
|
||||
except MattermostMessage.DoesNotExist as e:
|
||||
if notify_user_about_alert_async.request.retries >= 10:
|
||||
logger.error(
|
||||
f"Alert group mattermost message is not created {alert_group_pk}. Hence stopped retrying for user notification"
|
||||
)
|
||||
_create_error_log_record(
|
||||
UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_ALERT_GROUP_MESSAGE_NOT_FOUND
|
||||
)
|
||||
return
|
||||
else:
|
||||
raise e
|
||||
|
||||
mattermost_channel = MattermostChannel.get_channel_for_alert_group(alert_group=alert_group)
|
||||
if not mattermost_channel:
|
||||
logger.error(f"Mattermost channel not found for user notification {user_pk}")
|
||||
return
|
||||
|
||||
templated_alert = AlertGroupMattermostRenderer(alert_group).alert_renderer.templated_alert
|
||||
|
||||
if not hasattr(user, "mattermost_user_identity"):
|
||||
message = "{}\nTried to invite {} to look at the alert group. Unfortunately {} is not in mattermost.".format(
|
||||
templated_alert.title, user.username, user.username
|
||||
)
|
||||
_create_error_log_record(
|
||||
UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST
|
||||
)
|
||||
else:
|
||||
message = "{}\nInviting {} to look at the alert group.".format(
|
||||
templated_alert.title, user.mattermost_user_identity.mention_username
|
||||
)
|
||||
|
||||
payload = {"root_id": mattermost_messsage.post_id, "message": message}
|
||||
|
||||
try:
|
||||
client = MattermostClient()
|
||||
client.create_post(channel_id=mattermost_channel.channel_id, data=payload)
|
||||
except MattermostAPITokenInvalid:
|
||||
logger.error(f"Mattermost API token is invalid could not create post for alert {alert_group_pk}")
|
||||
_create_error_log_record(UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_API_TOKEN_INVALID)
|
||||
except MattermostAPIException as ex:
|
||||
logger.error(f"Mattermost API error {ex}")
|
||||
if ex.status != status.HTTP_401_UNAUTHORIZED:
|
||||
raise ex
|
||||
_create_error_log_record(UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_API_UNAUTHORIZED)
|
||||
else:
|
||||
UserNotificationPolicyLogRecord.objects.create(
|
||||
author=user,
|
||||
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS,
|
||||
notification_policy=notification_policy,
|
||||
alert_group=alert_group,
|
||||
notification_step=notification_policy.step,
|
||||
notification_channel=notification_policy.notify_by,
|
||||
)
|
||||
0
engine/apps/mattermost/tests/__init__.py
Normal file
0
engine/apps/mattermost/tests/__init__.py
Normal file
124
engine/apps/mattermost/tests/conftest.py
Normal file
124
engine/apps/mattermost/tests/conftest.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import pytest
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED:
|
||||
pytest.skip("Mattermost integration is not enabled", allow_module_level=True)
|
||||
else:
|
||||
from apps.mattermost.tests.factories import (
|
||||
MattermostChannelFactory,
|
||||
MattermostMessageFactory,
|
||||
MattermostUserFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_mattermost_channel():
|
||||
def _make_mattermost_channel(organization, **kwargs):
|
||||
return MattermostChannelFactory(organization=organization, **kwargs)
|
||||
|
||||
return _make_mattermost_channel
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_mattermost_get_channel_response():
|
||||
def _make_mattermost_get_channel_response():
|
||||
return {
|
||||
"id": "pbg5piuc5bgniftrserb88575h",
|
||||
"team_id": "oxfug4kgx3fx7jzow49cpxkmgo",
|
||||
"display_name": "Town Square",
|
||||
"name": "town-square",
|
||||
}
|
||||
|
||||
return _make_mattermost_get_channel_response
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_mattermost_get_user_response():
|
||||
def _make_mattermost_get_user_response():
|
||||
return {
|
||||
"id": "bew5wsjnctbt78mkq9z6ci9sme",
|
||||
"username": "fuzz",
|
||||
"nickname": "buzz",
|
||||
}
|
||||
|
||||
return _make_mattermost_get_user_response
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_mattermost_post_response():
|
||||
def _make_mattermost_post_response(**kwargs):
|
||||
return {
|
||||
"id": kwargs["id"] if "id" in kwargs else "bew5wsjnctbt78mkq9z6ci9sme",
|
||||
"channel_id": kwargs["channel_id"] if "channel_id" in kwargs else "cew5wstyetbt78mkq9z6ci9spq",
|
||||
"user_id": kwargs["user_id"] if "user_id" in kwargs else "uew5wsjnctbz78mkq9z6ci9sos",
|
||||
}
|
||||
|
||||
return _make_mattermost_post_response
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_mattermost_post_response_failure():
|
||||
def _make_mattermost_post_response(**kwargs):
|
||||
return {
|
||||
"status_code": kwargs["status_code"] if "status_code" in kwargs else 400,
|
||||
"id": kwargs["id"] if "id" in kwargs else "itre5wsjnctbz78mkq9z6ci9itue",
|
||||
"message": kwargs["message"] if "message" in kwargs else "API Error",
|
||||
"request_id": kwargs["request_id"] if "request_id" in kwargs else "reqe5wsjnctbz78mkq9z6ci9iqer",
|
||||
}
|
||||
|
||||
return _make_mattermost_post_response
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_mattermost_message():
|
||||
def _make_mattermost_message(alert_group, message_type, **kwargs):
|
||||
return MattermostMessageFactory(alert_group=alert_group, message_type=message_type, **kwargs)
|
||||
|
||||
return _make_mattermost_message
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_mattermost_user():
|
||||
def _make_mattermost_user(user, **kwargs):
|
||||
return MattermostUserFactory(user=user, **kwargs)
|
||||
|
||||
return _make_mattermost_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def set_random_mattermost_sigining_secret(settings):
|
||||
def _set_random_mattermost_sigining_secret():
|
||||
settings.MATTERMOST_SIGNING_SECRET = "n0cb4954bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d6284a6"
|
||||
|
||||
return _set_random_mattermost_sigining_secret
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_mattermost_event():
|
||||
def _make_mattermost_event(action, token, **kwargs):
|
||||
return {
|
||||
"user_id": kwargs["user_id"] if "user_id" in kwargs else "k8y8fccx57ygpq18oxp8pp3ntr",
|
||||
"user_name": kwargs["user_name"] if "user_name" in kwargs else "hbx80530",
|
||||
"channel_id": kwargs["channel_id"] if "channel_id" in kwargs else "gug81e7stfy8md747sewpeeqga",
|
||||
"channel_name": kwargs["channel_name"] if "channel_name" in kwargs else "camelcase",
|
||||
"team_id": kwargs["team_id"] if "team_id" in kwargs else "kjywdxcbjiyyupdgqst8bj8zrw",
|
||||
"team_domain": kwargs["team_domain"] if "team_domain" in kwargs else "local",
|
||||
"post_id": kwargs["post_id"] if "post_id" in kwargs else "cfsogqc61fbj3yssz78b1tarbw",
|
||||
"trigger_id": kwargs["trigger_id"]
|
||||
if "trigger_id" in kwargs
|
||||
else (
|
||||
"cXJhd2Zwc2V3aW5nanBjY2I2YzdxdTc5NmE6azh5OGZjY3"
|
||||
"g1N3lncHExOG94cDhwcDNudHI6MTcyODgyMzQxODU4NzpNRVFDSUgv"
|
||||
"bURORjQrWFB1R1QzWHdTWGhDZG9rdEpNb3cydFNJL3l5QktLMkZrVj"
|
||||
"dBaUFaMjdybFB3c21EWUlyMHFIeVpKVnIyR1gwa2N6RzY5YkpuSDdrOEpuVXhnPT0="
|
||||
),
|
||||
"type": kwargs["type"] if "type" in kwargs else "",
|
||||
"data_source": kwargs["data_source"] if "data_source" in kwargs else "",
|
||||
"context": {
|
||||
"action": action,
|
||||
"token": token,
|
||||
"alert": kwargs["alert"] if "alert" in kwargs else "",
|
||||
},
|
||||
}
|
||||
|
||||
return _make_mattermost_event
|
||||
0
engine/apps/mattermost/tests/events/__init__.py
Normal file
0
engine/apps/mattermost/tests/events/__init__.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.constants import ActionSource, AlertGroupState
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.mattermost.events.alert_group_actions_handler import AlertGroupActionHandler
|
||||
from apps.mattermost.events.types import EventAction
|
||||
from apps.mattermost.models import MattermostMessage
|
||||
from apps.mattermost.utils import MattermostEventAuthenticator
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"event_action,expected_state",
|
||||
[
|
||||
(EventAction.ACKNOWLEDGE, AlertGroupState.ACKNOWLEDGED),
|
||||
(EventAction.RESOLVE, AlertGroupState.RESOLVED),
|
||||
(EventAction.UNACKNOWLEDGE, AlertGroupState.FIRING),
|
||||
(EventAction.UNRESOLVE, AlertGroupState.FIRING),
|
||||
],
|
||||
)
|
||||
def test_alert_group_action_success(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_event,
|
||||
make_mattermost_message,
|
||||
make_mattermost_user,
|
||||
event_action,
|
||||
expected_state,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
|
||||
if event_action in [EventAction.ACKNOWLEDGE, EventAction.RESOLVE]:
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
elif event_action == EventAction.UNACKNOWLEDGE:
|
||||
alert_group = make_alert_group(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
acknowledged_at=timezone.now(),
|
||||
acknowledged=True,
|
||||
)
|
||||
elif event_action == EventAction.UNRESOLVE:
|
||||
alert_group = make_alert_group(alert_receive_channel, resolved=True)
|
||||
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
mattermost_message = make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
mattermost_user = make_mattermost_user(user=user)
|
||||
|
||||
token = MattermostEventAuthenticator.create_token(organization=organization)
|
||||
event = make_mattermost_event(
|
||||
event_action,
|
||||
token,
|
||||
post_id=mattermost_message.post_id,
|
||||
channel_id=mattermost_message.channel_id,
|
||||
user_id=mattermost_user.mattermost_user_id,
|
||||
alert=alert_group.public_primary_key,
|
||||
)
|
||||
handler = AlertGroupActionHandler(event=event, user=user)
|
||||
handler.process()
|
||||
alert_group.refresh_from_db()
|
||||
assert alert_group.state == expected_state
|
||||
assert alert_group.log_records.last().action_source == ActionSource.MATTERMOST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_group_not_found(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_event,
|
||||
make_mattermost_user,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
mattermost_user = make_mattermost_user(user=user)
|
||||
|
||||
token = MattermostEventAuthenticator.create_token(organization=organization)
|
||||
event = make_mattermost_event(
|
||||
EventAction.ACKNOWLEDGE, token, user_id=mattermost_user.mattermost_user_id, alert="ABC"
|
||||
)
|
||||
handler = AlertGroupActionHandler(event=event, user=user)
|
||||
handler.process()
|
||||
alert_group.refresh_from_db()
|
||||
assert not alert_group.acknowledged
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_group_action_not_found(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_event,
|
||||
make_mattermost_user,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
mattermost_user = make_mattermost_user(user=user)
|
||||
|
||||
token = MattermostEventAuthenticator.create_token(organization=organization)
|
||||
event = make_mattermost_event("", token, user_id=mattermost_user.mattermost_user_id, alert="ABC")
|
||||
handler = AlertGroupActionHandler(event=event, user=user)
|
||||
handler.process()
|
||||
alert_group.refresh_from_db()
|
||||
assert not alert_group.acknowledged
|
||||
35
engine/apps/mattermost/tests/factories.py
Normal file
35
engine/apps/mattermost/tests/factories.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import factory
|
||||
|
||||
from apps.mattermost.models import MattermostChannel, MattermostMessage, MattermostUser
|
||||
from common.utils import UniqueFaker
|
||||
|
||||
|
||||
class MattermostChannelFactory(factory.DjangoModelFactory):
|
||||
mattermost_team_id = factory.LazyAttribute(
|
||||
lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate())
|
||||
)
|
||||
channel_id = factory.LazyAttribute(lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate()))
|
||||
channel_name = factory.Faker("word")
|
||||
display_name = factory.Faker("word")
|
||||
|
||||
class Meta:
|
||||
model = MattermostChannel
|
||||
|
||||
|
||||
class MattermostMessageFactory(factory.DjangoModelFactory):
|
||||
post_id = factory.LazyAttribute(lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate()))
|
||||
channel_id = factory.LazyAttribute(lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate()))
|
||||
|
||||
class Meta:
|
||||
model = MattermostMessage
|
||||
|
||||
|
||||
class MattermostUserFactory(factory.DjangoModelFactory):
|
||||
mattermost_user_id = factory.LazyAttribute(
|
||||
lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate())
|
||||
)
|
||||
username = factory.Faker("word")
|
||||
nickname = factory.Faker("word")
|
||||
|
||||
class Meta:
|
||||
model = MattermostUser
|
||||
90
engine/apps/mattermost/tests/models/test_channel.py
Normal file
90
engine/apps/mattermost/tests/models/test_channel.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import pytest
|
||||
|
||||
from apps.mattermost.models import MattermostChannel
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_channel_for_alert_group(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
channel = make_mattermost_channel(organization=organization)
|
||||
channel_filter = make_channel_filter(
|
||||
alert_receive_channel,
|
||||
notification_backends={"MATTERMOST": {"channel": channel.public_primary_key, "enabled": True}},
|
||||
)
|
||||
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
ch = MattermostChannel.get_channel_for_alert_group(alert_group)
|
||||
assert ch.public_primary_key == channel.public_primary_key
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_mattermost_channel_disabled_for_route(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
channel = make_mattermost_channel(organization=organization)
|
||||
channel_filter = make_channel_filter(
|
||||
alert_receive_channel,
|
||||
notification_backends={"MATTERMOST": {"channel": channel.public_primary_key, "enabled": False}},
|
||||
)
|
||||
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
ch = MattermostChannel.get_channel_for_alert_group(alert_group)
|
||||
assert ch is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_mattermost_channel_invalid_route_channel(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
default_channel = make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
channel_filter = make_channel_filter(
|
||||
alert_receive_channel, notification_backends={"MATTERMOST": {"channel": "invalid_id", "enabled": True}}
|
||||
)
|
||||
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
ch = MattermostChannel.get_channel_for_alert_group(alert_group)
|
||||
assert ch.public_primary_key == default_channel.public_primary_key
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_mattermost_channel_channel_filter_not_configured(
|
||||
make_organization, make_alert_receive_channel, make_alert_group, make_alert, make_mattermost_channel
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
default_channel = make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
ch = MattermostChannel.get_channel_for_alert_group(alert_group)
|
||||
assert ch.public_primary_key == default_channel.public_primary_key
|
||||
48
engine/apps/mattermost/tests/test_alert_rendering.py
Normal file
48
engine/apps/mattermost/tests/test_alert_rendering.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.mattermost.alert_rendering import MattermostMessageRenderer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"expected_button_ids,expected_button_names,color_code,alert_type",
|
||||
[
|
||||
(["acknowledge", "resolve"], ["Acknowledge", "Resolve"], "#a30200", "unack"),
|
||||
(["unacknowledge", "resolve"], ["Unacknowledge", "Resolve"], "#daa038", "ack"),
|
||||
(["unresolve"], ["Unresolve"], "#2eb886", "resolved"),
|
||||
],
|
||||
)
|
||||
def test_alert_group_message_renderer(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
expected_button_ids,
|
||||
expected_button_names,
|
||||
color_code,
|
||||
alert_type,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
if alert_type == "unack":
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
elif alert_type == "ack":
|
||||
alert_group = make_alert_group(alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
elif alert_type == "resolved":
|
||||
alert_group = make_alert_group(alert_receive_channel, resolved_at=timezone.now(), resolved=True)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
message = MattermostMessageRenderer(alert_group=alert_group).render_alert_group_message()
|
||||
actions = message["props"]["attachments"][0]["actions"]
|
||||
color = message["props"]["attachments"][0]["color"]
|
||||
assert color == color_code
|
||||
ids = [a["id"] for a in actions]
|
||||
for id in ids:
|
||||
assert id in expected_button_ids
|
||||
names = [a["name"] for a in actions]
|
||||
for name in names:
|
||||
assert name in expected_button_names
|
||||
94
engine/apps/mattermost/tests/test_backend.py
Normal file
94
engine/apps/mattermost/tests/test_backend.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import pytest
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.mattermost.backend import MattermostBackend
|
||||
from apps.user_management.models import User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unlink_user(make_organization_and_user, make_mattermost_user):
|
||||
_, user = make_organization_and_user()
|
||||
make_mattermost_user(user=user)
|
||||
backend = MattermostBackend()
|
||||
backend.unlink_user(user)
|
||||
user.refresh_from_db()
|
||||
|
||||
with pytest.raises(User.mattermost_user_identity.RelatedObjectDoesNotExist):
|
||||
user.mattermost_user_identity
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_serialize_user(make_organization_and_user, make_mattermost_user):
|
||||
_, user = make_organization_and_user()
|
||||
mattermost_user = make_mattermost_user(user=user)
|
||||
data = MattermostBackend().serialize_user(user)
|
||||
assert data["mattermost_user_id"] == mattermost_user.mattermost_user_id
|
||||
assert data["username"] == mattermost_user.username
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_serialize_user_not_found(
|
||||
make_organization_and_user,
|
||||
):
|
||||
_, user = make_organization_and_user()
|
||||
data = MattermostBackend().serialize_user(user)
|
||||
assert data is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_channel_filter_data(
|
||||
make_organization,
|
||||
make_mattermost_channel,
|
||||
):
|
||||
organization = make_organization()
|
||||
channel = make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
input_data = {"channel": channel.public_primary_key, "enabled": True}
|
||||
data = MattermostBackend().validate_channel_filter_data(organization=organization, data=input_data)
|
||||
assert data["channel"] == channel.public_primary_key
|
||||
assert data["enabled"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_channel_filter_data_update_only_channel(
|
||||
make_organization,
|
||||
make_mattermost_channel,
|
||||
):
|
||||
organization = make_organization()
|
||||
channel = make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
input_data = {"channel": channel.public_primary_key}
|
||||
data = MattermostBackend().validate_channel_filter_data(organization=organization, data=input_data)
|
||||
assert data["channel"] == channel.public_primary_key
|
||||
assert "enabled" not in data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"input_data,expected_data",
|
||||
[
|
||||
({}, {}),
|
||||
({"enabled": True}, {"enabled": True}),
|
||||
({"enabled": False}, {"enabled": False}),
|
||||
({"enabled": 1}, {"enabled": True}),
|
||||
({"enabled": 0}, {"enabled": False}),
|
||||
({"channel": None, "enabled": True}, {"channel": None, "enabled": True}),
|
||||
({"channel": None}, {"channel": None}),
|
||||
],
|
||||
)
|
||||
def test_validate_channel_filter_data_toggle_flag(
|
||||
make_organization,
|
||||
input_data,
|
||||
expected_data,
|
||||
):
|
||||
organization = make_organization()
|
||||
data = MattermostBackend().validate_channel_filter_data(organization=organization, data=input_data)
|
||||
assert data == expected_data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_channel_filter_data_invalid_channel(
|
||||
make_organization,
|
||||
):
|
||||
organization = make_organization()
|
||||
input_data = {"channel": "abcd", "enabled": True}
|
||||
with pytest.raises(serializers.ValidationError):
|
||||
MattermostBackend().validate_channel_filter_data(organization=organization, data=input_data)
|
||||
383
engine/apps/mattermost/tests/test_mattermost_channel.py
Normal file
383
engine/apps/mattermost/tests/test_mattermost_channel.py
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_not_authorized(make_organization_and_user_with_plugin_token, make_mattermost_channel):
|
||||
client = APIClient()
|
||||
|
||||
organization, _, _ = make_organization_and_user_with_plugin_token()
|
||||
mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
|
||||
url = reverse("mattermost:channel-list")
|
||||
response = client.post(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
url = reverse("mattermost:channel-list")
|
||||
response = client.get(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.get(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.delete(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"role,expected_status",
|
||||
[
|
||||
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
|
||||
],
|
||||
)
|
||||
def test_list_mattermost_channels_permissions(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
role,
|
||||
expected_status,
|
||||
):
|
||||
client = APIClient()
|
||||
_, user, token = make_organization_and_user_with_plugin_token(role)
|
||||
|
||||
url = reverse("mattermost:channel-list")
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"role,expected_status",
|
||||
[
|
||||
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
|
||||
],
|
||||
)
|
||||
def test_get_mattermost_channels_permissions(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_mattermost_channel,
|
||||
role,
|
||||
expected_status,
|
||||
):
|
||||
client = APIClient()
|
||||
organization, user, token = make_organization_and_user_with_plugin_token(role)
|
||||
mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
|
||||
url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"role,expected_status",
|
||||
[
|
||||
(LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT),
|
||||
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
|
||||
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
|
||||
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
|
||||
],
|
||||
)
|
||||
def test_delete_mattermost_channels_permissions(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_mattermost_channel,
|
||||
role,
|
||||
expected_status,
|
||||
):
|
||||
client = APIClient()
|
||||
organization, user, token = make_organization_and_user_with_plugin_token(role)
|
||||
mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
|
||||
url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.delete(url, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"role,expected_status",
|
||||
[
|
||||
(LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED),
|
||||
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
|
||||
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
|
||||
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
|
||||
],
|
||||
)
|
||||
def test_post_mattermost_channels_permissions(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_mattermost_get_channel_response,
|
||||
make_user_auth_headers,
|
||||
role,
|
||||
expected_status,
|
||||
):
|
||||
client = APIClient()
|
||||
_, user, token = make_organization_and_user_with_plugin_token(role)
|
||||
|
||||
data = make_mattermost_get_channel_response()
|
||||
channel_response = requests.Response()
|
||||
channel_response.status_code = status.HTTP_200_OK
|
||||
channel_response._content = json.dumps(data).encode()
|
||||
|
||||
url = reverse("mattermost:channel-list")
|
||||
with patch("apps.mattermost.client.requests.get", return_value=channel_response) as mock_request:
|
||||
response = client.post(
|
||||
url,
|
||||
data={"channel_id": "fuzzchannel"},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
assert response.status_code == expected_status
|
||||
if expected_status == status.HTTP_201_CREATED:
|
||||
res = response.json()
|
||||
mock_request.assert_called_once()
|
||||
assert res["channel_id"] == data["id"]
|
||||
assert res["channel_name"] == data["name"]
|
||||
assert res["display_name"] == data["display_name"]
|
||||
assert res["is_default_channel"] is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"request_body,expected_status",
|
||||
[
|
||||
({"channel_id": "fuzzchannel"}, status.HTTP_201_CREATED),
|
||||
({}, status.HTTP_400_BAD_REQUEST),
|
||||
],
|
||||
)
|
||||
def test_post_mattermost_channels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_mattermost_get_channel_response,
|
||||
make_user_auth_headers,
|
||||
request_body,
|
||||
expected_status,
|
||||
):
|
||||
client = APIClient()
|
||||
_, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
data = make_mattermost_get_channel_response()
|
||||
channel_response = requests.Response()
|
||||
channel_response.status_code = status.HTTP_200_OK
|
||||
channel_response._content = json.dumps(data).encode()
|
||||
|
||||
url = reverse("mattermost:channel-list")
|
||||
with patch("apps.mattermost.client.requests.get", return_value=channel_response) as mock_request:
|
||||
response = client.post(url, data=request_body, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
if expected_status == status.HTTP_201_CREATED:
|
||||
mock_request.assert_called_once()
|
||||
else:
|
||||
mock_request.assert_not_called()
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_post_mattermost_channels_mattermost_api_call_failure(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
client = APIClient()
|
||||
_, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
# Timeout Error
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
mock_response.request = requests.Request(
|
||||
url="https://example.com",
|
||||
method="GET",
|
||||
)
|
||||
mock_response.raise_for_status.side_effect = requests.Timeout(response=mock_response)
|
||||
|
||||
url = reverse("mattermost:channel-list")
|
||||
with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request:
|
||||
response = client.post(
|
||||
url,
|
||||
data={"channel_id": "fuzzchannel"},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
mock_request.assert_called_once()
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["detail"] == "Mattermost api call gateway timedout"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"role,expected_status",
|
||||
[
|
||||
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
|
||||
(LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN),
|
||||
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
|
||||
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
|
||||
],
|
||||
)
|
||||
def test_set_default_mattermost_channels_permissions(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_mattermost_channel,
|
||||
role,
|
||||
expected_status,
|
||||
):
|
||||
client = APIClient()
|
||||
|
||||
organization, user, token = make_organization_and_user_with_plugin_token(role)
|
||||
mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
|
||||
url = reverse("mattermost:channel-set-default", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.post(url, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_mattermost_channels(
|
||||
make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel
|
||||
):
|
||||
client = APIClient()
|
||||
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
first_mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
second_mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
|
||||
expected_payload = [
|
||||
{
|
||||
"id": first_mattermost_channel.public_primary_key,
|
||||
"channel_id": first_mattermost_channel.channel_id,
|
||||
"channel_name": first_mattermost_channel.channel_name,
|
||||
"display_name": first_mattermost_channel.display_name,
|
||||
"is_default_channel": first_mattermost_channel.is_default_channel,
|
||||
},
|
||||
{
|
||||
"id": second_mattermost_channel.public_primary_key,
|
||||
"channel_id": second_mattermost_channel.channel_id,
|
||||
"channel_name": second_mattermost_channel.channel_name,
|
||||
"display_name": second_mattermost_channel.display_name,
|
||||
"is_default_channel": second_mattermost_channel.is_default_channel,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse("mattermost:channel-list")
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert len(response_data) == 2
|
||||
for channel_data in expected_payload:
|
||||
assert channel_data in response_data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_mattermost_channel(
|
||||
make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel
|
||||
):
|
||||
client = APIClient()
|
||||
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
|
||||
expected_payload = {
|
||||
"id": mattermost_channel.public_primary_key,
|
||||
"channel_id": mattermost_channel.channel_id,
|
||||
"channel_name": mattermost_channel.channel_name,
|
||||
"display_name": mattermost_channel.display_name,
|
||||
"is_default_channel": mattermost_channel.is_default_channel,
|
||||
}
|
||||
|
||||
url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == expected_payload
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_mattermost_channel(
|
||||
make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel
|
||||
):
|
||||
client = APIClient()
|
||||
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
|
||||
url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.delete(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.get(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_other_organization_mattermost_channels(
|
||||
make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel
|
||||
):
|
||||
client = APIClient()
|
||||
|
||||
organization, _, _ = make_organization_and_user_with_plugin_token()
|
||||
mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
|
||||
_, other_user, other_token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.get(url, **make_user_auth_headers(other_user, other_token))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.delete(url, **make_user_auth_headers(other_user, other_token))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
url = reverse("mattermost:channel-list")
|
||||
response = client.get(url, **make_user_auth_headers(other_user, other_token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
url = reverse("mattermost:channel-set-default", kwargs={"pk": mattermost_channel.public_primary_key})
|
||||
response = client.post(url, **make_user_auth_headers(other_user, other_token))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_set_default(make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel):
|
||||
client = APIClient()
|
||||
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
first_mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
second_mattermost_channel = make_mattermost_channel(organization=organization)
|
||||
|
||||
# If no channel is default
|
||||
url = reverse("mattermost:channel-set-default", kwargs={"pk": first_mattermost_channel.public_primary_key})
|
||||
response = client.post(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
first_mattermost_channel.refresh_from_db()
|
||||
second_mattermost_channel.refresh_from_db()
|
||||
assert first_mattermost_channel.is_default_channel is True
|
||||
assert second_mattermost_channel.is_default_channel is False
|
||||
|
||||
# If there is an existing default channel
|
||||
url = reverse("mattermost:channel-set-default", kwargs={"pk": second_mattermost_channel.public_primary_key})
|
||||
response = client.post(url, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
first_mattermost_channel.refresh_from_db()
|
||||
second_mattermost_channel.refresh_from_db()
|
||||
assert first_mattermost_channel.is_default_channel is False
|
||||
assert second_mattermost_channel.is_default_channel is True
|
||||
161
engine/apps/mattermost/tests/test_mattermost_client.py
Normal file
161
engine/apps/mattermost/tests/test_mattermost_client.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpretty
|
||||
import pytest
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from rest_framework import status
|
||||
|
||||
from apps.mattermost.client import MattermostAPIException, MattermostAPITokenInvalid, MattermostClient
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mattermost_client_initialization():
|
||||
settings.MATTERMOST_BOT_TOKEN = None
|
||||
with pytest.raises(MattermostAPITokenInvalid) as exc:
|
||||
MattermostClient()
|
||||
assert type(exc) is MattermostAPITokenInvalid
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
def test_get_channel_by_id_ok(make_mattermost_get_channel_response):
|
||||
client = MattermostClient("abcd")
|
||||
data = make_mattermost_get_channel_response()
|
||||
url = "{}/api/v4/channels/{}".format(settings.MATTERMOST_HOST, data["id"])
|
||||
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK)
|
||||
httpretty.register_uri(httpretty.GET, url, responses=[mock_response])
|
||||
|
||||
channel_response = client.get_channel_by_id(data["id"])
|
||||
|
||||
last_request = httpretty.last_request()
|
||||
assert last_request.method == "GET"
|
||||
assert last_request.url == url
|
||||
assert channel_response.channel_id == data["id"]
|
||||
assert channel_response.team_id == data["team_id"]
|
||||
assert channel_response.channel_name == data["name"]
|
||||
assert channel_response.display_name == data["display_name"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
def test_get_user_ok(make_mattermost_get_user_response):
|
||||
client = MattermostClient("abcd")
|
||||
data = make_mattermost_get_user_response()
|
||||
url = "{}/api/v4/users/{}".format(settings.MATTERMOST_HOST, data["id"])
|
||||
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK)
|
||||
httpretty.register_uri(httpretty.GET, url, responses=[mock_response])
|
||||
|
||||
mattermost_user = client.get_user(data["id"])
|
||||
|
||||
last_request = httpretty.last_request()
|
||||
assert last_request.method == "GET"
|
||||
assert last_request.url == url
|
||||
assert mattermost_user.user_id == data["id"]
|
||||
assert mattermost_user.username == data["username"]
|
||||
assert mattermost_user.nickname == data["nickname"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"client_method,params,method",
|
||||
[
|
||||
("get_channel_by_id", ["fuzz"], "GET"),
|
||||
("get_user", ["fuzz"], "GET"),
|
||||
("create_post", ["fuzz", {}], "POST"),
|
||||
("update_post", ["fuzz", {}], "PUT"),
|
||||
],
|
||||
)
|
||||
def test_check_response_failures(client_method, params, method):
|
||||
client = MattermostClient("abcd")
|
||||
data = {
|
||||
"status_code": status.HTTP_400_BAD_REQUEST,
|
||||
"id": "fuzzbuzz",
|
||||
"message": "Client Error",
|
||||
"request_id": "foobar",
|
||||
}
|
||||
|
||||
# HTTP Error
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = status.HTTP_400_BAD_REQUEST
|
||||
mock_response.json.return_value = data
|
||||
mock_response.request = requests.Request(
|
||||
url="https://example.com",
|
||||
method=method,
|
||||
)
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError(response=mock_response)
|
||||
with patch(f"apps.mattermost.client.requests.{method.lower()}", return_value=mock_response) as mock_request:
|
||||
with pytest.raises(MattermostAPIException) as exc:
|
||||
getattr(client, client_method)(*params)
|
||||
mock_request.assert_called_once()
|
||||
|
||||
# Timeout Error
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
mock_response.request = requests.Request(
|
||||
url="https://example.com",
|
||||
method=method,
|
||||
)
|
||||
mock_response.raise_for_status.side_effect = requests.Timeout(response=mock_response)
|
||||
with patch(f"apps.mattermost.client.requests.{method.lower()}", return_value=mock_response) as mock_request:
|
||||
with pytest.raises(MattermostAPIException) as exc:
|
||||
getattr(client, client_method)(*params)
|
||||
assert exc.value.msg == "Mattermost api call gateway timedout"
|
||||
mock_request.assert_called_once()
|
||||
|
||||
# RequestException Error
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
mock_response.request = requests.Request(
|
||||
url="https://example.com",
|
||||
method=method,
|
||||
)
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.RequestException(response=mock_response)
|
||||
with patch(f"apps.mattermost.client.requests.{method.lower()}", return_value=mock_response) as mock_request:
|
||||
with pytest.raises(MattermostAPIException) as exc:
|
||||
getattr(client, client_method)(*params)
|
||||
assert exc.value.msg == "Unexpected error from mattermost server"
|
||||
mock_request.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
def test_create_post_ok(make_mattermost_post_response):
|
||||
client = MattermostClient("abcd")
|
||||
data = make_mattermost_post_response()
|
||||
url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST)
|
||||
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK)
|
||||
httpretty.register_uri(httpretty.POST, url, responses=[mock_response])
|
||||
|
||||
mattermost_post = client.create_post(data["id"], {})
|
||||
|
||||
last_request = httpretty.last_request()
|
||||
assert last_request.method == "POST"
|
||||
assert last_request.url == url
|
||||
assert mattermost_post.post_id == data["id"]
|
||||
assert mattermost_post.channel_id == data["channel_id"]
|
||||
assert mattermost_post.user_id == data["user_id"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
def test_update_post_ok(make_mattermost_post_response):
|
||||
client = MattermostClient("abcd")
|
||||
data = make_mattermost_post_response()
|
||||
url = "{}/api/v4/posts/{}".format(settings.MATTERMOST_HOST, data["id"])
|
||||
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK)
|
||||
httpretty.register_uri(httpretty.PUT, url, responses=[mock_response])
|
||||
|
||||
mattermost_post = client.update_post(data["id"], {})
|
||||
|
||||
last_request = httpretty.last_request()
|
||||
assert last_request.method == "PUT"
|
||||
assert last_request.url == url
|
||||
assert mattermost_post.post_id == data["id"]
|
||||
assert mattermost_post.channel_id == data["channel_id"]
|
||||
assert mattermost_post.user_id == data["user_id"]
|
||||
155
engine/apps/mattermost/tests/test_mattermost_event.py
Normal file
155
engine/apps/mattermost/tests/test_mattermost_event.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.alerts.constants import ActionSource, AlertGroupState
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.mattermost.events.types import EventAction
|
||||
from apps.mattermost.models import MattermostMessage
|
||||
from apps.mattermost.utils import MattermostEventAuthenticator
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"event_action,expected_state",
|
||||
[
|
||||
(EventAction.ACKNOWLEDGE, AlertGroupState.ACKNOWLEDGED),
|
||||
(EventAction.RESOLVE, AlertGroupState.RESOLVED),
|
||||
(EventAction.UNACKNOWLEDGE, AlertGroupState.FIRING),
|
||||
(EventAction.UNRESOLVE, AlertGroupState.FIRING),
|
||||
],
|
||||
)
|
||||
def test_mattermost_alert_group_event_success(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_mattermost_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_event,
|
||||
make_mattermost_message,
|
||||
make_mattermost_user,
|
||||
event_action,
|
||||
expected_state,
|
||||
):
|
||||
organization, user, _ = make_organization_and_user_with_plugin_token()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
|
||||
if event_action in [EventAction.ACKNOWLEDGE, EventAction.RESOLVE]:
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
elif event_action == EventAction.UNACKNOWLEDGE:
|
||||
alert_group = make_alert_group(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
acknowledged_at=timezone.now(),
|
||||
acknowledged=True,
|
||||
)
|
||||
elif event_action == EventAction.UNRESOLVE:
|
||||
alert_group = make_alert_group(alert_receive_channel, resolved=True)
|
||||
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
mattermost_message = make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
mattermost_user = make_mattermost_user(user=user)
|
||||
|
||||
token = MattermostEventAuthenticator.create_token(organization=organization)
|
||||
event = make_mattermost_event(
|
||||
event_action,
|
||||
token,
|
||||
post_id=mattermost_message.post_id,
|
||||
channel_id=mattermost_message.channel_id,
|
||||
user_id=mattermost_user.mattermost_user_id,
|
||||
alert=alert_group.public_primary_key,
|
||||
)
|
||||
|
||||
url = reverse("mattermost:incoming_mattermost_event")
|
||||
client = APIClient()
|
||||
response = client.post(url, event, format="json")
|
||||
alert_group.refresh_from_db()
|
||||
assert alert_group.state == expected_state
|
||||
assert alert_group.log_records.last().action_source == ActionSource.MATTERMOST
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mattermost_alert_group_event_incorrect_token(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_mattermost_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_event,
|
||||
make_mattermost_message,
|
||||
make_mattermost_user,
|
||||
):
|
||||
organization, user, _ = make_organization_and_user_with_plugin_token()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
mattermost_message = make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
mattermost_user = make_mattermost_user(user=user)
|
||||
|
||||
token = MattermostEventAuthenticator.create_token(organization=organization)
|
||||
token += "abx"
|
||||
event = make_mattermost_event(
|
||||
EventAction.ACKNOWLEDGE,
|
||||
token,
|
||||
post_id=mattermost_message.post_id,
|
||||
channel_id=mattermost_message.channel_id,
|
||||
user_id=mattermost_user.mattermost_user_id,
|
||||
alert=alert_group.public_primary_key,
|
||||
)
|
||||
|
||||
url = reverse("mattermost:incoming_mattermost_event")
|
||||
client = APIClient()
|
||||
response = client.post(url, event, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mattermost_alert_group_event_insufficient_permission(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_mattermost_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_event,
|
||||
make_mattermost_message,
|
||||
make_mattermost_user,
|
||||
):
|
||||
organization, user, _ = make_organization_and_user_with_plugin_token(LegacyAccessControlRole.VIEWER)
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
mattermost_message = make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
mattermost_user = make_mattermost_user(user=user)
|
||||
|
||||
token = MattermostEventAuthenticator.create_token(organization=organization)
|
||||
event = make_mattermost_event(
|
||||
EventAction.ACKNOWLEDGE,
|
||||
token,
|
||||
post_id=mattermost_message.post_id,
|
||||
channel_id=mattermost_message.channel_id,
|
||||
user_id=mattermost_user.mattermost_user_id,
|
||||
alert=alert_group.public_primary_key,
|
||||
)
|
||||
|
||||
url = reverse("mattermost:incoming_mattermost_event")
|
||||
client = APIClient()
|
||||
response = client.post(url, event, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
100
engine/apps/mattermost/tests/test_representative.py
Normal file
100
engine/apps/mattermost/tests/test_representative.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.models import AlertGroupLogRecord, AlertReceiveChannel
|
||||
from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_handler(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_mattermost_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_alert_group_log_record,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
ack_alert_group = make_alert_group(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
acknowledged_at=timezone.now(),
|
||||
acknowledged=True,
|
||||
)
|
||||
make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None)
|
||||
handler = AlertGroupMattermostRepresentative(log_record=log_record).get_handler()
|
||||
assert handler.__name__ == "on_alert_group_action"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_applicable_success(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_mattermost_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_alert_group_log_record,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
ack_alert_group = make_alert_group(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
acknowledged_at=timezone.now(),
|
||||
acknowledged=True,
|
||||
)
|
||||
make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None)
|
||||
assert AlertGroupMattermostRepresentative(log_record=log_record).is_applicable()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_applicable_without_channels(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_alert_group_log_record,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
ack_alert_group = make_alert_group(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
acknowledged_at=timezone.now(),
|
||||
acknowledged=True,
|
||||
)
|
||||
make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None)
|
||||
assert not AlertGroupMattermostRepresentative(log_record=log_record).is_applicable()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_applicable_invalid_type(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_mattermost_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_alert_group_log_record,
|
||||
):
|
||||
organization = make_organization()
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
|
||||
)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
ack_alert_group = make_alert_group(
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
acknowledged_at=timezone.now(),
|
||||
acknowledged=True,
|
||||
)
|
||||
make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_RE_INVITE, author=None)
|
||||
assert not AlertGroupMattermostRepresentative(log_record=log_record).is_applicable()
|
||||
495
engine/apps/mattermost/tests/test_tasks.py
Normal file
495
engine/apps/mattermost/tests/test_tasks.py
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpretty
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from apps.alerts.models import AlertGroupLogRecord
|
||||
from apps.base.models import UserNotificationPolicyLogRecord
|
||||
from apps.base.models.user_notification_policy import UserNotificationPolicy
|
||||
from apps.mattermost.client import MattermostAPIException
|
||||
from apps.mattermost.models import MattermostMessage
|
||||
from apps.mattermost.tasks import (
|
||||
notify_user_about_alert_async,
|
||||
on_alert_group_action_triggered_async,
|
||||
on_create_alert_async,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
def test_on_create_alert_async_success(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_post_response,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
alert = make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
|
||||
url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST)
|
||||
data = make_mattermost_post_response()
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK)
|
||||
httpretty.register_uri(httpretty.POST, url, responses=[mock_response])
|
||||
|
||||
on_create_alert_async(alert_pk=alert.pk)
|
||||
|
||||
mattermost_message = alert_group.mattermost_messages.order_by("created_at").first()
|
||||
assert mattermost_message.post_id == data["id"]
|
||||
assert mattermost_message.channel_id == data["channel_id"]
|
||||
assert mattermost_message.message_type == MattermostMessage.ALERT_GROUP_MESSAGE
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_on_create_alert_async_skip_post_for_duplicate(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_message,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
alert = make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
|
||||
with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call:
|
||||
on_create_alert_async(alert_pk=alert.pk)
|
||||
|
||||
mock_post_call.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_on_create_alert_async_skip_post_for_no_channel(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_message,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
alert = make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
|
||||
with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call:
|
||||
on_create_alert_async(alert_pk=alert.pk)
|
||||
|
||||
mock_post_call.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
@pytest.mark.parametrize("status_code", [400, 401])
|
||||
def test_on_create_alert_async_mattermost_api_failure(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_post_response_failure,
|
||||
status_code,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
alert = make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
|
||||
url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST)
|
||||
data = make_mattermost_post_response_failure(status_code=status_code)
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status_code)
|
||||
httpretty.register_uri(httpretty.POST, url, status=status_code, responses=[mock_response])
|
||||
|
||||
on_create_alert_async(alert_pk=alert.pk)
|
||||
|
||||
mattermost_message = alert_group.mattermost_messages.order_by("created_at").first()
|
||||
assert mattermost_message is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
def test_on_alert_group_action_triggered_async_success(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_post_response,
|
||||
make_alert_group_log_record,
|
||||
make_mattermost_message,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
ack_alert_group = make_alert_group(alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True)
|
||||
make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
ack_log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None)
|
||||
mattermost_message = make_mattermost_message(ack_alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
expected_button_ids = ["unacknowledge", "resolve"]
|
||||
|
||||
url = "{}/api/v4/posts/{}".format(settings.MATTERMOST_HOST, mattermost_message.post_id)
|
||||
data = make_mattermost_post_response()
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK)
|
||||
httpretty.register_uri(httpretty.PUT, url, responses=[mock_response])
|
||||
|
||||
on_alert_group_action_triggered_async(ack_log_record.pk)
|
||||
|
||||
last_request = httpretty.last_request()
|
||||
assert last_request.method == "PUT"
|
||||
assert last_request.url == url
|
||||
|
||||
request_body = json.loads(last_request.body)
|
||||
ids = [a["id"] for a in request_body["props"]["attachments"][0]["actions"]]
|
||||
for id in ids:
|
||||
assert id in expected_button_ids
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
def test_on_alert_group_action_triggered_async_fails_without_alert_group_message(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_alert_group_log_record,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
ack_alert_group = make_alert_group(alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True)
|
||||
make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
ack_log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None)
|
||||
|
||||
with pytest.raises(MattermostMessage.DoesNotExist):
|
||||
on_alert_group_action_triggered_async(ack_log_record.pk)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
@pytest.mark.parametrize("status_code", [400, 401])
|
||||
def test_on_alert_group_action_triggered_async_failure(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_alert_group_log_record,
|
||||
make_mattermost_message,
|
||||
make_mattermost_post_response_failure,
|
||||
status_code,
|
||||
):
|
||||
organization, _ = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
ack_alert_group = make_alert_group(alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True)
|
||||
make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
ack_log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None)
|
||||
mattermost_message = make_mattermost_message(ack_alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
|
||||
url = "{}/api/v4/posts/{}".format(settings.MATTERMOST_HOST, mattermost_message.post_id)
|
||||
data = make_mattermost_post_response_failure(status_code=status_code)
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status_code)
|
||||
httpretty.register_uri(httpretty.PUT, url, status=status_code, responses=[mock_response])
|
||||
|
||||
if status_code != 401:
|
||||
with pytest.raises(MattermostAPIException):
|
||||
on_alert_group_action_triggered_async(ack_log_record.pk)
|
||||
else:
|
||||
on_alert_group_action_triggered_async(ack_log_record.pk)
|
||||
|
||||
last_request = httpretty.last_request()
|
||||
assert last_request.method == "PUT"
|
||||
assert last_request.url == url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
def test_notify_user_about_alert_async_success(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_post_response,
|
||||
make_mattermost_message,
|
||||
make_user_notification_policy,
|
||||
make_mattermost_user,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
|
||||
make_mattermost_user(user=user)
|
||||
user_notification_policy = make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY,
|
||||
)
|
||||
|
||||
url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST)
|
||||
data = make_mattermost_post_response()
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK)
|
||||
httpretty.register_uri(httpretty.POST, url, responses=[mock_response])
|
||||
|
||||
notify_user_about_alert_async(
|
||||
user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk
|
||||
)
|
||||
|
||||
log_record = alert_group.personal_log_records.last()
|
||||
assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS
|
||||
assert log_record.alert_group.pk == alert_group.pk
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_user_about_alert_async_user_does_not_exist(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_message,
|
||||
make_mattermost_user,
|
||||
make_user_notification_policy,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
make_mattermost_user(user=user)
|
||||
user_notification_policy = make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY,
|
||||
)
|
||||
|
||||
with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call:
|
||||
notify_user_about_alert_async(
|
||||
user_pk=123, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk
|
||||
)
|
||||
|
||||
mock_post_call.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_user_about_alert_async_alert_does_not_exist(
|
||||
make_organization_and_user,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_user,
|
||||
make_user_notification_policy,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
make_mattermost_user(user=user)
|
||||
user_notification_policy = make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY,
|
||||
)
|
||||
|
||||
with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call:
|
||||
notify_user_about_alert_async(
|
||||
user_pk=user.pk, alert_group_pk=123, notification_policy_pk=user_notification_policy.pk
|
||||
)
|
||||
|
||||
mock_post_call.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_user_about_alert_async_notification_policy_does_not_exist(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_message,
|
||||
make_mattermost_user,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
make_mattermost_user(user=user)
|
||||
|
||||
with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call:
|
||||
notify_user_about_alert_async(user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=123)
|
||||
|
||||
mock_post_call.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_user_about_alert_async_mattermost_message_does_not_exist(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_user,
|
||||
make_user_notification_policy,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
make_mattermost_user(user=user)
|
||||
user_notification_policy = make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY,
|
||||
)
|
||||
|
||||
with pytest.raises(MattermostMessage.DoesNotExist):
|
||||
notify_user_about_alert_async(
|
||||
user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
def test_notify_user_about_alert_async_mattermost_user_does_not_exist(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_message,
|
||||
make_user_notification_policy,
|
||||
make_mattermost_post_response,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
user_notification_policy = make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY,
|
||||
)
|
||||
|
||||
url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST)
|
||||
data = make_mattermost_post_response()
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK)
|
||||
httpretty.register_uri(httpretty.POST, url, responses=[mock_response])
|
||||
|
||||
notify_user_about_alert_async(
|
||||
user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk
|
||||
)
|
||||
|
||||
assert alert_group.personal_log_records.filter(
|
||||
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST
|
||||
).exists()
|
||||
log_record = alert_group.personal_log_records.last()
|
||||
assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS
|
||||
assert log_record.alert_group.pk == alert_group.pk
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@httpretty.activate(verbose=True, allow_net_connect=False)
|
||||
@pytest.mark.parametrize("status_code", [400, 401])
|
||||
def test_notify_user_about_alert_async_api_failure(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_channel,
|
||||
make_mattermost_message,
|
||||
make_mattermost_post_response_failure,
|
||||
make_user_notification_policy,
|
||||
make_mattermost_user,
|
||||
status_code,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_channel(organization=organization, is_default_channel=True)
|
||||
make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
make_mattermost_user(user=user)
|
||||
user_notification_policy = make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY,
|
||||
)
|
||||
|
||||
url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST)
|
||||
data = make_mattermost_post_response_failure(status_code=status_code)
|
||||
mock_response = httpretty.Response(json.dumps(data), status=status_code)
|
||||
httpretty.register_uri(httpretty.POST, url, status=status_code, responses=[mock_response])
|
||||
|
||||
if status_code != 401:
|
||||
with pytest.raises(MattermostAPIException):
|
||||
notify_user_about_alert_async(
|
||||
user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk
|
||||
)
|
||||
else:
|
||||
notify_user_about_alert_async(
|
||||
user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk
|
||||
)
|
||||
log_record = alert_group.personal_log_records.last()
|
||||
assert (
|
||||
log_record.notification_error_code
|
||||
== UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_API_UNAUTHORIZED
|
||||
)
|
||||
|
||||
last_request = httpretty.last_request()
|
||||
assert last_request.method == "POST"
|
||||
assert last_request.url == url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notify_user_about_alert_async_skip_post_for_no_channel(
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_mattermost_message,
|
||||
make_mattermost_user,
|
||||
make_user_notification_policy,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload)
|
||||
make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE)
|
||||
make_mattermost_user(user=user)
|
||||
user_notification_policy = make_user_notification_policy(
|
||||
user=user,
|
||||
step=UserNotificationPolicy.Step.NOTIFY,
|
||||
notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY,
|
||||
)
|
||||
|
||||
with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call:
|
||||
notify_user_about_alert_async(
|
||||
user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk
|
||||
)
|
||||
|
||||
mock_post_call.assert_not_called()
|
||||
26
engine/apps/mattermost/tests/test_utils.py
Normal file
26
engine/apps/mattermost/tests/test_utils.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import pytest
|
||||
|
||||
from apps.mattermost.exceptions import MattermostEventTokenInvalid
|
||||
from apps.mattermost.utils import MattermostEventAuthenticator
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_jwt_token_validation_success(
|
||||
make_organization,
|
||||
):
|
||||
organization = make_organization()
|
||||
token = MattermostEventAuthenticator.create_token(organization=organization)
|
||||
payload = MattermostEventAuthenticator.verify(token)
|
||||
assert payload["organization_id"] == organization.public_primary_key
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_jwt_token_validation_failure(
|
||||
make_organization,
|
||||
set_random_mattermost_sigining_secret,
|
||||
):
|
||||
organization = make_organization()
|
||||
token = MattermostEventAuthenticator.create_token(organization=organization)
|
||||
set_random_mattermost_sigining_secret()
|
||||
with pytest.raises(MattermostEventTokenInvalid):
|
||||
MattermostEventAuthenticator.verify(token)
|
||||
14
engine/apps/mattermost/urls.py
Normal file
14
engine/apps/mattermost/urls.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from django.urls import include, path
|
||||
|
||||
from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
|
||||
|
||||
from .views import MattermostChannelViewSet, MattermostEventView
|
||||
|
||||
app_name = "mattermost"
|
||||
router = OptionalSlashRouter()
|
||||
router.register(r"channels", MattermostChannelViewSet, basename="channel")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
optional_slash_path("event", MattermostEventView.as_view(), name="incoming_mattermost_event"),
|
||||
]
|
||||
38
engine/apps/mattermost/utils.py
Normal file
38
engine/apps/mattermost/utils.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import datetime
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.mattermost.exceptions import MattermostEventTokenInvalid
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class MattermostEventAuthenticator:
|
||||
@staticmethod
|
||||
def create_token(organization: typing.Optional["Organization"]):
|
||||
secret = settings.MATTERMOST_SIGNING_SECRET
|
||||
expiration = timezone.now() + datetime.timedelta(days=30)
|
||||
payload = {
|
||||
"organization_id": organization.public_primary_key,
|
||||
"exp": expiration,
|
||||
}
|
||||
token = jwt.encode(payload, secret, algorithm="HS256")
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def verify(token: str):
|
||||
secret = settings.MATTERMOST_SIGNING_SECRET
|
||||
try:
|
||||
payload = jwt.decode(token, secret, algorithms="HS256")
|
||||
return payload
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.error(f"Error while verifying mattermost token {e}")
|
||||
raise MattermostEventTokenInvalid(msg="Invalid token from mattermost server")
|
||||
82
engine/apps/mattermost/views.py
Normal file
82
engine/apps/mattermost/views.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.mattermost.auth import MattermostEventAuthentication
|
||||
from apps.mattermost.events.event_manager import EventManager
|
||||
from apps.mattermost.models import MattermostChannel
|
||||
from apps.mattermost.serializers import MattermostChannelSerializer
|
||||
from common.api_helpers.mixins import PublicPrimaryKeyMixin
|
||||
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
||||
|
||||
|
||||
class MattermostChannelViewSet(
|
||||
PublicPrimaryKeyMixin[MattermostChannel],
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = (IsAuthenticated, RBACPermission)
|
||||
|
||||
rbac_permissions = {
|
||||
"list": [RBACPermission.Permissions.CHATOPS_READ],
|
||||
"retrieve": [RBACPermission.Permissions.CHATOPS_READ],
|
||||
"create": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS],
|
||||
"destroy": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS],
|
||||
"set_default": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS],
|
||||
}
|
||||
|
||||
serializer_class = MattermostChannelSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return MattermostChannel.objects.filter(organization=self.request.user.organization)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def set_default(self, request, pk):
|
||||
mattermost_channel = self.get_object()
|
||||
mattermost_channel.make_channel_default(request.user)
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
instance = serializer.instance
|
||||
write_chatops_insight_log(
|
||||
author=self.request.user,
|
||||
event_name=ChatOpsEvent.CHANNEL_CONNECTED,
|
||||
chatops_type=ChatOpsTypePlug.MATTERMOST.value,
|
||||
channel_name=instance.channel_name,
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
write_chatops_insight_log(
|
||||
author=self.request.user,
|
||||
event_name=ChatOpsEvent.CHANNEL_DISCONNECTED,
|
||||
chatops_type=ChatOpsTypePlug.MATTERMOST.value,
|
||||
channel_name=instance.channel_name,
|
||||
channel_id=instance.channel_id,
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
|
||||
class MattermostEventView(APIView):
|
||||
authentication_classes = (MattermostEventAuthentication,)
|
||||
permission_classes = (IsAuthenticated, RBACPermission)
|
||||
|
||||
rbac_permissions = {
|
||||
"post": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
|
||||
}
|
||||
|
||||
def get(self, request, format=None):
|
||||
return Response("hello")
|
||||
|
||||
def post(self, request):
|
||||
EventManager.process_request(request=request)
|
||||
return Response(status=200)
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from social_core.backends.google import GoogleOAuth2 as BaseGoogleOAuth2
|
||||
from social_core.backends.oauth import BaseOAuth2
|
||||
from social_core.backends.slack import SlackOAuth2
|
||||
from social_core.utils import handle_http_errors
|
||||
|
||||
from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME
|
||||
from apps.auth_token.models import GoogleOAuth2Token, SlackAuthToken
|
||||
from apps.auth_token.constants import MATTERMOST_AUTH_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
|
||||
from apps.auth_token.models import GoogleOAuth2Token, MattermostAuthToken, SlackAuthToken
|
||||
from apps.mattermost.client import MattermostClient
|
||||
from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid
|
||||
|
||||
from .exceptions import UserLoginOAuth2MattermostException
|
||||
|
||||
# Scopes for slack user token.
|
||||
# It is main purpose - retrieve user data in SlackOAuth2V2 but we are using it in legacy code or weird Slack api cases.
|
||||
|
|
@ -201,3 +207,76 @@ class InstallSlackOAuth2V2(SlackOAuth2V2):
|
|||
|
||||
def get_scope(self):
|
||||
return {"user_scope": USER_SCOPE, "scope": BOT_SCOPE}
|
||||
|
||||
|
||||
MATTERMOST_LOGIN_BACKEND = "mattermost-login"
|
||||
|
||||
|
||||
class LoginMattermostOAuth2(BaseOAuth2):
|
||||
name = MATTERMOST_LOGIN_BACKEND
|
||||
|
||||
REDIRECT_STATE = False
|
||||
"""
|
||||
Remove redirect state because we lose session during redirects
|
||||
"""
|
||||
|
||||
STATE_PARAMETER = False
|
||||
"""
|
||||
keep `False` to avoid having `BaseOAuth2` check the `state` query param against a session value
|
||||
"""
|
||||
|
||||
ACCESS_TOKEN_METHOD = "POST"
|
||||
AUTH_TOKEN_NAME = MATTERMOST_AUTH_TOKEN_NAME
|
||||
|
||||
def authorization_url(self):
|
||||
return f"{settings.MATTERMOST_HOST}/oauth/authorize"
|
||||
|
||||
def access_token_url(self):
|
||||
return f"{settings.MATTERMOST_HOST}/oauth/access_token"
|
||||
|
||||
def get_user_details(self, response):
|
||||
"""
|
||||
Return user details from Mattermost Account
|
||||
|
||||
Sample response
|
||||
{
|
||||
"access_token":"opoj5nbi6tyipdkjry8gc6tkqr",
|
||||
"token_type":"bearer",
|
||||
"expires_in":2553990,
|
||||
"scope":"",
|
||||
"refresh_token":"8gacxj3rwtr5mxczwred9xbmoh",
|
||||
"id_token":""
|
||||
}
|
||||
|
||||
"""
|
||||
return response
|
||||
|
||||
def user_data(self, access_token, *args, **kwargs):
|
||||
try:
|
||||
client = MattermostClient(token=access_token)
|
||||
user = client.get_user()
|
||||
except (MattermostAPITokenInvalid, MattermostAPIException) as ex:
|
||||
raise UserLoginOAuth2MattermostException(
|
||||
f"Error while trying to fetch mattermost user: {ex.msg} status: {ex.status}"
|
||||
)
|
||||
response = {}
|
||||
response["user"] = {}
|
||||
response["user"]["user_id"] = user.user_id
|
||||
response["user"]["username"] = user.username
|
||||
response["user"]["nickname"] = user.nickname
|
||||
return response
|
||||
|
||||
def auth_params(self, state=None):
|
||||
"""
|
||||
Override to generate `MattermostOAuth2Token` token to include as `state` query parameter.
|
||||
|
||||
https://developers.google.com/identity/protocols/oauth2/web-server#:~:text=Specifies%20any%20string%20value%20that%20your%20application%20uses%20to%20maintain%20state%20between%20your%20authorization%20request%20and%20the%20authorization%20server%27s%20response
|
||||
"""
|
||||
|
||||
params = super().auth_params(state)
|
||||
|
||||
_, token_string = MattermostAuthToken.create_auth_token(
|
||||
self.strategy.request.user, self.strategy.request.auth.organization
|
||||
)
|
||||
params["state"] = token_string
|
||||
return params
|
||||
|
|
|
|||
|
|
@ -2,4 +2,9 @@ class InstallMultiRegionSlackException(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class UserLoginOAuth2MattermostException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR = "missing_granted_scope"
|
||||
MATTERMOST_AUTH_FETCH_USER_ERROR = "failed_to_fetch_user"
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ class LiveSettingDjangoStrategy(DjangoStrategy):
|
|||
"""
|
||||
Overridden DjangoStrategy's method to substitute and force the host value from ENV
|
||||
"""
|
||||
if (
|
||||
settings.MATTERMOST_LOGIN_RETURN_REDIRECT_HOST is not None
|
||||
and path is not None
|
||||
and path == "/api/internal/v1/complete/mattermost-login/"
|
||||
):
|
||||
return create_engine_url(path, override_base=settings.MATTERMOST_LOGIN_RETURN_REDIRECT_HOST)
|
||||
if live_settings.SLACK_INSTALL_RETURN_REDIRECT_HOST is not None and path is not None:
|
||||
return create_engine_url(path, override_base=live_settings.SLACK_INSTALL_RETURN_REDIRECT_HOST)
|
||||
if self.request:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ from social_django.middleware import SocialAuthExceptionMiddleware
|
|||
|
||||
from apps.grafana_plugin.ui_url_builder import UIURLBuilder
|
||||
from apps.social_auth.backends import LoginSlackOAuth2V2
|
||||
from apps.social_auth.exceptions import InstallMultiRegionSlackException
|
||||
from apps.social_auth.exceptions import (
|
||||
MATTERMOST_AUTH_FETCH_USER_ERROR,
|
||||
InstallMultiRegionSlackException,
|
||||
UserLoginOAuth2MattermostException,
|
||||
)
|
||||
from common.constants.slack_auth import REDIRECT_AFTER_SLACK_INSTALL, SLACK_AUTH_FAILED, SLACK_REGION_ERROR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -43,3 +47,5 @@ class SocialAuthAuthCanceledExceptionMiddleware(SocialAuthExceptionMiddleware):
|
|||
return HttpResponse(status=status.HTTP_401_UNAUTHORIZED)
|
||||
elif isinstance(exception, InstallMultiRegionSlackException):
|
||||
return redirect(url_builder_function(f"?tab=Slack&slack_error={SLACK_REGION_ERROR}"))
|
||||
elif isinstance(exception, UserLoginOAuth2MattermostException):
|
||||
return redirect(url_builder_function(f"?mattermost_error={MATTERMOST_AUTH_FETCH_USER_ERROR}"))
|
||||
|
|
|
|||
|
|
@ -25,3 +25,7 @@ def set_user_and_organization_from_request(
|
|||
"user": user,
|
||||
"organization": organization,
|
||||
}
|
||||
|
||||
|
||||
def delete_auth_token(strategy, *args, **kwargs):
|
||||
strategy.request.auth.delete()
|
||||
|
|
|
|||
31
engine/apps/social_auth/pipeline/mattermost.py
Normal file
31
engine/apps/social_auth/pipeline/mattermost.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from apps.social_auth.backends import MATTERMOST_LOGIN_BACKEND
|
||||
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
|
||||
|
||||
|
||||
def connect_user_to_mattermost(response, backend, strategy, user, organization, *args, **kwargs):
|
||||
from apps.mattermost.models import MattermostUser
|
||||
|
||||
if backend.name != MATTERMOST_LOGIN_BACKEND:
|
||||
return
|
||||
|
||||
# at this point everything is correct and we can create the MattermostUser
|
||||
# be sure to clear any pre-existing sessions, in case the user previously enecountered errors we want
|
||||
# to be sure to clear these so they do not see them again
|
||||
strategy.session.flush()
|
||||
|
||||
MattermostUser.objects.get_or_create(
|
||||
user=user,
|
||||
mattermost_user_id=response["user"]["user_id"],
|
||||
defaults={
|
||||
"username": response["user"]["username"],
|
||||
"nickname": response["user"]["nickname"],
|
||||
},
|
||||
)
|
||||
|
||||
write_chatops_insight_log(
|
||||
author=user,
|
||||
event_name=ChatOpsEvent.USER_LINKED,
|
||||
chatops_type=ChatOpsTypePlug.MATTERMOST.value,
|
||||
linked_user=user.username,
|
||||
linked_user_id=user.public_primary_key,
|
||||
)
|
||||
|
|
@ -88,7 +88,3 @@ def populate_slack_identities(response, backend, user, organization, **kwargs):
|
|||
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):
|
||||
strategy.request.auth.delete()
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class ChatOpsTypePlug(enum.Enum):
|
|||
# ChatOpsTypePlug provides backend_id string for chatops integration not supporting messaging_backends.
|
||||
SLACK = "slack"
|
||||
TELEGRAM = "telegram"
|
||||
MATTERMOST = "mattermost"
|
||||
|
||||
|
||||
def write_chatops_insight_log(author: "User", event_name: ChatOpsEvent, chatops_type: str, **kwargs):
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ from apps.auth_token.models import (
|
|||
ApiAuthToken,
|
||||
GoogleOAuth2Token,
|
||||
IntegrationBacksyncAuthToken,
|
||||
MattermostAuthToken,
|
||||
PluginAuthToken,
|
||||
ServiceAccountToken,
|
||||
SlackAuthToken,
|
||||
|
|
@ -329,6 +330,14 @@ def make_slack_token_for_user():
|
|||
return _make_slack_token_for_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_mattermost_token_for_user():
|
||||
def _make_mattermost_token_for_user(user):
|
||||
return MattermostAuthToken.create_auth_token(organization=user.organization, user=user)
|
||||
|
||||
return _make_mattermost_token_for_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_google_oauth2_token_for_user():
|
||||
def _make_google_oauth2_token_for_user(user):
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
|
|||
path("slack/", include("apps.slack.urls")),
|
||||
]
|
||||
|
||||
if settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED:
|
||||
urlpatterns += [
|
||||
path("api/internal/v1/mattermost/", include("apps.mattermost.urls")),
|
||||
]
|
||||
|
||||
if settings.IS_OPEN_SOURCE:
|
||||
urlpatterns += [
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ charset-normalizer==3.3.2
|
|||
# requests
|
||||
distlib==0.3.8
|
||||
# via virtualenv
|
||||
django==4.2.19
|
||||
django==4.2.20
|
||||
# via
|
||||
# -c requirements.txt
|
||||
# django-stubs
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ babel==2.12.1
|
|||
beautifulsoup4==4.12.2
|
||||
celery[redis]==5.3.6
|
||||
cryptography==43.0.1
|
||||
django==4.2.19
|
||||
django==4.2.20
|
||||
django-add-default-value==0.10.0
|
||||
django-anymail[amazon-ses]==12.0
|
||||
django-cors-headers==3.7.0
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ deprecated==1.2.14
|
|||
# opentelemetry-api
|
||||
# opentelemetry-exporter-otlp-proto-grpc
|
||||
# opentelemetry-semantic-conventions
|
||||
django==4.2.19
|
||||
django==4.2.20
|
||||
# via
|
||||
# -r requirements.in
|
||||
# django-add-default-value
|
||||
|
|
|
|||
|
|
@ -691,6 +691,7 @@ SOCIAL_AUTH_STRATEGY = "apps.social_auth.live_setting_django_strategy.LiveSettin
|
|||
|
||||
# https://python-social-auth.readthedocs.io/en/latest/configuration/settings.html
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"apps.social_auth.backends.LoginMattermostOAuth2",
|
||||
"apps.social_auth.backends.InstallSlackOAuth2V2",
|
||||
"apps.social_auth.backends.LoginSlackOAuth2V2",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
|
|
@ -729,14 +730,30 @@ SLACK_IRM_ROOT_COMMAND = os.environ.get("SLACK_IRM_ROOT_COMMAND", "/grafana").ls
|
|||
# Controls if slack integration can be installed/uninstalled.
|
||||
SLACK_INTEGRATION_MAINTENANCE_ENABLED = os.environ.get("SLACK_INTEGRATION_MAINTENANCE_ENABLED", False)
|
||||
|
||||
# Mattermost
|
||||
FEATURE_MATTERMOST_INTEGRATION_ENABLED = getenv_boolean("FEATURE_MATTERMOST_INTEGRATION_ENABLED", default=False)
|
||||
MATTERMOST_CLIENT_OAUTH_ID = os.environ.get("MATTERMOST_CLIENT_OAUTH_ID")
|
||||
MATTERMOST_CLIENT_OAUTH_SECRET = os.environ.get("MATTERMOST_CLIENT_OAUTH_SECRET")
|
||||
MATTERMOST_HOST = os.environ.get("MATTERMOST_HOST")
|
||||
MATTERMOST_BOT_TOKEN = os.environ.get("MATTERMOST_BOT_TOKEN")
|
||||
MATTERMOST_LOGIN_RETURN_REDIRECT_HOST = os.environ.get("MATTERMOST_LOGIN_RETURN_REDIRECT_HOST", None)
|
||||
MATTERMOST_SIGNING_SECRET = os.environ.get("MATTERMOST_SIGNING_SECRET", None)
|
||||
|
||||
if FEATURE_MATTERMOST_INTEGRATION_ENABLED:
|
||||
INSTALLED_APPS += ["apps.mattermost"]
|
||||
|
||||
SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID
|
||||
SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET
|
||||
SOCIAL_AUTH_MATTERMOST_LOGIN_KEY = MATTERMOST_CLIENT_OAUTH_ID
|
||||
SOCIAL_AUTH_MATTERMOST_LOGIN_SECRET = MATTERMOST_CLIENT_OAUTH_SECRET
|
||||
|
||||
SOCIAL_AUTH_SETTING_NAME_TO_LIVE_SETTING_NAME = {
|
||||
"SOCIAL_AUTH_SLACK_LOGIN_KEY": "SLACK_CLIENT_OAUTH_ID",
|
||||
"SOCIAL_AUTH_SLACK_LOGIN_SECRET": "SLACK_CLIENT_OAUTH_SECRET",
|
||||
"SOCIAL_AUTH_SLACK_INSTALL_FREE_KEY": "SLACK_CLIENT_OAUTH_ID",
|
||||
"SOCIAL_AUTH_SLACK_INSTALL_FREE_SECRET": "SLACK_CLIENT_OAUTH_SECRET",
|
||||
"SOCIAL_AUTH_MATTERMOST_LOGIN_KEY": "MATTERMOST_CLIENT_OAUTH_ID",
|
||||
"SOCIAL_AUTH_MATTERMOST_LOGIN_SECRET": "MATTERMOST_CLIENT_OAUTH_SECRET",
|
||||
}
|
||||
SOCIAL_AUTH_SLACK_INSTALL_FREE_CUSTOM_SCOPE = [
|
||||
"bot",
|
||||
|
|
@ -752,7 +769,8 @@ SOCIAL_AUTH_PIPELINE = (
|
|||
"social_core.pipeline.social_auth.social_details",
|
||||
"apps.social_auth.pipeline.slack.connect_user_to_slack",
|
||||
"apps.social_auth.pipeline.slack.populate_slack_identities",
|
||||
"apps.social_auth.pipeline.slack.delete_slack_auth_token",
|
||||
"apps.social_auth.pipeline.mattermost.connect_user_to_mattermost",
|
||||
"apps.social_auth.pipeline.common.delete_auth_token",
|
||||
)
|
||||
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_PIPELINE = (
|
||||
|
|
@ -864,6 +882,10 @@ EMAIL_BACKEND_INTERNAL_ID = 8
|
|||
if FEATURE_EMAIL_INTEGRATION_ENABLED:
|
||||
EXTRA_MESSAGING_BACKENDS += [("apps.email.backend.EmailBackend", EMAIL_BACKEND_INTERNAL_ID)]
|
||||
|
||||
MATTERMOST_BACKEND_INTERNAL_ID = 9
|
||||
if FEATURE_MATTERMOST_INTEGRATION_ENABLED:
|
||||
EXTRA_MESSAGING_BACKENDS += [("apps.mattermost.backend.MattermostBackend", MATTERMOST_BACKEND_INTERNAL_ID)]
|
||||
|
||||
# Inbound email settings
|
||||
INBOUND_EMAIL_ESP = os.getenv("INBOUND_EMAIL_ESP")
|
||||
INBOUND_EMAIL_DOMAIN = os.getenv("INBOUND_EMAIL_DOMAIN")
|
||||
|
|
|
|||
|
|
@ -189,4 +189,8 @@ CELERY_TASK_ROUTES = {
|
|||
"apps.webhooks.tasks.alert_group_status.alert_group_created": {"queue": "webhook"},
|
||||
"apps.webhooks.tasks.alert_group_status.alert_group_status_change": {"queue": "webhook"},
|
||||
"apps.webhooks.tasks.notify_user.notify_user_async": {"queue": "webhook"},
|
||||
# MATTERMOST
|
||||
"apps.mattermost.tasks.on_create_alert_async": {"queue": "mattermost"},
|
||||
"apps.mattermost.tasks.on_alert_group_action_triggered_async": {"queue": "mattermost"},
|
||||
"apps.mattermost.tasks.notify_user_about_alert_async": {"queue": "mattermost"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,3 +57,10 @@ EXTRA_MESSAGING_BACKENDS = [("apps.base.tests.messaging_backend.TestOnlyBackend"
|
|||
# File "/usr/local/lib/python3.12/site-packages/silk/model_factory.py", line 243, in construct_request_model
|
||||
# request_model = models.Request.objects.create(
|
||||
SILK_PROFILER_ENABLED = False
|
||||
|
||||
FEATURE_MATTERMOST_INTEGRATION_ENABLED = True
|
||||
if FEATURE_MATTERMOST_INTEGRATION_ENABLED:
|
||||
INSTALLED_APPS += ["apps.mattermost"]
|
||||
MATTERMOST_HOST = "http://localhost:8065"
|
||||
MATTERMOST_BOT_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX"
|
||||
MATTERMOST_SIGNING_SECRET = "f0cb4953bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d7284a6"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ SWAGGER_SETTINGS = {
|
|||
if TESTING:
|
||||
EXTRA_MESSAGING_BACKENDS = [("apps.base.tests.messaging_backend.TestOnlyBackend", 42)]
|
||||
TELEGRAM_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX"
|
||||
MATTERMOST_HOST = "http://localhost:8065"
|
||||
MATTERMOST_BOT_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX"
|
||||
MATTERMOST_SIGNING_SECRET = "f0cb4953bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d7284a6"
|
||||
|
||||
TWILIO_AUTH_TOKEN = "twilio_auth_token"
|
||||
|
||||
# charset/collation related tests don't work without this
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
import { merge } from 'lodash-es'
|
||||
|
||||
import { AppFeature } from 'state/features';
|
||||
|
||||
import { TemplateForEdit, commonTemplateForEdit } from './CommonAlertTemplatesForm.config';
|
||||
|
||||
export const getTemplatesForEdit = (features: Record<string, boolean>) => {
|
||||
const templatesForEdit = {...commonTemplateForEdit}
|
||||
if (features?.[AppFeature.MsTeams]) {
|
||||
return { ...commonTemplateForEdit, ...additionalTemplateForEdit };
|
||||
merge(templatesForEdit, msteamsTemplateForEdit)
|
||||
}
|
||||
return commonTemplateForEdit;
|
||||
if (features?.[AppFeature.Mattermost]) {
|
||||
merge(templatesForEdit, mattermostTemplateForEdit)
|
||||
}
|
||||
return templatesForEdit;
|
||||
};
|
||||
|
||||
const additionalTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
||||
const msteamsTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
||||
msteams_title_template: {
|
||||
name: 'msteams_title_template',
|
||||
displayName: 'MS Teams title',
|
||||
|
|
@ -42,4 +48,37 @@ const additionalTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
|||
},
|
||||
};
|
||||
|
||||
const mattermostTemplateForEdit: { [id: string]: TemplateForEdit } = {
|
||||
mattermost_title_template: {
|
||||
name: 'mattermost_title_template',
|
||||
displayName: 'Mattermost title',
|
||||
description: '',
|
||||
additionalData: {
|
||||
chatOpsName: 'mattermost',
|
||||
chatOpsDisplayName: 'Mattermost',
|
||||
},
|
||||
type: 'plain',
|
||||
},
|
||||
mattermost_message_template: {
|
||||
name: 'mattermost_message_template',
|
||||
displayName: 'Mattermost message',
|
||||
description: '',
|
||||
additionalData: {
|
||||
chatOpsName: 'mattermost',
|
||||
chatOpsDisplayName: 'Mattermost',
|
||||
},
|
||||
type: 'plain',
|
||||
},
|
||||
mattermost_image_url_template: {
|
||||
name: 'mattermost_image_url_template',
|
||||
displayName: 'Mattermost image url',
|
||||
description: '',
|
||||
additionalData: {
|
||||
chatOpsName: 'mattermost',
|
||||
chatOpsDisplayName: 'Mattermost',
|
||||
},
|
||||
type: 'plain',
|
||||
},
|
||||
};
|
||||
|
||||
export const FORM_NAME = 'AlertTemplates';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Stack, useTheme2 } from '@grafana/ui';
|
|||
|
||||
import { Timeline } from 'components/Timeline/Timeline';
|
||||
import { MSTeamsConnector } from 'containers/AlertRules/parts/connectors/MSTeamsConnector';
|
||||
import { MattermostConnector } from 'containers/AlertRules/parts/connectors/MattermostConnector';
|
||||
import { SlackConnector } from 'containers/AlertRules/parts/connectors/SlackConnector';
|
||||
import { TelegramConnector } from 'containers/AlertRules/parts/connectors/TelegramConnector';
|
||||
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
|
||||
|
|
@ -20,7 +21,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
|
|||
|
||||
const store = useStore();
|
||||
const theme = useTheme2();
|
||||
const { organizationStore, telegramChannelStore, msteamsChannelStore } = store;
|
||||
const { organizationStore, telegramChannelStore, msteamsChannelStore, mattermostChannelStore } = store;
|
||||
|
||||
const isSlackInstalled = Boolean(organizationStore.currentOrganization?.slack_team_identity);
|
||||
const isTelegramInstalled =
|
||||
|
|
@ -28,11 +29,13 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
|
|||
|
||||
useEffect(() => {
|
||||
msteamsChannelStore.updateMSTeamsChannels();
|
||||
mattermostChannelStore.updateMattermostChannels();
|
||||
}, []);
|
||||
|
||||
const isMSTeamsInstalled = msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0;
|
||||
const isMattermostInstalled = store.hasFeature(AppFeature.Mattermost) && Object.keys(mattermostChannelStore.items).length > 0;
|
||||
|
||||
if (!isSlackInstalled && !isTelegramInstalled && !isMSTeamsInstalled) {
|
||||
if (!isSlackInstalled && !isTelegramInstalled && !isMSTeamsInstalled && !isMattermostInstalled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +45,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => {
|
|||
{isSlackInstalled && <SlackConnector channelFilterId={channelFilterId} />}
|
||||
{isTelegramInstalled && <TelegramConnector channelFilterId={channelFilterId} />}
|
||||
{isMSTeamsInstalled && <MSTeamsConnector channelFilterId={channelFilterId} />}
|
||||
{isMattermostInstalled && <MattermostConnector channelFilterId={channelFilterId}/>}
|
||||
</Stack>
|
||||
</Timeline.Item>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { cx } from '@emotion/css';
|
||||
import { InlineSwitch, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { UserActions } from 'helpers/authorization/authorization';
|
||||
import { StackSize } from 'helpers/consts';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { GSelect } from 'containers/GSelect/GSelect';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
|
||||
import { MattermostChannel } from 'models/mattermost/mattermost.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
import { getConnectorsStyles } from './Connectors.styles';
|
||||
|
||||
interface MattermostConnectorProps {
|
||||
channelFilterId: ChannelFilter['id'];
|
||||
}
|
||||
|
||||
export const MattermostConnector = observer((props: MattermostConnectorProps) => {
|
||||
const { channelFilterId } = props;
|
||||
|
||||
const store = useStore();
|
||||
const styles = useStyles2(getConnectorsStyles);
|
||||
|
||||
const {
|
||||
alertReceiveChannelStore,
|
||||
mattermostChannelStore,
|
||||
// dereferencing items is needed to rerender GSelect
|
||||
mattermostChannelStore: { items: mattermostChannelItems },
|
||||
} = store;
|
||||
|
||||
const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId];
|
||||
|
||||
const handleMattermostChannelChange = useCallback((_value: MattermostChannel['id'], mattermostChannel: MattermostChannel) => {
|
||||
alertReceiveChannelStore.saveChannelFilter(channelFilterId, {
|
||||
notification_backends: {
|
||||
MATTERMOST: { channel: mattermostChannel?.id || null },
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleChannelFilterNotifyInMattermostChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
alertReceiveChannelStore.saveChannelFilter(channelFilterId, {
|
||||
notification_backends: { MATTERMOST: { enabled: event.target.checked } },
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Stack wrap="wrap" gap={StackSize.sm}>
|
||||
<div>
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<InlineSwitch
|
||||
value={channelFilter.notification_backends?.MATTERMOST?.enabled}
|
||||
onChange={handleChannelFilterNotifyInMattermostChange}
|
||||
transparent
|
||||
/>
|
||||
</WithPermissionControlTooltip>
|
||||
</div>
|
||||
Post to Mattermost channel
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<GSelect<MattermostChannel>
|
||||
allowClear
|
||||
className={cx('select', 'control')}
|
||||
items={mattermostChannelItems}
|
||||
fetchItemsFn={mattermostChannelStore.updateItems}
|
||||
fetchItemFn={mattermostChannelStore.updateById}
|
||||
getSearchResult={mattermostChannelStore.getSearchResult}
|
||||
displayField="display_name"
|
||||
valueField="id"
|
||||
placeholder="Select Mattermost Channel"
|
||||
value={channelFilter.notification_backends?.MATTERMOST?.channel}
|
||||
onChange={handleMattermostChannelChange}
|
||||
/>
|
||||
</WithPermissionControlTooltip>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { clone } from 'lodash-es'
|
||||
|
||||
import { MONACO_INPUT_HEIGHT_SMALL, MONACO_INPUT_HEIGHT_TALL } from 'pages/integration/IntegrationCommon.config';
|
||||
import { AppFeature } from 'state/features';
|
||||
|
||||
|
|
@ -24,11 +26,35 @@ const additionalTemplatesToRender: TemplateBlock[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Mattermost',
|
||||
contents: [
|
||||
{
|
||||
name: 'mattermost_title_template',
|
||||
label: 'Title',
|
||||
height: MONACO_INPUT_HEIGHT_SMALL,
|
||||
},
|
||||
{
|
||||
name: 'mattermost_message_template',
|
||||
label: 'Message',
|
||||
height: MONACO_INPUT_HEIGHT_TALL,
|
||||
},
|
||||
{
|
||||
name: 'mattermost_image_url_template',
|
||||
label: 'Image',
|
||||
height: MONACO_INPUT_HEIGHT_SMALL,
|
||||
},
|
||||
],
|
||||
}
|
||||
];
|
||||
|
||||
export const getTemplatesToRender = (features?: Record<string, boolean>) => {
|
||||
const templatesToRender = clone(commonTemplatesToRender)
|
||||
if (features?.[AppFeature.MsTeams]) {
|
||||
return commonTemplatesToRender.concat(additionalTemplatesToRender);
|
||||
templatesToRender.push(additionalTemplatesToRender[0]);
|
||||
}
|
||||
return commonTemplatesToRender;
|
||||
if (features?.[AppFeature.Mattermost]) {
|
||||
templatesToRender.push(additionalTemplatesToRender[1])
|
||||
}
|
||||
return templatesToRender;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { Button, Modal, Field, Input, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { UserActions } from 'helpers/authorization/authorization';
|
||||
import { openErrorNotification } from 'helpers/helpers';
|
||||
import { get } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
interface MattermostIntegrationProps {
|
||||
disabled?: boolean;
|
||||
size?: 'md' | 'lg';
|
||||
onUpdate: () => void;
|
||||
}
|
||||
export const MattermostIntegrationButton = observer((props: MattermostIntegrationProps) => {
|
||||
const { disabled, size = 'md', onUpdate } = props;
|
||||
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
|
||||
const onModalCreateCallback = useCallback(() => {
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const onModalCancelCallback = useCallback(() => {
|
||||
setShowModal(false);
|
||||
}, []);
|
||||
|
||||
const onModalUpdateCallback = useCallback(() => {
|
||||
setShowModal(false);
|
||||
|
||||
onUpdate();
|
||||
}, [onUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<Button size={size} variant="primary" icon="plus" disabled={disabled} onClick={onModalCreateCallback}>
|
||||
Add Mattermost channel
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
{showModal && <MattermostChannelForm onHide={onModalCancelCallback} onUpdate={onModalUpdateCallback} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface MattermostCreationModalProps {
|
||||
onHide: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
interface FormFields {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
const MattermostChannelForm = (props: MattermostCreationModalProps) => {
|
||||
const { onHide, onUpdate } = props;
|
||||
const store = useStore();
|
||||
|
||||
const formMethods = useForm<FormFields>({
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
} = formMethods;
|
||||
|
||||
const channelId = watch('channelId');
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Modal title="Add Mattermost Channel" isOpen closeOnEscape={false} onDismiss={onUpdate}>
|
||||
<FormProvider {...formMethods}>
|
||||
<form onSubmit={handleSubmit(onCreateChannelCallback)}>
|
||||
<Stack direction="column">
|
||||
{renderChannelIdInput()}
|
||||
<Stack justifyContent="flex-end">
|
||||
<Button variant="secondary" onClick={() => onHide()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!channelId} variant="primary">
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
function renderChannelIdInput() {
|
||||
return (
|
||||
<Controller
|
||||
name="channelId"
|
||||
control={control}
|
||||
rules={{ required: 'Channel Id is required' }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Mattermost Channel ID"
|
||||
invalid={Boolean(errors['channelId'])}
|
||||
error={errors['channelId']?.message}
|
||||
className={styles.field}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
className={styles.channelFormFieldInput}
|
||||
maxLength={50}
|
||||
placeholder="Enter Mattermost Channel ID"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function onCreateChannelCallback() {
|
||||
try {
|
||||
await store.mattermostChannelStore.create({ channel_id: channelId }, true);
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
openErrorNotification(get(error, 'response.data.detail', 'error creating channel'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
channelFormFieldInput: css `
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
`,
|
||||
|
||||
field: css `
|
||||
flex-grow: 1;
|
||||
`
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,10 @@ enum GoogleError {
|
|||
MISSING_GRANTED_SCOPE = 'missing_granted_scope',
|
||||
}
|
||||
|
||||
enum MattermostError {
|
||||
MATTERMOST_AUTH_FETCH_USER_ERROR = 'failed_to_fetch_user',
|
||||
}
|
||||
|
||||
interface UserFormProps {
|
||||
onHide: () => void;
|
||||
id: ApiSchemas['User']['pk'] | 'new';
|
||||
|
|
@ -41,9 +45,23 @@ function getGoogleMessage(googleError: GoogleError) {
|
|||
return <>Couldn't connect your Google account.</>;
|
||||
}
|
||||
|
||||
function getMattermostErrorMessage(mattermostError: MattermostError) {
|
||||
if (mattermostError === MattermostError.MATTERMOST_AUTH_FETCH_USER_ERROR) {
|
||||
return (
|
||||
<>
|
||||
Couldn't connect your Mattermost account. Failed to fetch user information from your mattermost server. Please
|
||||
check your mattermost ENV variable values and retry.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <>Couldn't connect your Mattermost account.</>;
|
||||
}
|
||||
|
||||
const UserAlerts: React.FC = () => {
|
||||
const queryParams = useQueryParams();
|
||||
const [showGoogleConnectAlert, setShowGoogleConnectAlert] = useState<GoogleError | undefined>();
|
||||
const [showMattermostConnectAlert, setshowMattermostConnectAlert] = useState<MattermostError | undefined>();
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
|
|
@ -51,18 +69,41 @@ const UserAlerts: React.FC = () => {
|
|||
setShowGoogleConnectAlert(undefined);
|
||||
}, []);
|
||||
|
||||
const handleCloseMattermostAlert = useCallback(() => {
|
||||
setshowMattermostConnectAlert(undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryParams.get('google_error')) {
|
||||
setShowGoogleConnectAlert(queryParams.get('google_error') as GoogleError);
|
||||
|
||||
LocationHelper.update({ google_error: undefined }, 'partial');
|
||||
} else if (queryParams.get('mattermost_error')) {
|
||||
setshowMattermostConnectAlert(queryParams.get('mattermost_error') as MattermostError);
|
||||
|
||||
LocationHelper.update({ mattermost_error: undefined }, 'partial');
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!showGoogleConnectAlert) {
|
||||
if (!showGoogleConnectAlert && !showMattermostConnectAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showMattermostConnectAlert) {
|
||||
return (
|
||||
<div className={cx('alerts-container')}>
|
||||
<Alert
|
||||
className={cx('alert')}
|
||||
onRemove={handleCloseMattermostAlert}
|
||||
severity="error"
|
||||
title="Mattermost integration error"
|
||||
>
|
||||
{getMattermostErrorMessage(showMattermostConnectAlert)}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.alertsContainer}>
|
||||
<Alert
|
||||
|
|
@ -110,6 +151,7 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn
|
|||
showMobileAppConnectionTab,
|
||||
showMsTeamsConnectionTab,
|
||||
showGoogleCalendarTab,
|
||||
showMattermostConnectionTab,
|
||||
] = [
|
||||
!isDesktopOrLaptop,
|
||||
isCurrent && organizationStore.currentOrganization?.slack_team_identity && !storeUser.slack_user_identity,
|
||||
|
|
@ -118,6 +160,7 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn
|
|||
isCurrent,
|
||||
store.hasFeature(AppFeature.MsTeams) && !storeUser.messaging_backends.MSTEAMS,
|
||||
isCurrent && store.hasFeature(AppFeature.GoogleOauth2),
|
||||
isCurrent && store.hasFeature(AppFeature.Mattermost) && !storeUser.messaging_backends.MATTERMOST,
|
||||
];
|
||||
|
||||
const title = (
|
||||
|
|
@ -147,6 +190,7 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn
|
|||
showMobileAppConnectionTab={showMobileAppConnectionTab}
|
||||
showMsTeamsConnectionTab={showMsTeamsConnectionTab}
|
||||
showGoogleCalendarTab={showGoogleCalendarTab}
|
||||
showMattermostConnectionTab={showMattermostConnectionTab}
|
||||
/>
|
||||
<TabsContent id={id} activeTab={activeTab} onTabChange={onTabChange} isDesktopOrLaptop={isDesktopOrLaptop} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ export enum UserSettingsTab {
|
|||
PersonalWebhookInfo,
|
||||
MSTeamsInfo,
|
||||
MobileAppConnection,
|
||||
MattermostInfo,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab'
|
|||
import { CloudPhoneSettings } from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings';
|
||||
import { GoogleCalendar } from 'containers/UserSettings/parts/tabs/GoogleCalendar/GoogleCalendar';
|
||||
import { MSTeamsInfo } from 'containers/UserSettings/parts/tabs/MSTeamsInfo/MSTeamsInfo';
|
||||
import { MattermostInfo } from 'containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo';
|
||||
import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab';
|
||||
import { PersonalWebhookInfo } from 'containers/UserSettings/parts/tabs/PersonalWebhookInfo/PersonalWebhookInfo';
|
||||
import { PhoneVerification } from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification';
|
||||
|
|
@ -32,6 +33,7 @@ interface TabsProps {
|
|||
showTelegramConnectionTab: boolean;
|
||||
showPersonalWebhookConnectionTab: boolean;
|
||||
showMsTeamsConnectionTab: boolean;
|
||||
showMattermostConnectionTab: boolean;
|
||||
}
|
||||
|
||||
export const Tabs = ({
|
||||
|
|
@ -44,6 +46,7 @@ export const Tabs = ({
|
|||
showTelegramConnectionTab,
|
||||
showPersonalWebhookConnectionTab,
|
||||
showMsTeamsConnectionTab,
|
||||
showMattermostConnectionTab,
|
||||
}: TabsProps) => {
|
||||
const getTabClickHandler = useCallback(
|
||||
(tab: UserSettingsTab) => {
|
||||
|
|
@ -133,6 +136,15 @@ export const Tabs = ({
|
|||
data-testid="tab-msteams"
|
||||
/>
|
||||
)}
|
||||
{showMattermostConnectionTab && (
|
||||
<Tab
|
||||
active={activeTab === UserSettingsTab.MattermostInfo}
|
||||
label="Mattermost Connection"
|
||||
key={UserSettingsTab.MattermostInfo}
|
||||
onChangeTab={getTabClickHandler(UserSettingsTab.MattermostInfo)}
|
||||
data-testid="tab-mattermost"
|
||||
/>
|
||||
)}
|
||||
</TabsBar>
|
||||
);
|
||||
};
|
||||
|
|
@ -180,6 +192,7 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
|
|||
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}
|
||||
{activeTab === UserSettingsTab.PersonalWebhookInfo && <PersonalWebhookInfo />}
|
||||
{activeTab === UserSettingsTab.MSTeamsInfo && <MSTeamsInfo />}
|
||||
{activeTab === UserSettingsTab.MattermostInfo && <MattermostInfo />}
|
||||
</TabContent>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useStore } from 'state/useStore';
|
|||
|
||||
import { ICalConnector } from './ICalConnector';
|
||||
import { MSTeamsConnector } from './MSTeamsConnector';
|
||||
import { MattermostConnector } from './MattermostConnector';
|
||||
import { MobileAppConnector } from './MobileAppConnector';
|
||||
import { PersonalWebhookConnector } from './PersonalWebhookConnector';
|
||||
import { PhoneConnector } from './PhoneConnector';
|
||||
|
|
@ -30,6 +31,7 @@ export const Connectors: FC<ConnectorsProps> = observer((props) => {
|
|||
<SlackConnector {...props} />
|
||||
{store.hasFeature(AppFeature.Telegram) && <TelegramConnector {...props} />}
|
||||
{store.hasFeature(AppFeature.PersonalWebhook) && <PersonalWebhookConnector {...props} />}
|
||||
{store.hasFeature(AppFeature.Mattermost) && <MattermostConnector {...props} />}
|
||||
{store.hasFeature(AppFeature.MsTeams) && <MSTeamsConnector {...props} />}
|
||||
<Legend>Calendar export</Legend>
|
||||
<ICalConnector {...props} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { Button, InlineField, Input, Stack } from '@grafana/ui';
|
||||
import { StackSize } from 'helpers/consts';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
|
||||
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
interface MattermostConnectorProps {
|
||||
id: ApiSchemas['User']['pk'];
|
||||
onTabChange: (tab: UserSettingsTab) => void;
|
||||
}
|
||||
export const MattermostConnector = observer((props: MattermostConnectorProps) => {
|
||||
const { id, onTabChange } = props;
|
||||
|
||||
const store = useStore();
|
||||
const { userStore } = store;
|
||||
|
||||
const storeUser = userStore.items[id];
|
||||
|
||||
const isCurrentUser = id === store.userStore.currentUserPk;
|
||||
|
||||
const handleConnectButtonClick = useCallback(() => {
|
||||
onTabChange(UserSettingsTab.MattermostInfo);
|
||||
}, []);
|
||||
|
||||
const handleUnlinkMattermostAccount = useCallback(() => {
|
||||
userStore.unlinkBackend(id, 'MATTERMOST');
|
||||
}, []);
|
||||
|
||||
const mattermostConfigured = storeUser.messaging_backends['MATTERMOST'];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{storeUser.messaging_backends.MATTERMOST ? (
|
||||
<InlineField label="Mattermost" labelWidth={12}>
|
||||
<Stack gap={StackSize.xs}>
|
||||
<Input disabled={true} value={mattermostConfigured?.username ? '@' + mattermostConfigured?.username : ''} />
|
||||
<WithConfirm title="Are you sure to disconnect your Mattermost account?" confirmText="Disconnect">
|
||||
<Button
|
||||
disabled={!isCurrentUser}
|
||||
variant="destructive"
|
||||
icon="times"
|
||||
onClick={handleUnlinkMattermostAccount}
|
||||
tooltip={'Unlink Mattermost Account'}
|
||||
/>
|
||||
</WithConfirm>
|
||||
</Stack>
|
||||
</InlineField>
|
||||
) : (
|
||||
<div>
|
||||
<InlineField label="Mattermost" labelWidth={12} disabled={!isCurrentUser}>
|
||||
<Button onClick={handleConnectButtonClick}>Connect account</Button>
|
||||
</InlineField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { Button, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { UserActions } from 'helpers/authorization/authorization';
|
||||
import { DOCS_MATTERMOST_SETUP, StackSize } from 'helpers/consts';
|
||||
|
||||
import { Block } from 'components/GBlock/Block';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
export const MattermostInfo = () => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { mattermostStore } = useStore();
|
||||
|
||||
const handleClickConnectMattermostAccount = useCallback(() => {
|
||||
mattermostStore.mattermostLogin();
|
||||
}, [mattermostStore]);
|
||||
|
||||
return (
|
||||
<WithPermissionControlDisplay userAction={UserActions.UserSettingsWrite}>
|
||||
<Stack direction="column" gap={StackSize.lg}>
|
||||
<Block bordered withBackground className={styles.mattermostInfoblock}>
|
||||
<Stack direction="column" alignItems="center" gap={StackSize.lg}>
|
||||
<Text>
|
||||
Personal Mattermost connection will allow you to manage alert groups in your connected Mattermost channel
|
||||
</Text>
|
||||
<Text>To link your Mattermost account, click the button below and login to your server</Text>
|
||||
|
||||
<Text type="secondary">
|
||||
More details in{' '}
|
||||
<a href={DOCS_MATTERMOST_SETUP} target="_blank" rel="noreferrer">
|
||||
<Text type="link">our documentation</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Block>
|
||||
<Stack gap={StackSize.xs} alignItems="center">
|
||||
<Button onClick={handleClickConnectMattermostAccount} icon="external-link-alt">
|
||||
Open Mattermost connection page
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</WithPermissionControlDisplay>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
mattermostInfoblock: css`
|
||||
text-align: center;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -99,6 +99,7 @@ export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/oncall/latest/notif
|
|||
export const DOCS_SERVICE_ACCOUNTS = 'https://grafana.com/docs/grafana/latest/administration/service-accounts/';
|
||||
export const DOCS_ONCALL_OSS_INSTALL =
|
||||
'https://grafana.com/docs/oncall/latest/set-up/open-source/#install-grafana-oncall-oss';
|
||||
export const DOCS_MATTERMOST_SETUP = 'https://grafana.com/docs/oncall/latest/manage/notify/mattermost/';
|
||||
|
||||
export const generateAssignToTeamInputDescription = (objectName: string): string =>
|
||||
`Assigning to a team allows you to filter ${objectName} and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details.`;
|
||||
|
|
|
|||
30
grafana-plugin/src/models/mattermost/mattermost.ts
Normal file
30
grafana-plugin/src/models/mattermost/mattermost.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { GENERIC_ERROR } from 'helpers/consts';
|
||||
import { openErrorNotification } from 'helpers/helpers';
|
||||
import { makeObservable } from 'mobx';
|
||||
|
||||
import { BaseStore } from 'models/base_store';
|
||||
import { makeRequestRaw } from 'network/network';
|
||||
import { RootStore } from 'state/rootStore';
|
||||
|
||||
export class MattermostStore extends BaseStore {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
async mattermostLogin() {
|
||||
try {
|
||||
const response = await makeRequestRaw('/login/mattermost-login/', {});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
grafana-plugin/src/models/mattermost/mattermost.types.ts
Normal file
7
grafana-plugin/src/models/mattermost/mattermost.types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export interface MattermostChannel {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
display_name: string;
|
||||
is_default_channel: false;
|
||||
}
|
||||
101
grafana-plugin/src/models/mattermost/mattermost_channel.ts
Normal file
101
grafana-plugin/src/models/mattermost/mattermost_channel.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { action, observable, makeObservable, runInAction } from 'mobx';
|
||||
|
||||
import { BaseStore } from 'models/base_store';
|
||||
import { makeRequest } from 'network/network';
|
||||
import { RootStore } from 'state/rootStore';
|
||||
|
||||
import { MattermostChannel } from './mattermost.types';
|
||||
|
||||
export class MattermostChannelStore extends BaseStore {
|
||||
@observable.shallow
|
||||
items: { [id: string]: MattermostChannel } = {};
|
||||
|
||||
@observable.shallow
|
||||
searchResult: { [key: string]: Array<MattermostChannel['id']> } = {};
|
||||
|
||||
private autoUpdateTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
makeObservable(this);
|
||||
|
||||
this.path = '/mattermost/channels/';
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async updateMattermostChannels() {
|
||||
const response = await makeRequest<MattermostChannel[]>(this.path, {});
|
||||
|
||||
const items = response.reduce(
|
||||
(acc: any, mattermostChannel: MattermostChannel) => ({
|
||||
...acc,
|
||||
[mattermostChannel.id]: mattermostChannel,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.items = {
|
||||
...this.items,
|
||||
...items,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async updateById(id: MattermostChannel['id']) {
|
||||
const response = await this.getById(id);
|
||||
|
||||
runInAction(() => {
|
||||
this.items = {
|
||||
...this.items,
|
||||
[id]: response,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async updateItems(query = '') {
|
||||
const result = await this.getAll();
|
||||
|
||||
runInAction(() => {
|
||||
this.items = {
|
||||
...this.items,
|
||||
...result.reduce(
|
||||
(acc: { [key: number]: MattermostChannel }, item: MattermostChannel) => ({
|
||||
...acc,
|
||||
[item.id]: item,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
};
|
||||
|
||||
this.searchResult = {
|
||||
...this.searchResult,
|
||||
[query]: result.map((item: MattermostChannel) => item.id),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getSearchResult = (query = '') => {
|
||||
if (!this.searchResult[query]) {
|
||||
return undefined;
|
||||
}
|
||||
return this.searchResult[query].map(
|
||||
(mattermostChannelId: MattermostChannel['id']) => this.items[mattermostChannelId]
|
||||
);
|
||||
};
|
||||
|
||||
@action.bound
|
||||
async makeMattermostChannelDefault(id: MattermostChannel['id']) {
|
||||
return makeRequest(`/mattermost/channels/${id}/set_default`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMattermostChannel(id: MattermostChannel['id']) {
|
||||
return super.delete(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -29,5 +29,6 @@ export interface Organization {
|
|||
verification_call: boolean;
|
||||
verification_sms: boolean;
|
||||
};
|
||||
mattermost_configured: boolean;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,8 +68,9 @@ export const IntegrationHelper = {
|
|||
const hasTelegram =
|
||||
store.hasFeature(AppFeature.Telegram) && store.telegramChannelStore.currentTeamToTelegramChannel?.length > 0;
|
||||
const isMSTeamsInstalled = Boolean(store.msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0);
|
||||
const isMattermostInstalled = Object.keys(store.mattermostChannelStore.items).length > 0;
|
||||
|
||||
return hasSlack || hasTelegram || isMSTeamsInstalled;
|
||||
return hasSlack || hasTelegram || isMSTeamsInstalled || isMattermostInstalled;
|
||||
},
|
||||
|
||||
getChatOpsChannels(channelFilter: ChannelFilter, store: RootStore): Array<{ name: string; icon: IconName }> {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { observer } from 'mobx-react';
|
|||
|
||||
import { VerticalTabsBar, VerticalTab } from 'components/VerticalTabsBar/VerticalTabsBar';
|
||||
import { MSTeamsSettings } from 'pages/settings/tabs/ChatOps/tabs/MSTeamsSettings/MSTeamsSettings';
|
||||
import { MattermostSettings } from 'pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings';
|
||||
import { SlackSettings } from 'pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings';
|
||||
import { TelegramSettings } from 'pages/settings/tabs/ChatOps/tabs/TelegramSettings/TelegramSettings';
|
||||
import { AppFeature } from 'state/features';
|
||||
|
|
@ -22,6 +23,7 @@ export enum ChatOpsTab {
|
|||
Slack = 'Slack',
|
||||
Telegram = 'Telegram',
|
||||
MSTeams = 'MSTeams',
|
||||
Mattermost = 'Mattermost',
|
||||
}
|
||||
interface ChatOpsProps extends AppRootProps, WithStoreProps, Themeable2 {}
|
||||
interface ChatOpsState {
|
||||
|
|
@ -92,7 +94,8 @@ export class _ChatOpsPage extends React.Component<ChatOpsProps, ChatOpsState> {
|
|||
return (
|
||||
store.hasFeature(AppFeature.Slack) ||
|
||||
store.hasFeature(AppFeature.Telegram) ||
|
||||
store.hasFeature(AppFeature.MsTeams)
|
||||
store.hasFeature(AppFeature.MsTeams) ||
|
||||
store.hasFeature(AppFeature.Mattermost)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -140,6 +143,14 @@ const Tabs = (props: TabsProps) => {
|
|||
</Stack>
|
||||
</VerticalTab>
|
||||
)}
|
||||
{store.hasFeature(AppFeature.Mattermost) && (
|
||||
<VerticalTab id={ChatOpsTab.Mattermost}>
|
||||
<Stack>
|
||||
<Icon name="message" />
|
||||
Mattermost
|
||||
</Stack>
|
||||
</VerticalTab>
|
||||
)}
|
||||
</VerticalTabsBar>
|
||||
);
|
||||
};
|
||||
|
|
@ -157,6 +168,7 @@ const TabsContent = (props: TabsContentProps) => {
|
|||
{store.hasFeature(AppFeature.Slack) && activeTab === ChatOpsTab.Slack && <SlackSettings />}
|
||||
{store.hasFeature(AppFeature.Telegram) && activeTab === ChatOpsTab.Telegram && <TelegramSettings />}
|
||||
{store.hasFeature(AppFeature.MsTeams) && activeTab === ChatOpsTab.MSTeams && <MSTeamsSettings />}
|
||||
{store.hasFeature(AppFeature.Mattermost) && activeTab === ChatOpsTab.Mattermost && <MattermostSettings />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { Badge, Button, LoadingPlaceholder, Stack } from '@grafana/ui';
|
||||
import { DOCS_MATTERMOST_SETUP, StackSize } from 'helpers/consts';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { Block } from 'components/GBlock/Block';
|
||||
import { GTable } from 'components/GTable/GTable';
|
||||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
|
||||
import { MattermostIntegrationButton } from 'containers/MattermostIntegrationButton/MattermostIntegrationButton';
|
||||
import { MattermostChannel } from 'models/mattermost/mattermost.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
|
||||
interface MattermostProps extends WithStoreProps {}
|
||||
|
||||
interface MattermostState {}
|
||||
|
||||
@observer
|
||||
class _MattermostSettings extends Component<MattermostProps, MattermostState> {
|
||||
state: MattermostState = {};
|
||||
|
||||
componentDidMount() {
|
||||
this.update();
|
||||
}
|
||||
|
||||
update = () => {
|
||||
const { store } = this.props;
|
||||
|
||||
store.mattermostChannelStore.updateItems();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { store } = this.props;
|
||||
const { mattermostChannelStore, organizationStore } = store;
|
||||
const connectedChannels = mattermostChannelStore.getSearchResult();
|
||||
const styles = getStyles();
|
||||
|
||||
const mattermostConfigured = organizationStore.currentOrganization?.env_status.mattermost_configured;
|
||||
|
||||
if (!mattermostConfigured && store.hasFeature(AppFeature.LiveSettings)) {
|
||||
return (
|
||||
<Stack direction="column" gap={StackSize.lg}>
|
||||
<Text.Title level={2}>Connect Mattermost workspace</Text.Title>
|
||||
<Block bordered withBackground className={styles.mattermostInfoBlock}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
<Text className={styles.infoBlockText}>
|
||||
Connecting Mattermost App will allow you to manage alert groups in your team Mattermost workspace.
|
||||
</Text>
|
||||
|
||||
<Text className={styles.infoBlockText}>
|
||||
After a basic workspace connection your team members need to connect their personal Mattermost accounts
|
||||
in order to be allowed to manage alert groups.
|
||||
</Text>
|
||||
<Text type="secondary" className={styles.infoBlockText}>
|
||||
More details in{' '}
|
||||
<a href={DOCS_MATTERMOST_SETUP} target="_blank" rel="noreferrer">
|
||||
<Text type="link">our documentation</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Block>
|
||||
<PluginLink query={{ page: 'live-settings' }}>
|
||||
<Button variant="primary">Setup ENV Variables</Button>
|
||||
</PluginLink>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!connectedChannels) {
|
||||
return <LoadingPlaceholder text="Loading..." />;
|
||||
}
|
||||
|
||||
if (!connectedChannels.length) {
|
||||
return (
|
||||
<Stack direction="column" gap={StackSize.lg}>
|
||||
<Text.Title level={2}>Connect Mattermost workspace</Text.Title>
|
||||
<Block bordered withBackground className={styles.mattermostInfoBlock}>
|
||||
<Stack direction="column" alignItems="center">
|
||||
<Text className={styles.infoBlockText}>
|
||||
Connecting Mattermost App will allow you to manage alert groups in your team Mattermost workspace.
|
||||
</Text>
|
||||
|
||||
<Text className={styles.infoBlockText}>
|
||||
After a basic workspace connection your team members need to connect their personal Mattermost accounts
|
||||
in order to be allowed to manage alert groups.
|
||||
</Text>
|
||||
<Text type="secondary" className={styles.infoBlockText}>
|
||||
More details in{' '}
|
||||
<a href={DOCS_MATTERMOST_SETUP} target="_blank" rel="noreferrer">
|
||||
<Text type="link">our documentation</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Block>
|
||||
<Stack>
|
||||
<MattermostIntegrationButton size="md" onUpdate={this.update} />
|
||||
{store.hasFeature(AppFeature.LiveSettings) && (
|
||||
<PluginLink query={{ page: 'live-settings' }}>
|
||||
<Button variant="primary">See ENV Variables</Button>
|
||||
</PluginLink>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
width: '35%',
|
||||
title: 'Channel Name',
|
||||
key: 'name',
|
||||
render: this.renderChannelName,
|
||||
},
|
||||
{
|
||||
width: '35%',
|
||||
title: 'Channel ID',
|
||||
render: this.renderChannelId,
|
||||
},
|
||||
{
|
||||
width: '30%',
|
||||
key: 'action',
|
||||
render: this.renderActionButtons,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{connectedChannels && (
|
||||
<div className={styles.root}>
|
||||
<GTable
|
||||
title={() => (
|
||||
<div className={styles.header}>
|
||||
<Text.Title level={3}>Mattermost Channels</Text.Title>
|
||||
<MattermostIntegrationButton onUpdate={this.update} />
|
||||
</div>
|
||||
)}
|
||||
emptyText={connectedChannels ? 'No Mattermost channels connected' : 'Loading...'}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
data={connectedChannels}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderChannelName = (record: MattermostChannel) => {
|
||||
return (
|
||||
<>
|
||||
{record.display_name} {record.is_default_channel && <Badge text="Default" color="green" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
renderChannelId = (record: MattermostChannel) => {
|
||||
return <>{record.channel_id}</>;
|
||||
};
|
||||
|
||||
renderActionButtons = (record: MattermostChannel) => {
|
||||
return (
|
||||
<Stack justifyContent="flex-end">
|
||||
<Button
|
||||
onClick={() => this.makeMattermostChannelDefault(record.id)}
|
||||
disabled={record.is_default_channel}
|
||||
fill="text"
|
||||
>
|
||||
Make default
|
||||
</Button>
|
||||
<WithConfirm title="Are you sure to disconnect?">
|
||||
<Button onClick={() => this.disconnectMattermostChannel(record.id)} fill="text" variant="destructive">
|
||||
Disconnect
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
makeMattermostChannelDefault = async (id: MattermostChannel['id']) => {
|
||||
const { store } = this.props;
|
||||
const { mattermostChannelStore } = store;
|
||||
|
||||
await mattermostChannelStore.makeMattermostChannelDefault(id);
|
||||
mattermostChannelStore.updateItems();
|
||||
};
|
||||
|
||||
disconnectMattermostChannel = async (id: MattermostChannel['id']) => {
|
||||
const { store } = this.props;
|
||||
const { mattermostChannelStore } = store;
|
||||
|
||||
await mattermostChannelStore.deleteMattermostChannel(id);
|
||||
mattermostChannelStore.updateItems();
|
||||
};
|
||||
}
|
||||
|
||||
export const MattermostSettings = withMobXProviderContext(_MattermostSettings);
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
root: css`
|
||||
display: block;
|
||||
`,
|
||||
header: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`,
|
||||
mattermostInfoBlock: css`
|
||||
text-align: center;
|
||||
width: 725px;
|
||||
`,
|
||||
|
||||
infoBlockText: css`
|
||||
margin-left: 48px;
|
||||
margin-right: 48px;
|
||||
margin-top: 24px;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -9,4 +9,5 @@ export enum AppFeature {
|
|||
MsTeams = 'msteams',
|
||||
GoogleOauth2 = 'google_oauth2',
|
||||
PersonalWebhook = 'personal_webhook',
|
||||
Mattermost = 'mattermost',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
|
|||
import { HeartbeatStore } from 'models/heartbeat/heartbeat';
|
||||
import { LabelStore } from 'models/label/label';
|
||||
import { LoaderStore } from 'models/loader/loader';
|
||||
import { MattermostStore } from 'models/mattermost/mattermost';
|
||||
import { MattermostChannelStore } from 'models/mattermost/mattermost_channel';
|
||||
import { MSTeamsChannelStore } from 'models/msteams_channel/msteams_channel';
|
||||
import { OrganizationStore } from 'models/organization/organization';
|
||||
import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook';
|
||||
|
|
@ -82,6 +84,8 @@ export class RootBaseStore {
|
|||
telegramChannelStore = new TelegramChannelStore(this);
|
||||
slackStore = new SlackStore(this);
|
||||
slackChannelStore = new SlackChannelStore(this);
|
||||
mattermostStore = new MattermostStore(this);
|
||||
mattermostChannelStore = new MattermostChannelStore(this);
|
||||
heartbeatStore = new HeartbeatStore(this);
|
||||
scheduleStore = new ScheduleStore(this);
|
||||
userGroupStore = new UserGroupStore(this);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue