Skip to content
This repository was archived by the owner on Mar 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
e4d513f
Add mattermost OAuth2 flow
ravishankar15 Aug 6, 2024
adfeb1f
Correcting the comments
ravishankar15 Aug 13, 2024
7a9aa2c
Fix the config checks API
ravishankar15 Aug 13, 2024
9b750b4
Fixing the lint errors
ravishankar15 Aug 13, 2024
a039a59
Add mattermost OAuth2 flow
ravishankar15 Aug 6, 2024
9126d1d
Mattermost Connect Channels
ravishankar15 Aug 27, 2024
726c80e
Addressed the review comments
ravishankar15 Aug 30, 2024
71588db
Adding default action and test cases
ravishankar15 Sep 7, 2024
5dc0afa
Review comments
ravishankar15 Sep 10, 2024
2712c29
Fix exception handling of mattermost API
ravishankar15 Sep 11, 2024
5225f2d
Make the feature flag true by default
ravishankar15 Sep 12, 2024
bcc3887
Review comments and changes for latest update on freature branch
ravishankar15 Sep 14, 2024
384a52a
Add token to base for testing
ravishankar15 Sep 14, 2024
2ff3a1d
Remove config checks from OrganizationConfigChecksView
ravishankar15 Sep 17, 2024
20bfd9a
Remove related config check code
ravishankar15 Sep 17, 2024
23d5bbe
Remove missed config check
ravishankar15 Sep 17, 2024
c06a792
Mattermost Channel Integration UI Changes
ravishankar15 Sep 18, 2024
f1b5b0f
Use channel id insted of channel name and team name
ravishankar15 Sep 19, 2024
e85ffb1
Review comments
ravishankar15 Sep 24, 2024
3abfab3
Update to emotion styling
ravishankar15 Oct 1, 2024
7c10ace
Mattermost User Integration
ravishankar15 Sep 20, 2024
ae20e64
Moving to emotion styling review comments
ravishankar15 Oct 1, 2024
6a101ce
Remove unnecessary unique index
ravishankar15 Oct 7, 2024
7e1c528
Mattermost Alert Flow
ravishankar15 Oct 14, 2024
5faec3c
Fix spelling
ravishankar15 Oct 16, 2024
058a60a
Adding tests and review comments
ravishankar15 Oct 31, 2024
a411d60
Fix duplication and lint fixes
ravishankar15 Nov 12, 2024
c4183d5
Add config for ci test
ravishankar15 Nov 12, 2024
95289d8
Address Review comments
ravishankar15 Nov 14, 2024
472c3c3
Fixing Lint and User auth redirect flow
ravishankar15 Nov 19, 2024
b2326ae
Mattermost incoming event handler
ravishankar15 Nov 20, 2024
4d69512
Review comments and tests
ravishankar15 Nov 21, 2024
dbd8f7f
User Notification and Escalation Chain flow
ravishankar15 Nov 26, 2024
9caf6b9
Save notification record and review comments
ravishankar15 Nov 30, 2024
0cb8cca
Remove print statement
ravishankar15 Dec 3, 2024
95c3928
Regenerate migrations
matiasb Dec 3, 2024
a3126fe
Documentation for mattermost integration
ravishankar15 Dec 4, 2024
4fca9b0
Update docs, fix lint
matiasb Dec 4, 2024
6136504
Add mattermost alert group integration flow
ravishankar15 Dec 7, 2024
93552d3
Add chatops display condition and review comments
ravishankar15 Dec 10, 2024
e7b4dfa
Minor updates and refactorings
matiasb Dec 27, 2024
1aaff07
Updates from review
matiasb Jan 3, 2025
d40c8fc
Update db migration
matiasb Jan 7, 2025
a104a9b
Updates post-rebase
matiasb Mar 3, 2025
53afedd
Upgrade django
matiasb Apr 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Mattermost Alert Flow
Add message type column

Fix minor changes
  • Loading branch information
ravishankar15 authored and matiasb committed Apr 21, 2025
commit 7e1c52833af6391204abdaf8532b14d9f1c99414
5 changes: 5 additions & 0 deletions engine/apps/alerts/models/alert_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions engine/apps/base/models/live_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class LiveSetting(models.Model):
"MATTERMOST_HOST",
"MATTERMOST_BOT_TOKEN",
"MATTERMOST_LOGIN_RETURN_REDIRECT_HOST",
"MATTERMOST_SIGNING_SECRET",
)

DESCRIPTIONS = {
Expand Down Expand Up @@ -217,6 +218,11 @@ class LiveSetting(models.Model):
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
),
"MATTERMOST_SIGNING_SECRET": (
"Check <a href='"
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
),
}

SECRET_SETTING_NAMES = (
Expand Down
87 changes: 87 additions & 0 deletions engine/apps/mattermost/alert_group_representative.py
Original file line number Diff line number Diff line change
@@ -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]
128 changes: 128 additions & 0 deletions engine/apps/mattermost/alert_rendering.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions engine/apps/mattermost/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@

class MattermostConfig(AppConfig):
name = "apps.mattermost"

def ready(self) -> None:
import apps.mattermost.signals # noqa: F401
24 changes: 24 additions & 0 deletions engine/apps/mattermost/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from dataclasses import dataclass
from typing import Optional

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"])
8 changes: 8 additions & 0 deletions engine/apps/mattermost/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
16 changes: 14 additions & 2 deletions engine/apps/mattermost/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,6 +12,7 @@ class Migration(migrations.Migration):

dependencies = [
('user_management', '0022_alter_team_unique_together'),
('alerts', '0060_relatedincident'),
]

operations = [
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions engine/apps/mattermost/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .channel import MattermostChannel # noqa: F401
from .message import MattermostMessage # noqa F401
from .user import MattermostUser # noqa F401
11 changes: 11 additions & 0 deletions engine/apps/mattermost/models/channel.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down
Loading