diff --git a/alerts/forms.py b/alerts/forms.py new file mode 100644 index 0000000..3afe99a --- /dev/null +++ b/alerts/forms.py @@ -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 diff --git a/alerts/migrations/0001_initial.py b/alerts/migrations/0001_initial.py new file mode 100644 index 0000000..8585973 --- /dev/null +++ b/alerts/migrations/0001_initial.py @@ -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", + ), + ), + ], + ), + ] diff --git a/alerts/migrations/__init__.py b/alerts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alerts/models.py b/alerts/models.py index 6b20219..2d452c5 100644 --- a/alerts/models.py +++ b/alerts/models.py @@ -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) diff --git a/alerts/service_backends/__init__.py b/alerts/service_backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alerts/service_backends/slack.py b/alerts/service_backends/slack.py new file mode 100644 index 0000000..7792b5b --- /dev/null +++ b/alerts/service_backends/slack.py @@ -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) diff --git a/alerts/tasks.py b/alerts/tasks.py index f8000cd..153b1be 100644 --- a/alerts/tasks.py +++ b/alerts/tasks.py @@ -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})', diff --git a/projects/templates/projects/project_alerts_setup.html b/projects/templates/projects/project_alerts_setup.html new file mode 100644 index 0000000..748ef24 --- /dev/null +++ b/projects/templates/projects/project_alerts_setup.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Alerts · {{ project.name }} · {{ site_title }}{% endblock %} + +{% block content %} + + + +