mirror of
https://github.com/jlengrand/bugsink.git
synced 2026-03-10 08:01:17 +00:00
21
alerts/forms.py
Normal file
21
alerts/forms.py
Normal 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
|
||||
52
alerts/migrations/0001_initial.py
Normal file
52
alerts/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
alerts/migrations/__init__.py
Normal file
0
alerts/migrations/__init__.py
Normal 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)
|
||||
|
||||
0
alerts/service_backends/__init__.py
Normal file
0
alerts/service_backends/__init__.py
Normal file
164
alerts/service_backends/slack.py
Normal file
164
alerts/service_backends/slack.py
Normal 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("&", "&").replace("<", "<").replace(">", ">").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)
|
||||
@@ -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})',
|
||||
|
||||
82
projects/templates/projects/project_alerts_setup.html
Normal file
82
projects/templates/projects/project_alerts_setup.html
Normal 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 %}
|
||||
@@ -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)">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user