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:
Matias Bordese 2025-04-21 14:23:37 -03:00 committed by GitHub
parent df086e686b
commit dcae98b02a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 4429 additions and 37 deletions

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ class ActionSource(IntegerChoices):
TELEGRAM = 3, "Telegram"
API = 4, "API"
BACKSYNC = 5, "Backsync"
MATTERMOST = 6, "Mattermost"
TASK_DELAY_SECONDS = 1

View file

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

View file

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

View file

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

View file

@ -20,6 +20,7 @@ mock_env_status = {
"verification_call": False,
"verification_sms": False,
},
"mattermost_configured": False,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

View 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]

View 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

View 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

View 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

View 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

View 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"])

View file

@ -0,0 +1 @@
from .alert_group_actions_handler import AlertGroupActionHandler # noqa: F401

View 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"]

View 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

View 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

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

View 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}"

View 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')},
),
]

View file

@ -0,0 +1,3 @@
from .channel import MattermostChannel # noqa: F401
from .message import MattermostMessage # noqa F401
from .user import MattermostUser # noqa F401

View 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,
)

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

View 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}"

View 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,
}
)

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

View 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,
)

View file

View 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

View 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

View 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

View 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

View 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

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

View 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

View 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"]

View 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

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

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

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

View 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"),
]

View 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")

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
)

View file

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

View file

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

View file

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

View file

@ -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 += [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
`
}
}

View file

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

View file

@ -8,4 +8,5 @@ export enum UserSettingsTab {
PersonalWebhookInfo,
MSTeamsInfo,
MobileAppConnection,
MattermostInfo,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);
}
}
}
}

View file

@ -0,0 +1,7 @@
export interface MattermostChannel {
id: string;
channel_id: string;
channel_name: string;
display_name: string;
is_default_channel: false;
}

View 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);
}
}

View file

@ -29,5 +29,6 @@ export interface Organization {
verification_call: boolean;
verification_sms: boolean;
};
mattermost_configured: boolean;
};
}

View file

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

View file

@ -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 />}
</>
);
};

View file

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

View file

@ -9,4 +9,5 @@ export enum AppFeature {
MsTeams = 'msteams',
GoogleOauth2 = 'google_oauth2',
PersonalWebhook = 'personal_webhook',
Mattermost = 'mattermost',
}

View file

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