Slack Alerts

Fix #3
This commit is contained in:
Klaas van Schelven
2025-06-10 22:00:37 +02:00
parent 33d5865579
commit fac5b19966
12 changed files with 499 additions and 2 deletions

21
alerts/forms.py Normal file
View File

@@ -0,0 +1,21 @@
from django.forms import ModelForm
from .models import MessagingServiceConfig
class MessagingServiceConfigForm(ModelForm):
def __init__(self, project, *args, **kwargs):
super().__init__(*args, **kwargs)
self.project = project
class Meta:
model = MessagingServiceConfig
fields = ["display_name", "kind"]
def save(self, commit=True):
instance = super().save(commit=False)
instance.project = self.project
if commit:
instance.save()
return instance

View File

@@ -0,0 +1,52 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("projects", "0011_fill_stored_event_count"),
]
operations = [
migrations.CreateModel(
name="MessagingServiceConfig",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"display_name",
models.CharField(
help_text='For display in the UI, e.g. "#general on company Slack"',
max_length=100,
),
),
(
"kind",
models.CharField(
choices=[("slack", "Slack (or compatible)")],
default="slack",
max_length=20,
),
),
("config", models.TextField()),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="service_configs",
to="projects.project",
),
),
],
),
]

View File

View File

@@ -1 +1,18 @@
# Create your models here. from django.db import models
from projects.models import Project
from .service_backends.slack import SlackBackend
class MessagingServiceConfig(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="service_configs")
display_name = models.CharField(max_length=100, blank=False,
help_text='For display in the UI, e.g. "#general on company Slack"')
kind = models.CharField(choices=[("slack", "Slack (or compatible)"), ], max_length=20, default="slack")
config = models.TextField(blank=False)
def get_backend(self):
# once we have multiple backends: lookup by kind.
return SlackBackend(self)

View File

View File

@@ -0,0 +1,164 @@
import json
import requests
from django import forms
from django.template.defaultfilters import truncatechars
from snappea.decorators import shared_task
from bugsink.app_settings import get_settings
from issues.models import Issue
class SlackConfigForm(forms.Form):
webhook_url = forms.URLField(required=True)
def __init__(self, *args, **kwargs):
config = kwargs.pop("config", None)
super().__init__(*args, **kwargs)
if config:
self.fields["webhook_url"].initial = config.get("webhook_url", "")
def get_config(self):
return {
"webhook_url": self.cleaned_data.get("webhook_url"),
}
def _safe_markdown(text):
# Slack assigns a special meaning to some characters, so we need to escape them
# to prevent them from being interpreted as formatting/special characters.
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("*", "\\*").replace("_", "\\_")
@shared_task
def slack_backend_send_test_message(webhook_url, project_name, display_name):
# See Slack's Block Kit Builder
data = {"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "TEST issue",
},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Test message by Bugsink to test the webhook setup.",
},
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*project*: " + _safe_markdown(project_name),
},
{
"type": "mrkdwn",
"text": "*message backend*: " + _safe_markdown(display_name),
},
]
}
]}
result = requests.post(
"https://hooks.slack.com/services/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",
data=json.dumps(data),
headers={"Content-Type": "application/json"},
)
result.raise_for_status()
@shared_task
def slack_backend_send_alert(webhook_url, issue_id, state_description, alert_article, alert_reason, unmute_reason=None):
issue = Issue.objects.get(id=issue_id)
issue_url = get_settings().BASE_URL + issue.get_absolute_url()
link = f"<{issue_url}|" + _safe_markdown(truncatechars(issue.title().replace("|", ""), 200)) + ">"
sections = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"{alert_reason} issue",
},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": link,
},
},
]
if unmute_reason:
sections.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": unmute_reason,
},
})
# assumption: visavis email, project.name is of less importance, because in slack-like things you may (though not
# always) do one-channel per project. more so for site_title (if you have multiple Bugsinks, you'll surely have
# multiple slack channels)
fields = {
"project": issue.project.name
}
# left as a (possible) TODO, because the amount of refactoring (passing event to this function) is too big for now
# if event.release:
# fields["release"] = event.release
# if event.environment:
# fields["environment"] = event.environment
data = {"blocks": sections + [
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": f"*{field}*: " + _safe_markdown(value),
} for field, value in fields.items()
]
},
]}
result = requests.post(
webhook_url,
data=json.dumps(data),
headers={"Content-Type": "application/json"},
)
result.raise_for_status()
class SlackBackend:
def __init__(self, service_config):
self.service_config = service_config
def get_form_class(self):
return SlackConfigForm
def send_test_message(self):
slack_backend_send_test_message.delay(
json.loads(self.service_config.config)["webhook_url"],
self.service_config.project.name,
self.service_config.display_name,
)
def send_alert(self, issue_id, state_description, alert_article, alert_reason, **kwargs):
slack_backend_send_alert.delay(
json.loads(self.service_config.config)["webhook_url"],
issue_id, state_description, alert_article, alert_reason, **kwargs)

View File

@@ -63,9 +63,21 @@ def send_unmute_alert(issue_id, unmute_reason):
def _send_alert(issue_id, state_description, alert_article, alert_reason, **kwargs): def _send_alert(issue_id, state_description, alert_article, alert_reason, **kwargs):
# NOTE: as it stands, there is a bit of asymmetry here: _send_alert is always called in delayed fashion; it delays
# some work itself (message backends) though not all (emails). I kept it like this to be able to add functionality
# without breaking too much (in particular, I like the 3 entry points (send_xx_alert) in the current setup). The
# present solution at least has the advantage that possibly frickle external calls don't break each other.
# The way forward is probably to keep the single 3-way callpoint, but make that non-delayed, and do the calls of
# both message-service and email based alerts in delayed fashion.
from issues.models import Issue # avoid circular import from issues.models import Issue # avoid circular import
issue = Issue.objects.get(id=issue_id) issue = Issue.objects.get(id=issue_id)
for service in issue.project.service_configs.all():
service_backend = service.get_backend()
service_backend.send_alert(issue_id, state_description, alert_article, alert_reason, **kwargs)
for user in _get_users_for_email_alert(issue): for user in _get_users_for_email_alert(issue):
send_rendered_email( send_rendered_email(
subject=f'"{truncatechars(issue.title(), 80)}" in "{issue.project.name}" ({state_description})', subject=f'"{truncatechars(issue.title(), 80)}" in "{issue.project.name}" ({state_description})',

View File

@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Alerts · {{ project.name }} · {{ site_title }}{% endblock %}
{% block content %}
<div class="flex items-center justify-center">
<div class="m-4 max-w-4xl flex-auto">
{% if messages %}
<ul class="mb-4">
{% for message in messages %}
{# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
<li class="bg-cyan-50 border-2 border-cyan-800 p-4 rounded-lg">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="flex">
<h1 class="text-4xl mt-4 font-bold">{{ project.name }} · Alerts</h1>
<div class="ml-auto mt-6">
<a class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md" href="{% url "project_messaging_service_add" project_pk=project.pk %}">Add</a>
</div>
</div>
<div>
<form action="." method="post">
{% csrf_token %}
<table class="w-full mt-8">
<tbody>
<thead>
<tr class="bg-slate-200">
<th class="w-full p-4 text-left text-xl" colspan="2">Messaging Services</th>
</tr>
{% for service_config in service_configs %}
<tr class="bg-white border-slate-200 border-b-2">
<td class="w-full p-4">
<div>
<a href="{% url "project_messaging_service_edit" project_pk=project.pk service_pk=service_config.pk %}" class="text-xl text-cyan-500 font-bold">{{ service_config.display_name }}</a>
</div>
</td>
<td class="p-4">
<div class="flex justify-end">
<button name="action" value="test:{{ service_config.id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Test</button>
<button name="action" value="remove:{{ service_config.id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Remove</button>
</div>
</td>
</tr>
{% empty %}
<tr class="bg-white border-slate-200 border-b-2">
<td class="w-full p-4">
<div>
No Messaging Services Configured. <a href="{% url "project_messaging_service_add" project_pk=project.pk %}" class="text-cyan-500 font-bold">Add Messaging Service</a>.
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
<div class="flex flex-direction-row">
<div class="ml-auto py-8 pr-4">
<a href="{% url "project_edit" project_pk=project.pk %}" class="text-cyan-500 font-bold">Settings</a>
<span class="font-bold text-slate-500">|</span> <a href="{% url "project_list" %}" class="text-cyan-500 font-bold">Back to Projects</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -103,6 +103,18 @@
{% endif %} {% endif %}
</td> </td>
<td class="pr-2">
{% if project.member or request.user.is_superuser %}
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer" onclick="followContainedLink(this);" title="Alerting Settings">
<a href="{% url 'project_alerts_setup' project_pk=project.id %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.835 2.535M10.34 6.66a23.847 23.847 0 0 0 8.835-2.535m0 0A23.74 23.74 0 0 0 18.795 3m.38 1.125a23.91 23.91 0 0 1 1.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 0 0 1.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 0 1 0 3.46" />
</svg>
</a>
</div>
{% endif %}
</td>
<td class="pr-2"> <td class="pr-2">
{% if project.member or request.user.is_superuser %} {% if project.member or request.user.is_superuser %}
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer" onclick="followContainedLink(this);" title="SDK setup (connect app)"> <div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer" onclick="followContainedLink(this);" title="SDK setup (connect app)">

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% load static %}
{% load tailwind_forms %}
{% block title %}Messaging Service · {{ project.name }} · {{ site_title }}{% endblock %}
{% block content %}
<div class="flex items-center justify-center">
<div class="m-4 max-w-4xl flex-auto">
<form action="." method="post">
{% csrf_token %}
{% if messages %}
<ul>
{% for message in messages %}
{# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
<li class="bg-cyan-50 border-2 border-cyan-800 p-4 rounded-lg">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div>
<h1 class="text-4xl my-4 font-bold">Messaging Service | {{ project.name }}</h1>
</div>
{% for field in form %}
{% tailwind_formfield field %}
{% endfor %}
{% for field in config_form %}
{% tailwind_formfield field %}
{% endfor %}
<button name="action" value="add" class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md">Save</button>
<a href="{% url "project_alerts_setup" project_pk=project.pk %}" class="font-bold text-slate-500 ml-4">Cancel</a>
</form>
</div>
</div>
{% endblock %}

View File

@@ -2,7 +2,8 @@ from django.urls import path
from .views import ( from .views import (
project_list, project_members, project_members_accept, project_member_settings, project_members_invite, project_list, project_members, project_members_accept, project_member_settings, project_members_invite,
project_members_accept_new_user, project_new, project_edit, project_sdk_setup) project_members_accept_new_user, project_new, project_edit, project_sdk_setup, project_alerts_setup,
project_messaging_service_add, project_messaging_service_edit)
urlpatterns = [ urlpatterns = [
path('', project_list, name="project_list"), path('', project_list, name="project_list"),
@@ -21,4 +22,10 @@ urlpatterns = [
path('<int:project_pk>/sdk-setup/', project_sdk_setup, name="project_sdk_setup"), path('<int:project_pk>/sdk-setup/', project_sdk_setup, name="project_sdk_setup"),
path('<int:project_pk>/sdk-setup/<str:platform>/', project_sdk_setup, name="project_sdk_setup_platform"), path('<int:project_pk>/sdk-setup/<str:platform>/', project_sdk_setup, name="project_sdk_setup_platform"),
path('<int:project_pk>/alerts/', project_alerts_setup, name="project_alerts_setup"),
path('<int:project_pk>/alerts/service/add/', project_messaging_service_add, name="project_messaging_service_add"),
path(
'<int:project_pk>/alerts/service/<int:service_pk>/edit/', project_messaging_service_edit,
name="project_messaging_service_edit"),
] ]

View File

@@ -1,3 +1,4 @@
import json
from datetime import timedelta from datetime import timedelta
from django.shortcuts import render from django.shortcuts import render
@@ -17,6 +18,10 @@ from teams.models import TeamMembership, Team, TeamRole
from bugsink.app_settings import get_settings, CB_ANYBODY, CB_MEMBERS, CB_ADMINS from bugsink.app_settings import get_settings, CB_ANYBODY, CB_MEMBERS, CB_ADMINS
from bugsink.decorators import login_exempt, atomic_for_request_method from bugsink.decorators import login_exempt, atomic_for_request_method
from alerts.models import MessagingServiceConfig
from alerts.forms import MessagingServiceConfigForm
from alerts.service_backends.slack import SlackConfigForm
from .models import Project, ProjectMembership, ProjectRole, ProjectVisibility from .models import Project, ProjectMembership, ProjectRole, ProjectVisibility
from .forms import ProjectMembershipForm, MyProjectMembershipForm, ProjectMemberInviteForm, ProjectForm from .forms import ProjectMembershipForm, MyProjectMembershipForm, ProjectMemberInviteForm, ProjectForm
from .tasks import send_project_invite_email, send_project_invite_email_new_user from .tasks import send_project_invite_email, send_project_invite_email_new_user
@@ -399,3 +404,84 @@ def project_sdk_setup(request, project_pk, platform=""):
"project": project, "project": project,
"dsn": project.dsn, "dsn": project.dsn,
}) })
@atomic_for_request_method
def project_alerts_setup(request, project_pk):
project = Project.objects.get(id=project_pk)
_check_project_admin(project, request.user)
if request.method == 'POST':
full_action_str = request.POST.get('action')
action, service_id = full_action_str.split(":", 1)
if action == "remove":
MessagingServiceConfig.objects.filter(project=project_pk, id=service_id).delete()
elif action == "test":
service = MessagingServiceConfig.objects.get(project=project_pk, id=service_id)
service_backend = service.get_backend()
service_backend.send_test_message()
messages.success(
request, "Test message sent; check the configured service to see if it arrived.")
return render(request, 'projects/project_alerts_setup.html', {
'project': project,
'service_configs': project.service_configs.all(),
})
@atomic_for_request_method
def project_messaging_service_add(request, project_pk):
project = Project.objects.get(id=project_pk)
_check_project_admin(project, request.user)
if request.method == 'POST':
form = MessagingServiceConfigForm(project, request.POST)
config_form = SlackConfigForm(data=request.POST)
if form.is_valid() and config_form.is_valid():
service = form.save(commit=False)
service.config = json.dumps(config_form.get_config())
service.save()
messages.success(request, "Messaging service added successfully.")
return redirect('project_alerts_setup', project_pk=project_pk)
else:
form = MessagingServiceConfigForm(project)
config_form = SlackConfigForm()
return render(request, 'projects/project_messaging_service_edit.html', {
'project': project,
'form': form,
'config_form': config_form,
})
@atomic_for_request_method
def project_messaging_service_edit(request, project_pk, service_pk):
project = Project.objects.get(id=project_pk)
_check_project_admin(project, request.user)
instance = project.service_configs.get(id=service_pk)
if request.method == 'POST':
form = MessagingServiceConfigForm(project, request.POST, instance=instance)
config_form = SlackConfigForm(data=request.POST)
if form.is_valid() and config_form.is_valid():
service = form.save(commit=False)
service.config = json.dumps(config_form.get_config())
service.save()
messages.success(request, "Messaging service updated successfully.")
return redirect('project_alerts_setup', project_pk=project_pk)
else:
form = MessagingServiceConfigForm(project, instance=instance)
config_form = SlackConfigForm(config=json.loads(instance.config))
return render(request, 'projects/project_messaging_service_edit.html', {
'project': project,
'form': form,
'config_form': config_form,
})