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})',
|
||||
|
||||
Reference in New Issue
Block a user