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):
# 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
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):
send_rendered_email(
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 %}
</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">
{% 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)">

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 (
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 = [
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/<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 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.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 .forms import ProjectMembershipForm, MyProjectMembershipForm, ProjectMemberInviteForm, ProjectForm
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,
"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,
})