From 2c1e7b0db80b913bc9482167cbd6082b6d80fa25 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 6 Aug 2024 22:35:52 +0530 Subject: [PATCH 01/43] Add mattermost OAuth2 flow Settings Frontend flow Change UI text for mattermost integration OAuth login flow OAuth Flow Fix env status loading Lint fixes --- dev/.env.dev.example | 4 + docs/sources/set-up/open-source/index.md | 4 + engine/apps/api/serializers/organization.py | 9 ++ engine/apps/api/tests/test_organization.py | 11 +++ engine/apps/api/views/auth.py | 9 +- engine/apps/api/views/features.py | 4 + engine/apps/auth_token/auth.py | 13 ++- engine/apps/auth_token/constants.py | 1 + .../migrations/0007_mattermostauthtoken.py | 32 ++++++ engine/apps/auth_token/models/__init__.py | 1 + .../models/mattermost_auth_token.py | 44 +++++++++ engine/apps/base/models/live_setting.py | 20 ++++ engine/apps/social_auth/backends.py | 61 +++++++++++- engine/settings/base.py | 12 +++ grafana-plugin/src/helpers/consts.ts | 1 + grafana-plugin/src/models/base_store.ts | 2 + .../src/models/mattermost/mattermost.ts | 30 ++++++ .../src/models/mattermost/mattermost.types.ts | 0 .../src/models/organization/organization.ts | 14 ++- .../models/organization/organization.types.ts | 9 ++ .../pages/settings/tabs/ChatOps/ChatOps.tsx | 14 ++- .../MattermostSettings.module.css | 15 +++ .../MattermostSettings/MattermostSettings.tsx | 98 +++++++++++++++++++ grafana-plugin/src/state/features.ts | 1 + .../src/state/rootBaseStore/RootBaseStore.ts | 3 + 25 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 engine/apps/auth_token/migrations/0007_mattermostauthtoken.py create mode 100644 engine/apps/auth_token/models/mattermost_auth_token.py create mode 100644 grafana-plugin/src/models/mattermost/mattermost.ts create mode 100644 grafana-plugin/src/models/mattermost/mattermost.types.ts create mode 100644 grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css create mode 100644 grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx diff --git a/dev/.env.dev.example b/dev/.env.dev.example index 40db0b4eba..c372154ba9 100644 --- a/dev/.env.dev.example +++ b/dev/.env.dev.example @@ -13,6 +13,10 @@ TWILIO_VERIFY_SERVICE_SID= TWILIO_AUTH_TOKEN= TWILIO_NUMBER= +MATTERMOST_CLIENT_OAUTH_ID= +MATTERMOST_CLIENT_OAUTH_SECRET= +MATTERMOST_HOST= + DJANGO_SETTINGS_MODULE=settings.dev SECRET_KEY=jyRnfRIeMjYfKdoFa9dKXcNaEGGc8GH1TChmYoWW BASE_URL=http://localhost:8080 diff --git a/docs/sources/set-up/open-source/index.md b/docs/sources/set-up/open-source/index.md index 106e6d8c79..f1a3d204a1 100644 --- a/docs/sources/set-up/open-source/index.md +++ b/docs/sources/set-up/open-source/index.md @@ -211,6 +211,10 @@ 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. +## Mattermost Setup + +TODO: To Be Updated + ## Grafana OSS-Cloud Setup The benefits of connecting to Grafana Cloud OnCall include: diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index e502e8a3c7..5bdd853c00 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -102,14 +102,23 @@ class Meta: class CurrentOrganizationConfigChecksSerializer(serializers.ModelSerializer): is_chatops_connected = serializers.SerializerMethodField() is_integration_chatops_connected = serializers.SerializerMethodField() + mattermost = serializers.SerializerMethodField() class Meta: model = Organization fields = [ "is_chatops_connected", "is_integration_chatops_connected", + "mattermost", ] + def get_mattermost(self, obj): + env_status = not LiveSetting.objects.filter(name__startswith="MATTERMOST", error__isnull=False).exists() + return { + "env_status": env_status, + "is_integrated": False, # TODO: Add logic to verify if mattermost is integrated + } + def get_is_chatops_connected(self, obj): msteams_backend = get_messaging_backend_from_id("MSTEAMS") return bool( diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index b00ef95688..64eb2d972b 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -278,6 +278,10 @@ def test_get_organization_slack_config_checks( expected_result = { "is_chatops_connected": False, "is_integration_chatops_connected": False, + "mattermost": { + "env_status": True, + "is_integrated": False, + }, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK @@ -333,6 +337,10 @@ def test_get_organization_telegram_config_checks( expected_result = { "is_chatops_connected": False, "is_integration_chatops_connected": False, + "mattermost": { + "env_status": True, + "is_integrated": False, + }, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK @@ -360,3 +368,6 @@ def test_get_organization_telegram_config_checks( assert response.status_code == status.HTTP_200_OK expected_result["is_integration_chatops_connected"] = True assert response.json() == expected_result + + +# TODO: Add test to validate mattermost is integrated once integration PR changes are made diff --git a/engine/apps/api/views/auth.py b/engine/apps/api/views/auth.py index 63aafc7f84..b97debb44f 100644 --- a/engine/apps/api/views/auth.py +++ b/engine/apps/api/views/auth.py @@ -13,7 +13,12 @@ from social_django.utils import psa from social_django.views import _do_login -from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication +from apps.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, @@ -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") diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 7e35597aaf..aa19e76bcc 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -26,6 +26,7 @@ class Feature(enum.StrEnum): GRAFANA_ALERTING_V2 = "grafana_alerting_v2" LABELS = "labels" GOOGLE_OAUTH2 = "google_oauth2" + MATTERMOST = "mattermost" class FeaturesAPIView(APIView): @@ -72,4 +73,7 @@ def _get_enabled_features(self, request): if settings.GOOGLE_OAUTH2_ENABLED: enabled_features.append(Feature.GOOGLE_OAUTH2) + if settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED: + enabled_features.append(Feature.MATTERMOST) + return enabled_features diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index f96e5ef6f2..d84b0233a3 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -19,12 +19,18 @@ 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 diff --git a/engine/apps/auth_token/constants.py b/engine/apps/auth_token/constants.py index 4e0169321e..b05db9ab56 100644 --- a/engine/apps/auth_token/constants.py +++ b/engine/apps/auth_token/constants.py @@ -5,6 +5,7 @@ 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. diff --git a/engine/apps/auth_token/migrations/0007_mattermostauthtoken.py b/engine/apps/auth_token/migrations/0007_mattermostauthtoken.py new file mode 100644 index 0000000000..bb2a25d4b0 --- /dev/null +++ b/engine/apps/auth_token/migrations/0007_mattermostauthtoken.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.11 on 2024-08-08 14:08 + +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', '0022_alter_team_unique_together'), + ('auth_token', '0006_googleoauth2token'), + ] + + 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.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_auth_token_set', to='user_management.user')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/engine/apps/auth_token/models/__init__.py b/engine/apps/auth_token/models/__init__.py index 42cc60c516..858763c82c 100644 --- a/engine/apps/auth_token/models/__init__.py +++ b/engine/apps/auth_token/models/__init__.py @@ -2,6 +2,7 @@ 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 diff --git a/engine/apps/auth_token/models/mattermost_auth_token.py b/engine/apps/auth_token/models/mattermost_auth_token.py new file mode 100644 index 0000000000..5143dd6391 --- /dev/null +++ b/engine/apps/auth_token/models/mattermost_auth_token.py @@ -0,0 +1,44 @@ +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 MattermostAuthTokenQuerySet(models.QuerySet): + def filter(self, *args, **kwargs): + now = timezone.now() + return super().filter(*args, **kwargs, revoked_at=None, expire_date__gte=now) + + def delete(self): + self.update(revoked_at=timezone.now()) + + +class MattermostAuthToken(BaseAuthToken): + objects = MattermostAuthTokenQuerySet.as_manager() + user = models.ForeignKey("user_management.User", related_name="mattermost_auth_token_set", 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]: + 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 diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index 3c4f1e56fc..79450e436e 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -80,6 +80,9 @@ 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", ) DESCRIPTIONS = { @@ -187,6 +190,21 @@ 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 instruction for details how to set up Mattermost. " + ), + "MATTERMOST_CLIENT_OAUTH_SECRET": ( + "Check instruction for details how to set up Mattermost. " + ), + "MATTERMOST_HOST": ( + "Check instruction for details how to set up Mattermost. " + ), } SECRET_SETTING_NAMES = ( @@ -205,6 +223,8 @@ class LiveSetting(models.Model): "ZVONOK_API_KEY", "EXOTEL_ACCOUNT_SID", "EXOTEL_API_TOKEN", + "MATTERMOST_CLIENT_OAUTH_ID", + "MATTERMOST_CLIENT_OAUTH_SECRET", ) def __str__(self): diff --git a/engine/apps/social_auth/backends.py b/engine/apps/social_auth/backends.py index 2021f2a9e5..d5a0767c4c 100644 --- a/engine/apps/social_auth/backends.py +++ b/engine/apps/social_auth/backends.py @@ -1,11 +1,13 @@ from urllib.parse import urljoin 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.base.utils import live_settings # 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 +203,58 @@ class InstallSlackOAuth2V2(SlackOAuth2V2): def get_scope(self): return {"user_scope": USER_SCOPE, "scope": BOT_SCOPE} + + +class InstallMattermostOAuth2(BaseOAuth2): + name = "mattermost-install" + + 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"{live_settings.MATTERMOST_HOST}/oauth/authorize" + + def access_token_url(self): + return f"{live_settings.MATTERMOST_HOST}/oauth/access_token" + + def get_user_details(self, response): + """ + Return user details from Google API account + + Sample response + { + "access_token":"opoj5nbi6tyipdkjry8gc6tkqr", + "token_type":"bearer", + "expires_in":2553990, + "scope":"", + "refresh_token":"8gacxj3rwtr5mxczwred9xbmoh", + "id_token":"" + } + + """ + return {} + + 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 diff --git a/engine/settings/base.py b/engine/settings/base.py index 007779f195..8e14898e5b 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -690,6 +690,7 @@ class BrokerTypes: # https://python-social-auth.readthedocs.io/en/latest/configuration/settings.html AUTHENTICATION_BACKENDS = [ + "apps.social_auth.backends.InstallMattermostOAuth2", "apps.social_auth.backends.InstallSlackOAuth2V2", "apps.social_auth.backends.LoginSlackOAuth2V2", "django.contrib.auth.backends.ModelBackend", @@ -728,14 +729,25 @@ class BrokerTypes: # 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 = os.environ.get("FEATURE_MATTERMOST_INTEGRATION_ENABLED", 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") + + SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET +SOCIAL_AUTH_MATTERMOST_INSTALL_KEY = MATTERMOST_CLIENT_OAUTH_ID +SOCIAL_AUTH_MATTERMOST_INSTALL_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_INSTALL_KEY": "MATTERMOST_CLIENT_OAUTH_ID", + "SOCIAL_AUTH_MATTERMOST_INSTALL_SECRET": "MATTERMOST_CLIENT_OAUTH_SECRET", } SOCIAL_AUTH_SLACK_INSTALL_FREE_CUSTOM_SCOPE = [ "bot", diff --git a/grafana-plugin/src/helpers/consts.ts b/grafana-plugin/src/helpers/consts.ts index 0bfa08c0ac..de2fa3efa6 100644 --- a/grafana-plugin/src/helpers/consts.ts +++ b/grafana-plugin/src/helpers/consts.ts @@ -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.`; diff --git a/grafana-plugin/src/models/base_store.ts b/grafana-plugin/src/models/base_store.ts index 47b036b6f1..313d1577e4 100644 --- a/grafana-plugin/src/models/base_store.ts +++ b/grafana-plugin/src/models/base_store.ts @@ -89,6 +89,7 @@ export class BaseStore { // Update env_status field for current team await this.rootStore.organizationStore.loadCurrentOrganization(); + await this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); return result; } catch (error) { this.onApiError(error, skipErrorHandling); @@ -103,6 +104,7 @@ export class BaseStore { }); // Update env_status field for current team await this.rootStore.organizationStore.loadCurrentOrganization(); + await this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); return result; } catch (error) { this.onApiError(error); diff --git a/grafana-plugin/src/models/mattermost/mattermost.ts b/grafana-plugin/src/models/mattermost/mattermost.ts new file mode 100644 index 0000000000..2880a9ecac --- /dev/null +++ b/grafana-plugin/src/models/mattermost/mattermost.ts @@ -0,0 +1,30 @@ +import { makeObservable } from 'mobx'; + +import { BaseStore } from 'models/base_store'; +import { makeRequestRaw } from 'network/network'; +import { RootStore } from 'state/rootStore'; +import { GENERIC_ERROR } from 'utils/consts'; +import { openErrorNotification } from 'utils/utils'; + +export class MattermostStore extends BaseStore { + constructor(rootStore: RootStore) { + super(rootStore); + makeObservable(this); + } + + async installMattermostIntegration() { + try { + const response = await makeRequestRaw('/login/mattermost-install/', {}); + + if (response.status === 201) { + this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); + } else if (response.status === 200) { + window.location = response.data; + } + } catch (ex) { + if (ex.response?.status === 500) { + openErrorNotification(GENERIC_ERROR); + } + } + } +} diff --git a/grafana-plugin/src/models/mattermost/mattermost.types.ts b/grafana-plugin/src/models/mattermost/mattermost.types.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/grafana-plugin/src/models/organization/organization.ts b/grafana-plugin/src/models/organization/organization.ts index 3278c427f0..ff837c2a17 100644 --- a/grafana-plugin/src/models/organization/organization.ts +++ b/grafana-plugin/src/models/organization/organization.ts @@ -4,12 +4,15 @@ import { BaseStore } from 'models/base_store'; import { makeRequest } from 'network/network'; import { RootStore } from 'state/rootStore'; -import { Organization } from './organization.types'; +import { Organization, OrganizationConfigChecks } from './organization.types'; export class OrganizationStore extends BaseStore { @observable currentOrganization?: Organization; + @observable + organizationConfigChecks?: OrganizationConfigChecks; + constructor(rootStore: RootStore) { super(rootStore); makeObservable(this); @@ -25,6 +28,15 @@ export class OrganizationStore extends BaseStore { }); } + @action.bound + async loadCurrentOrganizationConfigChecks() { + const organizationConfigChecks = await makeRequest(`${this.path}config-checks`, {}); + + runInAction(() => { + this.organizationConfigChecks = organizationConfigChecks; + }); + } + async saveCurrentOrganization(data: Partial) { this.currentOrganization = await makeRequest(this.path, { method: 'PUT', diff --git a/grafana-plugin/src/models/organization/organization.types.ts b/grafana-plugin/src/models/organization/organization.types.ts index 2c01dc4b34..3786013e17 100644 --- a/grafana-plugin/src/models/organization/organization.types.ts +++ b/grafana-plugin/src/models/organization/organization.types.ts @@ -31,3 +31,12 @@ export interface Organization { }; }; } + +export interface OrganizationConfigChecks { + is_chatops_connected: boolean; + is_integration_chatops_connected: boolean; + mattermost: { + env_status: boolean; + is_integrated: boolean; + }; +} diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx index ea768c1eff..5f030fe2d9 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx @@ -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 { 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) => { )} + {store.hasFeature(AppFeature.Mattermost) && ( + + + + Mattermost + + + )} ); }; @@ -157,6 +168,7 @@ const TabsContent = (props: TabsContentProps) => { {store.hasFeature(AppFeature.Slack) && activeTab === ChatOpsTab.Slack && } {store.hasFeature(AppFeature.Telegram) && activeTab === ChatOpsTab.Telegram && } {store.hasFeature(AppFeature.MsTeams) && activeTab === ChatOpsTab.MSTeams && } + {store.hasFeature(AppFeature.Mattermost) && activeTab === ChatOpsTab.Mattermost && } ); }; diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css new file mode 100644 index 0000000000..f67b8557af --- /dev/null +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css @@ -0,0 +1,15 @@ +.mattermost-infoblock { + text-align: center; + width: 725px; +} + +.infoblock-text { + margin-left: 48px; + margin-right: 48px; + margin-top: 24px; +} + +.external-link-style { + margin-right: 4px; + align-self: baseline; +} diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx new file mode 100644 index 0000000000..d8b26c4cb8 --- /dev/null +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx @@ -0,0 +1,98 @@ +import React, { Component } from 'react'; + +import { Button, HorizontalGroup, VerticalGroup, Icon } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import { Block } from 'components/GBlock/Block'; +import { PluginLink } from 'components/PluginLink/PluginLink'; +import { Text } from 'components/Text/Text'; +import { AppFeature } from 'state/features'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; +import { DOCS_MATTERMOST_SETUP } from 'helpers/consts'; +import { showApiError } from 'helpers/helpers'; + +import styles from './MattermostSettings.module.css'; + +const cx = cn.bind(styles); + +interface MattermostProps extends WithStoreProps {} + +interface MattermostState {} + +@observer +class _MattermostSettings extends Component { + state: MattermostState = {}; + + handleOpenMattermostInstructions = async () => { + const { store } = this.props; + try { + await store.mattermostStore.installMattermostIntegration(); + } catch (err) { + showApiError(err); + } + }; + + render() { + const { store } = this.props; + const { organizationStore } = store; + const envStatus = organizationStore.organizationConfigChecks?.mattermost.env_status; + const isIntegrated = organizationStore.organizationConfigChecks?.mattermost.is_integrated; + + if (!isIntegrated) { + return ( + + Connect Mattermost workspace + + + + Connecting Mattermost App will allow you to manage alert groups in your team Mattermost workspace. + + + + After a basic workspace connection your team members need to connect their personal Mattermost accounts + in order to be allowed to manage alert groups. + + + More details in{' '} + + our documentation + + + + + {envStatus ? ( + + + {store.hasFeature(AppFeature.LiveSettings) && ( + + + + )} + + ) : ( + + + + + + )} + + ); + } else { + return ( + + Connected Mattermost workspace + + ); + } + } +} + +export const MattermostSettings = withMobXProviderContext(_MattermostSettings); diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index b36b0a1de8..67a960375b 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -8,4 +8,5 @@ export enum AppFeature { Labels = 'labels', MsTeams = 'msteams', GoogleOauth2 = 'google_oauth2', + Mattermost = 'mattermost', } diff --git a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts index c23080828f..b4c7d881af 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -20,6 +20,7 @@ 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 { MSTeamsChannelStore } from 'models/msteams_channel/msteams_channel'; import { OrganizationStore } from 'models/organization/organization'; import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; @@ -82,6 +83,7 @@ export class RootBaseStore { telegramChannelStore = new TelegramChannelStore(this); slackStore = new SlackStore(this); slackChannelStore = new SlackChannelStore(this); + mattermostStore = new MattermostStore(this); heartbeatStore = new HeartbeatStore(this); scheduleStore = new ScheduleStore(this); userGroupStore = new UserGroupStore(this); @@ -114,6 +116,7 @@ export class RootBaseStore { await retryFailingPromises([ () => this.userStore.loadCurrentUser(), () => this.organizationStore.loadCurrentOrganization(), + () => this.organizationStore.loadCurrentOrganizationConfigChecks(), () => this.grafanaTeamStore.updateItems(), () => updateFeatures(), () => this.alertReceiveChannelStore.fetchAlertReceiveChannelOptions(), From b37626c728d20eb0c90b3b29b643e03658c2d19b Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 13 Aug 2024 17:21:34 +0530 Subject: [PATCH 02/43] Correcting the comments --- engine/apps/social_auth/backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/social_auth/backends.py b/engine/apps/social_auth/backends.py index d5a0767c4c..370db9a61f 100644 --- a/engine/apps/social_auth/backends.py +++ b/engine/apps/social_auth/backends.py @@ -229,7 +229,7 @@ def access_token_url(self): def get_user_details(self, response): """ - Return user details from Google API account + Return user details from Mattermost Account Sample response { From 85d1b8fc5cd18e9867b598289329cbdc9a1fceaa Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 13 Aug 2024 21:15:08 +0530 Subject: [PATCH 03/43] Fix the config checks API --- engine/apps/api/serializers/organization.py | 13 ++++--------- engine/apps/api/tests/test_organization.py | 9 +-------- grafana-plugin/src/models/base_store.ts | 2 -- grafana-plugin/src/models/mattermost/mattermost.ts | 2 +- .../src/models/organization/organization.ts | 14 +------------- .../src/models/organization/organization.types.ts | 10 +--------- .../tabs/MattermostSettings/MattermostSettings.tsx | 4 ++-- .../src/state/rootBaseStore/RootBaseStore.ts | 1 - 8 files changed, 10 insertions(+), 45 deletions(-) diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index 5bdd853c00..e6ae40d742 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -84,9 +84,13 @@ def get_env_status(self, obj): 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, } @@ -102,23 +106,14 @@ class Meta: class CurrentOrganizationConfigChecksSerializer(serializers.ModelSerializer): is_chatops_connected = serializers.SerializerMethodField() is_integration_chatops_connected = serializers.SerializerMethodField() - mattermost = serializers.SerializerMethodField() class Meta: model = Organization fields = [ "is_chatops_connected", "is_integration_chatops_connected", - "mattermost", ] - def get_mattermost(self, obj): - env_status = not LiveSetting.objects.filter(name__startswith="MATTERMOST", error__isnull=False).exists() - return { - "env_status": env_status, - "is_integrated": False, # TODO: Add logic to verify if mattermost is integrated - } - def get_is_chatops_connected(self, obj): msteams_backend = get_messaging_backend_from_id("MSTEAMS") return bool( diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index 64eb2d972b..46f7cc6552 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -20,6 +20,7 @@ "verification_call": False, "verification_sms": False, }, + "mattermost_configured": False, } @@ -278,10 +279,6 @@ def test_get_organization_slack_config_checks( expected_result = { "is_chatops_connected": False, "is_integration_chatops_connected": False, - "mattermost": { - "env_status": True, - "is_integrated": False, - }, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK @@ -337,10 +334,6 @@ def test_get_organization_telegram_config_checks( expected_result = { "is_chatops_connected": False, "is_integration_chatops_connected": False, - "mattermost": { - "env_status": True, - "is_integrated": False, - }, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK diff --git a/grafana-plugin/src/models/base_store.ts b/grafana-plugin/src/models/base_store.ts index 313d1577e4..47b036b6f1 100644 --- a/grafana-plugin/src/models/base_store.ts +++ b/grafana-plugin/src/models/base_store.ts @@ -89,7 +89,6 @@ export class BaseStore { // Update env_status field for current team await this.rootStore.organizationStore.loadCurrentOrganization(); - await this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); return result; } catch (error) { this.onApiError(error, skipErrorHandling); @@ -104,7 +103,6 @@ export class BaseStore { }); // Update env_status field for current team await this.rootStore.organizationStore.loadCurrentOrganization(); - await this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); return result; } catch (error) { this.onApiError(error); diff --git a/grafana-plugin/src/models/mattermost/mattermost.ts b/grafana-plugin/src/models/mattermost/mattermost.ts index 2880a9ecac..9fe4f9a07e 100644 --- a/grafana-plugin/src/models/mattermost/mattermost.ts +++ b/grafana-plugin/src/models/mattermost/mattermost.ts @@ -17,7 +17,7 @@ export class MattermostStore extends BaseStore { const response = await makeRequestRaw('/login/mattermost-install/', {}); if (response.status === 201) { - this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); + this.rootStore.organizationStore.loadCurrentOrganization(); } else if (response.status === 200) { window.location = response.data; } diff --git a/grafana-plugin/src/models/organization/organization.ts b/grafana-plugin/src/models/organization/organization.ts index ff837c2a17..3278c427f0 100644 --- a/grafana-plugin/src/models/organization/organization.ts +++ b/grafana-plugin/src/models/organization/organization.ts @@ -4,15 +4,12 @@ import { BaseStore } from 'models/base_store'; import { makeRequest } from 'network/network'; import { RootStore } from 'state/rootStore'; -import { Organization, OrganizationConfigChecks } from './organization.types'; +import { Organization } from './organization.types'; export class OrganizationStore extends BaseStore { @observable currentOrganization?: Organization; - @observable - organizationConfigChecks?: OrganizationConfigChecks; - constructor(rootStore: RootStore) { super(rootStore); makeObservable(this); @@ -28,15 +25,6 @@ export class OrganizationStore extends BaseStore { }); } - @action.bound - async loadCurrentOrganizationConfigChecks() { - const organizationConfigChecks = await makeRequest(`${this.path}config-checks`, {}); - - runInAction(() => { - this.organizationConfigChecks = organizationConfigChecks; - }); - } - async saveCurrentOrganization(data: Partial) { this.currentOrganization = await makeRequest(this.path, { method: 'PUT', diff --git a/grafana-plugin/src/models/organization/organization.types.ts b/grafana-plugin/src/models/organization/organization.types.ts index 3786013e17..336b5f0583 100644 --- a/grafana-plugin/src/models/organization/organization.types.ts +++ b/grafana-plugin/src/models/organization/organization.types.ts @@ -29,14 +29,6 @@ export interface Organization { verification_call: boolean; verification_sms: boolean; }; - }; -} - -export interface OrganizationConfigChecks { - is_chatops_connected: boolean; - is_integration_chatops_connected: boolean; - mattermost: { - env_status: boolean; - is_integrated: boolean; + mattermost_configured: boolean; }; } diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx index d8b26c4cb8..98f0e6b1ca 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx @@ -37,8 +37,8 @@ class _MattermostSettings extends Component { render() { const { store } = this.props; const { organizationStore } = store; - const envStatus = organizationStore.organizationConfigChecks?.mattermost.env_status; - const isIntegrated = organizationStore.organizationConfigChecks?.mattermost.is_integrated; + const envStatus = organizationStore.currentOrganization?.env_status.mattermost_configured; + const isIntegrated = false // TODO: Check if integration is configured and can show channels view if (!isIntegrated) { return ( diff --git a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts index b4c7d881af..ffe72b95a3 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -116,7 +116,6 @@ export class RootBaseStore { await retryFailingPromises([ () => this.userStore.loadCurrentUser(), () => this.organizationStore.loadCurrentOrganization(), - () => this.organizationStore.loadCurrentOrganizationConfigChecks(), () => this.grafanaTeamStore.updateItems(), () => updateFeatures(), () => this.alertReceiveChannelStore.fetchAlertReceiveChannelOptions(), From 3fd7d65d5cd60a0ff1600b316d6c021dac535a3a Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 13 Aug 2024 22:22:20 +0530 Subject: [PATCH 04/43] Fixing the lint errors --- .../tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx index 98f0e6b1ca..fc97517dd6 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx @@ -38,7 +38,7 @@ class _MattermostSettings extends Component { const { store } = this.props; const { organizationStore } = store; const envStatus = organizationStore.currentOrganization?.env_status.mattermost_configured; - const isIntegrated = false // TODO: Check if integration is configured and can show channels view + const isIntegrated = false; // TODO: Check if integration is configured and can show channels view if (!isIntegrated) { return ( From be6612d86e816483b720ac442e75fe1bfba7e1ae Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 6 Aug 2024 22:35:52 +0530 Subject: [PATCH 05/43] Add mattermost OAuth2 flow Settings Frontend flow Change UI text for mattermost integration OAuth login flow OAuth Flow Fix env status loading Lint fixes --- engine/apps/api/serializers/organization.py | 9 +++++++++ engine/apps/api/tests/test_organization.py | 8 ++++++++ grafana-plugin/src/models/base_store.ts | 2 ++ .../src/models/organization/organization.ts | 14 +++++++++++++- .../src/models/organization/organization.types.ts | 9 +++++++++ .../src/pages/settings/tabs/ChatOps/ChatOps.tsx | 8 ++++++++ .../src/state/rootBaseStore/RootBaseStore.ts | 1 + 7 files changed, 50 insertions(+), 1 deletion(-) diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index e6ae40d742..593d324b4b 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -106,14 +106,23 @@ class Meta: class CurrentOrganizationConfigChecksSerializer(serializers.ModelSerializer): is_chatops_connected = serializers.SerializerMethodField() is_integration_chatops_connected = serializers.SerializerMethodField() + mattermost = serializers.SerializerMethodField() class Meta: model = Organization fields = [ "is_chatops_connected", "is_integration_chatops_connected", + "mattermost", ] + def get_mattermost(self, obj): + env_status = not LiveSetting.objects.filter(name__startswith="MATTERMOST", error__isnull=False).exists() + return { + "env_status": env_status, + "is_integrated": False, # TODO: Add logic to verify if mattermost is integrated + } + def get_is_chatops_connected(self, obj): msteams_backend = get_messaging_backend_from_id("MSTEAMS") return bool( diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index 46f7cc6552..c694d62b59 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -279,6 +279,10 @@ def test_get_organization_slack_config_checks( expected_result = { "is_chatops_connected": False, "is_integration_chatops_connected": False, + "mattermost": { + "env_status": True, + "is_integrated": False, + }, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK @@ -334,6 +338,10 @@ def test_get_organization_telegram_config_checks( expected_result = { "is_chatops_connected": False, "is_integration_chatops_connected": False, + "mattermost": { + "env_status": True, + "is_integrated": False, + }, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK diff --git a/grafana-plugin/src/models/base_store.ts b/grafana-plugin/src/models/base_store.ts index 47b036b6f1..313d1577e4 100644 --- a/grafana-plugin/src/models/base_store.ts +++ b/grafana-plugin/src/models/base_store.ts @@ -89,6 +89,7 @@ export class BaseStore { // Update env_status field for current team await this.rootStore.organizationStore.loadCurrentOrganization(); + await this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); return result; } catch (error) { this.onApiError(error, skipErrorHandling); @@ -103,6 +104,7 @@ export class BaseStore { }); // Update env_status field for current team await this.rootStore.organizationStore.loadCurrentOrganization(); + await this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); return result; } catch (error) { this.onApiError(error); diff --git a/grafana-plugin/src/models/organization/organization.ts b/grafana-plugin/src/models/organization/organization.ts index 3278c427f0..ff837c2a17 100644 --- a/grafana-plugin/src/models/organization/organization.ts +++ b/grafana-plugin/src/models/organization/organization.ts @@ -4,12 +4,15 @@ import { BaseStore } from 'models/base_store'; import { makeRequest } from 'network/network'; import { RootStore } from 'state/rootStore'; -import { Organization } from './organization.types'; +import { Organization, OrganizationConfigChecks } from './organization.types'; export class OrganizationStore extends BaseStore { @observable currentOrganization?: Organization; + @observable + organizationConfigChecks?: OrganizationConfigChecks; + constructor(rootStore: RootStore) { super(rootStore); makeObservable(this); @@ -25,6 +28,15 @@ export class OrganizationStore extends BaseStore { }); } + @action.bound + async loadCurrentOrganizationConfigChecks() { + const organizationConfigChecks = await makeRequest(`${this.path}config-checks`, {}); + + runInAction(() => { + this.organizationConfigChecks = organizationConfigChecks; + }); + } + async saveCurrentOrganization(data: Partial) { this.currentOrganization = await makeRequest(this.path, { method: 'PUT', diff --git a/grafana-plugin/src/models/organization/organization.types.ts b/grafana-plugin/src/models/organization/organization.types.ts index 336b5f0583..86ec2696cf 100644 --- a/grafana-plugin/src/models/organization/organization.types.ts +++ b/grafana-plugin/src/models/organization/organization.types.ts @@ -32,3 +32,12 @@ export interface Organization { mattermost_configured: boolean; }; } + +export interface OrganizationConfigChecks { + is_chatops_connected: boolean; + is_integration_chatops_connected: boolean; + mattermost: { + env_status: boolean; + is_integrated: boolean; + }; +} diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx index 5f030fe2d9..db01c5b8e7 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx @@ -151,6 +151,14 @@ const Tabs = (props: TabsProps) => { )} + {store.hasFeature(AppFeature.Mattermost) && ( + + + + Mattermost + + + )} ); }; diff --git a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts index ffe72b95a3..b4c7d881af 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -116,6 +116,7 @@ export class RootBaseStore { await retryFailingPromises([ () => this.userStore.loadCurrentUser(), () => this.organizationStore.loadCurrentOrganization(), + () => this.organizationStore.loadCurrentOrganizationConfigChecks(), () => this.grafanaTeamStore.updateItems(), () => updateFeatures(), () => this.alertReceiveChannelStore.fetchAlertReceiveChannelOptions(), From 02b5a4bf55e06b75d2dc1f93278ad3cd685ebaea Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 27 Aug 2024 10:06:35 +0530 Subject: [PATCH 06/43] Mattermost Connect Channels Add channels flow Fix migrations --- dev/.env.dev.example | 2 + engine/apps/base/models/live_setting.py | 7 ++ engine/apps/mattermost/__init__.py | 0 engine/apps/mattermost/apps.py | 5 ++ engine/apps/mattermost/client.py | 66 +++++++++++++++++++ engine/apps/mattermost/exceptions.py | 13 ++++ .../mattermost/migrations/0001_initial.py | 29 ++++++++ engine/apps/mattermost/migrations/__init__.py | 0 engine/apps/mattermost/models/__init__.py | 1 + .../mattermost/models/mattermost_channel.py | 38 +++++++++++ engine/apps/mattermost/serializers.py | 48 ++++++++++++++ engine/apps/mattermost/tests/__init__.py | 0 engine/apps/mattermost/urls.py | 13 ++++ engine/apps/mattermost/views.py | 53 +++++++++++++++ .../insight_log/chatops_insight_logs.py | 1 + engine/engine/urls.py | 4 ++ engine/settings/base.py | 3 +- 17 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 engine/apps/mattermost/__init__.py create mode 100644 engine/apps/mattermost/apps.py create mode 100644 engine/apps/mattermost/client.py create mode 100644 engine/apps/mattermost/exceptions.py create mode 100644 engine/apps/mattermost/migrations/0001_initial.py create mode 100644 engine/apps/mattermost/migrations/__init__.py create mode 100644 engine/apps/mattermost/models/__init__.py create mode 100644 engine/apps/mattermost/models/mattermost_channel.py create mode 100644 engine/apps/mattermost/serializers.py create mode 100644 engine/apps/mattermost/tests/__init__.py create mode 100644 engine/apps/mattermost/urls.py create mode 100644 engine/apps/mattermost/views.py diff --git a/dev/.env.dev.example b/dev/.env.dev.example index c372154ba9..47e8a8fdb8 100644 --- a/dev/.env.dev.example +++ b/dev/.env.dev.example @@ -16,6 +16,7 @@ TWILIO_NUMBER= MATTERMOST_CLIENT_OAUTH_ID= MATTERMOST_CLIENT_OAUTH_SECRET= MATTERMOST_HOST= +MATTERMOST_BOT_TOKEN= DJANGO_SETTINGS_MODULE=settings.dev SECRET_KEY=jyRnfRIeMjYfKdoFa9dKXcNaEGGc8GH1TChmYoWW @@ -23,6 +24,7 @@ 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 diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index 79450e436e..4a5eb2ed2c 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -83,6 +83,7 @@ class LiveSetting(models.Model): "MATTERMOST_CLIENT_OAUTH_ID", "MATTERMOST_CLIENT_OAUTH_SECRET", "MATTERMOST_HOST", + "MATTERMOST_BOT_TOKEN", ) DESCRIPTIONS = { @@ -205,6 +206,11 @@ class LiveSetting(models.Model): "https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup" "' target='_blank'>instruction for details how to set up Mattermost. " ), + "MATTERMOST_BOT_TOKEN": ( + "Check instruction for details how to set up Mattermost. " + ), } SECRET_SETTING_NAMES = ( @@ -225,6 +231,7 @@ class LiveSetting(models.Model): "EXOTEL_API_TOKEN", "MATTERMOST_CLIENT_OAUTH_ID", "MATTERMOST_CLIENT_OAUTH_SECRET", + "MATTERMOST_BOT_TOKEN", ) def __str__(self): diff --git a/engine/apps/mattermost/__init__.py b/engine/apps/mattermost/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/mattermost/apps.py b/engine/apps/mattermost/apps.py new file mode 100644 index 0000000000..7a6fc4ec38 --- /dev/null +++ b/engine/apps/mattermost/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MattermostConfig(AppConfig): + name = "apps.mattermost" diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py new file mode 100644 index 0000000000..6b00b0a1d9 --- /dev/null +++ b/engine/apps/mattermost/client.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from typing import Optional + +import requests +from requests.auth import AuthBase +from requests.models import PreparedRequest + +from apps.base.utils import live_settings +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 MattermostChannel: + channel_id: str + channel_name: str + + +class MattermostClient: + def __init__(self, token: Optional[str] = None) -> None: + self.token = token or live_settings.MATTERMOST_BOT_TOKEN + self.base_url = f"{live_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_name_and_team_name(self, team_name: str, channel_name: str) -> MattermostChannel: + url = f"{self.base_url}/teams/name/{team_name}/channels/name/{channel_name}" + 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"], channel_name=data["name"]) diff --git a/engine/apps/mattermost/exceptions.py b/engine/apps/mattermost/exceptions.py new file mode 100644 index 0000000000..5df5a56f38 --- /dev/null +++ b/engine/apps/mattermost/exceptions.py @@ -0,0 +1,13 @@ +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}" diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py new file mode 100644 index 0000000000..71fb0eaee4 --- /dev/null +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.15 on 2024-08-29 17:15 + +import apps.mattermost.models.mattermost_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', '0022_alter_team_unique_together'), + ] + + 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.mattermost_channel.generate_public_primary_key_for_slack_channel, max_length=20, unique=True, validators=[django.core.validators.MinLengthValidator(13)])), + ('channel_id', models.CharField(default=None, max_length=100)), + ('channel_name', models.CharField(default=None, max_length=100)), + ('datetime', models.DateTimeField(auto_now_add=True)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_channels', to='user_management.organization')), + ], + ), + ] diff --git a/engine/apps/mattermost/migrations/__init__.py b/engine/apps/mattermost/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/mattermost/models/__init__.py b/engine/apps/mattermost/models/__init__.py new file mode 100644 index 0000000000..1b84eac9c5 --- /dev/null +++ b/engine/apps/mattermost/models/__init__.py @@ -0,0 +1 @@ +from .mattermost_channel import MattermostChannel # noqa: F401 diff --git a/engine/apps/mattermost/models/mattermost_channel.py b/engine/apps/mattermost/models/mattermost_channel.py new file mode 100644 index 0000000000..df0e6f561c --- /dev/null +++ b/engine/apps/mattermost/models/mattermost_channel.py @@ -0,0 +1,38 @@ +from django.conf import settings +from django.core.validators import MinLengthValidator +from django.db import models + +from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length + + +def generate_public_primary_key_for_slack_channel(): + prefix = "M" + 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_slack_channel, + ) + + channel_id = models.CharField(max_length=100, default=None) + channel_name = models.CharField(max_length=100, default=None) + datetime = models.DateTimeField(auto_now_add=True) diff --git a/engine/apps/mattermost/serializers.py b/engine/apps/mattermost/serializers.py new file mode 100644 index 0000000000..ce60575a2f --- /dev/null +++ b/engine/apps/mattermost/serializers.py @@ -0,0 +1,48 @@ +from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator + +from apps.mattermost.client import MattermostClient +from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid +from apps.mattermost.models import MattermostChannel +from common.api_helpers.utils import CurrentOrganizationDefault + + +class MattermostChannelSerializer(serializers.Serializer): + id = serializers.CharField(read_only=True, source="public_primary_key") + organization = serializers.HiddenField(default=CurrentOrganizationDefault()) + channel_id = serializers.CharField() + channel_name = serializers.CharField() + + class Meta: + model = MattermostChannel + fields = [ + "id", + "organization", + "channel_id", + "channel_name", + ] + validators = [ + UniqueTogetherValidator(queryset=MattermostChannel.objects.all(), fields=["organization", "channel_id"]) + ] + + def create(self, validated_data): + return MattermostChannel.objects.create(**validated_data) + + def to_internal_value(self, data): + team_name = data.get("team_name") + channel_name = data.get("channel_name") + + if not team_name: + raise serializers.ValidationError({"team_name": "This field is required."}) + + if not channel_name: + raise serializers.ValidationError({"channel_name": "This field is required."}) + + try: + response = MattermostClient().get_channel_by_name_and_team_name( + team_name=team_name, channel_name=channel_name + ) + except (MattermostAPIException, MattermostAPITokenInvalid): + raise serializers.ValidationError("Unable to fetch channel from mattermost server") + + return super().to_internal_value({"channel_id": response.channel_id, "channel_name": response.channel_name}) diff --git a/engine/apps/mattermost/tests/__init__.py b/engine/apps/mattermost/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/mattermost/urls.py b/engine/apps/mattermost/urls.py new file mode 100644 index 0000000000..0fba7b76d2 --- /dev/null +++ b/engine/apps/mattermost/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path + +from common.api_helpers.optional_slash_router import OptionalSlashRouter + +from .views import MattermostChannelViewSet + +app_name = "mattermost" +router = OptionalSlashRouter() +router.register(r"channels", MattermostChannelViewSet, basename="channel") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/engine/apps/mattermost/views.py b/engine/apps/mattermost/views.py new file mode 100644 index 0000000000..7522f86504 --- /dev/null +++ b/engine/apps/mattermost/views.py @@ -0,0 +1,53 @@ +from rest_framework import mixins, viewsets +from rest_framework.permissions import IsAuthenticated + +from apps.api.permissions import RBACPermission +from apps.auth_token.auth import PluginAuthentication +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], + } + + serializer_class = MattermostChannelSerializer + + def get_queryset(self): + return MattermostChannel.objects.filter(organization=self.request.user.organization) + + 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() diff --git a/engine/common/insight_log/chatops_insight_logs.py b/engine/common/insight_log/chatops_insight_logs.py index f6de200b66..34aa4d1ef7 100644 --- a/engine/common/insight_log/chatops_insight_logs.py +++ b/engine/common/insight_log/chatops_insight_logs.py @@ -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): diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 1a2f933998..ebf9902c93 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -63,6 +63,10 @@ path("slack/", include("apps.slack.urls")), ] +if settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED: + urlpatterns += [ + path("api/internal/v1/mattermost/", include("apps.mattermost.urls", namespace="mattermost")), + ] if settings.IS_OPEN_SOURCE: urlpatterns += [ diff --git a/engine/settings/base.py b/engine/settings/base.py index 8e14898e5b..1ab13f4b54 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -311,6 +311,7 @@ class DatabaseTypes: "drf_spectacular", "apps.google", "apps.chatops_proxy", + "apps.mattermost", ] if DATABASE_TYPE == DatabaseTypes.MYSQL: @@ -734,7 +735,7 @@ class BrokerTypes: 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") SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET From 5398f16312005ab235de0a21abe2404ef9d72fb2 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Sat, 31 Aug 2024 02:04:27 +0530 Subject: [PATCH 07/43] Addressed the review comments --- engine/apps/mattermost/client.py | 3 ++- engine/apps/mattermost/migrations/0001_initial.py | 10 +++++++--- engine/apps/mattermost/models/__init__.py | 2 +- .../models/{mattermost_channel.py => channel.py} | 10 +++++++--- engine/apps/mattermost/serializers.py | 15 ++++++++++----- 5 files changed, 27 insertions(+), 13 deletions(-) rename engine/apps/mattermost/models/{mattermost_channel.py => channel.py} (80%) diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py index 6b00b0a1d9..c85c51631a 100644 --- a/engine/apps/mattermost/client.py +++ b/engine/apps/mattermost/client.py @@ -22,6 +22,7 @@ def __call__(self, request: PreparedRequest) -> PreparedRequest: class MattermostChannel: channel_id: str channel_name: str + display_name: str class MattermostClient: @@ -63,4 +64,4 @@ def get_channel_by_name_and_team_name(self, team_name: str, channel_name: str) - 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"], channel_name=data["name"]) + return MattermostChannel(channel_id=data["id"], channel_name=data["name"], display_name=data["display_name"]) diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index 71fb0eaee4..bcf024a563 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,6 +1,6 @@ -# Generated by Django 4.2.15 on 2024-08-29 17:15 +# Generated by Django 4.2.15 on 2024-08-30 20:09 -import apps.mattermost.models.mattermost_channel +import apps.mattermost.models.channel import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -19,11 +19,15 @@ class Migration(migrations.Migration): 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.mattermost_channel.generate_public_primary_key_for_slack_channel, max_length=20, unique=True, validators=[django.core.validators.MinLengthValidator(13)])), + ('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)])), ('channel_id', models.CharField(default=None, max_length=100)), ('channel_name', models.CharField(default=None, max_length=100)), + ('display_name', models.CharField(default=None, max_length=100)), ('datetime', models.DateTimeField(auto_now_add=True)), ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_channels', to='user_management.organization')), ], + options={ + 'unique_together': {('organization', 'channel_id')}, + }, ), ] diff --git a/engine/apps/mattermost/models/__init__.py b/engine/apps/mattermost/models/__init__.py index 1b84eac9c5..6e596eb1bb 100644 --- a/engine/apps/mattermost/models/__init__.py +++ b/engine/apps/mattermost/models/__init__.py @@ -1 +1 @@ -from .mattermost_channel import MattermostChannel # noqa: F401 +from .channel import MattermostChannel # noqa: F401 diff --git a/engine/apps/mattermost/models/mattermost_channel.py b/engine/apps/mattermost/models/channel.py similarity index 80% rename from engine/apps/mattermost/models/mattermost_channel.py rename to engine/apps/mattermost/models/channel.py index df0e6f561c..264e67f832 100644 --- a/engine/apps/mattermost/models/mattermost_channel.py +++ b/engine/apps/mattermost/models/channel.py @@ -5,8 +5,8 @@ from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length -def generate_public_primary_key_for_slack_channel(): - prefix = "M" +def generate_public_primary_key_for_mattermost_channel(): + prefix = "MT" new_public_primary_key = generate_public_primary_key(prefix) failure_counter = 0 @@ -30,9 +30,13 @@ class MattermostChannel(models.Model): max_length=20, validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)], unique=True, - default=generate_public_primary_key_for_slack_channel, + default=generate_public_primary_key_for_mattermost_channel, ) channel_id = models.CharField(max_length=100, default=None) channel_name = models.CharField(max_length=100, default=None) + display_name = models.CharField(max_length=100, default=None) datetime = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("organization", "channel_id") diff --git a/engine/apps/mattermost/serializers.py b/engine/apps/mattermost/serializers.py index ce60575a2f..4efea10bc9 100644 --- a/engine/apps/mattermost/serializers.py +++ b/engine/apps/mattermost/serializers.py @@ -7,11 +7,12 @@ from common.api_helpers.utils import CurrentOrganizationDefault -class MattermostChannelSerializer(serializers.Serializer): +class MattermostChannelSerializer(serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") organization = serializers.HiddenField(default=CurrentOrganizationDefault()) channel_id = serializers.CharField() channel_name = serializers.CharField() + display_name = serializers.CharField() class Meta: model = MattermostChannel @@ -20,9 +21,7 @@ class Meta: "organization", "channel_id", "channel_name", - ] - validators = [ - UniqueTogetherValidator(queryset=MattermostChannel.objects.all(), fields=["organization", "channel_id"]) + "display_name", ] def create(self, validated_data): @@ -45,4 +44,10 @@ def to_internal_value(self, data): except (MattermostAPIException, MattermostAPITokenInvalid): raise serializers.ValidationError("Unable to fetch channel from mattermost server") - return super().to_internal_value({"channel_id": response.channel_id, "channel_name": response.channel_name}) + return super().to_internal_value( + { + "channel_id": response.channel_id, + "channel_name": response.channel_name, + "display_name": response.display_name, + } + ) From b2e7d2b77d9c0540245c1a28b6bdeb9d27b7fbcd Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Sat, 7 Sep 2024 16:14:49 +0530 Subject: [PATCH 08/43] Adding default action and test cases --- engine/apps/mattermost/client.py | 5 +- .../mattermost/migrations/0001_initial.py | 4 +- engine/apps/mattermost/models/channel.py | 32 +- engine/apps/mattermost/serializers.py | 13 +- engine/apps/mattermost/tests/conftest.py | 14 + engine/apps/mattermost/tests/factories.py | 16 + .../tests/test_mattermost_channel.py | 351 ++++++++++++++++++ .../tests/test_mattermost_client.py | 86 +++++ engine/apps/mattermost/views.py | 12 +- engine/conftest.py | 10 + engine/settings/ci_test.py | 3 + 11 files changed, 538 insertions(+), 8 deletions(-) create mode 100644 engine/apps/mattermost/tests/conftest.py create mode 100644 engine/apps/mattermost/tests/factories.py create mode 100644 engine/apps/mattermost/tests/test_mattermost_channel.py create mode 100644 engine/apps/mattermost/tests/test_mattermost_client.py diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py index c85c51631a..1bab374fa7 100644 --- a/engine/apps/mattermost/client.py +++ b/engine/apps/mattermost/client.py @@ -21,6 +21,7 @@ def __call__(self, request: PreparedRequest) -> PreparedRequest: @dataclass class MattermostChannel: channel_id: str + team_id: str channel_name: str display_name: str @@ -64,4 +65,6 @@ def get_channel_by_name_and_team_name(self, team_name: str, channel_name: str) - 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"], channel_name=data["name"], display_name=data["display_name"]) + return MattermostChannel( + channel_id=data["id"], team_id=data["team_id"], channel_name=data["name"], display_name=data["display_name"] + ) diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index bcf024a563..3db98fba1b 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-08-30 20:09 +# Generated by Django 4.2.15 on 2024-09-07 05:21 import apps.mattermost.models.channel import django.core.validators @@ -20,9 +20,11 @@ class Migration(migrations.Migration): 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(default=None, max_length=100)), ('channel_id', models.CharField(default=None, 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)), ('datetime', models.DateTimeField(auto_now_add=True)), ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_channels', to='user_management.organization')), ], diff --git a/engine/apps/mattermost/models/channel.py b/engine/apps/mattermost/models/channel.py index 264e67f832..64d19e5cc3 100644 --- a/engine/apps/mattermost/models/channel.py +++ b/engine/apps/mattermost/models/channel.py @@ -1,7 +1,8 @@ from django.conf import settings from django.core.validators import MinLengthValidator -from django.db import models +from django.db import models, transaction +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 @@ -33,10 +34,39 @@ class MattermostChannel(models.Model): default=generate_public_primary_key_for_mattermost_channel, ) + mattermost_team_id = models.CharField(max_length=100, default=None) channel_id = models.CharField(max_length=100, default=None) 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) datetime = models.DateTimeField(auto_now_add=True) + @property + def unique_display_name(self) -> str: + return f"{self.display_name}-{self.mattermost_team_id[:5]}" + class Meta: unique_together = ("organization", "channel_id") + + 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"]) + + print(f"Model: {self.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, + ) diff --git a/engine/apps/mattermost/serializers.py b/engine/apps/mattermost/serializers.py index 4efea10bc9..cb54b1c196 100644 --- a/engine/apps/mattermost/serializers.py +++ b/engine/apps/mattermost/serializers.py @@ -1,5 +1,4 @@ from rest_framework import serializers -from rest_framework.validators import UniqueTogetherValidator from apps.mattermost.client import MattermostClient from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid @@ -10,23 +9,28 @@ class MattermostChannelSerializer(serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") organization = serializers.HiddenField(default=CurrentOrganizationDefault()) - channel_id = serializers.CharField() - channel_name = serializers.CharField() - display_name = serializers.CharField() class Meta: model = MattermostChannel fields = [ "id", "organization", + "mattermost_team_id", "channel_id", "channel_name", "display_name", + "is_default_channel", ] def create(self, validated_data): return MattermostChannel.objects.create(**validated_data) + def to_representation(self, instance): + ret = super().to_representation(instance) + del ret["mattermost_team_id"] + ret["display_name"] = instance.unique_display_name + return ret + def to_internal_value(self, data): team_name = data.get("team_name") channel_name = data.get("channel_name") @@ -47,6 +51,7 @@ def to_internal_value(self, data): 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, } diff --git a/engine/apps/mattermost/tests/conftest.py b/engine/apps/mattermost/tests/conftest.py new file mode 100644 index 0000000000..81f09cf998 --- /dev/null +++ b/engine/apps/mattermost/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest + + +@pytest.fixture() +def make_mattermost_get_channel_by_name_team_name_response(): + def _make_mattermost_get_channel_by_name_team_name_response(): + return { + "id": "pbg5piuc5bgniftrserb88575h", + "team_id": "oxfug4kgx3fx7jzow49cpxkmgo", + "display_name": "Town Square", + "name": "town-square", + } + + return _make_mattermost_get_channel_by_name_team_name_response diff --git a/engine/apps/mattermost/tests/factories.py b/engine/apps/mattermost/tests/factories.py new file mode 100644 index 0000000000..57457e8842 --- /dev/null +++ b/engine/apps/mattermost/tests/factories.py @@ -0,0 +1,16 @@ +import factory + +from apps.mattermost.models import MattermostChannel +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 diff --git a/engine/apps/mattermost/tests/test_mattermost_channel.py b/engine/apps/mattermost/tests/test_mattermost_channel.py new file mode 100644 index 0000000000..a4494f376c --- /dev/null +++ b/engine/apps/mattermost/tests/test_mattermost_channel.py @@ -0,0 +1,351 @@ +import json +from unittest.mock import 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_by_name_team_name_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_by_name_team_name_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={"team_name": "fuzzteam", "channel_name": "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"] == f"{data['display_name']}-{data['team_id'][:5]}" + assert res["is_default_channel"] is False + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "request_body,expected_status", + [ + ({"team_name": "fuzzteam", "channel_name": "fuzzchannel"}, status.HTTP_201_CREATED), + ({"team_name": "fuzzteam"}, status.HTTP_400_BAD_REQUEST), + ({"channel_name": "fuzzchannel"}, status.HTTP_400_BAD_REQUEST), + ], +) +def test_post_mattermost_channels( + make_organization_and_user_with_plugin_token, + make_mattermost_get_channel_by_name_team_name_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_by_name_team_name_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 +@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.unique_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.unique_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 + assert response.json() == expected_payload + + +@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.unique_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 diff --git a/engine/apps/mattermost/tests/test_mattermost_client.py b/engine/apps/mattermost/tests/test_mattermost_client.py new file mode 100644 index 0000000000..98e3bc7eb0 --- /dev/null +++ b/engine/apps/mattermost/tests/test_mattermost_client.py @@ -0,0 +1,86 @@ +import json +from unittest.mock import Mock, patch + +import pytest +import requests +from rest_framework import status + +from apps.base.utils import live_settings +from apps.mattermost.client import MattermostAPIException, MattermostAPITokenInvalid, MattermostClient + + +@pytest.mark.django_db +def test_mattermost_client_initialization(): + live_settings.MATTERMOST_BOT_TOKEN = None + with pytest.raises(MattermostAPITokenInvalid) as exc: + MattermostClient() + assert type(exc) is MattermostAPITokenInvalid + + +@pytest.mark.django_db +def test_get_channel_by_name_and_team_name_ok(make_mattermost_get_channel_by_name_team_name_response): + client = MattermostClient("abcd") + data = make_mattermost_get_channel_by_name_team_name_response() + channel_response = requests.Response() + channel_response.status_code = status.HTTP_200_OK + channel_response._content = json.dumps(data).encode() + with patch("apps.mattermost.client.requests.get", return_value=channel_response) as mock_request: + response = client.get_channel_by_name_and_team_name("test-team", "test-channel") + mock_request.assert_called_once() + assert response.channel_id == data["id"] + assert response.team_id == data["team_id"] + assert response.display_name == data["display_name"] + assert response.channel_name == data["name"] + + +@pytest.mark.django_db +def test_get_channel_by_name_and_team_name_failure(): + 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="GET", + ) + mock_response.raise_for_status.side_effect = requests.HTTPError(response=mock_response) + with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: + with pytest.raises(MattermostAPIException) as exc: + client.get_channel_by_name_and_team_name("test-team", "test-channel") + 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="GET", + ) + mock_response.raise_for_status.side_effect = requests.Timeout(response=mock_response) + with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: + with pytest.raises(MattermostAPIException) as exc: + client.get_channel_by_name_and_team_name("test-team", "test-channel") + 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="GET", + ) + mock_response.raise_for_status.side_effect = requests.exceptions.RequestException(response=mock_response) + with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: + with pytest.raises(MattermostAPIException) as exc: + client.get_channel_by_name_and_team_name("test-team", "test-channel") + assert exc.value.msg == "Unexpected error from mattermost server" + mock_request.assert_called_once() diff --git a/engine/apps/mattermost/views.py b/engine/apps/mattermost/views.py index 7522f86504..feac1106cb 100644 --- a/engine/apps/mattermost/views.py +++ b/engine/apps/mattermost/views.py @@ -1,5 +1,7 @@ -from rest_framework import mixins, viewsets +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 apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication @@ -25,6 +27,7 @@ class MattermostChannelViewSet( "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 @@ -32,6 +35,13 @@ class MattermostChannelViewSet( 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 diff --git a/engine/conftest.py b/engine/conftest.py index c7e7475cf4..786fad83d8 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -76,6 +76,7 @@ LabelValueFactory, WebhookAssociatedLabelFactory, ) +from apps.mattermost.tests.factories import MattermostChannelFactory from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken from apps.phone_notifications.phone_backend import PhoneBackend from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory @@ -158,6 +159,7 @@ register(AlertReceiveChannelAssociatedLabelFactory) register(GoogleOAuth2UserFactory) register(UserNotificationBundleFactory) +register(MattermostChannelFactory) IS_RBAC_ENABLED = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True" @@ -907,6 +909,14 @@ def _make_telegram_message(alert_group, message_type, **kwargs): return _make_telegram_message +@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_phone_call_record(): def _make_phone_call_record(receiver, **kwargs): diff --git a/engine/settings/ci_test.py b/engine/settings/ci_test.py index 9ac6dca1ea..f901598688 100644 --- a/engine/settings/ci_test.py +++ b/engine/settings/ci_test.py @@ -57,3 +57,6 @@ # 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 + +# Dummy token +MATTERMOST_BOT_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" From 52da9cfa232ca3fe779b5485fcc4efd510fb8657 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 10 Sep 2024 08:39:55 +0530 Subject: [PATCH 09/43] Review comments --- engine/apps/mattermost/migrations/0001_initial.py | 8 ++++---- engine/apps/mattermost/models/channel.py | 8 +++----- engine/apps/mattermost/serializers.py | 5 ++++- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index 3db98fba1b..c9a457e3c8 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-09-07 05:21 +# Generated by Django 4.2.15 on 2024-09-10 03:01 import apps.mattermost.models.channel import django.core.validators @@ -20,12 +20,12 @@ class Migration(migrations.Migration): 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(default=None, max_length=100)), - ('channel_id', models.CharField(default=None, max_length=100)), + ('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)), - ('datetime', models.DateTimeField(auto_now_add=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')), ], options={ diff --git a/engine/apps/mattermost/models/channel.py b/engine/apps/mattermost/models/channel.py index 64d19e5cc3..8b0277a2c7 100644 --- a/engine/apps/mattermost/models/channel.py +++ b/engine/apps/mattermost/models/channel.py @@ -34,12 +34,12 @@ class MattermostChannel(models.Model): default=generate_public_primary_key_for_mattermost_channel, ) - mattermost_team_id = models.CharField(max_length=100, default=None) - channel_id = models.CharField(max_length=100, default=None) + 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) - datetime = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True) @property def unique_display_name(self) -> str: @@ -61,8 +61,6 @@ def make_channel_default(self, author): with transaction.atomic(): old_default_channel.save(update_fields=["is_default_channel"]) self.save(update_fields=["is_default_channel"]) - - print(f"Model: {self.is_default_channel}") write_chatops_insight_log( author=author, event_name=ChatOpsEvent.DEFAULT_CHANNEL_CHANGED, diff --git a/engine/apps/mattermost/serializers.py b/engine/apps/mattermost/serializers.py index cb54b1c196..3c4f4398eb 100644 --- a/engine/apps/mattermost/serializers.py +++ b/engine/apps/mattermost/serializers.py @@ -21,13 +21,16 @@ class Meta: "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_representation(self, instance): ret = super().to_representation(instance) - del ret["mattermost_team_id"] ret["display_name"] = instance.unique_display_name return ret From 0e8d8dc2f8c6329a5d71dbc6b24479bb1818d7c8 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Wed, 11 Sep 2024 10:41:14 +0530 Subject: [PATCH 10/43] Fix exception handling of mattermost API --- engine/apps/mattermost/serializers.py | 5 +-- .../tests/test_mattermost_channel.py | 32 ++++++++++++++++++- engine/engine/urls.py | 2 +- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/engine/apps/mattermost/serializers.py b/engine/apps/mattermost/serializers.py index 3c4f4398eb..dd6f94a76b 100644 --- a/engine/apps/mattermost/serializers.py +++ b/engine/apps/mattermost/serializers.py @@ -3,6 +3,7 @@ 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 @@ -48,8 +49,8 @@ def to_internal_value(self, data): response = MattermostClient().get_channel_by_name_and_team_name( team_name=team_name, channel_name=channel_name ) - except (MattermostAPIException, MattermostAPITokenInvalid): - raise serializers.ValidationError("Unable to fetch channel from mattermost server") + except (MattermostAPIException, MattermostAPITokenInvalid) as ex: + raise BadRequest(detail=ex.msg) return super().to_internal_value( { diff --git a/engine/apps/mattermost/tests/test_mattermost_channel.py b/engine/apps/mattermost/tests/test_mattermost_channel.py index a4494f376c..f632dd3fe9 100644 --- a/engine/apps/mattermost/tests/test_mattermost_channel.py +++ b/engine/apps/mattermost/tests/test_mattermost_channel.py @@ -1,5 +1,5 @@ import json -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import requests @@ -191,6 +191,36 @@ def test_post_mattermost_channels( 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={"team_name": "fuzzteam", "channel_name": "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", diff --git a/engine/engine/urls.py b/engine/engine/urls.py index ebf9902c93..635c7ba85d 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -65,7 +65,7 @@ if settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED: urlpatterns += [ - path("api/internal/v1/mattermost/", include("apps.mattermost.urls", namespace="mattermost")), + path("api/internal/v1/mattermost/", include("apps.mattermost.urls")), ] if settings.IS_OPEN_SOURCE: From a71b2354efce4773a58b399fc617f05505402079 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Thu, 12 Sep 2024 07:49:00 +0530 Subject: [PATCH 11/43] Make the feature flag true by default --- engine/settings/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/settings/base.py b/engine/settings/base.py index 1ab13f4b54..9289a9b9aa 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -731,7 +731,7 @@ class BrokerTypes: SLACK_INTEGRATION_MAINTENANCE_ENABLED = os.environ.get("SLACK_INTEGRATION_MAINTENANCE_ENABLED", False) # Mattermost -FEATURE_MATTERMOST_INTEGRATION_ENABLED = os.environ.get("FEATURE_MATTERMOST_INTEGRATION_ENABLED", False) +FEATURE_MATTERMOST_INTEGRATION_ENABLED = os.environ.get("FEATURE_MATTERMOST_INTEGRATION_ENABLED", True) 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") From 7c5e7e0577ac7dc56a8d6568f396b5ac0154a2b6 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Sat, 14 Sep 2024 13:12:09 +0530 Subject: [PATCH 12/43] Review comments and changes for latest update on freature branch --- engine/apps/mattermost/client.py | 6 ++-- engine/apps/mattermost/serializers.py | 4 ++- .../tests/test_mattermost_channel.py | 5 ++- .../tests/test_mattermost_client.py | 4 +-- .../src/models/mattermost/mattermost.ts | 4 +-- .../pages/settings/tabs/ChatOps/ChatOps.tsx | 8 ----- .../MattermostSettings/MattermostSettings.tsx | 34 +++++++++---------- 7 files changed, 31 insertions(+), 34 deletions(-) diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py index 1bab374fa7..de6339b205 100644 --- a/engine/apps/mattermost/client.py +++ b/engine/apps/mattermost/client.py @@ -2,10 +2,10 @@ from typing import Optional import requests +from django.conf import settings from requests.auth import AuthBase from requests.models import PreparedRequest -from apps.base.utils import live_settings from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid @@ -28,8 +28,8 @@ class MattermostChannel: class MattermostClient: def __init__(self, token: Optional[str] = None) -> None: - self.token = token or live_settings.MATTERMOST_BOT_TOKEN - self.base_url = f"{live_settings.MATTERMOST_HOST}/api/v4" + 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: diff --git a/engine/apps/mattermost/serializers.py b/engine/apps/mattermost/serializers.py index dd6f94a76b..77d9db1969 100644 --- a/engine/apps/mattermost/serializers.py +++ b/engine/apps/mattermost/serializers.py @@ -49,8 +49,10 @@ def to_internal_value(self, data): response = MattermostClient().get_channel_by_name_and_team_name( team_name=team_name, channel_name=channel_name ) - except (MattermostAPIException, MattermostAPITokenInvalid) as ex: + except MattermostAPIException as ex: raise BadRequest(detail=ex.msg) + except MattermostAPITokenInvalid: + raise BadRequest(detail="Mattermost API token is invalid.") return super().to_internal_value( { diff --git a/engine/apps/mattermost/tests/test_mattermost_channel.py b/engine/apps/mattermost/tests/test_mattermost_channel.py index f632dd3fe9..39d6d15d90 100644 --- a/engine/apps/mattermost/tests/test_mattermost_channel.py +++ b/engine/apps/mattermost/tests/test_mattermost_channel.py @@ -280,7 +280,10 @@ def test_list_mattermost_channels( url = reverse("mattermost:channel-list") response = client.get(url, **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK - assert response.json() == expected_payload + 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 diff --git a/engine/apps/mattermost/tests/test_mattermost_client.py b/engine/apps/mattermost/tests/test_mattermost_client.py index 98e3bc7eb0..f82768fc0e 100644 --- a/engine/apps/mattermost/tests/test_mattermost_client.py +++ b/engine/apps/mattermost/tests/test_mattermost_client.py @@ -3,15 +3,15 @@ import pytest import requests +from django.conf import settings from rest_framework import status -from apps.base.utils import live_settings from apps.mattermost.client import MattermostAPIException, MattermostAPITokenInvalid, MattermostClient @pytest.mark.django_db def test_mattermost_client_initialization(): - live_settings.MATTERMOST_BOT_TOKEN = None + settings.MATTERMOST_BOT_TOKEN = None with pytest.raises(MattermostAPITokenInvalid) as exc: MattermostClient() assert type(exc) is MattermostAPITokenInvalid diff --git a/grafana-plugin/src/models/mattermost/mattermost.ts b/grafana-plugin/src/models/mattermost/mattermost.ts index 9fe4f9a07e..7b54110126 100644 --- a/grafana-plugin/src/models/mattermost/mattermost.ts +++ b/grafana-plugin/src/models/mattermost/mattermost.ts @@ -1,10 +1,10 @@ +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'; -import { GENERIC_ERROR } from 'utils/consts'; -import { openErrorNotification } from 'utils/utils'; export class MattermostStore extends BaseStore { constructor(rootStore: RootStore) { diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx index db01c5b8e7..5f030fe2d9 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx @@ -151,14 +151,6 @@ const Tabs = (props: TabsProps) => { )} - {store.hasFeature(AppFeature.Mattermost) && ( - - - - Mattermost - - - )} ); }; diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx index fc97517dd6..7dc7b45c55 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx @@ -1,7 +1,9 @@ import React, { Component } from 'react'; -import { Button, HorizontalGroup, VerticalGroup, Icon } from '@grafana/ui'; +import { Button, Stack, Icon } from '@grafana/ui'; import cn from 'classnames/bind'; +import { DOCS_MATTERMOST_SETUP, StackSize } from 'helpers/consts'; +import { showApiError } from 'helpers/helpers'; import { observer } from 'mobx-react'; import { Block } from 'components/GBlock/Block'; @@ -10,8 +12,6 @@ import { Text } from 'components/Text/Text'; import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; -import { DOCS_MATTERMOST_SETUP } from 'helpers/consts'; -import { showApiError } from 'helpers/helpers'; import styles from './MattermostSettings.module.css'; @@ -42,10 +42,10 @@ class _MattermostSettings extends Component { if (!isIntegrated) { return ( - + Connect Mattermost workspace - + Connecting Mattermost App will allow you to manage alert groups in your team Mattermost workspace. @@ -60,36 +60,36 @@ class _MattermostSettings extends Component { our documentation - + {envStatus ? ( - + {store.hasFeature(AppFeature.LiveSettings) && ( )} - + ) : ( - + - + )} - + ); } else { return ( - + Connected Mattermost workspace - + ); } } From 2e0e1523dc228fd3e2a2f85c6f26cdd6b2763f88 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Sat, 14 Sep 2024 14:55:51 +0530 Subject: [PATCH 13/43] Add token to base for testing --- engine/settings/dev.py | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/settings/dev.py b/engine/settings/dev.py index 078b0735d4..73d1cc6194 100644 --- a/engine/settings/dev.py +++ b/engine/settings/dev.py @@ -64,6 +64,7 @@ if TESTING: EXTRA_MESSAGING_BACKENDS = [("apps.base.tests.messaging_backend.TestOnlyBackend", 42)] TELEGRAM_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" + MATTERMOST_BOT_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" TWILIO_AUTH_TOKEN = "twilio_auth_token" # charset/collation related tests don't work without this From fe20a2861245627242b68e92217cb6482ea495de Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 17 Sep 2024 21:50:09 +0530 Subject: [PATCH 14/43] Remove config checks from OrganizationConfigChecksView --- engine/apps/api/serializers/organization.py | 9 --------- engine/apps/api/tests/test_organization.py | 8 -------- .../src/models/organization/organization.types.ts | 4 ---- 3 files changed, 21 deletions(-) diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index 593d324b4b..e6ae40d742 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -106,23 +106,14 @@ class Meta: class CurrentOrganizationConfigChecksSerializer(serializers.ModelSerializer): is_chatops_connected = serializers.SerializerMethodField() is_integration_chatops_connected = serializers.SerializerMethodField() - mattermost = serializers.SerializerMethodField() class Meta: model = Organization fields = [ "is_chatops_connected", "is_integration_chatops_connected", - "mattermost", ] - def get_mattermost(self, obj): - env_status = not LiveSetting.objects.filter(name__startswith="MATTERMOST", error__isnull=False).exists() - return { - "env_status": env_status, - "is_integrated": False, # TODO: Add logic to verify if mattermost is integrated - } - def get_is_chatops_connected(self, obj): msteams_backend = get_messaging_backend_from_id("MSTEAMS") return bool( diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index c694d62b59..46f7cc6552 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -279,10 +279,6 @@ def test_get_organization_slack_config_checks( expected_result = { "is_chatops_connected": False, "is_integration_chatops_connected": False, - "mattermost": { - "env_status": True, - "is_integrated": False, - }, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK @@ -338,10 +334,6 @@ def test_get_organization_telegram_config_checks( expected_result = { "is_chatops_connected": False, "is_integration_chatops_connected": False, - "mattermost": { - "env_status": True, - "is_integrated": False, - }, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK diff --git a/grafana-plugin/src/models/organization/organization.types.ts b/grafana-plugin/src/models/organization/organization.types.ts index 86ec2696cf..987ac7486a 100644 --- a/grafana-plugin/src/models/organization/organization.types.ts +++ b/grafana-plugin/src/models/organization/organization.types.ts @@ -36,8 +36,4 @@ export interface Organization { export interface OrganizationConfigChecks { is_chatops_connected: boolean; is_integration_chatops_connected: boolean; - mattermost: { - env_status: boolean; - is_integrated: boolean; - }; } From c2af483fbab8208746b73cd60fe264cf707edc0a Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 17 Sep 2024 21:57:33 +0530 Subject: [PATCH 15/43] Remove related config check code --- grafana-plugin/src/models/base_store.ts | 2 -- .../src/models/organization/organization.ts | 14 +------------- .../src/models/organization/organization.types.ts | 5 ----- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/grafana-plugin/src/models/base_store.ts b/grafana-plugin/src/models/base_store.ts index 313d1577e4..47b036b6f1 100644 --- a/grafana-plugin/src/models/base_store.ts +++ b/grafana-plugin/src/models/base_store.ts @@ -89,7 +89,6 @@ export class BaseStore { // Update env_status field for current team await this.rootStore.organizationStore.loadCurrentOrganization(); - await this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); return result; } catch (error) { this.onApiError(error, skipErrorHandling); @@ -104,7 +103,6 @@ export class BaseStore { }); // Update env_status field for current team await this.rootStore.organizationStore.loadCurrentOrganization(); - await this.rootStore.organizationStore.loadCurrentOrganizationConfigChecks(); return result; } catch (error) { this.onApiError(error); diff --git a/grafana-plugin/src/models/organization/organization.ts b/grafana-plugin/src/models/organization/organization.ts index ff837c2a17..3278c427f0 100644 --- a/grafana-plugin/src/models/organization/organization.ts +++ b/grafana-plugin/src/models/organization/organization.ts @@ -4,15 +4,12 @@ import { BaseStore } from 'models/base_store'; import { makeRequest } from 'network/network'; import { RootStore } from 'state/rootStore'; -import { Organization, OrganizationConfigChecks } from './organization.types'; +import { Organization } from './organization.types'; export class OrganizationStore extends BaseStore { @observable currentOrganization?: Organization; - @observable - organizationConfigChecks?: OrganizationConfigChecks; - constructor(rootStore: RootStore) { super(rootStore); makeObservable(this); @@ -28,15 +25,6 @@ export class OrganizationStore extends BaseStore { }); } - @action.bound - async loadCurrentOrganizationConfigChecks() { - const organizationConfigChecks = await makeRequest(`${this.path}config-checks`, {}); - - runInAction(() => { - this.organizationConfigChecks = organizationConfigChecks; - }); - } - async saveCurrentOrganization(data: Partial) { this.currentOrganization = await makeRequest(this.path, { method: 'PUT', diff --git a/grafana-plugin/src/models/organization/organization.types.ts b/grafana-plugin/src/models/organization/organization.types.ts index 987ac7486a..336b5f0583 100644 --- a/grafana-plugin/src/models/organization/organization.types.ts +++ b/grafana-plugin/src/models/organization/organization.types.ts @@ -32,8 +32,3 @@ export interface Organization { mattermost_configured: boolean; }; } - -export interface OrganizationConfigChecks { - is_chatops_connected: boolean; - is_integration_chatops_connected: boolean; -} From e079a9f45347e1fd12e6fd5bbfcd00935168f83f Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 17 Sep 2024 22:04:51 +0530 Subject: [PATCH 16/43] Remove missed config check --- grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts index b4c7d881af..ffe72b95a3 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -116,7 +116,6 @@ export class RootBaseStore { await retryFailingPromises([ () => this.userStore.loadCurrentUser(), () => this.organizationStore.loadCurrentOrganization(), - () => this.organizationStore.loadCurrentOrganizationConfigChecks(), () => this.grafanaTeamStore.updateItems(), () => updateFeatures(), () => this.alertReceiveChannelStore.fetchAlertReceiveChannelOptions(), From de83c3edd9f283a25078b0a3b254b3bf7796d710 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Wed, 18 Sep 2024 10:23:33 +0530 Subject: [PATCH 17/43] Mattermost Channel Integration UI Changes --- .../MattermostIntegrationButton.module.css | 13 ++ .../MattermostIntegrationButton.tsx | 169 ++++++++++++++++++ .../src/models/mattermost/mattermost.types.ts | 7 + .../models/mattermost/mattermost_channel.ts | 66 +++++++ .../MattermostSettings.module.css | 9 + .../MattermostSettings/MattermostSettings.tsx | 166 +++++++++++++---- .../src/state/rootBaseStore/RootBaseStore.ts | 2 + 7 files changed, 397 insertions(+), 35 deletions(-) create mode 100644 grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.module.css create mode 100644 grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx create mode 100644 grafana-plugin/src/models/mattermost/mattermost_channel.ts diff --git a/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.module.css b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.module.css new file mode 100644 index 0000000000..79ec523193 --- /dev/null +++ b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.module.css @@ -0,0 +1,13 @@ +.channelFormField__inputContainer { + width: 100%; + display: flex; +} + +.channelFormField__input { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.field { + flex-grow: 1; +} diff --git a/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx new file mode 100644 index 0000000000..1a4209f977 --- /dev/null +++ b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useState } from 'react'; + +import { Button, Modal, Field, Input, Stack } from '@grafana/ui'; +import cn from 'classnames/bind'; +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'; + +import styles from './MattermostIntegrationButton.module.css'; + +const cx = cn.bind(styles); + +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(false); + + const onModalCreateCallback = useCallback(() => { + setShowModal(true); + }, []); + + const onModalCancelCallback = useCallback(() => { + setShowModal(false); + }, []); + + const onModalUpdateCallback = useCallback(() => { + setShowModal(false); + + onUpdate(); + }, [onUpdate]); + + return ( + <> + + + + {showModal && } + + ); +}); + +interface MattermostCreationModalProps { + onHide: () => void; + onUpdate: () => void; +} + +interface FormFields { + teamName: string; + channelName: string; +} + +const MattermostChannelForm = (props: MattermostCreationModalProps) => { + const { onHide, onUpdate } = props; + const store = useStore(); + + const formMethods = useForm({ + mode: 'onChange', + }); + + const { + control, + watch, + formState: { errors }, + handleSubmit, + } = formMethods; + + const teamName = watch('teamName'); + const channelName = watch('channelName'); + + return ( + + +
+ + {renderTeamNameInput()} + {renderChannelNameInput()} + + + + + +
+
+
+ ); + + function renderTeamNameInput() { + return ( + ( + + + + )} + /> + ); + } + + function renderChannelNameInput() { + return ( + ( + + + + )} + /> + ); + } + + async function onCreateChannelCallback() { + try { + await store.mattermostChannelStore.create( + { + team_name: teamName, + channel_name: channelName, + }, + true + ); + onUpdate(); + } catch (error) { + openErrorNotification(get(error, 'response.data.detail', 'error creating channel')); + } + } +}; diff --git a/grafana-plugin/src/models/mattermost/mattermost.types.ts b/grafana-plugin/src/models/mattermost/mattermost.types.ts index e69de29bb2..2c84f06feb 100644 --- a/grafana-plugin/src/models/mattermost/mattermost.types.ts +++ b/grafana-plugin/src/models/mattermost/mattermost.types.ts @@ -0,0 +1,7 @@ +export interface MattermostChannel { + id: string; + channel_id: string; + channel_name: string; + display_name: string; + is_default_channel: false; +} diff --git a/grafana-plugin/src/models/mattermost/mattermost_channel.ts b/grafana-plugin/src/models/mattermost/mattermost_channel.ts new file mode 100644 index 0000000000..99dc8d792d --- /dev/null +++ b/grafana-plugin/src/models/mattermost/mattermost_channel.ts @@ -0,0 +1,66 @@ +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 } = {}; + + constructor(rootStore: RootStore) { + super(rootStore); + + makeObservable(this); + + this.path = '/mattermost/channels/'; + } + + @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); + } +} diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css index f67b8557af..d2b2b3574d 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css @@ -1,3 +1,12 @@ +.root { + display: block; +} + +.header { + display: flex; + justify-content: space-between; +} + .mattermost-infoblock { text-align: center; width: 725px; diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx index 7dc7b45c55..f707f0fd65 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx @@ -1,14 +1,17 @@ import React, { Component } from 'react'; -import { Button, Stack, Icon } from '@grafana/ui'; +import { Badge, Button, LoadingPlaceholder, Stack } from '@grafana/ui'; import cn from 'classnames/bind'; import { DOCS_MATTERMOST_SETUP, StackSize } from 'helpers/consts'; -import { showApiError } from 'helpers/helpers'; 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'; @@ -25,22 +28,23 @@ interface MattermostState {} class _MattermostSettings extends Component { state: MattermostState = {}; - handleOpenMattermostInstructions = async () => { + componentDidMount() { + this.update(); + } + + update = () => { const { store } = this.props; - try { - await store.mattermostStore.installMattermostIntegration(); - } catch (err) { - showApiError(err); - } + + store.mattermostChannelStore.updateItems(); }; render() { const { store } = this.props; - const { organizationStore } = store; - const envStatus = organizationStore.currentOrganization?.env_status.mattermost_configured; - const isIntegrated = false; // TODO: Check if integration is configured and can show channels view + const { mattermostChannelStore, organizationStore } = store; + const connectedChannels = mattermostChannelStore.getSearchResult(); + const mattermostConfigured = organizationStore.currentOrganization?.env_status.mattermost_configured; - if (!isIntegrated) { + if (!mattermostConfigured && store.hasFeature(AppFeature.LiveSettings)) { return ( Connect Mattermost workspace @@ -62,37 +66,129 @@ class _MattermostSettings extends Component { - {envStatus ? ( - - - {store.hasFeature(AppFeature.LiveSettings) && ( - - - - )} - - ) : ( - - - - - - )} + + + ); - } else { + } + + if (!connectedChannels) { + return ; + } + + if (!connectedChannels.length) { return ( - Connected Mattermost workspace + Connect Mattermost workspace + + + + Connecting Mattermost App will allow you to manage alert groups in your team Mattermost workspace. + + + + After a basic workspace connection your team members need to connect their personal Mattermost accounts + in order to be allowed to manage alert groups. + + + More details in{' '} + + our documentation + + + + + + + {store.hasFeature(AppFeature.LiveSettings) && ( + + + + )} + ); } + + const columns = [ + { + width: '70%', + title: 'Channel', + key: 'name', + render: this.renderChannelName, + }, + { + width: '30%', + key: 'action', + render: this.renderActionButtons, + }, + ]; + + return ( +
+ {connectedChannels && ( +
+ ( +
+ Mattermost Channels + +
+ )} + emptyText={connectedChannels ? 'No Mattermost channels connected' : 'Loading...'} + rowKey="id" + columns={columns} + data={connectedChannels} + /> +
+ )} +
+ ); } + + renderChannelName = (record: MattermostChannel) => { + return ( + <> + {record.display_name} {record.is_default_channel && } + + ); + }; + + renderActionButtons = (record: MattermostChannel) => { + return ( + + + + + + + ); + }; + + 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); diff --git a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts index ffe72b95a3..1e6fc98dc0 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -21,6 +21,7 @@ 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'; @@ -84,6 +85,7 @@ export class RootBaseStore { 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); From ec5aee2c8be7ccf69ffe8f956d2cb41d89630e8e Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Thu, 19 Sep 2024 19:13:50 +0530 Subject: [PATCH 18/43] Use channel id insted of channel name and team name --- engine/apps/mattermost/client.py | 4 +- engine/apps/mattermost/serializers.py | 14 ++--- engine/apps/mattermost/tests/conftest.py | 6 +- .../tests/test_mattermost_channel.py | 17 +++--- .../tests/test_mattermost_client.py | 14 ++--- .../MattermostIntegrationButton.tsx | 59 ++++--------------- 6 files changed, 36 insertions(+), 78 deletions(-) diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py index de6339b205..266dad5216 100644 --- a/engine/apps/mattermost/client.py +++ b/engine/apps/mattermost/client.py @@ -60,8 +60,8 @@ def _check_response(self, response: requests.models.Response): method=ex.response.request.method, ) - def get_channel_by_name_and_team_name(self, team_name: str, channel_name: str) -> MattermostChannel: - url = f"{self.base_url}/teams/name/{team_name}/channels/name/{channel_name}" + 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() diff --git a/engine/apps/mattermost/serializers.py b/engine/apps/mattermost/serializers.py index 77d9db1969..accb03613a 100644 --- a/engine/apps/mattermost/serializers.py +++ b/engine/apps/mattermost/serializers.py @@ -36,19 +36,13 @@ def to_representation(self, instance): return ret def to_internal_value(self, data): - team_name = data.get("team_name") - channel_name = data.get("channel_name") + channel_id = data.get("channel_id") - if not team_name: - raise serializers.ValidationError({"team_name": "This field is required."}) - - if not channel_name: - raise serializers.ValidationError({"channel_name": "This field is required."}) + if not channel_id: + raise serializers.ValidationError({"channel_id": "This field is required."}) try: - response = MattermostClient().get_channel_by_name_and_team_name( - team_name=team_name, channel_name=channel_name - ) + response = MattermostClient().get_channel_by_id(channel_id=channel_id) except MattermostAPIException as ex: raise BadRequest(detail=ex.msg) except MattermostAPITokenInvalid: diff --git a/engine/apps/mattermost/tests/conftest.py b/engine/apps/mattermost/tests/conftest.py index 81f09cf998..b53253ed1d 100644 --- a/engine/apps/mattermost/tests/conftest.py +++ b/engine/apps/mattermost/tests/conftest.py @@ -2,8 +2,8 @@ @pytest.fixture() -def make_mattermost_get_channel_by_name_team_name_response(): - def _make_mattermost_get_channel_by_name_team_name_response(): +def make_mattermost_get_channel_response(): + def _make_mattermost_get_channel_response(): return { "id": "pbg5piuc5bgniftrserb88575h", "team_id": "oxfug4kgx3fx7jzow49cpxkmgo", @@ -11,4 +11,4 @@ def _make_mattermost_get_channel_by_name_team_name_response(): "name": "town-square", } - return _make_mattermost_get_channel_by_name_team_name_response + return _make_mattermost_get_channel_response diff --git a/engine/apps/mattermost/tests/test_mattermost_channel.py b/engine/apps/mattermost/tests/test_mattermost_channel.py index 39d6d15d90..81b20ff48b 100644 --- a/engine/apps/mattermost/tests/test_mattermost_channel.py +++ b/engine/apps/mattermost/tests/test_mattermost_channel.py @@ -125,7 +125,7 @@ def test_delete_mattermost_channels_permissions( ) def test_post_mattermost_channels_permissions( make_organization_and_user_with_plugin_token, - make_mattermost_get_channel_by_name_team_name_response, + make_mattermost_get_channel_response, make_user_auth_headers, role, expected_status, @@ -133,7 +133,7 @@ def test_post_mattermost_channels_permissions( client = APIClient() _, user, token = make_organization_and_user_with_plugin_token(role) - data = make_mattermost_get_channel_by_name_team_name_response() + 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() @@ -142,7 +142,7 @@ def test_post_mattermost_channels_permissions( with patch("apps.mattermost.client.requests.get", return_value=channel_response) as mock_request: response = client.post( url, - data={"team_name": "fuzzteam", "channel_name": "fuzzchannel"}, + data={"channel_id": "fuzzchannel"}, format="json", **make_user_auth_headers(user, token), ) @@ -160,14 +160,13 @@ def test_post_mattermost_channels_permissions( @pytest.mark.parametrize( "request_body,expected_status", [ - ({"team_name": "fuzzteam", "channel_name": "fuzzchannel"}, status.HTTP_201_CREATED), - ({"team_name": "fuzzteam"}, status.HTTP_400_BAD_REQUEST), - ({"channel_name": "fuzzchannel"}, status.HTTP_400_BAD_REQUEST), + ({"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_by_name_team_name_response, + make_mattermost_get_channel_response, make_user_auth_headers, request_body, expected_status, @@ -175,7 +174,7 @@ def test_post_mattermost_channels( client = APIClient() _, user, token = make_organization_and_user_with_plugin_token() - data = make_mattermost_get_channel_by_name_team_name_response() + 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() @@ -212,7 +211,7 @@ def test_post_mattermost_channels_mattermost_api_call_failure( with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: response = client.post( url, - data={"team_name": "fuzzteam", "channel_name": "fuzzchannel"}, + data={"channel_id": "fuzzchannel"}, format="json", **make_user_auth_headers(user, token), ) diff --git a/engine/apps/mattermost/tests/test_mattermost_client.py b/engine/apps/mattermost/tests/test_mattermost_client.py index f82768fc0e..5c88d24329 100644 --- a/engine/apps/mattermost/tests/test_mattermost_client.py +++ b/engine/apps/mattermost/tests/test_mattermost_client.py @@ -18,14 +18,14 @@ def test_mattermost_client_initialization(): @pytest.mark.django_db -def test_get_channel_by_name_and_team_name_ok(make_mattermost_get_channel_by_name_team_name_response): +def test_get_channel_by_id_ok(make_mattermost_get_channel_response): client = MattermostClient("abcd") - data = make_mattermost_get_channel_by_name_team_name_response() + 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() with patch("apps.mattermost.client.requests.get", return_value=channel_response) as mock_request: - response = client.get_channel_by_name_and_team_name("test-team", "test-channel") + response = client.get_channel_by_id("fuzzz") mock_request.assert_called_once() assert response.channel_id == data["id"] assert response.team_id == data["team_id"] @@ -34,7 +34,7 @@ def test_get_channel_by_name_and_team_name_ok(make_mattermost_get_channel_by_nam @pytest.mark.django_db -def test_get_channel_by_name_and_team_name_failure(): +def test_get_channel_by_id_failure(): client = MattermostClient("abcd") data = { "status_code": status.HTTP_400_BAD_REQUEST, @@ -54,7 +54,7 @@ def test_get_channel_by_name_and_team_name_failure(): mock_response.raise_for_status.side_effect = requests.HTTPError(response=mock_response) with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: with pytest.raises(MattermostAPIException) as exc: - client.get_channel_by_name_and_team_name("test-team", "test-channel") + client.get_channel_by_id("fuzzz") mock_request.assert_called_once() # Timeout Error @@ -67,7 +67,7 @@ def test_get_channel_by_name_and_team_name_failure(): mock_response.raise_for_status.side_effect = requests.Timeout(response=mock_response) with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: with pytest.raises(MattermostAPIException) as exc: - client.get_channel_by_name_and_team_name("test-team", "test-channel") + client.get_channel_by_id("fuzzz") assert exc.value.msg == "Mattermost api call gateway timedout" mock_request.assert_called_once() @@ -81,6 +81,6 @@ def test_get_channel_by_name_and_team_name_failure(): mock_response.raise_for_status.side_effect = requests.exceptions.RequestException(response=mock_response) with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: with pytest.raises(MattermostAPIException) as exc: - client.get_channel_by_name_and_team_name("test-team", "test-channel") + client.get_channel_by_id("fuzzz") assert exc.value.msg == "Unexpected error from mattermost server" mock_request.assert_called_once() diff --git a/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx index 1a4209f977..99c8ed7ce4 100644 --- a/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx +++ b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx @@ -57,8 +57,7 @@ interface MattermostCreationModalProps { } interface FormFields { - teamName: string; - channelName: string; + channelId: string; } const MattermostChannelForm = (props: MattermostCreationModalProps) => { @@ -76,21 +75,19 @@ const MattermostChannelForm = (props: MattermostCreationModalProps) => { handleSubmit, } = formMethods; - const teamName = watch('teamName'); - const channelName = watch('channelName'); + const channelId = watch('channelId'); return (
- {renderTeamNameInput()} - {renderChannelNameInput()} + {renderChannelIdInput()} - @@ -100,50 +97,24 @@ const MattermostChannelForm = (props: MattermostCreationModalProps) => { ); - function renderTeamNameInput() { + function renderChannelIdInput() { return ( ( - - )} - /> - ); - } - - function renderChannelNameInput() { - return ( - ( - - @@ -154,13 +125,7 @@ const MattermostChannelForm = (props: MattermostCreationModalProps) => { async function onCreateChannelCallback() { try { - await store.mattermostChannelStore.create( - { - team_name: teamName, - channel_name: channelName, - }, - true - ); + await store.mattermostChannelStore.create({ channel_id: channelId }, true); onUpdate(); } catch (error) { openErrorNotification(get(error, 'response.data.detail', 'error creating channel')); From ca51d5b99fea54f239c01b474a27d10d73b927a5 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 24 Sep 2024 08:11:11 +0530 Subject: [PATCH 19/43] Review comments --- engine/apps/mattermost/models/channel.py | 4 ---- engine/apps/mattermost/serializers.py | 5 ----- .../mattermost/tests/test_mattermost_channel.py | 8 ++++---- .../tabs/MattermostSettings/MattermostSettings.tsx | 13 +++++++++++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/engine/apps/mattermost/models/channel.py b/engine/apps/mattermost/models/channel.py index 8b0277a2c7..f901b814eb 100644 --- a/engine/apps/mattermost/models/channel.py +++ b/engine/apps/mattermost/models/channel.py @@ -41,10 +41,6 @@ class MattermostChannel(models.Model): is_default_channel = models.BooleanField(null=True, default=False) created_at = models.DateTimeField(auto_now_add=True) - @property - def unique_display_name(self) -> str: - return f"{self.display_name}-{self.mattermost_team_id[:5]}" - class Meta: unique_together = ("organization", "channel_id") diff --git a/engine/apps/mattermost/serializers.py b/engine/apps/mattermost/serializers.py index accb03613a..cb4d7a8c4d 100644 --- a/engine/apps/mattermost/serializers.py +++ b/engine/apps/mattermost/serializers.py @@ -30,11 +30,6 @@ class Meta: def create(self, validated_data): return MattermostChannel.objects.create(**validated_data) - def to_representation(self, instance): - ret = super().to_representation(instance) - ret["display_name"] = instance.unique_display_name - return ret - def to_internal_value(self, data): channel_id = data.get("channel_id") diff --git a/engine/apps/mattermost/tests/test_mattermost_channel.py b/engine/apps/mattermost/tests/test_mattermost_channel.py index 81b20ff48b..ba11fbe68a 100644 --- a/engine/apps/mattermost/tests/test_mattermost_channel.py +++ b/engine/apps/mattermost/tests/test_mattermost_channel.py @@ -152,7 +152,7 @@ def test_post_mattermost_channels_permissions( mock_request.assert_called_once() assert res["channel_id"] == data["id"] assert res["channel_name"] == data["name"] - assert res["display_name"] == f"{data['display_name']}-{data['team_id'][:5]}" + assert res["display_name"] == data["display_name"] assert res["is_default_channel"] is False @@ -264,14 +264,14 @@ def test_list_mattermost_channels( "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.unique_display_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.unique_display_name, + "display_name": second_mattermost_channel.display_name, "is_default_channel": second_mattermost_channel.is_default_channel, }, ] @@ -298,7 +298,7 @@ def test_get_mattermost_channel( "id": mattermost_channel.public_primary_key, "channel_id": mattermost_channel.channel_id, "channel_name": mattermost_channel.channel_name, - "display_name": mattermost_channel.unique_display_name, + "display_name": mattermost_channel.display_name, "is_default_channel": mattermost_channel.is_default_channel, } diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx index f707f0fd65..4615ac2ba6 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx @@ -113,11 +113,16 @@ class _MattermostSettings extends Component { const columns = [ { - width: '70%', - title: 'Channel', + width: '35%', + title: 'Channel Name', key: 'name', render: this.renderChannelName, }, + { + width: '35%', + title: 'Channel ID', + render: this.renderChannelId, + }, { width: '30%', key: 'action', @@ -155,6 +160,10 @@ class _MattermostSettings extends Component { ); }; + renderChannelId = (record: MattermostChannel) => { + return <>{record.channel_id}; + }; + renderActionButtons = (record: MattermostChannel) => { return ( From 63a575aaf7573eebfb77ed1c65ef90bf014c77ba Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 1 Oct 2024 09:53:28 +0530 Subject: [PATCH 20/43] Update to emotion styling --- .../MattermostIntegrationButton.module.css | 13 ----- .../MattermostIntegrationButton.tsx | 27 +++++++--- .../MattermostSettings.module.css | 24 --------- .../MattermostSettings/MattermostSettings.tsx | 50 +++++++++++++------ 4 files changed, 54 insertions(+), 60 deletions(-) delete mode 100644 grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.module.css delete mode 100644 grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css diff --git a/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.module.css b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.module.css deleted file mode 100644 index 79ec523193..0000000000 --- a/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.module.css +++ /dev/null @@ -1,13 +0,0 @@ -.channelFormField__inputContainer { - width: 100%; - display: flex; -} - -.channelFormField__input { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} - -.field { - flex-grow: 1; -} diff --git a/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx index 99c8ed7ce4..0fa5835673 100644 --- a/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx +++ b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useState } from 'react'; -import { Button, Modal, Field, Input, Stack } from '@grafana/ui'; -import cn from 'classnames/bind'; +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'; @@ -11,10 +11,6 @@ import { Controller, FormProvider, useForm } from 'react-hook-form'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { useStore } from 'state/useStore'; -import styles from './MattermostIntegrationButton.module.css'; - -const cx = cn.bind(styles); - interface MattermostIntegrationProps { disabled?: boolean; size?: 'md' | 'lg'; @@ -77,6 +73,8 @@ const MattermostChannelForm = (props: MattermostCreationModalProps) => { const channelId = watch('channelId'); + const styles = useStyles2(getStyles); + return ( @@ -108,11 +106,11 @@ const MattermostChannelForm = (props: MattermostCreationModalProps) => { label="Mattermost Channel ID" invalid={Boolean(errors['channelId'])} error={errors['channelId']?.message} - className={cx('field')} + className={styles.field} > { } } }; + +const getStyles = () => { + return { + channelFormFieldInput: css ` + border-top-right-radius: 0; + border-bottom-right-radius: 0; + `, + + field: css ` + flex-grow: 1; + ` + } +} diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css deleted file mode 100644 index d2b2b3574d..0000000000 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.module.css +++ /dev/null @@ -1,24 +0,0 @@ -.root { - display: block; -} - -.header { - display: flex; - justify-content: space-between; -} - -.mattermost-infoblock { - text-align: center; - width: 725px; -} - -.infoblock-text { - margin-left: 48px; - margin-right: 48px; - margin-top: 24px; -} - -.external-link-style { - margin-right: 4px; - align-self: baseline; -} diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx index 4615ac2ba6..3ec492d351 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; +import { css } from '@emotion/css'; import { Badge, Button, LoadingPlaceholder, Stack } from '@grafana/ui'; -import cn from 'classnames/bind'; import { DOCS_MATTERMOST_SETUP, StackSize } from 'helpers/consts'; import { observer } from 'mobx-react'; @@ -16,10 +16,6 @@ import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; -import styles from './MattermostSettings.module.css'; - -const cx = cn.bind(styles); - interface MattermostProps extends WithStoreProps {} interface MattermostState {} @@ -42,23 +38,25 @@ class _MattermostSettings extends Component { 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 ( Connect Mattermost workspace - + - + Connecting Mattermost App will allow you to manage alert groups in your team Mattermost workspace. - + After a basic workspace connection your team members need to connect their personal Mattermost accounts in order to be allowed to manage alert groups. - + More details in{' '} our documentation @@ -81,17 +79,17 @@ class _MattermostSettings extends Component { return ( Connect Mattermost workspace - + - + Connecting Mattermost App will allow you to manage alert groups in your team Mattermost workspace. - + After a basic workspace connection your team members need to connect their personal Mattermost accounts in order to be allowed to manage alert groups. - + More details in{' '} our documentation @@ -133,10 +131,10 @@ class _MattermostSettings extends Component { return (
{connectedChannels && ( -
+
( -
+
Mattermost Channels
@@ -201,3 +199,25 @@ class _MattermostSettings extends Component { } 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; + `, + }; +}; From 0068a5a49e8a331042f05b92f62d3dbea7ab114f Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Fri, 20 Sep 2024 21:33:10 +0530 Subject: [PATCH 21/43] Mattermost User Integration Fix lint --- dev/.env.dev.example | 1 + engine/apps/api/tests/test_auth.py | 83 ++++++++++++++++++- engine/apps/api/views/auth.py | 2 +- .../migrations/0007_mattermostauthtoken.py | 4 +- .../models/mattermost_auth_token.py | 16 ++-- engine/apps/base/models/live_setting.py | 6 ++ engine/apps/mattermost/backend.py | 23 +++++ engine/apps/mattermost/client.py | 14 ++++ .../mattermost/migrations/0001_initial.py | 16 +++- engine/apps/mattermost/models/__init__.py | 1 + engine/apps/mattermost/models/user.py | 12 +++ engine/apps/mattermost/tests/conftest.py | 12 +++ engine/apps/social_auth/backends.py | 34 ++++++-- engine/apps/social_auth/exceptions.py | 5 ++ .../live_setting_django_strategy.py | 6 ++ engine/apps/social_auth/middlewares.py | 4 +- engine/apps/social_auth/pipeline/common.py | 4 + .../apps/social_auth/pipeline/mattermost.py | 31 +++++++ engine/apps/social_auth/pipeline/slack.py | 4 - engine/conftest.py | 9 ++ engine/settings/base.py | 18 ++-- .../containers/UserSettings/UserSettings.tsx | 46 +++++++++- .../UserSettings/UserSettings.types.ts | 1 + .../UserSettings/parts/UserSettingsParts.tsx | 13 +++ .../parts/connectors/Connectors.tsx | 2 + .../parts/connectors/MattermostConnector.tsx | 58 +++++++++++++ .../MattermostInfo/MattermostInfo.module.css | 13 +++ .../tabs/MattermostInfo/MattermostInfo.tsx | 50 +++++++++++ .../src/models/mattermost/mattermost.ts | 4 +- 29 files changed, 455 insertions(+), 37 deletions(-) create mode 100644 engine/apps/mattermost/backend.py create mode 100644 engine/apps/mattermost/models/user.py create mode 100644 engine/apps/social_auth/pipeline/mattermost.py create mode 100644 grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx create mode 100644 grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.module.css create mode 100644 grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx diff --git a/dev/.env.dev.example b/dev/.env.dev.example index 47e8a8fdb8..da276f8a23 100644 --- a/dev/.env.dev.example +++ b/dev/.env.dev.example @@ -17,6 +17,7 @@ MATTERMOST_CLIENT_OAUTH_ID= MATTERMOST_CLIENT_OAUTH_SECRET= MATTERMOST_HOST= MATTERMOST_BOT_TOKEN= +MATTERMOST_LOGIN_RETURN_REDIRECT_HOST=http://localhost:8080 DJANGO_SETTINGS_MODULE=settings.dev SECRET_KEY=jyRnfRIeMjYfKdoFa9dKXcNaEGGc8GH1TChmYoWW diff --git a/engine/apps/api/tests/test_auth.py b/engine/apps/api/tests/test_auth.py index 4800107e18..d4c7fbe350 100644 --- a/engine/apps/api/tests/test_auth.py +++ b/engine/apps/api/tests/test_auth.py @@ -8,8 +8,8 @@ from rest_framework import status from rest_framework.test import APIClient -from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME -from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND +from 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( diff --git a/engine/apps/api/views/auth.py b/engine/apps/api/views/auth.py index b97debb44f..d40e08dbae 100644 --- a/engine/apps/api/views/auth.py +++ b/engine/apps/api/views/auth.py @@ -25,7 +25,7 @@ ) 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__) diff --git a/engine/apps/auth_token/migrations/0007_mattermostauthtoken.py b/engine/apps/auth_token/migrations/0007_mattermostauthtoken.py index bb2a25d4b0..8e32d58679 100644 --- a/engine/apps/auth_token/migrations/0007_mattermostauthtoken.py +++ b/engine/apps/auth_token/migrations/0007_mattermostauthtoken.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-08-08 14:08 +# Generated by Django 4.2.15 on 2024-09-18 15:13 import apps.auth_token.models.mattermost_auth_token from django.db import migrations, models @@ -23,7 +23,7 @@ class Migration(migrations.Migration): ('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.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_auth_token_set', to='user_management.user')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_auth_token', to='user_management.user')), ], options={ 'abstract': False, diff --git a/engine/apps/auth_token/models/mattermost_auth_token.py b/engine/apps/auth_token/models/mattermost_auth_token.py index 5143dd6391..627f87fc81 100644 --- a/engine/apps/auth_token/models/mattermost_auth_token.py +++ b/engine/apps/auth_token/models/mattermost_auth_token.py @@ -13,18 +13,8 @@ def get_expire_date(): return timezone.now() + timezone.timedelta(seconds=AUTH_TOKEN_TIMEOUT_SECONDS) -class MattermostAuthTokenQuerySet(models.QuerySet): - def filter(self, *args, **kwargs): - now = timezone.now() - return super().filter(*args, **kwargs, revoked_at=None, expire_date__gte=now) - - def delete(self): - self.update(revoked_at=timezone.now()) - - class MattermostAuthToken(BaseAuthToken): - objects = MattermostAuthTokenQuerySet.as_manager() - user = models.ForeignKey("user_management.User", related_name="mattermost_auth_token_set", on_delete=models.CASCADE) + 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 ) @@ -32,6 +22,10 @@ class MattermostAuthToken(BaseAuthToken): @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) diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index 4a5eb2ed2c..32bdeadb93 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -84,6 +84,7 @@ class LiveSetting(models.Model): "MATTERMOST_CLIENT_OAUTH_SECRET", "MATTERMOST_HOST", "MATTERMOST_BOT_TOKEN", + "MATTERMOST_LOGIN_RETURN_REDIRECT_HOST", ) DESCRIPTIONS = { @@ -211,6 +212,11 @@ class LiveSetting(models.Model): "https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup" "' target='_blank'>instruction
for details how to set up Mattermost. " ), + "MATTERMOST_LOGIN_RETURN_REDIRECT_HOST": ( + "Check instruction for details how to set up Mattermost. " + ), } SECRET_SETTING_NAMES = ( diff --git a/engine/apps/mattermost/backend.py b/engine/apps/mattermost/backend.py new file mode 100644 index 0000000000..68705ab82f --- /dev/null +++ b/engine/apps/mattermost/backend.py @@ -0,0 +1,23 @@ +from apps.base.messaging import BaseMessagingBackend + + +class MattermostBackend(BaseMessagingBackend): + backend_id = "MATTERMOST" + label = "Mattermost" + short_label = "Mattermost" + available_for_use = True + + 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_connection", None) + if not mattermost_user: + return None + return { + "mattermost_user_id": mattermost_user.mattermost_user_id, + "username": mattermost_user.username, + } diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py index 266dad5216..5a3c001168 100644 --- a/engine/apps/mattermost/client.py +++ b/engine/apps/mattermost/client.py @@ -18,6 +18,13 @@ def __call__(self, request: PreparedRequest) -> PreparedRequest: return request +@dataclass +class MattermostUser: + user_id: str + username: str + nickname: str + + @dataclass class MattermostChannel: channel_id: str @@ -68,3 +75,10 @@ def get_channel_by_id(self, channel_id: str) -> MattermostChannel: 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"]) diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index c9a457e3c8..f8ff55dece 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-09-10 03:01 +# Generated by Django 4.2.15 on 2024-09-18 15:13 import apps.mattermost.models.channel import django.core.validators @@ -15,6 +15,20 @@ class Migration(migrations.Migration): ] operations = [ + 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_connection', to='user_management.user')), + ], + options={ + 'unique_together': {('user', 'mattermost_user_id')}, + }, + ), migrations.CreateModel( name='MattermostChannel', fields=[ diff --git a/engine/apps/mattermost/models/__init__.py b/engine/apps/mattermost/models/__init__.py index 6e596eb1bb..0cf4dd7f73 100644 --- a/engine/apps/mattermost/models/__init__.py +++ b/engine/apps/mattermost/models/__init__.py @@ -1 +1,2 @@ from .channel import MattermostChannel # noqa: F401 +from .user import MattermostUser # noqa F401 diff --git a/engine/apps/mattermost/models/user.py b/engine/apps/mattermost/models/user.py new file mode 100644 index 0000000000..7a4f841587 --- /dev/null +++ b/engine/apps/mattermost/models/user.py @@ -0,0 +1,12 @@ +from django.db import models + + +class MattermostUser(models.Model): + user = models.OneToOneField("user_management.User", on_delete=models.CASCADE, related_name="mattermost_connection") + 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: + unique_together = ("user", "mattermost_user_id") diff --git a/engine/apps/mattermost/tests/conftest.py b/engine/apps/mattermost/tests/conftest.py index b53253ed1d..20b8fe28aa 100644 --- a/engine/apps/mattermost/tests/conftest.py +++ b/engine/apps/mattermost/tests/conftest.py @@ -12,3 +12,15 @@ def _make_mattermost_get_channel_response(): } 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 diff --git a/engine/apps/social_auth/backends.py b/engine/apps/social_auth/backends.py index 370db9a61f..33824536d7 100644 --- a/engine/apps/social_auth/backends.py +++ b/engine/apps/social_auth/backends.py @@ -1,5 +1,6 @@ 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 @@ -7,7 +8,10 @@ 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.base.utils import live_settings +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. @@ -205,8 +209,11 @@ def get_scope(self): return {"user_scope": USER_SCOPE, "scope": BOT_SCOPE} -class InstallMattermostOAuth2(BaseOAuth2): - name = "mattermost-install" +MATTERMOST_LOGIN_BACKEND = "mattermost-login" + + +class LoginMattermostOAuth2(BaseOAuth2): + name = MATTERMOST_LOGIN_BACKEND REDIRECT_STATE = False """ @@ -222,10 +229,10 @@ class InstallMattermostOAuth2(BaseOAuth2): AUTH_TOKEN_NAME = MATTERMOST_AUTH_TOKEN_NAME def authorization_url(self): - return f"{live_settings.MATTERMOST_HOST}/oauth/authorize" + return f"{settings.MATTERMOST_HOST}/oauth/authorize" def access_token_url(self): - return f"{live_settings.MATTERMOST_HOST}/oauth/access_token" + return f"{settings.MATTERMOST_HOST}/oauth/access_token" def get_user_details(self, response): """ @@ -242,7 +249,22 @@ def get_user_details(self, response): } """ - return {} + 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): """ diff --git a/engine/apps/social_auth/exceptions.py b/engine/apps/social_auth/exceptions.py index 8912c9a55b..dd68101f2c 100644 --- a/engine/apps/social_auth/exceptions.py +++ b/engine/apps/social_auth/exceptions.py @@ -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" diff --git a/engine/apps/social_auth/live_setting_django_strategy.py b/engine/apps/social_auth/live_setting_django_strategy.py index 6e103bfb21..7e4f295fc2 100644 --- a/engine/apps/social_auth/live_setting_django_strategy.py +++ b/engine/apps/social_auth/live_setting_django_strategy.py @@ -37,6 +37,12 @@ def build_absolute_uri(self, path=None): """ 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: diff --git a/engine/apps/social_auth/middlewares.py b/engine/apps/social_auth/middlewares.py index 09583dcd89..9ec7a2e0d7 100644 --- a/engine/apps/social_auth/middlewares.py +++ b/engine/apps/social_auth/middlewares.py @@ -8,7 +8,7 @@ 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 +43,5 @@ def process_exception(self, request, exception): 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}")) diff --git a/engine/apps/social_auth/pipeline/common.py b/engine/apps/social_auth/pipeline/common.py index bf33377b53..6b84a935c3 100644 --- a/engine/apps/social_auth/pipeline/common.py +++ b/engine/apps/social_auth/pipeline/common.py @@ -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() diff --git a/engine/apps/social_auth/pipeline/mattermost.py b/engine/apps/social_auth/pipeline/mattermost.py new file mode 100644 index 0000000000..702e8abcd7 --- /dev/null +++ b/engine/apps/social_auth/pipeline/mattermost.py @@ -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, + ) diff --git a/engine/apps/social_auth/pipeline/slack.py b/engine/apps/social_auth/pipeline/slack.py index 1564c5a264..00cacc4685 100644 --- a/engine/apps/social_auth/pipeline/slack.py +++ b/engine/apps/social_auth/pipeline/slack.py @@ -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() diff --git a/engine/conftest.py b/engine/conftest.py index 786fad83d8..75552a49ea 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -52,6 +52,7 @@ ApiAuthToken, GoogleOAuth2Token, IntegrationBacksyncAuthToken, + MattermostAuthToken, PluginAuthToken, ServiceAccountToken, SlackAuthToken, @@ -327,6 +328,14 @@ def _make_slack_token_for_user(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): diff --git a/engine/settings/base.py b/engine/settings/base.py index 9289a9b9aa..bed942837a 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -691,7 +691,7 @@ class BrokerTypes: # https://python-social-auth.readthedocs.io/en/latest/configuration/settings.html AUTHENTICATION_BACKENDS = [ - "apps.social_auth.backends.InstallMattermostOAuth2", + "apps.social_auth.backends.LoginMattermostOAuth2", "apps.social_auth.backends.InstallSlackOAuth2V2", "apps.social_auth.backends.LoginSlackOAuth2V2", "django.contrib.auth.backends.ModelBackend", @@ -736,19 +736,20 @@ class BrokerTypes: 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) SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET -SOCIAL_AUTH_MATTERMOST_INSTALL_KEY = MATTERMOST_CLIENT_OAUTH_ID -SOCIAL_AUTH_MATTERMOST_INSTALL_SECRET = MATTERMOST_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_INSTALL_KEY": "MATTERMOST_CLIENT_OAUTH_ID", - "SOCIAL_AUTH_MATTERMOST_INSTALL_SECRET": "MATTERMOST_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", @@ -764,7 +765,8 @@ class BrokerTypes: "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 = ( @@ -876,6 +878,10 @@ class BrokerTypes: 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") diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index 0e77055972..ba9eb311ec 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -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(); + const [showMattermostConnectAlert, setshowMattermostConnectAlert] = useState(); 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 ( +
+ + {getMattermostErrorMessage(showMattermostConnectAlert)} + +
+ ); + } + return (
diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts index 6dfd1d9a5f..32fc08f2c7 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts @@ -7,4 +7,5 @@ export enum UserSettingsTab { TelegramInfo, MSTeamsInfo, MobileAppConnection, + MattermostInfo, } diff --git a/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx b/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx index 04ec6219d4..b04e7db945 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx @@ -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 { PhoneVerification } from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification'; import { TelegramInfo } from 'containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo'; @@ -30,6 +31,7 @@ interface TabsProps { showSlackConnectionTab: boolean; showTelegramConnectionTab: boolean; showMsTeamsConnectionTab: boolean; + showMattermostConnectionTab: boolean; } export const Tabs = ({ @@ -41,6 +43,7 @@ export const Tabs = ({ showSlackConnectionTab, showTelegramConnectionTab, showMsTeamsConnectionTab, + showMattermostConnectionTab, }: TabsProps) => { const getTabClickHandler = useCallback( (tab: UserSettingsTab) => { @@ -123,6 +126,15 @@ export const Tabs = ({ data-testid="tab-msteams" /> )} + {showMattermostConnectionTab && ( + + )} ); }; @@ -169,6 +181,7 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa {activeTab === UserSettingsTab.SlackInfo && } {activeTab === UserSettingsTab.TelegramInfo && } {activeTab === UserSettingsTab.MSTeamsInfo && } + {activeTab === UserSettingsTab.MattermostInfo && } ); diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx index 570c58dc0c..396766d311 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx @@ -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 { PhoneConnector } from './PhoneConnector'; import { SlackConnector } from './SlackConnector'; @@ -29,6 +30,7 @@ export const Connectors: FC = observer((props) => { {store.hasFeature(AppFeature.Telegram) && } {store.hasFeature(AppFeature.MsTeams) && } + {store.hasFeature(AppFeature.Mattermost) && } Calendar export diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx new file mode 100644 index 0000000000..f350ed1d99 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx @@ -0,0 +1,58 @@ +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); + }, [onTabChange]); + + const handleUnlinkMattermostAccount = useCallback(() => { + userStore.unlinkBackend(id, 'MATTERMOST'); + }, []); + + const mattermostConfigured = storeUser.messaging_backends['MATTERMOST']; + + return ( +
+ + {mattermostConfigured ? ( + + + + + )} + +
+ ); +}); diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.module.css b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.module.css new file mode 100644 index 0000000000..324f4487ba --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.module.css @@ -0,0 +1,13 @@ +.footer { + display: flex; + justify-content: flex-end; +} + +.mattermost-infoblock { + text-align: center; +} + +.external-link-style { + margin-right: 4px; + align-self: baseline; +} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx new file mode 100644 index 0000000000..4852e9da4c --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx @@ -0,0 +1,50 @@ +import React, { useCallback } from 'react'; + +import { Button, Stack } from '@grafana/ui'; +import cn from 'classnames/bind'; +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'; + +import styles from './MattermostInfo.module.css'; + +const cx = cn.bind(styles); + +export const MattermostInfo = () => { + const { mattermostStore } = useStore(); + + const handleClickConnectMattermostAccount = useCallback(() => { + mattermostStore.mattermostLogin(); + }, [mattermostStore]); + + return ( + + + + + + Personal Mattermost connection will allow you to manage alert group in your connected mattermost channel + + To setup personal mattermost click the button below and login to your mattermost server + + + More details in{' '} + + our documentation + + + + + + + + + + ); +}; diff --git a/grafana-plugin/src/models/mattermost/mattermost.ts b/grafana-plugin/src/models/mattermost/mattermost.ts index 7b54110126..478b80fba7 100644 --- a/grafana-plugin/src/models/mattermost/mattermost.ts +++ b/grafana-plugin/src/models/mattermost/mattermost.ts @@ -12,9 +12,9 @@ export class MattermostStore extends BaseStore { makeObservable(this); } - async installMattermostIntegration() { + async mattermostLogin() { try { - const response = await makeRequestRaw('/login/mattermost-install/', {}); + const response = await makeRequestRaw('/login/mattermost-login/', {}); if (response.status === 201) { this.rootStore.organizationStore.loadCurrentOrganization(); From 0d83050f47e3ab62bb9c53485bad2fde297f70f1 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 1 Oct 2024 09:09:20 +0530 Subject: [PATCH 22/43] Moving to emotion styling review comments --- .../MattermostInfo/MattermostInfo.module.css | 13 ------------ .../tabs/MattermostInfo/MattermostInfo.tsx | 20 ++++++++++++------- 2 files changed, 13 insertions(+), 20 deletions(-) delete mode 100644 grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.module.css diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.module.css b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.module.css deleted file mode 100644 index 324f4487ba..0000000000 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.module.css +++ /dev/null @@ -1,13 +0,0 @@ -.footer { - display: flex; - justify-content: flex-end; -} - -.mattermost-infoblock { - text-align: center; -} - -.external-link-style { - margin-right: 4px; - align-self: baseline; -} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx index 4852e9da4c..a4f3cac8cb 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react'; -import { Button, Stack } from '@grafana/ui'; -import cn from 'classnames/bind'; +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'; @@ -10,11 +10,9 @@ import { Text } from 'components/Text/Text'; import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay'; import { useStore } from 'state/useStore'; -import styles from './MattermostInfo.module.css'; - -const cx = cn.bind(styles); - export const MattermostInfo = () => { + const styles = useStyles2(getStyles); + const { mattermostStore } = useStore(); const handleClickConnectMattermostAccount = useCallback(() => { @@ -24,7 +22,7 @@ export const MattermostInfo = () => { return ( - + Personal Mattermost connection will allow you to manage alert group in your connected mattermost channel @@ -48,3 +46,11 @@ export const MattermostInfo = () => { ); }; + +const getStyles = () => { + return { + mattermostInfoblock: css` + text-align: center; + `, + }; +}; From 628538e89c495e9715db451f313d2a7520bc4e10 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Mon, 7 Oct 2024 07:44:53 +0530 Subject: [PATCH 23/43] Remove unnecessary unique index --- engine/apps/mattermost/migrations/0001_initial.py | 5 +---- engine/apps/mattermost/models/user.py | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index f8ff55dece..2601961c6d 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-09-18 15:13 +# Generated by Django 4.2.15 on 2024-10-07 02:12 import apps.mattermost.models.channel import django.core.validators @@ -25,9 +25,6 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True)), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_connection', to='user_management.user')), ], - options={ - 'unique_together': {('user', 'mattermost_user_id')}, - }, ), migrations.CreateModel( name='MattermostChannel', diff --git a/engine/apps/mattermost/models/user.py b/engine/apps/mattermost/models/user.py index 7a4f841587..86d3488962 100644 --- a/engine/apps/mattermost/models/user.py +++ b/engine/apps/mattermost/models/user.py @@ -7,6 +7,3 @@ class MattermostUser(models.Model): 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: - unique_together = ("user", "mattermost_user_id") From 04b265954a1c2091031280ee7492964d7d98a7d7 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Mon, 14 Oct 2024 13:18:56 +0530 Subject: [PATCH 24/43] Mattermost Alert Flow Add message type column Fix minor changes --- engine/apps/alerts/models/alert_group.py | 5 + engine/apps/base/models/live_setting.py | 6 + .../mattermost/alert_group_representative.py | 87 ++++++++++++ engine/apps/mattermost/alert_rendering.py | 128 ++++++++++++++++++ engine/apps/mattermost/apps.py | 3 + engine/apps/mattermost/client.py | 24 ++++ engine/apps/mattermost/exceptions.py | 8 ++ .../mattermost/migrations/0001_initial.py | 16 ++- engine/apps/mattermost/models/__init__.py | 1 + engine/apps/mattermost/models/channel.py | 11 ++ engine/apps/mattermost/models/message.py | 33 +++++ engine/apps/mattermost/models/user.py | 2 +- engine/apps/mattermost/signals.py | 5 + engine/apps/mattermost/tasks.py | 75 ++++++++++ engine/apps/mattermost/urls.py | 5 +- engine/apps/mattermost/utils.py | 37 +++++ engine/apps/mattermost/views.py | 27 ++++ engine/common/api_helpers/mixins.py | 5 +- engine/settings/base.py | 1 + engine/settings/celery_task_routes.py | 3 + .../AlertTemplatesForm.config.ts | 45 +++++- .../IntegrationTemplatesList.config.ts | 30 +++- 22 files changed, 546 insertions(+), 11 deletions(-) create mode 100644 engine/apps/mattermost/alert_group_representative.py create mode 100644 engine/apps/mattermost/alert_rendering.py create mode 100644 engine/apps/mattermost/models/message.py create mode 100644 engine/apps/mattermost/signals.py create mode 100644 engine/apps/mattermost/tasks.py create mode 100644 engine/apps/mattermost/utils.py diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 6bcb8d735f..be2079b2e6 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -50,6 +50,7 @@ ) from apps.base.models import UserNotificationPolicyLogRecord from apps.labels.models import AlertGroupAssociatedLabel + from apps.mattermost.models import MattermostMessage from apps.slack.models import SlackMessage logger = logging.getLogger(__name__) @@ -2007,6 +2008,10 @@ def slack_message(self) -> typing.Optional["SlackMessage"]: except AttributeError: return self.slack_messages.order_by("created_at").first() + @property + def mattermost_message(self) -> typing.Optional["MattermostMessage"]: + return self.mattermost_messages.order_by("created_at").first() + @cached_property def last_stop_escalation_log(self): from apps.alerts.models import AlertGroupLogRecord diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index 32bdeadb93..5921f4bf58 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -85,6 +85,7 @@ class LiveSetting(models.Model): "MATTERMOST_HOST", "MATTERMOST_BOT_TOKEN", "MATTERMOST_LOGIN_RETURN_REDIRECT_HOST", + "MATTERMOST_SIGNING_SECRET", ) DESCRIPTIONS = { @@ -217,6 +218,11 @@ class LiveSetting(models.Model): "https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup" "' target='_blank'>instruction for details how to set up Mattermost. " ), + "MATTERMOST_SIGNING_SECRET": ( + "Check instruction for details how to set up Mattermost. " + ), } SECRET_SETTING_NAMES = ( diff --git a/engine/apps/mattermost/alert_group_representative.py b/engine/apps/mattermost/alert_group_representative.py new file mode 100644 index 0000000000..d653619b3c --- /dev/null +++ b/engine/apps/mattermost/alert_group_representative.py @@ -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_message + 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] diff --git a/engine/apps/mattermost/alert_rendering.py b/engine/apps/mattermost/alert_rendering.py new file mode 100644 index 0000000000..7b77841f47 --- /dev/null +++ b/engine/apps/mattermost/alert_rendering.py @@ -0,0 +1,128 @@ +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.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, + "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("unacknowledge", "Unacknowledge", token)) + else: + actions.append(_make_actions("acknowledge", "Acknonwledge", token)) + + if self.alert_group.resolved: + actions.append(_make_actions("unresolve", "Unresolve", token)) + else: + actions.append(_make_actions("resolve", "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 diff --git a/engine/apps/mattermost/apps.py b/engine/apps/mattermost/apps.py index 7a6fc4ec38..841c7fc88f 100644 --- a/engine/apps/mattermost/apps.py +++ b/engine/apps/mattermost/apps.py @@ -3,3 +3,6 @@ class MattermostConfig(AppConfig): name = "apps.mattermost" + + def ready(self) -> None: + import apps.mattermost.signals # noqa: F401 diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py index 5a3c001168..ac789f50a4 100644 --- a/engine/apps/mattermost/client.py +++ b/engine/apps/mattermost/client.py @@ -1,3 +1,4 @@ +import json from dataclasses import dataclass from typing import Optional @@ -33,6 +34,13 @@ class MattermostChannel: 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 @@ -82,3 +90,19 @@ def get_user(self, user_id: str = "me"): 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"]) diff --git a/engine/apps/mattermost/exceptions.py b/engine/apps/mattermost/exceptions.py index 5df5a56f38..a96b90c993 100644 --- a/engine/apps/mattermost/exceptions.py +++ b/engine/apps/mattermost/exceptions.py @@ -11,3 +11,11 @@ def __init__(self, status, url, msg="", method="GET"): 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}" diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index 2601961c6d..da5e82a0a6 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-10-07 02:12 +# Generated by Django 4.2.15 on 2024-10-15 05:17 import apps.mattermost.models.channel import django.core.validators @@ -12,6 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('user_management', '0022_alter_team_unique_together'), + ('alerts', '0060_relatedincident'), ] operations = [ @@ -23,7 +24,18 @@ class Migration(migrations.Migration): ('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_connection', to='user_management.user')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_user_identity', to='user_management.user')), + ], + ), + 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')), ], ), migrations.CreateModel( diff --git a/engine/apps/mattermost/models/__init__.py b/engine/apps/mattermost/models/__init__.py index 0cf4dd7f73..cada7d9f23 100644 --- a/engine/apps/mattermost/models/__init__.py +++ b/engine/apps/mattermost/models/__init__.py @@ -1,2 +1,3 @@ from .channel import MattermostChannel # noqa: F401 +from .message import MattermostMessage # noqa F401 from .user import MattermostUser # noqa F401 diff --git a/engine/apps/mattermost/models/channel.py b/engine/apps/mattermost/models/channel.py index f901b814eb..a8ea351c94 100644 --- a/engine/apps/mattermost/models/channel.py +++ b/engine/apps/mattermost/models/channel.py @@ -1,7 +1,10 @@ +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 @@ -44,6 +47,14 @@ class MattermostChannel(models.Model): class Meta: unique_together = ("organization", "channel_id") + @classmethod + def get_channel_for_alert_group(cls, alert_group: AlertGroup) -> typing.Optional["MattermostChannel"]: + default_channel = cls.objects.filter( + organization=alert_group.channel.organization, is_default_channel=True + ).first() + + return default_channel + def make_channel_default(self, author): try: old_default_channel = MattermostChannel.objects.get(organization=self.organization, is_default_channel=True) diff --git a/engine/apps/mattermost/models/message.py b/engine/apps/mattermost/models/message.py new file mode 100644 index 0000000000..e41713fd57 --- /dev/null +++ b/engine/apps/mattermost/models/message.py @@ -0,0 +1,33 @@ +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) + + @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 + ) diff --git a/engine/apps/mattermost/models/user.py b/engine/apps/mattermost/models/user.py index 86d3488962..6029e0a4f6 100644 --- a/engine/apps/mattermost/models/user.py +++ b/engine/apps/mattermost/models/user.py @@ -2,7 +2,7 @@ class MattermostUser(models.Model): - user = models.OneToOneField("user_management.User", on_delete=models.CASCADE, related_name="mattermost_connection") + 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) diff --git a/engine/apps/mattermost/signals.py b/engine/apps/mattermost/signals.py new file mode 100644 index 0000000000..f18b28bdc0 --- /dev/null +++ b/engine/apps/mattermost/signals.py @@ -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) diff --git a/engine/apps/mattermost/tasks.py b/engine/apps/mattermost/tasks.py new file mode 100644 index 0000000000..274f0ed692 --- /dev/null +++ b/engine/apps/mattermost/tasks.py @@ -0,0 +1,75 @@ +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 +from apps.mattermost.alert_rendering import MattermostMessageRenderer +from apps.mattermost.client import MattermostClient +from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid +from apps.mattermost.models import MattermostChannel, MattermostMessage +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) + 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 + 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) diff --git a/engine/apps/mattermost/urls.py b/engine/apps/mattermost/urls.py index 0fba7b76d2..6743c7b1fc 100644 --- a/engine/apps/mattermost/urls.py +++ b/engine/apps/mattermost/urls.py @@ -1,8 +1,8 @@ from django.urls import include, path -from common.api_helpers.optional_slash_router import OptionalSlashRouter +from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path -from .views import MattermostChannelViewSet +from .views import MattermostChannelViewSet, MattermostEventView app_name = "mattermost" router = OptionalSlashRouter() @@ -10,4 +10,5 @@ urlpatterns = [ path("", include(router.urls)), + optional_slash_path("event", MattermostEventView.as_view(), name="incoming_mattermost_event"), ] diff --git a/engine/apps/mattermost/utils.py b/engine/apps/mattermost/utils.py new file mode 100644 index 0000000000..44f3a97daf --- /dev/null +++ b/engine/apps/mattermost/utils.py @@ -0,0 +1,37 @@ +import typing +import datetime +import logging + +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") diff --git a/engine/apps/mattermost/views.py b/engine/apps/mattermost/views.py index feac1106cb..84fa263a1e 100644 --- a/engine/apps/mattermost/views.py +++ b/engine/apps/mattermost/views.py @@ -2,6 +2,7 @@ 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 @@ -61,3 +62,29 @@ def perform_destroy(self, instance): channel_id=instance.channel_id, ) instance.delete() + + +class MattermostEventView(APIView): + def get(self, request, format=None): + return Response("hello") + + # Sample Request Payload + # { + # "user_id":"k8y8fccx57ygpq18oxp8pp3ntr", + # "user_name":"hbx80530", + # "channel_id":"gug81e7stfy8md747sewpeeqga", + # "channel_name":"camelcase", + # "team_id":"kjywdxcbjiyyupdgqst8bj8zrw", + # "team_domain":"local", + # "post_id":"cfsogqc61fbj3yssz78b1tarbw", + # "trigger_id":"cXJhd2Zwc2V3aW5nanBjY2I2YzdxdTc5NmE6azh5OGZjY3g1N3lncHExOG94cDhwcDNudHI6MTcyODgyMzQxODU4NzpNRVFDSUgvbURORjQrWFB1R1QzWHdTWGhDZG9rdEpNb3cydFNJL3l5QktLMkZrVjdBaUFaMjdybFB3c21EWUlyMHFIeVpKVnIyR1gwa2N6RzY5YkpuSDdrOEpuVXhnPT0=", + # "type":"", + # "data_source":"", + # "context":{ + # "action":"acknowledge", + # "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdhbml6YXRpb25faWQiOiJPMjlJWUQ3S0dRWURNIiwiZXhwIjoxNzMxNDE1Mzc0fQ.RbETrJS_lRDFDa9asGZbNlhMx13qkK0bc10-dj6x4-U" + # } + # } + def post(self, request): + # TODO: Implement the webhook + return Response(status=200) diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index 552aaade45..25bfb33398 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -23,6 +23,7 @@ ) from apps.alerts.models import Alert, AlertGroup from apps.base.messaging import get_messaging_backends +from apps.mattermost.alert_rendering import AlertMattermostTemplater from common.api_helpers.exceptions import BadRequest from common.jinja_templater import apply_jinja_template from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning @@ -238,8 +239,9 @@ def filter_lookups(child): PHONE_CALL = "phone_call" SMS = "sms" TELEGRAM = "telegram" +MATTERMOST = "mattermost" # templates with its own field in db, this concept replaced by messaging_backend_templates field -NOTIFICATION_CHANNEL_OPTIONS = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM] +NOTIFICATION_CHANNEL_OPTIONS = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM, MATTERMOST] TITLE = "title" MESSAGE = "message" @@ -258,6 +260,7 @@ def filter_lookups(child): PHONE_CALL: AlertPhoneCallTemplater, SMS: AlertSmsTemplater, TELEGRAM: AlertTelegramTemplater, + MATTERMOST: AlertMattermostTemplater, } # add additionally supported messaging backends diff --git a/engine/settings/base.py b/engine/settings/base.py index bed942837a..83e2917ddc 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -737,6 +737,7 @@ class BrokerTypes: 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) SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index fff08a2aa1..deb0eedbf8 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -185,4 +185,7 @@ "apps.webhooks.tasks.trigger_webhook.send_webhook_event": {"queue": "webhook"}, "apps.webhooks.tasks.alert_group_status.alert_group_created": {"queue": "webhook"}, "apps.webhooks.tasks.alert_group_status.alert_group_status_change": {"queue": "webhook"}, + # MATTERMOST + "apps.mattermost.tasks.on_create_alert_async": {"queue": "mattermost"}, + "apps.mattermost.tasks.on_alert_group_action_triggered_async": {"queue": "mattermost"}, } diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts index b8c5efb0c9..1b0d6c4b07 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts @@ -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) => { + const templatesForEdit = {...commonTemplateForEdit} if (features?.[AppFeature.MsTeams]) { - return { ...commonTemplateForEdit, ...additionalTemplateForEdit }; + merge(templatesForEdit, msteamsTemplateForEdit) + } + if (features?.[AppFeature.Mattermost]) { + merge(templatesForEdit, mattermostTemplateForEdit) } - return commonTemplateForEdit; + 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'; diff --git a/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts b/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts index 59b79a5469..616d2ad4e0 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts +++ b/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts @@ -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) => { + const templatesToRender = clone(commonTemplatesToRender) if (features?.[AppFeature.MsTeams]) { - return commonTemplatesToRender.concat(additionalTemplatesToRender); + templatesToRender.push(additionalTemplatesToRender[0]); + } + if (features?.[AppFeature.Mattermost]) { + templatesToRender.push(additionalTemplatesToRender[1]) } - return commonTemplatesToRender; + return templatesToRender; }; From b4f173f8a066f4c5ea00423ae4a20f37a0401c94 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Wed, 16 Oct 2024 10:21:42 +0530 Subject: [PATCH 25/43] Fix spelling --- engine/apps/mattermost/alert_rendering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/mattermost/alert_rendering.py b/engine/apps/mattermost/alert_rendering.py index 7b77841f47..af0d81593a 100644 --- a/engine/apps/mattermost/alert_rendering.py +++ b/engine/apps/mattermost/alert_rendering.py @@ -104,7 +104,7 @@ def _make_actions(id, name, token): if self.alert_group.acknowledged: actions.append(_make_actions("unacknowledge", "Unacknowledge", token)) else: - actions.append(_make_actions("acknowledge", "Acknonwledge", token)) + actions.append(_make_actions("acknowledge", "Acknowledge", token)) if self.alert_group.resolved: actions.append(_make_actions("unresolve", "Unresolve", token)) From 19e5913c9c62cc228de83b1c5067bdfa520debf0 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Fri, 1 Nov 2024 01:01:18 +0530 Subject: [PATCH 26/43] Adding tests and review comments --- engine/apps/alerts/models/alert_group.py | 5 - .../mattermost/alert_group_representative.py | 2 +- engine/apps/mattermost/models/user.py | 4 +- engine/apps/mattermost/tests/conftest.py | 35 ++++ engine/apps/mattermost/tests/factories.py | 10 +- .../mattermost/tests/test_alert_rendering.py | 56 ++++++ .../tests/test_mattermost_client.py | 115 +++++++++--- .../mattermost/tests/test_representative.py | 104 +++++++++++ engine/apps/mattermost/tests/test_tasks.py | 166 ++++++++++++++++++ engine/apps/mattermost/tests/test_utils.py | 24 +++ engine/apps/mattermost/utils.py | 3 +- engine/conftest.py | 3 +- engine/settings/ci_test.py | 1 + 13 files changed, 498 insertions(+), 30 deletions(-) create mode 100644 engine/apps/mattermost/tests/test_alert_rendering.py create mode 100644 engine/apps/mattermost/tests/test_representative.py create mode 100644 engine/apps/mattermost/tests/test_tasks.py create mode 100644 engine/apps/mattermost/tests/test_utils.py diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index be2079b2e6..6bcb8d735f 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -50,7 +50,6 @@ ) from apps.base.models import UserNotificationPolicyLogRecord from apps.labels.models import AlertGroupAssociatedLabel - from apps.mattermost.models import MattermostMessage from apps.slack.models import SlackMessage logger = logging.getLogger(__name__) @@ -2008,10 +2007,6 @@ def slack_message(self) -> typing.Optional["SlackMessage"]: except AttributeError: return self.slack_messages.order_by("created_at").first() - @property - def mattermost_message(self) -> typing.Optional["MattermostMessage"]: - return self.mattermost_messages.order_by("created_at").first() - @cached_property def last_stop_escalation_log(self): from apps.alerts.models import AlertGroupLogRecord diff --git a/engine/apps/mattermost/alert_group_representative.py b/engine/apps/mattermost/alert_group_representative.py index d653619b3c..b342b63b75 100644 --- a/engine/apps/mattermost/alert_group_representative.py +++ b/engine/apps/mattermost/alert_group_representative.py @@ -46,7 +46,7 @@ def get_handler_map(): 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_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) diff --git a/engine/apps/mattermost/models/user.py b/engine/apps/mattermost/models/user.py index 6029e0a4f6..0f9796641d 100644 --- a/engine/apps/mattermost/models/user.py +++ b/engine/apps/mattermost/models/user.py @@ -2,7 +2,9 @@ class MattermostUser(models.Model): - user = models.OneToOneField("user_management.User", on_delete=models.CASCADE, related_name="mattermost_user_identity") + 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) diff --git a/engine/apps/mattermost/tests/conftest.py b/engine/apps/mattermost/tests/conftest.py index 20b8fe28aa..2460dec6b9 100644 --- a/engine/apps/mattermost/tests/conftest.py +++ b/engine/apps/mattermost/tests/conftest.py @@ -1,5 +1,7 @@ import pytest +from apps.mattermost.tests.factories import MattermostMessageFactory + @pytest.fixture() def make_mattermost_get_channel_response(): @@ -24,3 +26,36 @@ def _make_mattermost_get_user_response(): } return _make_mattermost_get_user_response + + +@pytest.fixture() +def make_mattermost_post_response(): + def _make_mattermost_post_response(): + return { + "id": "bew5wsjnctbt78mkq9z6ci9sme", + "channel_id": "cew5wstyetbt78mkq9z6ci9spq", + "user_id": "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["message"] if "message" 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 diff --git a/engine/apps/mattermost/tests/factories.py b/engine/apps/mattermost/tests/factories.py index 57457e8842..1908fbb74a 100644 --- a/engine/apps/mattermost/tests/factories.py +++ b/engine/apps/mattermost/tests/factories.py @@ -1,6 +1,6 @@ import factory -from apps.mattermost.models import MattermostChannel +from apps.mattermost.models import MattermostChannel, MattermostMessage from common.utils import UniqueFaker @@ -14,3 +14,11 @@ class MattermostChannelFactory(factory.DjangoModelFactory): 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 diff --git a/engine/apps/mattermost/tests/test_alert_rendering.py b/engine/apps/mattermost/tests/test_alert_rendering.py new file mode 100644 index 0000000000..7a2371cb1b --- /dev/null +++ b/engine/apps/mattermost/tests/test_alert_rendering.py @@ -0,0 +1,56 @@ +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() + timezone.timedelta(hours=1), + 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() + timezone.timedelta(hours=1), + 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 diff --git a/engine/apps/mattermost/tests/test_mattermost_client.py b/engine/apps/mattermost/tests/test_mattermost_client.py index 5c88d24329..5c0a88aef5 100644 --- a/engine/apps/mattermost/tests/test_mattermost_client.py +++ b/engine/apps/mattermost/tests/test_mattermost_client.py @@ -1,6 +1,7 @@ import json from unittest.mock import Mock, patch +import httpretty import pytest import requests from django.conf import settings @@ -18,23 +19,57 @@ def test_mattermost_client_initialization(): @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() - channel_response = requests.Response() - channel_response.status_code = status.HTTP_200_OK - channel_response._content = json.dumps(data).encode() - with patch("apps.mattermost.client.requests.get", return_value=channel_response) as mock_request: - response = client.get_channel_by_id("fuzzz") - mock_request.assert_called_once() - assert response.channel_id == data["id"] - assert response.team_id == data["team_id"] - assert response.display_name == data["display_name"] - assert response.channel_name == data["name"] + 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 -def test_get_channel_by_id_failure(): +@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, @@ -49,12 +84,12 @@ def test_get_channel_by_id_failure(): mock_response.json.return_value = data mock_response.request = requests.Request( url="https://example.com", - method="GET", + method=method, ) mock_response.raise_for_status.side_effect = requests.HTTPError(response=mock_response) - with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: + with patch(f"apps.mattermost.client.requests.{method.lower()}", return_value=mock_response) as mock_request: with pytest.raises(MattermostAPIException) as exc: - client.get_channel_by_id("fuzzz") + getattr(client, client_method)(*params) mock_request.assert_called_once() # Timeout Error @@ -62,12 +97,12 @@ def test_get_channel_by_id_failure(): mock_response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR mock_response.request = requests.Request( url="https://example.com", - method="GET", + method=method, ) mock_response.raise_for_status.side_effect = requests.Timeout(response=mock_response) - with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: + with patch(f"apps.mattermost.client.requests.{method.lower()}", return_value=mock_response) as mock_request: with pytest.raises(MattermostAPIException) as exc: - client.get_channel_by_id("fuzzz") + getattr(client, client_method)(*params) assert exc.value.msg == "Mattermost api call gateway timedout" mock_request.assert_called_once() @@ -76,11 +111,51 @@ def test_get_channel_by_id_failure(): mock_response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR mock_response.request = requests.Request( url="https://example.com", - method="GET", + method=method, ) mock_response.raise_for_status.side_effect = requests.exceptions.RequestException(response=mock_response) - with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: + with patch(f"apps.mattermost.client.requests.{method.lower()}", return_value=mock_response) as mock_request: with pytest.raises(MattermostAPIException) as exc: - client.get_channel_by_id("fuzzz") + 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"] diff --git a/engine/apps/mattermost/tests/test_representative.py b/engine/apps/mattermost/tests/test_representative.py new file mode 100644 index 0000000000..e2c62aa03c --- /dev/null +++ b/engine/apps/mattermost/tests/test_representative.py @@ -0,0 +1,104 @@ +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() + timezone.timedelta(hours=1), + 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() + timezone.timedelta(hours=1), + 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() + timezone.timedelta(hours=1), + 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() + timezone.timedelta(hours=1), + 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() diff --git a/engine/apps/mattermost/tests/test_tasks.py b/engine/apps/mattermost/tests/test_tasks.py new file mode 100644 index 0000000000..a4f2aed4f6 --- /dev/null +++ b/engine/apps/mattermost/tests/test_tasks.py @@ -0,0 +1,166 @@ +import json +import pytest +from django.utils import timezone + +import httpretty +from django.conf import settings +from rest_framework import status + +from apps.mattermost.tasks import on_create_alert_async, on_alert_group_action_triggered_async +from apps.mattermost.models import MattermostMessage +from apps.mattermost.client import MattermostAPIException +from apps.alerts.models import AlertGroupLogRecord + +@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 +@httpretty.activate(verbose=True, allow_net_connect=False) +@pytest.mark.parametrize( + "data", + [ + {"status_code": 400}, + {"status_code": 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, + data, +): + 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=data["status_code"]) + mock_response = httpretty.Response(json.dumps(data), status=data["status_code"]) + httpretty.register_uri(httpretty.POST, url, status=data["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() + timezone.timedelta(hours=1), + 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) +@pytest.mark.parametrize( + "data", + [ + {"status_code": 400}, + {"status_code": 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, + data, +): + 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() + timezone.timedelta(hours=1), + 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=data["status_code"]) + mock_response = httpretty.Response(json.dumps(data), status=data["status_code"]) + httpretty.register_uri(httpretty.PUT, url, status=data["status_code"], responses=[mock_response]) + + if data["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 diff --git a/engine/apps/mattermost/tests/test_utils.py b/engine/apps/mattermost/tests/test_utils.py new file mode 100644 index 0000000000..c5ce1ae6ab --- /dev/null +++ b/engine/apps/mattermost/tests/test_utils.py @@ -0,0 +1,24 @@ +import pytest + +from django.conf import settings +from apps.mattermost.utils import MattermostEventAuthenticator +from apps.mattermost.exceptions import MattermostEventTokenInvalid + +@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, +): + organization = make_organization() + token = MattermostEventAuthenticator.create_token(organization=organization) + settings.MATTERMOST_SIGNING_SECRET = "n0cb4954bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d6284a6" + with pytest.raises(MattermostEventTokenInvalid): + MattermostEventAuthenticator.verify(token) \ No newline at end of file diff --git a/engine/apps/mattermost/utils.py b/engine/apps/mattermost/utils.py index 44f3a97daf..72e6aa2604 100644 --- a/engine/apps/mattermost/utils.py +++ b/engine/apps/mattermost/utils.py @@ -1,12 +1,13 @@ -import typing 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 diff --git a/engine/conftest.py b/engine/conftest.py index 75552a49ea..39d4b6437f 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -77,7 +77,7 @@ LabelValueFactory, WebhookAssociatedLabelFactory, ) -from apps.mattermost.tests.factories import MattermostChannelFactory +from apps.mattermost.tests.factories import MattermostChannelFactory, MattermostMessageFactory from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken from apps.phone_notifications.phone_backend import PhoneBackend from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory @@ -161,6 +161,7 @@ register(GoogleOAuth2UserFactory) register(UserNotificationBundleFactory) register(MattermostChannelFactory) +register(MattermostMessageFactory) IS_RBAC_ENABLED = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True" diff --git a/engine/settings/ci_test.py b/engine/settings/ci_test.py index f901598688..898b02f940 100644 --- a/engine/settings/ci_test.py +++ b/engine/settings/ci_test.py @@ -60,3 +60,4 @@ # Dummy token MATTERMOST_BOT_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" +MATTERMOST_SIGNING_SECRET = "f0cb4953bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d7284a6" From 933671d3948352cd79c82da57e7a82778a68b81d Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 12 Nov 2024 15:32:21 +0530 Subject: [PATCH 27/43] Fix duplication and lint fixes --- engine/apps/mattermost/tasks.py | 16 ++++ .../mattermost/tests/test_alert_rendering.py | 8 +- .../mattermost/tests/test_representative.py | 28 +++---- engine/apps/mattermost/tests/test_tasks.py | 82 +++++++++++++++---- engine/apps/mattermost/tests/test_utils.py | 8 +- 5 files changed, 100 insertions(+), 42 deletions(-) diff --git a/engine/apps/mattermost/tasks.py b/engine/apps/mattermost/tasks.py index 274f0ed692..60fc9b4cfa 100644 --- a/engine/apps/mattermost/tasks.py +++ b/engine/apps/mattermost/tasks.py @@ -33,6 +33,12 @@ def on_create_alert_async(self, alert_pk): raise e alert_group = alert.group + + 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 + mattermost_channel = MattermostChannel.get_channel_for_alert_group(alert_group=alert_group) payload = MattermostMessageRenderer(alert_group).render_alert_group_message() @@ -66,6 +72,16 @@ def on_alert_group_action_triggered_async(log_record_id): 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}" ) diff --git a/engine/apps/mattermost/tests/test_alert_rendering.py b/engine/apps/mattermost/tests/test_alert_rendering.py index 7a2371cb1b..3ef17b7ccb 100644 --- a/engine/apps/mattermost/tests/test_alert_rendering.py +++ b/engine/apps/mattermost/tests/test_alert_rendering.py @@ -31,16 +31,12 @@ def test_alert_group_message_renderer( 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() + timezone.timedelta(hours=1), - acknowledged=True + alert_receive_channel, acknowledged_at=timezone.now() + timezone.timedelta(hours=1), 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() + timezone.timedelta(hours=1), - resolved=True + alert_receive_channel, resolved_at=timezone.now() + timezone.timedelta(hours=1), resolved=True ) make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) diff --git a/engine/apps/mattermost/tests/test_representative.py b/engine/apps/mattermost/tests/test_representative.py index e2c62aa03c..3610d6bc86 100644 --- a/engine/apps/mattermost/tests/test_representative.py +++ b/engine/apps/mattermost/tests/test_representative.py @@ -4,6 +4,7 @@ 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, @@ -21,15 +22,14 @@ def test_get_handler( ack_alert_group = make_alert_group( alert_receive_channel=alert_receive_channel, acknowledged_at=timezone.now() + timezone.timedelta(hours=1), - acknowledged=True + 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 - ) + 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, @@ -47,14 +47,13 @@ def test_is_applicable_success( ack_alert_group = make_alert_group( alert_receive_channel=alert_receive_channel, acknowledged_at=timezone.now() + timezone.timedelta(hours=1), - acknowledged=True + 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 - ) + 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, @@ -70,14 +69,13 @@ def test_is_applicable_without_channels( ack_alert_group = make_alert_group( alert_receive_channel=alert_receive_channel, acknowledged_at=timezone.now() + timezone.timedelta(hours=1), - acknowledged=True + 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 - ) + 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, @@ -95,10 +93,8 @@ def test_is_applicable_invalid_type( ack_alert_group = make_alert_group( alert_receive_channel=alert_receive_channel, acknowledged_at=timezone.now() + timezone.timedelta(hours=1), - acknowledged=True + 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 - ) + 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() diff --git a/engine/apps/mattermost/tests/test_tasks.py b/engine/apps/mattermost/tests/test_tasks.py index a4f2aed4f6..6101874cfa 100644 --- a/engine/apps/mattermost/tests/test_tasks.py +++ b/engine/apps/mattermost/tests/test_tasks.py @@ -1,15 +1,16 @@ import json -import pytest -from django.utils import timezone import httpretty +import pytest from django.conf import settings +from django.utils import timezone from rest_framework import status -from apps.mattermost.tasks import on_create_alert_async, on_alert_group_action_triggered_async -from apps.mattermost.models import MattermostMessage -from apps.mattermost.client import MattermostAPIException from apps.alerts.models import AlertGroupLogRecord +from apps.mattermost.client import MattermostAPIException +from apps.mattermost.models import MattermostMessage +from apps.mattermost.tasks import on_alert_group_action_triggered_async, on_create_alert_async + @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) @@ -39,6 +40,36 @@ def test_on_create_alert_async_success( assert mattermost_message.channel_id == data["channel_id"] assert mattermost_message.message_type == MattermostMessage.ALERT_GROUP_MESSAGE + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +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_post_response, + 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) + + 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) + + request = httpretty.last_request() + assert request.url is None + + @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) @pytest.mark.parametrize( @@ -73,6 +104,7 @@ def test_on_create_alert_async_mattermost_api_failure( 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( @@ -88,15 +120,11 @@ def test_on_alert_group_action_triggered_async_success( 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() + timezone.timedelta(hours=1), - acknowledged=True + alert_receive_channel, acknowledged_at=timezone.now() + timezone.timedelta(hours=1), 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 - ) + 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"] @@ -116,6 +144,30 @@ def test_on_alert_group_action_triggered_async_success( 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() + timezone.timedelta(hours=1), 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( @@ -139,15 +191,11 @@ def test_on_alert_group_action_triggered_async_failure( 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() + timezone.timedelta(hours=1), - acknowledged=True + alert_receive_channel, acknowledged_at=timezone.now() + timezone.timedelta(hours=1), 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 - ) + 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) diff --git a/engine/apps/mattermost/tests/test_utils.py b/engine/apps/mattermost/tests/test_utils.py index c5ce1ae6ab..acafbdf79f 100644 --- a/engine/apps/mattermost/tests/test_utils.py +++ b/engine/apps/mattermost/tests/test_utils.py @@ -1,8 +1,9 @@ import pytest - from django.conf import settings -from apps.mattermost.utils import MattermostEventAuthenticator + from apps.mattermost.exceptions import MattermostEventTokenInvalid +from apps.mattermost.utils import MattermostEventAuthenticator + @pytest.mark.django_db def test_jwt_token_validation_success( @@ -13,6 +14,7 @@ def test_jwt_token_validation_success( payload = MattermostEventAuthenticator.verify(token) assert payload["organization_id"] == organization.public_primary_key + @pytest.mark.django_db def test_jwt_token_validation_failure( make_organization, @@ -21,4 +23,4 @@ def test_jwt_token_validation_failure( token = MattermostEventAuthenticator.create_token(organization=organization) settings.MATTERMOST_SIGNING_SECRET = "n0cb4954bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d6284a6" with pytest.raises(MattermostEventTokenInvalid): - MattermostEventAuthenticator.verify(token) \ No newline at end of file + MattermostEventAuthenticator.verify(token) From e4996ccc7cf138ebb28d65967f35af3c8f5e448d Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 12 Nov 2024 15:44:23 +0530 Subject: [PATCH 28/43] Add config for ci test --- engine/settings/ci_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/settings/ci_test.py b/engine/settings/ci_test.py index 898b02f940..5d64a62021 100644 --- a/engine/settings/ci_test.py +++ b/engine/settings/ci_test.py @@ -59,5 +59,6 @@ SILK_PROFILER_ENABLED = False # Dummy token +MATTERMOST_HOST = "http://localhost:8065" MATTERMOST_BOT_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" MATTERMOST_SIGNING_SECRET = "f0cb4953bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d7284a6" From ee12818cc2e4a7c7c36a837c3805872b8c63a767 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Thu, 14 Nov 2024 19:27:07 +0530 Subject: [PATCH 29/43] Address Review comments Change the constraint --- .../mattermost/migrations/0001_initial.py | 15 +++-- engine/apps/mattermost/models/message.py | 9 +++ engine/apps/mattermost/tests/conftest.py | 10 +++- .../mattermost/tests/test_alert_rendering.py | 8 +-- .../mattermost/tests/test_representative.py | 8 +-- engine/apps/mattermost/tests/test_tasks.py | 59 ++++++------------- engine/apps/mattermost/tests/test_utils.py | 4 +- 7 files changed, 54 insertions(+), 59 deletions(-) diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index da5e82a0a6..25fb462afa 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-10-15 05:17 +# Generated by Django 4.2.15 on 2024-11-14 14:35 import apps.mattermost.models.channel import django.core.validators @@ -11,8 +11,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('user_management', '0022_alter_team_unique_together'), ('alerts', '0060_relatedincident'), + ('user_management', '0022_alter_team_unique_together'), ] operations = [ @@ -51,8 +51,13 @@ class Migration(migrations.Migration): ('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')), ], - options={ - 'unique_together': {('organization', 'channel_id')}, - }, + ), + migrations.AddConstraint( + model_name='mattermostmessage', + constraint=models.UniqueConstraint(condition=models.Q(('message_type__in', [0, 1])), fields=('alert_group', 'channel_id', 'message_type'), name='unique_alert_group_channel_id_message_type'), + ), + migrations.AlterUniqueTogether( + name='mattermostchannel', + unique_together={('organization', 'channel_id')}, ), ] diff --git a/engine/apps/mattermost/models/message.py b/engine/apps/mattermost/models/message.py index e41713fd57..19cb2355db 100644 --- a/engine/apps/mattermost/models/message.py +++ b/engine/apps/mattermost/models/message.py @@ -26,6 +26,15 @@ class MattermostMessage(models.Model): created_at = models.DateTimeField(auto_now_add=True) + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["alert_group", "channel_id", "message_type"], + condition=models.Q(message_type__in=[0, 1]), + name="unique_alert_group_channel_id_message_type", + ) + ] + @staticmethod def create_message(alert_group: AlertGroup, post: MattermostPost, message_type: int): return MattermostMessage.objects.create( diff --git a/engine/apps/mattermost/tests/conftest.py b/engine/apps/mattermost/tests/conftest.py index 2460dec6b9..402a0e1d64 100644 --- a/engine/apps/mattermost/tests/conftest.py +++ b/engine/apps/mattermost/tests/conftest.py @@ -47,7 +47,7 @@ def _make_mattermost_post_response(**kwargs): "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["message"] if "message" in kwargs else "reqe5wsjnctbz78mkq9z6ci9iqer", + "request_id": kwargs["request_id"] if "request_id" in kwargs else "reqe5wsjnctbz78mkq9z6ci9iqer", } return _make_mattermost_post_response @@ -59,3 +59,11 @@ 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 set_random_mattermost_sigining_secret(settings): + def _set_random_mattermost_sigining_secret(): + settings.MATTERMOST_SIGNING_SECRET = "n0cb4954bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d6284a6" + + return _set_random_mattermost_sigining_secret diff --git a/engine/apps/mattermost/tests/test_alert_rendering.py b/engine/apps/mattermost/tests/test_alert_rendering.py index 3ef17b7ccb..da9964a9cd 100644 --- a/engine/apps/mattermost/tests/test_alert_rendering.py +++ b/engine/apps/mattermost/tests/test_alert_rendering.py @@ -30,14 +30,10 @@ def test_alert_group_message_renderer( 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() + timezone.timedelta(hours=1), acknowledged=True - ) + 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() + timezone.timedelta(hours=1), resolved=True - ) + 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() diff --git a/engine/apps/mattermost/tests/test_representative.py b/engine/apps/mattermost/tests/test_representative.py index 3610d6bc86..b8082ded74 100644 --- a/engine/apps/mattermost/tests/test_representative.py +++ b/engine/apps/mattermost/tests/test_representative.py @@ -21,7 +21,7 @@ def test_get_handler( 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() + timezone.timedelta(hours=1), + acknowledged_at=timezone.now(), acknowledged=True, ) make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) @@ -46,7 +46,7 @@ def test_is_applicable_success( 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() + timezone.timedelta(hours=1), + acknowledged_at=timezone.now(), acknowledged=True, ) make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) @@ -68,7 +68,7 @@ def test_is_applicable_without_channels( ) ack_alert_group = make_alert_group( alert_receive_channel=alert_receive_channel, - acknowledged_at=timezone.now() + timezone.timedelta(hours=1), + acknowledged_at=timezone.now(), acknowledged=True, ) make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) @@ -92,7 +92,7 @@ def test_is_applicable_invalid_type( 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() + timezone.timedelta(hours=1), + acknowledged_at=timezone.now(), acknowledged=True, ) make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) diff --git a/engine/apps/mattermost/tests/test_tasks.py b/engine/apps/mattermost/tests/test_tasks.py index 6101874cfa..28fa440c5e 100644 --- a/engine/apps/mattermost/tests/test_tasks.py +++ b/engine/apps/mattermost/tests/test_tasks.py @@ -1,4 +1,5 @@ import json +from unittest.mock import patch import httpretty import pytest @@ -49,7 +50,6 @@ def test_on_create_alert_async_skip_post_for_duplicate( make_alert_group, make_alert, make_mattermost_channel, - make_mattermost_post_response, make_mattermost_message, ): organization, _ = make_organization_and_user() @@ -59,26 +59,15 @@ def test_on_create_alert_async_skip_post_for_duplicate( make_mattermost_channel(organization=organization, is_default_channel=True) make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) - 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) + with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call: + on_create_alert_async(alert_pk=alert.pk) - request = httpretty.last_request() - assert request.url is None + mock_post_call.assert_not_called() @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) -@pytest.mark.parametrize( - "data", - [ - {"status_code": 400}, - {"status_code": 401}, - ], -) +@pytest.mark.parametrize("status_code", [400, 401]) def test_on_create_alert_async_mattermost_api_failure( make_organization_and_user, make_alert_receive_channel, @@ -86,7 +75,7 @@ def test_on_create_alert_async_mattermost_api_failure( make_alert, make_mattermost_channel, make_mattermost_post_response_failure, - data, + status_code, ): organization, _ = make_organization_and_user() alert_receive_channel = make_alert_receive_channel(organization) @@ -95,9 +84,9 @@ def test_on_create_alert_async_mattermost_api_failure( 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=data["status_code"]) - mock_response = httpretty.Response(json.dumps(data), status=data["status_code"]) - httpretty.register_uri(httpretty.POST, url, status=data["status_code"], responses=[mock_response]) + 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) @@ -119,9 +108,7 @@ def test_on_alert_group_action_triggered_async_success( ): 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() + timezone.timedelta(hours=1), acknowledged=True - ) + 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) @@ -157,9 +144,7 @@ def test_on_alert_group_action_triggered_async_fails_without_alert_group_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() + timezone.timedelta(hours=1), acknowledged=True - ) + 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) @@ -170,13 +155,7 @@ def test_on_alert_group_action_triggered_async_fails_without_alert_group_message @pytest.mark.django_db @httpretty.activate(verbose=True, allow_net_connect=False) -@pytest.mark.parametrize( - "data", - [ - {"status_code": 400}, - {"status_code": 401}, - ], -) +@pytest.mark.parametrize("status_code", [400, 401]) def test_on_alert_group_action_triggered_async_failure( make_organization_and_user, make_alert_receive_channel, @@ -186,24 +165,22 @@ def test_on_alert_group_action_triggered_async_failure( make_alert_group_log_record, make_mattermost_message, make_mattermost_post_response_failure, - data, + 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() + timezone.timedelta(hours=1), acknowledged=True - ) + 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=data["status_code"]) - mock_response = httpretty.Response(json.dumps(data), status=data["status_code"]) - httpretty.register_uri(httpretty.PUT, url, status=data["status_code"], responses=[mock_response]) + 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 data["status_code"] != 401: + if status_code != 401: with pytest.raises(MattermostAPIException): on_alert_group_action_triggered_async(ack_log_record.pk) else: diff --git a/engine/apps/mattermost/tests/test_utils.py b/engine/apps/mattermost/tests/test_utils.py index acafbdf79f..761d210414 100644 --- a/engine/apps/mattermost/tests/test_utils.py +++ b/engine/apps/mattermost/tests/test_utils.py @@ -1,5 +1,4 @@ import pytest -from django.conf import settings from apps.mattermost.exceptions import MattermostEventTokenInvalid from apps.mattermost.utils import MattermostEventAuthenticator @@ -18,9 +17,10 @@ def test_jwt_token_validation_success( @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) - settings.MATTERMOST_SIGNING_SECRET = "n0cb4954bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d6284a6" + set_random_mattermost_sigining_secret() with pytest.raises(MattermostEventTokenInvalid): MattermostEventAuthenticator.verify(token) From 33d4ca94f7a4e36fa508ac524ac41466b6ed648b Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 19 Nov 2024 18:35:44 +0530 Subject: [PATCH 30/43] Fixing Lint and User auth redirect flow --- engine/apps/api/tests/test_auth.py | 2 +- engine/apps/api/views/auth.py | 2 +- engine/apps/mattermost/backend.py | 2 +- engine/apps/social_auth/middlewares.py | 6 +++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/engine/apps/api/tests/test_auth.py b/engine/apps/api/tests/test_auth.py index d4c7fbe350..30608f4284 100644 --- a/engine/apps/api/tests/test_auth.py +++ b/engine/apps/api/tests/test_auth.py @@ -102,7 +102,7 @@ def test_start_slack_ok( @pytest.mark.django_db @pytest.mark.parametrize( "backend_name,expected_url", - ((MATTERMOST_LOGIN_BACKEND, "/a/grafana-oncall-app/users/me"),), + ((MATTERMOST_LOGIN_BACKEND, "a/grafana-oncall-app/users/me"),), ) def test_complete_mattermost_auth_redirect_ok( make_organization, diff --git a/engine/apps/api/views/auth.py b/engine/apps/api/views/auth.py index d40e08dbae..8deecaa23c 100644 --- a/engine/apps/api/views/auth.py +++ b/engine/apps/api/views/auth.py @@ -103,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() ) diff --git a/engine/apps/mattermost/backend.py b/engine/apps/mattermost/backend.py index 68705ab82f..6750804102 100644 --- a/engine/apps/mattermost/backend.py +++ b/engine/apps/mattermost/backend.py @@ -14,7 +14,7 @@ def unlink_user(self, user): mattermost_user.delete() def serialize_user(self, user): - mattermost_user = getattr(user, "mattermost_connection", None) + mattermost_user = getattr(user, "mattermost_user_identity", None) if not mattermost_user: return None return { diff --git a/engine/apps/social_auth/middlewares.py b/engine/apps/social_auth/middlewares.py index 9ec7a2e0d7..7be391ad6d 100644 --- a/engine/apps/social_auth/middlewares.py +++ b/engine/apps/social_auth/middlewares.py @@ -8,7 +8,11 @@ from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.social_auth.backends import LoginSlackOAuth2V2 -from apps.social_auth.exceptions import MATTERMOST_AUTH_FETCH_USER_ERROR, InstallMultiRegionSlackException, UserLoginOAuth2MattermostException +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__) From a941c313aab227ea2e5f2cb9290b21d406f9a556 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Wed, 20 Nov 2024 22:03:50 +0530 Subject: [PATCH 31/43] Mattermost incoming event handler Remove print Add migrations --- engine/apps/alerts/constants.py | 1 + ...alter_alertgrouplogrecord_action_source.py | 18 ++ engine/apps/mattermost/alert_rendering.py | 10 +- engine/apps/mattermost/auth.py | 29 ++ engine/apps/mattermost/events/__init__.py | 1 + .../events/alert_group_actions_handler.py | 94 +++++++ .../apps/mattermost/events/event_handler.py | 18 ++ .../apps/mattermost/events/event_manager.py | 37 +++ engine/apps/mattermost/events/types.py | 29 ++ .../mattermost/migrations/0001_initial.py | 40 +-- engine/apps/mattermost/models/message.py | 4 + engine/apps/mattermost/models/user.py | 5 + engine/apps/mattermost/tests/conftest.py | 49 +++- .../apps/mattermost/tests/events/__init__.py | 0 .../events/test_alert_group_action_handler.py | 131 +++++++++ engine/apps/mattermost/tests/factories.py | 13 +- .../mattermost/tests/test_mattermost_event.py | 262 ++++++++++++++++++ engine/apps/mattermost/views.py | 28 +- 18 files changed, 724 insertions(+), 45 deletions(-) create mode 100644 engine/apps/alerts/migrations/0065_alter_alertgrouplogrecord_action_source.py create mode 100644 engine/apps/mattermost/auth.py create mode 100644 engine/apps/mattermost/events/__init__.py create mode 100644 engine/apps/mattermost/events/alert_group_actions_handler.py create mode 100644 engine/apps/mattermost/events/event_handler.py create mode 100644 engine/apps/mattermost/events/event_manager.py create mode 100644 engine/apps/mattermost/events/types.py create mode 100644 engine/apps/mattermost/tests/events/__init__.py create mode 100644 engine/apps/mattermost/tests/events/test_alert_group_action_handler.py create mode 100644 engine/apps/mattermost/tests/test_mattermost_event.py diff --git a/engine/apps/alerts/constants.py b/engine/apps/alerts/constants.py index 496836caaa..9fae20eaad 100644 --- a/engine/apps/alerts/constants.py +++ b/engine/apps/alerts/constants.py @@ -10,6 +10,7 @@ class ActionSource(IntegerChoices): TELEGRAM = 3, "Telegram" API = 4, "API" BACKSYNC = 5, "Backsync" + MATTERMOST = 6, "Mattermost" TASK_DELAY_SECONDS = 1 diff --git a/engine/apps/alerts/migrations/0065_alter_alertgrouplogrecord_action_source.py b/engine/apps/alerts/migrations/0065_alter_alertgrouplogrecord_action_source.py new file mode 100644 index 0000000000..a0ec55c823 --- /dev/null +++ b/engine/apps/alerts/migrations/0065_alter_alertgrouplogrecord_action_source.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-20 16:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0064_migrate_resolutionnoteslackmessage_slack_channel_id'), + ] + + 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')]), + ), + ] diff --git a/engine/apps/mattermost/alert_rendering.py b/engine/apps/mattermost/alert_rendering.py index af0d81593a..a41fd7ae13 100644 --- a/engine/apps/mattermost/alert_rendering.py +++ b/engine/apps/mattermost/alert_rendering.py @@ -1,6 +1,7 @@ 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 @@ -94,6 +95,7 @@ def _make_actions(id, name, token): "url": create_engine_url("api/internal/v1/mattermost/event/"), "context": { "action": id, + "alert": self.alert_group.pk, "token": token, }, }, @@ -102,14 +104,14 @@ def _make_actions(id, name, 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("unacknowledge", "Unacknowledge", token)) + actions.append(_make_actions(EventAction.UNACKNOWLEDGE.value, "Unacknowledge", token)) else: - actions.append(_make_actions("acknowledge", "Acknowledge", token)) + actions.append(_make_actions(EventAction.ACKNOWLEDGE.value, "Acknowledge", token)) if self.alert_group.resolved: - actions.append(_make_actions("unresolve", "Unresolve", token)) + actions.append(_make_actions(EventAction.UNRESOLVE.value, "Unresolve", token)) else: - actions.append(_make_actions("resolve", "Resolve", token)) + actions.append(_make_actions(EventAction.RESOLVE.value, "Resolve", token)) return actions diff --git a/engine/apps/mattermost/auth.py b/engine/apps/mattermost/auth.py new file mode 100644 index 0000000000..83b870f8b7 --- /dev/null +++ b/engine/apps/mattermost/auth.py @@ -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 diff --git a/engine/apps/mattermost/events/__init__.py b/engine/apps/mattermost/events/__init__.py new file mode 100644 index 0000000000..60bf9a0411 --- /dev/null +++ b/engine/apps/mattermost/events/__init__.py @@ -0,0 +1 @@ +from .alert_group_actions_handler import AlertGroupActionHandler # noqa: F401 diff --git a/engine/apps/mattermost/events/alert_group_actions_handler.py b/engine/apps/mattermost/events/alert_group_actions_handler.py new file mode 100644 index 0000000000..81a2dccd37 --- /dev/null +++ b/engine/apps/mattermost/events/alert_group_actions_handler.py @@ -0,0 +1,94 @@ +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 +from apps.mattermost.models import MattermostMessage + +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() or self._get_alert_group_from_message() + + 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(pk=self.event["context"]["alert"]) + except AlertGroup.DoesNotExist: + return + + return alert_group + + def _get_alert_group_from_message(self) -> typing.Optional[AlertGroup]: + try: + mattermost_message = MattermostMessage.objects.get( + channel_id=self.event["channel_id"], post_id=self.event["post_id"] + ) + return mattermost_message.alert_group + except MattermostMessage.DoesNotExist: + logger.info( + f"Mattermost message not found for channel_id: {self.event['channel_id']} and post_id {self.event['post_id']}" + ) + return + + 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"] diff --git a/engine/apps/mattermost/events/event_handler.py b/engine/apps/mattermost/events/event_handler.py new file mode 100644 index 0000000000..dc6fb886db --- /dev/null +++ b/engine/apps/mattermost/events/event_handler.py @@ -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 diff --git a/engine/apps/mattermost/events/event_manager.py b/engine/apps/mattermost/events/event_manager.py new file mode 100644 index 0000000000..138f04d0a3 --- /dev/null +++ b/engine/apps/mattermost/events/event_manager.py @@ -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 diff --git a/engine/apps/mattermost/events/types.py b/engine/apps/mattermost/events/types.py new file mode 100644 index 0000000000..499a9ca1a9 --- /dev/null +++ b/engine/apps/mattermost/events/types.py @@ -0,0 +1,29 @@ +import enum +import typing + + +class MattermostAlertGroupContext(typing.TypedDict): + action: str + token: str + alert: int + + +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" diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index 25fb462afa..3bbe08aea3 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-11-14 14:35 +# Generated by Django 4.2.16 on 2024-11-20 16:53 import apps.mattermost.models.channel import django.core.validators @@ -11,11 +11,25 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('alerts', '0060_relatedincident'), - ('user_management', '0022_alter_team_unique_together'), + ('user_management', '0026_auto_20241017_1919'), + ('alerts', '0065_alter_alertgrouplogrecord_action_source'), ] 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=[ @@ -26,6 +40,9 @@ class Migration(migrations.Migration): ('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', @@ -37,20 +54,9 @@ class Migration(migrations.Migration): ('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')), ], - ), - 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')), - ], + options={ + 'indexes': [models.Index(fields=['channel_id', 'post_id'], name='mattermost__channel_1fbf8b_idx')], + }, ), migrations.AddConstraint( model_name='mattermostmessage', diff --git a/engine/apps/mattermost/models/message.py b/engine/apps/mattermost/models/message.py index 19cb2355db..1389a4a3d3 100644 --- a/engine/apps/mattermost/models/message.py +++ b/engine/apps/mattermost/models/message.py @@ -35,6 +35,10 @@ class Meta: ) ] + indexes = [ + models.Index(fields=["channel_id", "post_id"]), + ] + @staticmethod def create_message(alert_group: AlertGroup, post: MattermostPost, message_type: int): return MattermostMessage.objects.create( diff --git a/engine/apps/mattermost/models/user.py b/engine/apps/mattermost/models/user.py index 0f9796641d..6493869490 100644 --- a/engine/apps/mattermost/models/user.py +++ b/engine/apps/mattermost/models/user.py @@ -9,3 +9,8 @@ class MattermostUser(models.Model): 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"]), + ] diff --git a/engine/apps/mattermost/tests/conftest.py b/engine/apps/mattermost/tests/conftest.py index 402a0e1d64..497c4a0c89 100644 --- a/engine/apps/mattermost/tests/conftest.py +++ b/engine/apps/mattermost/tests/conftest.py @@ -1,6 +1,6 @@ import pytest -from apps.mattermost.tests.factories import MattermostMessageFactory +from apps.mattermost.tests.factories import MattermostMessageFactory, MattermostUserFactory @pytest.fixture() @@ -30,11 +30,11 @@ def _make_mattermost_get_user_response(): @pytest.fixture() def make_mattermost_post_response(): - def _make_mattermost_post_response(): + def _make_mattermost_post_response(**kwargs): return { - "id": "bew5wsjnctbt78mkq9z6ci9sme", - "channel_id": "cew5wstyetbt78mkq9z6ci9spq", - "user_id": "uew5wsjnctbz78mkq9z6ci9sos", + "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 @@ -61,9 +61,48 @@ def _make_mattermost_message(alert_group, 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 diff --git a/engine/apps/mattermost/tests/events/__init__.py b/engine/apps/mattermost/tests/events/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py b/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py new file mode 100644 index 0000000000..9f44d7cdf7 --- /dev/null +++ b/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py @@ -0,0 +1,131 @@ +import pytest + +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 +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, +): + 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_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.pk, + ) + handler = AlertGroupActionHandler(event=event, user=user) + handler.process() + alert_group.refresh_from_db() + assert alert_group.acknowledged + + +@pytest.mark.django_db +def test_alert_group_from_message( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_event, + make_mattermost_message, + 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_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=123, + ) + handler = AlertGroupActionHandler(event=event, user=user) + handler.process() + alert_group.refresh_from_db() + assert alert_group.acknowledged + + +@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=123) + 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=123) + handler = AlertGroupActionHandler(event=event, user=user) + handler.process() + alert_group.refresh_from_db() + assert not alert_group.acknowledged diff --git a/engine/apps/mattermost/tests/factories.py b/engine/apps/mattermost/tests/factories.py index 1908fbb74a..8feb05dcf7 100644 --- a/engine/apps/mattermost/tests/factories.py +++ b/engine/apps/mattermost/tests/factories.py @@ -1,6 +1,6 @@ import factory -from apps.mattermost.models import MattermostChannel, MattermostMessage +from apps.mattermost.models import MattermostChannel, MattermostMessage, MattermostUser from common.utils import UniqueFaker @@ -22,3 +22,14 @@ class MattermostMessageFactory(factory.DjangoModelFactory): 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 diff --git a/engine/apps/mattermost/tests/test_mattermost_event.py b/engine/apps/mattermost/tests/test_mattermost_event.py new file mode 100644 index 0000000000..cf3dc69bb4 --- /dev/null +++ b/engine/apps/mattermost/tests/test_mattermost_event.py @@ -0,0 +1,262 @@ +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.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 +def test_mattermost_alert_group_event_acknowledge( + 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) + 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.pk, + ) + + url = reverse("mattermost:incoming_mattermost_event") + client = APIClient() + response = client.post(url, event, format="json") + alert_group.refresh_from_db() + assert alert_group.acknowledged + assert not alert_group.resolved + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_mattermost_alert_group_event_unacknowledge( + 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=alert_receive_channel, + acknowledged_at=timezone.now(), + acknowledged=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( + EventAction.UNACKNOWLEDGE, + token, + post_id=mattermost_message.post_id, + channel_id=mattermost_message.channel_id, + user_id=mattermost_user.mattermost_user_id, + alert=alert_group.pk, + ) + + url = reverse("mattermost:incoming_mattermost_event") + client = APIClient() + response = client.post(url, event, format="json") + alert_group.refresh_from_db() + assert not alert_group.acknowledged + assert not alert_group.resolved + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_mattermost_alert_group_event_resolve( + 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) + event = make_mattermost_event( + EventAction.RESOLVE, + token, + post_id=mattermost_message.post_id, + channel_id=mattermost_message.channel_id, + user_id=mattermost_user.mattermost_user_id, + alert=alert_group.pk, + ) + + url = reverse("mattermost:incoming_mattermost_event") + client = APIClient() + response = client.post(url, event, format="json") + alert_group.refresh_from_db() + assert not alert_group.acknowledged + assert alert_group.resolved + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_mattermost_alert_group_event_unresolve( + 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, 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( + EventAction.UNRESOLVE, + token, + post_id=mattermost_message.post_id, + channel_id=mattermost_message.channel_id, + user_id=mattermost_user.mattermost_user_id, + alert=alert_group.pk, + ) + + url = reverse("mattermost:incoming_mattermost_event") + client = APIClient() + response = client.post(url, event, format="json") + alert_group.refresh_from_db() + assert not alert_group.acknowledged + assert not alert_group.resolved + 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.pk, + ) + + 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.pk, + ) + + url = reverse("mattermost:incoming_mattermost_event") + client = APIClient() + response = client.post(url, event, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/engine/apps/mattermost/views.py b/engine/apps/mattermost/views.py index 84fa263a1e..2a8fe65052 100644 --- a/engine/apps/mattermost/views.py +++ b/engine/apps/mattermost/views.py @@ -6,6 +6,8 @@ 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 @@ -65,26 +67,16 @@ def perform_destroy(self, instance): 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") - # Sample Request Payload - # { - # "user_id":"k8y8fccx57ygpq18oxp8pp3ntr", - # "user_name":"hbx80530", - # "channel_id":"gug81e7stfy8md747sewpeeqga", - # "channel_name":"camelcase", - # "team_id":"kjywdxcbjiyyupdgqst8bj8zrw", - # "team_domain":"local", - # "post_id":"cfsogqc61fbj3yssz78b1tarbw", - # "trigger_id":"cXJhd2Zwc2V3aW5nanBjY2I2YzdxdTc5NmE6azh5OGZjY3g1N3lncHExOG94cDhwcDNudHI6MTcyODgyMzQxODU4NzpNRVFDSUgvbURORjQrWFB1R1QzWHdTWGhDZG9rdEpNb3cydFNJL3l5QktLMkZrVjdBaUFaMjdybFB3c21EWUlyMHFIeVpKVnIyR1gwa2N6RzY5YkpuSDdrOEpuVXhnPT0=", - # "type":"", - # "data_source":"", - # "context":{ - # "action":"acknowledge", - # "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdhbml6YXRpb25faWQiOiJPMjlJWUQ3S0dRWURNIiwiZXhwIjoxNzMxNDE1Mzc0fQ.RbETrJS_lRDFDa9asGZbNlhMx13qkK0bc10-dj6x4-U" - # } - # } def post(self, request): - # TODO: Implement the webhook + EventManager.process_request(request=request) return Response(status=200) From d442453764d45475de40a7404bcf860108d86061 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Thu, 21 Nov 2024 15:32:01 +0530 Subject: [PATCH 32/43] Review comments and tests --- engine/apps/mattermost/alert_rendering.py | 2 +- .../events/alert_group_actions_handler.py | 17 +- engine/apps/mattermost/events/types.py | 2 +- .../events/test_alert_group_action_handler.py | 71 ++++---- .../mattermost/tests/test_mattermost_event.py | 165 +++--------------- 5 files changed, 64 insertions(+), 193 deletions(-) diff --git a/engine/apps/mattermost/alert_rendering.py b/engine/apps/mattermost/alert_rendering.py index a41fd7ae13..6c99b4f148 100644 --- a/engine/apps/mattermost/alert_rendering.py +++ b/engine/apps/mattermost/alert_rendering.py @@ -95,7 +95,7 @@ def _make_actions(id, name, token): "url": create_engine_url("api/internal/v1/mattermost/event/"), "context": { "action": id, - "alert": self.alert_group.pk, + "alert": self.alert_group.public_primary_key, "token": token, }, }, diff --git a/engine/apps/mattermost/events/alert_group_actions_handler.py b/engine/apps/mattermost/events/alert_group_actions_handler.py index 81a2dccd37..9078519edb 100644 --- a/engine/apps/mattermost/events/alert_group_actions_handler.py +++ b/engine/apps/mattermost/events/alert_group_actions_handler.py @@ -5,7 +5,6 @@ from apps.alerts.models import AlertGroup from apps.mattermost.events.event_handler import MattermostEventHandler from apps.mattermost.events.types import EventAction -from apps.mattermost.models import MattermostMessage logger = logging.getLogger(__name__) @@ -46,31 +45,19 @@ def _get_action(self) -> typing.Optional[EventAction]: return def _get_alert_group(self) -> typing.Optional[AlertGroup]: - return self._get_alert_group_from_event() or self._get_alert_group_from_message() + 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(pk=self.event["context"]["alert"]) + alert_group = AlertGroup.objects.get(public_primary_key=self.event["context"]["alert"]) except AlertGroup.DoesNotExist: return return alert_group - def _get_alert_group_from_message(self) -> typing.Optional[AlertGroup]: - try: - mattermost_message = MattermostMessage.objects.get( - channel_id=self.event["channel_id"], post_id=self.event["post_id"] - ) - return mattermost_message.alert_group - except MattermostMessage.DoesNotExist: - logger.info( - f"Mattermost message not found for channel_id: {self.event['channel_id']} and post_id {self.event['post_id']}" - ) - return - def _get_action_function(self, alert_group: AlertGroup, action: EventAction) -> typing.Tuple[typing.Callable, dict]: action_to_fn = { EventAction.ACKNOWLEDGE: { diff --git a/engine/apps/mattermost/events/types.py b/engine/apps/mattermost/events/types.py index 499a9ca1a9..520c2d232c 100644 --- a/engine/apps/mattermost/events/types.py +++ b/engine/apps/mattermost/events/types.py @@ -5,7 +5,7 @@ class MattermostAlertGroupContext(typing.TypedDict): action: str token: str - alert: int + alert: str class MattermostEvent(typing.TypedDict): diff --git a/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py b/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py index 9f44d7cdf7..0bbc23e6d0 100644 --- a/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py +++ b/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py @@ -1,5 +1,7 @@ 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 @@ -8,6 +10,15 @@ @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, @@ -16,49 +27,26 @@ def test_alert_group_action_success( 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 ) - alert_group = make_alert_group(alert_receive_channel) - 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) + 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) - 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.pk, - ) - handler = AlertGroupActionHandler(event=event, user=user) - handler.process() - alert_group.refresh_from_db() - assert alert_group.acknowledged - - -@pytest.mark.django_db -def test_alert_group_from_message( - make_organization_and_user, - make_alert_receive_channel, - make_alert_group, - make_alert, - make_mattermost_event, - make_mattermost_message, - 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_message = make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) @@ -66,17 +54,18 @@ def test_alert_group_from_message( token = MattermostEventAuthenticator.create_token(organization=organization) event = make_mattermost_event( - EventAction.ACKNOWLEDGE, + event_action, token, post_id=mattermost_message.post_id, channel_id=mattermost_message.channel_id, user_id=mattermost_user.mattermost_user_id, - alert=123, + alert=alert_group.public_primary_key, ) handler = AlertGroupActionHandler(event=event, user=user) handler.process() alert_group.refresh_from_db() - assert alert_group.acknowledged + assert alert_group.state == expected_state + assert alert_group.log_records.last().action_source == ActionSource.MATTERMOST @pytest.mark.django_db @@ -98,7 +87,9 @@ def test_alert_group_not_found( 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=123) + 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() @@ -124,7 +115,7 @@ def test_alert_group_action_not_found( 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=123) + 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() diff --git a/engine/apps/mattermost/tests/test_mattermost_event.py b/engine/apps/mattermost/tests/test_mattermost_event.py index cf3dc69bb4..0344b28427 100644 --- a/engine/apps/mattermost/tests/test_mattermost_event.py +++ b/engine/apps/mattermost/tests/test_mattermost_event.py @@ -4,6 +4,7 @@ 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 @@ -12,7 +13,16 @@ @pytest.mark.django_db -def test_mattermost_alert_group_event_acknowledge( +@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, @@ -21,143 +31,26 @@ def test_mattermost_alert_group_event_acknowledge( 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 ) - 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.pk, - ) - - url = reverse("mattermost:incoming_mattermost_event") - client = APIClient() - response = client.post(url, event, format="json") - alert_group.refresh_from_db() - assert alert_group.acknowledged - assert not alert_group.resolved - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_mattermost_alert_group_event_unacknowledge( - 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=alert_receive_channel, - acknowledged_at=timezone.now(), - acknowledged=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( - EventAction.UNACKNOWLEDGE, - token, - post_id=mattermost_message.post_id, - channel_id=mattermost_message.channel_id, - user_id=mattermost_user.mattermost_user_id, - alert=alert_group.pk, - ) - - url = reverse("mattermost:incoming_mattermost_event") - client = APIClient() - response = client.post(url, event, format="json") - alert_group.refresh_from_db() - assert not alert_group.acknowledged - assert not alert_group.resolved - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_mattermost_alert_group_event_resolve( - 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) + 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) - token = MattermostEventAuthenticator.create_token(organization=organization) - event = make_mattermost_event( - EventAction.RESOLVE, - token, - post_id=mattermost_message.post_id, - channel_id=mattermost_message.channel_id, - user_id=mattermost_user.mattermost_user_id, - alert=alert_group.pk, - ) - - url = reverse("mattermost:incoming_mattermost_event") - client = APIClient() - response = client.post(url, event, format="json") - alert_group.refresh_from_db() - assert not alert_group.acknowledged - assert alert_group.resolved - assert response.status_code == status.HTTP_200_OK - - -@pytest.mark.django_db -def test_mattermost_alert_group_event_unresolve( - 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, 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) @@ -166,20 +59,20 @@ def test_mattermost_alert_group_event_unresolve( token = MattermostEventAuthenticator.create_token(organization=organization) event = make_mattermost_event( - EventAction.UNRESOLVE, + 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.pk, + 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 not alert_group.acknowledged - assert not alert_group.resolved + assert alert_group.state == expected_state + assert alert_group.log_records.last().action_source == ActionSource.MATTERMOST assert response.status_code == status.HTTP_200_OK @@ -214,7 +107,7 @@ def test_mattermost_alert_group_event_incorrect_token( post_id=mattermost_message.post_id, channel_id=mattermost_message.channel_id, user_id=mattermost_user.mattermost_user_id, - alert=alert_group.pk, + alert=alert_group.public_primary_key, ) url = reverse("mattermost:incoming_mattermost_event") @@ -253,7 +146,7 @@ def test_mattermost_alert_group_event_insufficient_permission( post_id=mattermost_message.post_id, channel_id=mattermost_message.channel_id, user_id=mattermost_user.mattermost_user_id, - alert=alert_group.pk, + alert=alert_group.public_primary_key, ) url = reverse("mattermost:incoming_mattermost_event") From 24ccefc9299ff2ffcb52bb7301bb8805a8246eb5 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 26 Nov 2024 15:46:09 +0530 Subject: [PATCH 33/43] User Notification and Escalation Chain flow add-new-line --- .../user_notification_policy_log_record.py | 8 +- engine/apps/mattermost/backend.py | 8 + .../mattermost/migrations/0001_initial.py | 6 +- engine/apps/mattermost/models/message.py | 9 +- engine/apps/mattermost/models/user.py | 4 + engine/apps/mattermost/tasks.py | 83 ++++- engine/apps/mattermost/tests/test_tasks.py | 316 +++++++++++++++++- engine/settings/celery_task_routes.py | 1 + 8 files changed, 424 insertions(+), 11 deletions(-) diff --git a/engine/apps/base/models/user_notification_policy_log_record.py b/engine/apps/base/models/user_notification_policy_log_record.py index 80185a8454..6785368ddc 100644 --- a/engine/apps/base/models/user_notification_policy_log_record.py +++ b/engine/apps/base/models/user_notification_policy_log_record.py @@ -107,7 +107,8 @@ 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, + ) = range(31) # for this errors we want to send message to general log channel ERRORS_TO_SEND_IN_SLACK_CHANNEL = [ @@ -323,6 +324,11 @@ def render_log_line_action(self, for_slack=False, substitute_author_with_tag=Fal 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: diff --git a/engine/apps/mattermost/backend.py b/engine/apps/mattermost/backend.py index 6750804102..efccf85b94 100644 --- a/engine/apps/mattermost/backend.py +++ b/engine/apps/mattermost/backend.py @@ -1,4 +1,5 @@ from apps.base.messaging import BaseMessagingBackend +from apps.mattermost.tasks import notify_user_about_alert_async class MattermostBackend(BaseMessagingBackend): @@ -21,3 +22,10 @@ def serialize_user(self, user): "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, + ) diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index 3bbe08aea3..b7143e65a2 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-11-20 16:53 +# Generated by Django 4.2.16 on 2024-11-26 09:48 import apps.mattermost.models.channel import django.core.validators @@ -11,8 +11,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('user_management', '0026_auto_20241017_1919'), ('alerts', '0065_alter_alertgrouplogrecord_action_source'), + ('user_management', '0026_auto_20241017_1919'), ] operations = [ @@ -50,7 +50,7 @@ class Migration(migrations.Migration): ('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')])), + ('message_type', models.IntegerField(choices=[(0, 'Alert group message'), (1, 'Log message'), (2, 'User notifcation 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')), ], diff --git a/engine/apps/mattermost/models/message.py b/engine/apps/mattermost/models/message.py index 1389a4a3d3..5145a2bc57 100644 --- a/engine/apps/mattermost/models/message.py +++ b/engine/apps/mattermost/models/message.py @@ -8,9 +8,14 @@ class MattermostMessage(models.Model): ( ALERT_GROUP_MESSAGE, LOG_MESSAGE, - ) = range(2) + USER_NOTIFACTION_MESSAGE, + ) = range(3) - MATTERMOST_MESSAGE_CHOICES = ((ALERT_GROUP_MESSAGE, "Alert group message"), (LOG_MESSAGE, "Log message")) + MATTERMOST_MESSAGE_CHOICES = ( + (ALERT_GROUP_MESSAGE, "Alert group message"), + (LOG_MESSAGE, "Log message"), + (USER_NOTIFACTION_MESSAGE, "User notifcation message"), + ) post_id = models.CharField(max_length=100) diff --git a/engine/apps/mattermost/models/user.py b/engine/apps/mattermost/models/user.py index 6493869490..6a35f3e179 100644 --- a/engine/apps/mattermost/models/user.py +++ b/engine/apps/mattermost/models/user.py @@ -14,3 +14,7 @@ class Meta: indexes = [ models.Index(fields=["mattermost_user_id"]), ] + + @property + def mention_username(self): + return f"@{self.username}" diff --git a/engine/apps/mattermost/tasks.py b/engine/apps/mattermost/tasks.py index 60fc9b4cfa..d139de542e 100644 --- a/engine/apps/mattermost/tasks.py +++ b/engine/apps/mattermost/tasks.py @@ -4,11 +4,12 @@ from django.conf import settings from rest_framework import status -from apps.alerts.models import Alert -from apps.mattermost.alert_rendering import MattermostMessageRenderer +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 @@ -33,13 +34,16 @@ def on_create_alert_async(self, alert_pk): 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 - mattermost_channel = MattermostChannel.get_channel_for_alert_group(alert_group=alert_group) payload = MattermostMessageRenderer(alert_group).render_alert_group_message() with OkToRetry(task=self, exc=(MattermostAPIException,), num_retries=3): @@ -89,3 +93,76 @@ def on_alert_group_action_triggered_async(log_record_id): 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 + + 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" + ) + 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 + ) + + UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + reason="User is not in Mattermost", + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST, + ).save() + 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() + 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_group_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.USER_NOTIFACTION_MESSAGE + ) diff --git a/engine/apps/mattermost/tests/test_tasks.py b/engine/apps/mattermost/tests/test_tasks.py index 28fa440c5e..64f2ec1091 100644 --- a/engine/apps/mattermost/tests/test_tasks.py +++ b/engine/apps/mattermost/tests/test_tasks.py @@ -8,9 +8,15 @@ 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 on_alert_group_action_triggered_async, on_create_alert_async +from apps.mattermost.tasks import ( + notify_user_about_alert_async, + on_alert_group_action_triggered_async, + on_create_alert_async, +) @pytest.mark.django_db @@ -43,7 +49,6 @@ def test_on_create_alert_async_success( @pytest.mark.django_db -@httpretty.activate(verbose=True, allow_net_connect=False) def test_on_create_alert_async_skip_post_for_duplicate( make_organization_and_user, make_alert_receive_channel, @@ -65,6 +70,26 @@ def test_on_create_alert_async_skip_post_for_duplicate( 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]) @@ -189,3 +214,290 @@ def test_on_alert_group_action_triggered_async_failure( 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 + ) + + mattermost_message = alert_group.mattermost_messages.order_by("created_at").last() + assert mattermost_message.post_id == data["id"] + assert mattermost_message.channel_id == data["channel_id"] + assert mattermost_message.message_type == MattermostMessage.USER_NOTIFACTION_MESSAGE + + +@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_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=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 + ) + + log_record = user_notification_policy.personal_log_records.last() + mattermost_message = alert_group.mattermost_messages.order_by("created_at").last() + assert mattermost_message.post_id == data["id"] + assert mattermost_message.channel_id == data["channel_id"] + assert mattermost_message.message_type == MattermostMessage.USER_NOTIFACTION_MESSAGE + assert ( + log_record.notification_error_code + == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST + ) + + +@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 + ) + + 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() diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index deb0eedbf8..2c3837a8ad 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -188,4 +188,5 @@ # 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"}, } From 2f2ff5196b30f9d62d2e48f2a772f25609d8a042 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Sat, 30 Nov 2024 22:08:49 +0530 Subject: [PATCH 34/43] Save notification record and review comments --- .../user_notification_policy_log_record.py | 5 ++- .../mattermost/migrations/0001_initial.py | 8 ++-- engine/apps/mattermost/models/message.py | 9 ++-- engine/apps/mattermost/tasks.py | 45 ++++++++++++------- engine/apps/mattermost/tests/test_tasks.py | 36 ++++++--------- 5 files changed, 55 insertions(+), 48 deletions(-) diff --git a/engine/apps/base/models/user_notification_policy_log_record.py b/engine/apps/base/models/user_notification_policy_log_record.py index 6785368ddc..75d7ed3888 100644 --- a/engine/apps/base/models/user_notification_policy_log_record.py +++ b/engine/apps/base/models/user_notification_policy_log_record.py @@ -108,7 +108,10 @@ class UserNotificationPolicyLogRecord(models.Model): ERROR_NOTIFICATION_FORMATTING_ERROR, ERROR_NOTIFICATION_IN_TELEGRAM_RATELIMIT, ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST, - ) = range(31) + 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 = [ diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index b7143e65a2..54372f23b5 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-11-26 09:48 +# Generated by Django 4.2.16 on 2024-11-30 16:34 import apps.mattermost.models.channel import django.core.validators @@ -11,8 +11,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('alerts', '0065_alter_alertgrouplogrecord_action_source'), ('user_management', '0026_auto_20241017_1919'), + ('alerts', '0065_alter_alertgrouplogrecord_action_source'), ] operations = [ @@ -50,7 +50,7 @@ class Migration(migrations.Migration): ('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'), (2, 'User notifcation message')])), + ('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')), ], @@ -60,7 +60,7 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='mattermostmessage', - constraint=models.UniqueConstraint(condition=models.Q(('message_type__in', [0, 1])), fields=('alert_group', 'channel_id', 'message_type'), name='unique_alert_group_channel_id_message_type'), + constraint=models.UniqueConstraint(fields=('alert_group', 'message_type', 'channel_id'), name='unique_alert_group_message_type_channel_id'), ), migrations.AlterUniqueTogether( name='mattermostchannel', diff --git a/engine/apps/mattermost/models/message.py b/engine/apps/mattermost/models/message.py index 5145a2bc57..4e9d3bd73f 100644 --- a/engine/apps/mattermost/models/message.py +++ b/engine/apps/mattermost/models/message.py @@ -8,13 +8,11 @@ class MattermostMessage(models.Model): ( ALERT_GROUP_MESSAGE, LOG_MESSAGE, - USER_NOTIFACTION_MESSAGE, - ) = range(3) + ) = range(2) MATTERMOST_MESSAGE_CHOICES = ( (ALERT_GROUP_MESSAGE, "Alert group message"), (LOG_MESSAGE, "Log message"), - (USER_NOTIFACTION_MESSAGE, "User notifcation message"), ) post_id = models.CharField(max_length=100) @@ -34,9 +32,8 @@ class MattermostMessage(models.Model): class Meta: constraints = [ models.UniqueConstraint( - fields=["alert_group", "channel_id", "message_type"], - condition=models.Q(message_type__in=[0, 1]), - name="unique_alert_group_channel_id_message_type", + fields=["alert_group", "message_type", "channel_id"], + name="unique_alert_group_message_type_channel_id", ) ] diff --git a/engine/apps/mattermost/tasks.py b/engine/apps/mattermost/tasks.py index d139de542e..a34a8596f0 100644 --- a/engine/apps/mattermost/tasks.py +++ b/engine/apps/mattermost/tasks.py @@ -101,6 +101,18 @@ def on_alert_group_action_triggered_async(log_record_id): 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) @@ -120,6 +132,9 @@ def notify_user_about_alert_async(user_pk, alert_group_pk, notification_policy_p 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 @@ -131,21 +146,14 @@ def notify_user_about_alert_async(user_pk, alert_group_pk, notification_policy_p templated_alert = AlertGroupMattermostRenderer(alert_group).alert_renderer.templated_alert + print("Check identity") 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 ) - - UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - reason="User is not in Mattermost", - notification_step=notification_policy.step, - notification_channel=notification_policy.notify_by, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST, - ).save() + _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 @@ -155,14 +163,21 @@ def notify_user_about_alert_async(user_pk, alert_group_pk, notification_policy_p try: client = MattermostClient() - mattermost_post = client.create_post(channel_id=mattermost_channel.channel_id, data=payload) + 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 not in [status.HTTP_401_UNAUTHORIZED]: + if ex.status != status.HTTP_401_UNAUTHORIZED: raise ex + _create_error_log_record(UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_API_UNAUTHORIZED) else: - MattermostMessage.create_message( - alert_group=alert_group, post=mattermost_post, message_type=MattermostMessage.USER_NOTIFACTION_MESSAGE + 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, ) diff --git a/engine/apps/mattermost/tests/test_tasks.py b/engine/apps/mattermost/tests/test_tasks.py index 64f2ec1091..c5ea6a2174 100644 --- a/engine/apps/mattermost/tests/test_tasks.py +++ b/engine/apps/mattermost/tests/test_tasks.py @@ -254,10 +254,9 @@ def test_notify_user_about_alert_async_success( user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk ) - mattermost_message = alert_group.mattermost_messages.order_by("created_at").last() - assert mattermost_message.post_id == data["id"] - assert mattermost_message.channel_id == data["channel_id"] - assert mattermost_message.message_type == MattermostMessage.USER_NOTIFACTION_MESSAGE + 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 @@ -296,21 +295,12 @@ def test_notify_user_about_alert_async_user_does_not_exist( @pytest.mark.django_db def test_notify_user_about_alert_async_alert_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, @@ -414,15 +404,12 @@ def test_notify_user_about_alert_async_mattermost_user_does_not_exist( user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk ) - log_record = user_notification_policy.personal_log_records.last() - mattermost_message = alert_group.mattermost_messages.order_by("created_at").last() - assert mattermost_message.post_id == data["id"] - assert mattermost_message.channel_id == data["channel_id"] - assert mattermost_message.message_type == MattermostMessage.USER_NOTIFACTION_MESSAGE - assert ( - log_record.notification_error_code - == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST - ) + 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 @@ -467,6 +454,11 @@ def test_notify_user_about_alert_async_api_failure( 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" From c1e72be3e60eaa491a02ec3da880f8c9e949f953 Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 3 Dec 2024 09:08:30 +0530 Subject: [PATCH 35/43] Remove print statement --- engine/apps/mattermost/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/engine/apps/mattermost/tasks.py b/engine/apps/mattermost/tasks.py index a34a8596f0..b4e1aaa17b 100644 --- a/engine/apps/mattermost/tasks.py +++ b/engine/apps/mattermost/tasks.py @@ -146,7 +146,6 @@ def _create_error_log_record(notification_error_code=None): templated_alert = AlertGroupMattermostRenderer(alert_group).alert_renderer.templated_alert - print("Check identity") 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 From c13b69f07fc3ef6edc05abce0ee69afb2a3f32c7 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 3 Dec 2024 10:14:12 -0300 Subject: [PATCH 36/43] Regenerate migrations --- ...e.py => 0072_alter_alertgrouplogrecord_action_source.py} | 4 ++-- ...7_mattermostauthtoken.py => 0008_mattermostauthtoken.py} | 6 +++--- engine/apps/mattermost/migrations/0001_initial.py | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) rename engine/apps/alerts/migrations/{0065_alter_alertgrouplogrecord_action_source.py => 0072_alter_alertgrouplogrecord_action_source.py} (77%) rename engine/apps/auth_token/migrations/{0007_mattermostauthtoken.py => 0008_mattermostauthtoken.py} (87%) diff --git a/engine/apps/alerts/migrations/0065_alter_alertgrouplogrecord_action_source.py b/engine/apps/alerts/migrations/0072_alter_alertgrouplogrecord_action_source.py similarity index 77% rename from engine/apps/alerts/migrations/0065_alter_alertgrouplogrecord_action_source.py rename to engine/apps/alerts/migrations/0072_alter_alertgrouplogrecord_action_source.py index a0ec55c823..ddfea7b072 100644 --- a/engine/apps/alerts/migrations/0065_alter_alertgrouplogrecord_action_source.py +++ b/engine/apps/alerts/migrations/0072_alter_alertgrouplogrecord_action_source.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-11-20 16:53 +# Generated by Django 4.2.15 on 2024-12-03 13:13 from django.db import migrations, models @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('alerts', '0064_migrate_resolutionnoteslackmessage_slack_channel_id'), + ('alerts', '0071_migrate_labels'), ] operations = [ diff --git a/engine/apps/auth_token/migrations/0007_mattermostauthtoken.py b/engine/apps/auth_token/migrations/0008_mattermostauthtoken.py similarity index 87% rename from engine/apps/auth_token/migrations/0007_mattermostauthtoken.py rename to engine/apps/auth_token/migrations/0008_mattermostauthtoken.py index 8e32d58679..7d189a0656 100644 --- a/engine/apps/auth_token/migrations/0007_mattermostauthtoken.py +++ b/engine/apps/auth_token/migrations/0008_mattermostauthtoken.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.15 on 2024-09-18 15:13 +# 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 @@ -8,8 +8,8 @@ class Migration(migrations.Migration): dependencies = [ - ('user_management', '0022_alter_team_unique_together'), - ('auth_token', '0006_googleoauth2token'), + ('user_management', '0029_remove_organization_general_log_channel_id_db'), + ('auth_token', '0007_serviceaccounttoken'), ] operations = [ diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index 54372f23b5..41ad91a65d 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -12,7 +12,6 @@ class Migration(migrations.Migration): dependencies = [ ('user_management', '0026_auto_20241017_1919'), - ('alerts', '0065_alter_alertgrouplogrecord_action_source'), ] operations = [ From 9108730f6bd9f0a32d70fd0cf822cd85ea9b6cba Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Wed, 4 Dec 2024 07:06:44 +0530 Subject: [PATCH 37/43] Documentation for mattermost integration --- .../sources/manage/notify/mattermost/index.md | 18 ++++++++++++++-- docs/sources/set-up/open-source/index.md | 21 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/sources/manage/notify/mattermost/index.md b/docs/sources/manage/notify/mattermost/index.md index 7bdae427a6..4e2f254c90 100644 --- a/docs/sources/manage/notify/mattermost/index.md +++ b/docs/sources/manage/notify/mattermost/index.md @@ -15,9 +15,23 @@ aliases: - ../../chat-options/configure-mattermost --- -# Mattermost +# Mattermost integration for Grafana OnCall + +The Mattermost integration for Grafana OnCall incorporates your mattermost channel directly into your incident response workflow to help your team focus on alert resolution with less friction + +## Before you begin +To install the Mattermost integration, you must have Admin Permissions in your Grafana instance as well as the Mattermost instance that you'd like to integrate. + +Follow the setup [documentation](https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup) + +## Connect to a Mattermost Channel +1. Navigate to the mattermost channel you want to integrate and click on the info icon and copy the channel id. +2. In OnCall, click on the **ChatOps** tab and select Mattermost in the side menu. +3. Click **Add Mattermost channel** button and paste the channel id from (1) and click **Create** +4. Choose a default channel for the alerts. + +(Note: Make sure the bot created as part of setup is added to the team the channel is part of and has `read_channel` privilages [Ref](https://api.mattermost.com/#tag/channels/operation/GetChannelByNameForTeamName)) -Mattermost support is not implemented yet. Please join [GitHub Issue](https://github.com/grafana/oncall/issues/96) or check [PR](https://github.com/grafana/oncall/pull/606). diff --git a/docs/sources/set-up/open-source/index.md b/docs/sources/set-up/open-source/index.md index f1a3d204a1..05e4607e26 100644 --- a/docs/sources/set-up/open-source/index.md +++ b/docs/sources/set-up/open-source/index.md @@ -213,7 +213,26 @@ Refer to the following steps to configure the Telegram integration: ## Mattermost Setup -TODO: To Be Updated +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. +4. Add the bot to required team in mattermost so that the channels from those teams can be integrated +5. Paste the token generated to 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 will be, + ```text + https:///api/internal/v1/complete/mattermost-login/ + ``` +7. Generate a JWT secret for authenticating the incoming messages from mattermost and add it to `MATTERMOST_SIGNING_SECRET` variable on the **ENV Variables** page of your Grafana OnCall instance. +8. Set the environment variables by navigating to your Grafana OnCall, then click on **ENV Variables** and set the following, + ```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 >> + ``` +9. Now users can integrate their mattermost account to Grafana Oncall in the **Users** page so that they can take action on the alert message sent on the mattermost channel ## Grafana OSS-Cloud Setup From 9b0d68adc6fe28c94feac4ae843e2312c603b2b0 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Wed, 4 Dec 2024 16:15:36 -0300 Subject: [PATCH 38/43] Update docs, fix lint --- .../sources/manage/notify/mattermost/index.md | 27 ++++++++------ docs/sources/set-up/open-source/index.md | 37 +++++++++++-------- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/docs/sources/manage/notify/mattermost/index.md b/docs/sources/manage/notify/mattermost/index.md index 4e2f254c90..b340334343 100644 --- a/docs/sources/manage/notify/mattermost/index.md +++ b/docs/sources/manage/notify/mattermost/index.md @@ -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 @@ -17,21 +17,24 @@ aliases: # Mattermost integration for Grafana OnCall -The Mattermost integration for Grafana OnCall incorporates your mattermost channel directly into your incident response workflow to help your team focus on alert resolution with less friction +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. + +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 instance as well as the Mattermost instance that you'd like to integrate. -Follow the setup [documentation](https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup) +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. -## Connect to a Mattermost Channel -1. Navigate to the mattermost channel you want to integrate and click on the info icon and copy the channel id. -2. In OnCall, click on the **ChatOps** tab and select Mattermost in the side menu. -3. Click **Add Mattermost channel** button and paste the channel id from (1) and click **Create** -4. Choose a default channel for the alerts. +Follow the steps in our [documentation](https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup). -(Note: Make sure the bot created as part of setup is added to the team the channel is part of and has `read_channel` privilages [Ref](https://api.mattermost.com/#tag/channels/operation/GetChannelByNameForTeamName)) +## 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. -Please join [GitHub Issue](https://github.com/grafana/oncall/issues/96) or -check [PR](https://github.com/grafana/oncall/pull/606). +(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)) diff --git a/docs/sources/set-up/open-source/index.md b/docs/sources/set-up/open-source/index.md index 05e4607e26..70cf3cfacd 100644 --- a/docs/sources/set-up/open-source/index.md +++ b/docs/sources/set-up/open-source/index.md @@ -213,32 +213,38 @@ Refer to the following steps to configure the Telegram integration: ## Mattermost Setup -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. +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. -4. Add the bot to required team in mattermost so that the channels from those teams can be integrated -5. Paste the token generated to 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 will be, +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:///api/internal/v1/complete/mattermost-login/ ``` -7. Generate a JWT secret for authenticating the incoming messages from mattermost and add it to `MATTERMOST_SIGNING_SECRET` variable on the **ENV Variables** page of your Grafana OnCall instance. -8. Set the environment variables by navigating to your Grafana OnCall, then click on **ENV Variables** and set the following, + + 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_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 >> ``` -9. Now users can integrate their mattermost account to Grafana Oncall in the **Users** page so that they can take action on the alert message sent on the mattermost channel - -## Grafana OSS-Cloud Setup - -The benefits of connecting to Grafana Cloud OnCall include: -- Grafana Cloud OnCall could monitor OSS OnCall uptime using heartbeat - SMS for user notifications - Phone calls for user notifications. @@ -377,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`. From d6624e5fa1a5bad5cf854c5da113e00d2c39d5ee Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Sat, 7 Dec 2024 11:42:48 +0530 Subject: [PATCH 39/43] Add mattermost alert group integration flow Add comment Lint fix --- engine/apps/mattermost/backend.py | 33 +++++++ engine/apps/mattermost/models/channel.py | 24 ++++- .../mattermost/tests/models/test_channel.py | 91 +++++++++++++++++++ engine/apps/mattermost/tests/test_backend.py | 91 +++++++++++++++++++ .../src/containers/AlertRules/AlertRules.tsx | 9 +- .../parts/connectors/MattermostConnector.tsx | 81 +++++++++++++++++ .../models/mattermost/mattermost_channel.ts | 12 +++ 7 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 engine/apps/mattermost/tests/models/test_channel.py create mode 100644 engine/apps/mattermost/tests/test_backend.py create mode 100644 grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx diff --git a/engine/apps/mattermost/backend.py b/engine/apps/mattermost/backend.py index efccf85b94..8f0759360f 100644 --- a/engine/apps/mattermost/backend.py +++ b/engine/apps/mattermost/backend.py @@ -1,4 +1,7 @@ +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 @@ -29,3 +32,33 @@ def notify_user(self, user, alert_group, notification_policy): 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 diff --git a/engine/apps/mattermost/models/channel.py b/engine/apps/mattermost/models/channel.py index a8ea351c94..2a2c11c662 100644 --- a/engine/apps/mattermost/models/channel.py +++ b/engine/apps/mattermost/models/channel.py @@ -49,11 +49,33 @@ class Meta: @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() - return default_channel + 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 or 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: diff --git a/engine/apps/mattermost/tests/models/test_channel.py b/engine/apps/mattermost/tests/models/test_channel.py new file mode 100644 index 0000000000..68de2c488e --- /dev/null +++ b/engine/apps/mattermost/tests/models/test_channel.py @@ -0,0 +1,91 @@ +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) + default_channel = 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": 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.public_primary_key == default_channel.public_primary_key + + +@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 diff --git a/engine/apps/mattermost/tests/test_backend.py b/engine/apps/mattermost/tests/test_backend.py new file mode 100644 index 0000000000..ac65526051 --- /dev/null +++ b/engine/apps/mattermost/tests/test_backend.py @@ -0,0 +1,91 @@ +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}), + ({"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) diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index 517d49e852..748db8d646 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -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,14 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => { useEffect(() => { msteamsChannelStore.updateMSTeamsChannels(); + mattermostChannelStore.updateItems(); }, []); const isMSTeamsInstalled = msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0; + const connectedChannels = mattermostChannelStore.getSearchResult(); + const isMattermostInstalled = store.hasFeature(AppFeature.Mattermost) && connectedChannels && connectedChannels.length - if (!isSlackInstalled && !isTelegramInstalled && !isMSTeamsInstalled) { + if (!isSlackInstalled && !isTelegramInstalled && !isMSTeamsInstalled && !isMattermostInstalled) { return null; } @@ -42,6 +46,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => { {isSlackInstalled && } {isTelegramInstalled && } {isMSTeamsInstalled && } + {isMattermostInstalled && } ); diff --git a/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx b/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx new file mode 100644 index 0000000000..fe8502350d --- /dev/null +++ b/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx @@ -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, + + 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) => { + alertReceiveChannelStore.saveChannelFilter(channelFilterId, { + notification_backends: { MATTERMOST: { enabled: event.target.checked } }, + }); + }, []); + + return ( +
+ +
+ + + +
+ Post to Mattermost channel + + + 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} + /> + +
+
+ ); +}) diff --git a/grafana-plugin/src/models/mattermost/mattermost_channel.ts b/grafana-plugin/src/models/mattermost/mattermost_channel.ts index 99dc8d792d..0276f21f4c 100644 --- a/grafana-plugin/src/models/mattermost/mattermost_channel.ts +++ b/grafana-plugin/src/models/mattermost/mattermost_channel.ts @@ -21,6 +21,18 @@ export class MattermostChannelStore extends BaseStore { this.path = '/mattermost/channels/'; } + @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(); From 2fbb42159edcea0567572fff79e228b9943c41dc Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 10 Dec 2024 07:40:40 +0530 Subject: [PATCH 40/43] Add chatops display condition and review comments --- engine/apps/mattermost/models/channel.py | 5 ++++- engine/apps/mattermost/tests/models/test_channel.py | 3 +-- engine/apps/mattermost/tests/test_backend.py | 3 +++ .../ExpandedIntegrationRouteDisplay.tsx | 3 ++- grafana-plugin/src/pages/integration/Integration.helper.ts | 4 +++- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/engine/apps/mattermost/models/channel.py b/engine/apps/mattermost/models/channel.py index 2a2c11c662..b1d86e2391 100644 --- a/engine/apps/mattermost/models/channel.py +++ b/engine/apps/mattermost/models/channel.py @@ -65,7 +65,10 @@ def get_channel_for_alert_group(cls, alert_group: AlertGroup) -> typing.Optional 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 or not channel_id: + if not enabled: + return None + + if not channel_id: return default_channel channel = cls.objects.filter( diff --git a/engine/apps/mattermost/tests/models/test_channel.py b/engine/apps/mattermost/tests/models/test_channel.py index 68de2c488e..65151d5bdf 100644 --- a/engine/apps/mattermost/tests/models/test_channel.py +++ b/engine/apps/mattermost/tests/models/test_channel.py @@ -39,7 +39,6 @@ def test_get_mattermost_channel_disabled_for_route( ): organization = make_organization() alert_receive_channel = make_alert_receive_channel(organization) - default_channel = make_mattermost_channel(organization=organization, is_default_channel=True) channel = make_mattermost_channel(organization=organization) channel_filter = make_channel_filter( alert_receive_channel, @@ -50,7 +49,7 @@ def test_get_mattermost_channel_disabled_for_route( 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 + assert ch is None @pytest.mark.django_db diff --git a/engine/apps/mattermost/tests/test_backend.py b/engine/apps/mattermost/tests/test_backend.py index ac65526051..67db285f8b 100644 --- a/engine/apps/mattermost/tests/test_backend.py +++ b/engine/apps/mattermost/tests/test_backend.py @@ -65,8 +65,11 @@ def test_validate_channel_filter_data_update_only_channel( @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}), ], diff --git a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx index 30455ffaf5..a970c3de67 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx @@ -100,6 +100,7 @@ export const ExpandedIntegrationRouteDisplay: React.FC { setIsLoading(true); (async () => { - await Promise.all([escalationChainStore.updateItems(), telegramChannelStore.updateTelegramChannels()]); + await Promise.all([escalationChainStore.updateItems(), telegramChannelStore.updateTelegramChannels(), mattermostChannelStore.updateItems()]); setIsLoading(false); })(); }, []); diff --git a/grafana-plugin/src/pages/integration/Integration.helper.ts b/grafana-plugin/src/pages/integration/Integration.helper.ts index 0812cc8ff5..c22f821e15 100644 --- a/grafana-plugin/src/pages/integration/Integration.helper.ts +++ b/grafana-plugin/src/pages/integration/Integration.helper.ts @@ -68,8 +68,10 @@ export const IntegrationHelper = { const hasTelegram = store.hasFeature(AppFeature.Telegram) && store.telegramChannelStore.currentTeamToTelegramChannel?.length > 0; const isMSTeamsInstalled = Boolean(store.msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0); + const connectedChannels = store.mattermostChannelStore.getSearchResult(); + const isMattermostInstalled = store.hasFeature(AppFeature.Mattermost) && Boolean(connectedChannels && connectedChannels.length) - return hasSlack || hasTelegram || isMSTeamsInstalled; + return hasSlack || hasTelegram || isMSTeamsInstalled || isMattermostInstalled; }, getChatOpsChannels(channelFilter: ChannelFilter, store: RootStore): Array<{ name: string; icon: IconName }> { From 5f0256ae9362c42c10fa1cab5ef4c90f2011ece7 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 27 Dec 2024 12:05:24 -0300 Subject: [PATCH 41/43] Minor updates and refactorings --- dev/.env.dev.example | 1 + engine/apps/api/tests/test_organization.py | 3 -- engine/apps/mattermost/backend.py | 1 + engine/apps/mattermost/tests/conftest.py | 18 ++++++- engine/common/api_helpers/mixins.py | 5 +- engine/conftest.py | 11 ----- engine/settings/base.py | 6 ++- engine/settings/ci_test.py | 10 ++-- engine/settings/dev.py | 3 ++ .../src/containers/AlertRules/AlertRules.tsx | 5 +- .../parts/connectors/MattermostConnector.tsx | 14 +++--- .../ExpandedIntegrationRouteDisplay.tsx | 3 +- .../containers/UserSettings/UserSettings.tsx | 2 +- .../UserSettings/parts/UserSettingsParts.tsx | 2 - .../parts/connectors/Connectors.tsx | 2 +- .../parts/connectors/MattermostConnector.tsx | 24 ++++++---- .../tabs/MattermostInfo/MattermostInfo.tsx | 4 +- .../models/mattermost/mattermost_channel.ts | 48 ++++++++++++++++++- .../pages/integration/Integration.helper.ts | 3 +- 19 files changed, 109 insertions(+), 56 deletions(-) diff --git a/dev/.env.dev.example b/dev/.env.dev.example index da276f8a23..426c2cd46b 100644 --- a/dev/.env.dev.example +++ b/dev/.env.dev.example @@ -18,6 +18,7 @@ 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 diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index 46f7cc6552..f4545cd6b8 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -361,6 +361,3 @@ def test_get_organization_telegram_config_checks( assert response.status_code == status.HTTP_200_OK expected_result["is_integration_chatops_connected"] = True assert response.json() == expected_result - - -# TODO: Add test to validate mattermost is integrated once integration PR changes are made diff --git a/engine/apps/mattermost/backend.py b/engine/apps/mattermost/backend.py index 8f0759360f..a43c7757cc 100644 --- a/engine/apps/mattermost/backend.py +++ b/engine/apps/mattermost/backend.py @@ -10,6 +10,7 @@ class MattermostBackend(BaseMessagingBackend): 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 diff --git a/engine/apps/mattermost/tests/conftest.py b/engine/apps/mattermost/tests/conftest.py index 497c4a0c89..aef00804d5 100644 --- a/engine/apps/mattermost/tests/conftest.py +++ b/engine/apps/mattermost/tests/conftest.py @@ -1,6 +1,22 @@ import pytest +from django.conf import settings -from apps.mattermost.tests.factories import MattermostMessageFactory, MattermostUserFactory +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() diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index 25bfb33398..552aaade45 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -23,7 +23,6 @@ ) from apps.alerts.models import Alert, AlertGroup from apps.base.messaging import get_messaging_backends -from apps.mattermost.alert_rendering import AlertMattermostTemplater from common.api_helpers.exceptions import BadRequest from common.jinja_templater import apply_jinja_template from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning @@ -239,9 +238,8 @@ def filter_lookups(child): PHONE_CALL = "phone_call" SMS = "sms" TELEGRAM = "telegram" -MATTERMOST = "mattermost" # templates with its own field in db, this concept replaced by messaging_backend_templates field -NOTIFICATION_CHANNEL_OPTIONS = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM, MATTERMOST] +NOTIFICATION_CHANNEL_OPTIONS = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM] TITLE = "title" MESSAGE = "message" @@ -260,7 +258,6 @@ def filter_lookups(child): PHONE_CALL: AlertPhoneCallTemplater, SMS: AlertSmsTemplater, TELEGRAM: AlertTelegramTemplater, - MATTERMOST: AlertMattermostTemplater, } # add additionally supported messaging backends diff --git a/engine/conftest.py b/engine/conftest.py index 39d4b6437f..11cd627f64 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -77,7 +77,6 @@ LabelValueFactory, WebhookAssociatedLabelFactory, ) -from apps.mattermost.tests.factories import MattermostChannelFactory, MattermostMessageFactory from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken from apps.phone_notifications.phone_backend import PhoneBackend from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory @@ -160,8 +159,6 @@ register(AlertReceiveChannelAssociatedLabelFactory) register(GoogleOAuth2UserFactory) register(UserNotificationBundleFactory) -register(MattermostChannelFactory) -register(MattermostMessageFactory) IS_RBAC_ENABLED = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True" @@ -919,14 +916,6 @@ def _make_telegram_message(alert_group, message_type, **kwargs): return _make_telegram_message -@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_phone_call_record(): def _make_phone_call_record(receiver, **kwargs): diff --git a/engine/settings/base.py b/engine/settings/base.py index 83e2917ddc..da33c13ce2 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -311,7 +311,6 @@ class DatabaseTypes: "drf_spectacular", "apps.google", "apps.chatops_proxy", - "apps.mattermost", ] if DATABASE_TYPE == DatabaseTypes.MYSQL: @@ -731,7 +730,7 @@ class BrokerTypes: SLACK_INTEGRATION_MAINTENANCE_ENABLED = os.environ.get("SLACK_INTEGRATION_MAINTENANCE_ENABLED", False) # Mattermost -FEATURE_MATTERMOST_INTEGRATION_ENABLED = os.environ.get("FEATURE_MATTERMOST_INTEGRATION_ENABLED", True) +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") @@ -739,6 +738,9 @@ class BrokerTypes: 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 diff --git a/engine/settings/ci_test.py b/engine/settings/ci_test.py index 5d64a62021..753c12961f 100644 --- a/engine/settings/ci_test.py +++ b/engine/settings/ci_test.py @@ -58,7 +58,9 @@ # request_model = models.Request.objects.create( SILK_PROFILER_ENABLED = False -# Dummy token -MATTERMOST_HOST = "http://localhost:8065" -MATTERMOST_BOT_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" -MATTERMOST_SIGNING_SECRET = "f0cb4953bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d7284a6" +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" diff --git a/engine/settings/dev.py b/engine/settings/dev.py index 73d1cc6194..0366fa0947 100644 --- a/engine/settings/dev.py +++ b/engine/settings/dev.py @@ -64,7 +64,10 @@ 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 diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index 748db8d646..69af50e507 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -29,12 +29,11 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => { useEffect(() => { msteamsChannelStore.updateMSTeamsChannels(); - mattermostChannelStore.updateItems(); + mattermostChannelStore.updateMattermostChannels(); }, []); const isMSTeamsInstalled = msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0; - const connectedChannels = mattermostChannelStore.getSearchResult(); - const isMattermostInstalled = store.hasFeature(AppFeature.Mattermost) && connectedChannels && connectedChannels.length + const isMattermostInstalled = store.hasFeature(AppFeature.Mattermost) && mattermostChannelStore.currentTeamToMattermostChannel?.length > 0; if (!isSlackInstalled && !isTelegramInstalled && !isMSTeamsInstalled && !isMattermostInstalled) { return null; diff --git a/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx b/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx index fe8502350d..7316619e45 100644 --- a/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx +++ b/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx @@ -8,7 +8,7 @@ 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 { ChannelFilter } from 'models/channel_filter/channel_filter.types'; import { MattermostChannel } from 'models/mattermost/mattermost.types'; import { useStore } from 'state/useStore'; @@ -27,9 +27,9 @@ export const MattermostConnector = observer((props: MattermostConnectorProps) => const { alertReceiveChannelStore, mattermostChannelStore, - + // dereferencing items is needed to rerender GSelect mattermostChannelStore: { items: mattermostChannelItems }, - } = store + } = store; const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId]; @@ -53,9 +53,9 @@ export const MattermostConnector = observer((props: MattermostConnectorProps) =>
@@ -78,4 +78,4 @@ export const MattermostConnector = observer((props: MattermostConnectorProps) =>
); -}) +}); diff --git a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx index a970c3de67..30455ffaf5 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx @@ -100,7 +100,6 @@ export const ExpandedIntegrationRouteDisplay: React.FC { setIsLoading(true); (async () => { - await Promise.all([escalationChainStore.updateItems(), telegramChannelStore.updateTelegramChannels(), mattermostChannelStore.updateItems()]); + await Promise.all([escalationChainStore.updateItems(), telegramChannelStore.updateTelegramChannels()]); setIsLoading(false); })(); }, []); diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index ba9eb311ec..6829e5c796 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -46,7 +46,7 @@ function getGoogleMessage(googleError: GoogleError) { } function getMattermostErrorMessage(mattermostError: MattermostError) { - if (mattermostError == MattermostError.MATTERMOST_AUTH_FETCH_USER_ERROR) { + 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 diff --git a/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx b/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx index b04e7db945..d79d0100e0 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx @@ -54,8 +54,6 @@ export const Tabs = ({ [onTabChange] ); - const styles = useStyles2(getUserSettingsPartsStyles); - return ( = observer((props) => { {store.hasFeature(AppFeature.Telegram) && } - {store.hasFeature(AppFeature.MsTeams) && } {store.hasFeature(AppFeature.Mattermost) && } + {store.hasFeature(AppFeature.MsTeams) && } Calendar export diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx index f350ed1d99..7b3ec934e6 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx @@ -25,7 +25,7 @@ export const MattermostConnector = observer((props: MattermostConnectorProps) => const handleConnectButtonClick = useCallback(() => { onTabChange(UserSettingsTab.MattermostInfo); - }, [onTabChange]); + }, []); const handleUnlinkMattermostAccount = useCallback(() => { userStore.unlinkBackend(id, 'MATTERMOST'); @@ -35,24 +35,28 @@ export const MattermostConnector = observer((props: MattermostConnectorProps) => return (
- - {mattermostConfigured ? ( + {storeUser.messaging_backends.MATTERMOST ? ( + - + - )} - + + ) : ( +
+ + + +
+ )}
); }); diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx index a4f3cac8cb..71b6852dd2 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx @@ -25,9 +25,9 @@ export const MattermostInfo = () => { - Personal Mattermost connection will allow you to manage alert group in your connected mattermost channel + Personal Mattermost connection will allow you to manage alert groups in your connected Mattermost channel - To setup personal mattermost click the button below and login to your mattermost server + To link your Mattermost account, click the button below and login to your server More details in{' '} diff --git a/grafana-plugin/src/models/mattermost/mattermost_channel.ts b/grafana-plugin/src/models/mattermost/mattermost_channel.ts index 0276f21f4c..1be33c7e78 100644 --- a/grafana-plugin/src/models/mattermost/mattermost_channel.ts +++ b/grafana-plugin/src/models/mattermost/mattermost_channel.ts @@ -1,4 +1,4 @@ -import { action, observable, makeObservable, runInAction } from 'mobx'; +import { action, computed, observable, makeObservable, runInAction } from 'mobx'; import { BaseStore } from 'models/base_store'; import { makeRequest } from 'network/network'; @@ -10,9 +10,14 @@ export class MattermostChannelStore extends BaseStore { @observable.shallow items: { [id: string]: MattermostChannel } = {}; + @observable + currentTeamToMattermostChannel?: Array; + @observable.shallow searchResult: { [key: string]: Array } = {}; + private autoUpdateTimer?: ReturnType; + constructor(rootStore: RootStore) { super(rootStore); @@ -21,6 +26,28 @@ export class MattermostChannelStore extends BaseStore { this.path = '/mattermost/channels/'; } + @action.bound + async updateMattermostChannels() { + const response = await makeRequest(this.path, {}); + + const items = response.reduce( + (acc: any, mattermostChannel: MattermostChannel) => ({ + ...acc, + [mattermostChannel.id]: mattermostChannel, + }), + {} + ); + + runInAction(() => { + this.items = { + ...this.items, + ...items, + }; + + this.currentTeamToMattermostChannel = response.map((mattermostChannel: MattermostChannel) => mattermostChannel.id); + }); + } + @action.bound async updateById(id: MattermostChannel['id']) { const response = await this.getById(id); @@ -65,6 +92,21 @@ export class MattermostChannelStore extends BaseStore { ); }; + @computed + get hasItems() { + return Boolean(this.getSearchResult('')?.length); + } + + async startAutoUpdate() { + this.autoUpdateTimer = setInterval(this.updateMattermostChannels.bind(this), 3000); + } + + async stopAutoUpdate() { + if (this.autoUpdateTimer) { + clearInterval(this.autoUpdateTimer); + } + } + @action.bound async makeMattermostChannelDefault(id: MattermostChannel['id']) { return makeRequest(`/mattermost/channels/${id}/set_default`, { @@ -75,4 +117,8 @@ export class MattermostChannelStore extends BaseStore { async deleteMattermostChannel(id: MattermostChannel['id']) { return super.delete(id); } + + async getMattermostChannels() { + return super.getAll(); + } } diff --git a/grafana-plugin/src/pages/integration/Integration.helper.ts b/grafana-plugin/src/pages/integration/Integration.helper.ts index c22f821e15..c0746ca4ef 100644 --- a/grafana-plugin/src/pages/integration/Integration.helper.ts +++ b/grafana-plugin/src/pages/integration/Integration.helper.ts @@ -68,8 +68,7 @@ export const IntegrationHelper = { const hasTelegram = store.hasFeature(AppFeature.Telegram) && store.telegramChannelStore.currentTeamToTelegramChannel?.length > 0; const isMSTeamsInstalled = Boolean(store.msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0); - const connectedChannels = store.mattermostChannelStore.getSearchResult(); - const isMattermostInstalled = store.hasFeature(AppFeature.Mattermost) && Boolean(connectedChannels && connectedChannels.length) + const isMattermostInstalled = Boolean(store.mattermostChannelStore.currentTeamToMattermostChannel?.length > 0); return hasSlack || hasTelegram || isMSTeamsInstalled || isMattermostInstalled; }, From 8f0b9c3076cba80a61e7dc1f4b29d1cea37f97c5 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 3 Jan 2025 09:49:05 -0300 Subject: [PATCH 42/43] Updates from review --- .../src/containers/AlertRules/AlertRules.tsx | 2 +- .../models/mattermost/mattermost_channel.ts | 27 ++----------------- .../pages/integration/Integration.helper.ts | 2 +- 3 files changed, 4 insertions(+), 27 deletions(-) diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index 69af50e507..d5d6285e5b 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -33,7 +33,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => { }, []); const isMSTeamsInstalled = msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0; - const isMattermostInstalled = store.hasFeature(AppFeature.Mattermost) && mattermostChannelStore.currentTeamToMattermostChannel?.length > 0; + const isMattermostInstalled = store.hasFeature(AppFeature.Mattermost) && Object.keys(mattermostChannelStore.items).length > 0; if (!isSlackInstalled && !isTelegramInstalled && !isMSTeamsInstalled && !isMattermostInstalled) { return null; diff --git a/grafana-plugin/src/models/mattermost/mattermost_channel.ts b/grafana-plugin/src/models/mattermost/mattermost_channel.ts index 1be33c7e78..7df8d89277 100644 --- a/grafana-plugin/src/models/mattermost/mattermost_channel.ts +++ b/grafana-plugin/src/models/mattermost/mattermost_channel.ts @@ -1,4 +1,4 @@ -import { action, computed, observable, makeObservable, runInAction } from 'mobx'; +import { action, observable, makeObservable, runInAction } from 'mobx'; import { BaseStore } from 'models/base_store'; import { makeRequest } from 'network/network'; @@ -10,9 +10,6 @@ export class MattermostChannelStore extends BaseStore { @observable.shallow items: { [id: string]: MattermostChannel } = {}; - @observable - currentTeamToMattermostChannel?: Array; - @observable.shallow searchResult: { [key: string]: Array } = {}; @@ -28,7 +25,7 @@ export class MattermostChannelStore extends BaseStore { @action.bound async updateMattermostChannels() { - const response = await makeRequest(this.path, {}); + const response = await makeRequest(this.path, {}); const items = response.reduce( (acc: any, mattermostChannel: MattermostChannel) => ({ @@ -43,8 +40,6 @@ export class MattermostChannelStore extends BaseStore { ...this.items, ...items, }; - - this.currentTeamToMattermostChannel = response.map((mattermostChannel: MattermostChannel) => mattermostChannel.id); }); } @@ -92,21 +87,6 @@ export class MattermostChannelStore extends BaseStore { ); }; - @computed - get hasItems() { - return Boolean(this.getSearchResult('')?.length); - } - - async startAutoUpdate() { - this.autoUpdateTimer = setInterval(this.updateMattermostChannels.bind(this), 3000); - } - - async stopAutoUpdate() { - if (this.autoUpdateTimer) { - clearInterval(this.autoUpdateTimer); - } - } - @action.bound async makeMattermostChannelDefault(id: MattermostChannel['id']) { return makeRequest(`/mattermost/channels/${id}/set_default`, { @@ -118,7 +98,4 @@ export class MattermostChannelStore extends BaseStore { return super.delete(id); } - async getMattermostChannels() { - return super.getAll(); - } } diff --git a/grafana-plugin/src/pages/integration/Integration.helper.ts b/grafana-plugin/src/pages/integration/Integration.helper.ts index c0746ca4ef..dd2cfa5688 100644 --- a/grafana-plugin/src/pages/integration/Integration.helper.ts +++ b/grafana-plugin/src/pages/integration/Integration.helper.ts @@ -68,7 +68,7 @@ export const IntegrationHelper = { const hasTelegram = store.hasFeature(AppFeature.Telegram) && store.telegramChannelStore.currentTeamToTelegramChannel?.length > 0; const isMSTeamsInstalled = Boolean(store.msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0); - const isMattermostInstalled = Boolean(store.mattermostChannelStore.currentTeamToMattermostChannel?.length > 0); + const isMattermostInstalled = Object.keys(store.mattermostChannelStore.items).length > 0; return hasSlack || hasTelegram || isMSTeamsInstalled || isMattermostInstalled; }, From 50fe306eb0c68b056e1b3c2b445682ee60f46e29 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Tue, 7 Jan 2025 09:17:14 -0300 Subject: [PATCH 43/43] Update db migration --- ...ource.py => 0074_alter_alertgrouplogrecord_action_source.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename engine/apps/alerts/migrations/{0072_alter_alertgrouplogrecord_action_source.py => 0074_alter_alertgrouplogrecord_action_source.py} (86%) diff --git a/engine/apps/alerts/migrations/0072_alter_alertgrouplogrecord_action_source.py b/engine/apps/alerts/migrations/0074_alter_alertgrouplogrecord_action_source.py similarity index 86% rename from engine/apps/alerts/migrations/0072_alter_alertgrouplogrecord_action_source.py rename to engine/apps/alerts/migrations/0074_alter_alertgrouplogrecord_action_source.py index ddfea7b072..5db02eccf9 100644 --- a/engine/apps/alerts/migrations/0072_alter_alertgrouplogrecord_action_source.py +++ b/engine/apps/alerts/migrations/0074_alter_alertgrouplogrecord_action_source.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('alerts', '0071_migrate_labels'), + ('alerts', '0073_update_direct_paging_integration_non_default_routes'), ] operations = [