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 %} + + + +
+ +
+ + {% if messages %} + + {% endif %} + +
+

{{ project.name }} · Alerts

+ +
+ Add +
+
+ +
+
+ {% csrf_token %} + + + + + + + + + {% for service_config in service_configs %} + + + + + + + {% empty %} + + + + + {% endfor %} + +
Messaging Services
+ + +
+ + +
+
+
+ No Messaging Services Configured. Add Messaging Service. +
+
+ +
+
+ + +
+ +{% endblock %} diff --git a/projects/templates/projects/project_list.html b/projects/templates/projects/project_list.html index 10c2276..cf7ed9d 100644 --- a/projects/templates/projects/project_list.html +++ b/projects/templates/projects/project_list.html @@ -103,6 +103,18 @@ {% endif %} + + {% if project.member or request.user.is_superuser %} +
+ + + + + +
+ {% endif %} + + {% if project.member or request.user.is_superuser %}
diff --git a/projects/templates/projects/project_messaging_service_edit.html b/projects/templates/projects/project_messaging_service_edit.html new file mode 100644 index 0000000..2b79823 --- /dev/null +++ b/projects/templates/projects/project_messaging_service_edit.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% load static %} +{% load tailwind_forms %} + +{% block title %}Messaging Service · {{ project.name }} · {{ site_title }}{% endblock %} + +{% block content %} + +
+ +
+
+ {% csrf_token %} + + {% if messages %} +
    + {% for message in messages %} + {# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + +
+

Messaging Service | {{ project.name }}

+
+ + {% for field in form %} + {% tailwind_formfield field %} + {% endfor %} + + {% for field in config_form %} + {% tailwind_formfield field %} + {% endfor %} + + + Cancel + +
+ +
+
+ +{% endblock %} diff --git a/projects/urls.py b/projects/urls.py index 6b08424..e9677fc 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -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('/sdk-setup/', project_sdk_setup, name="project_sdk_setup"), path('/sdk-setup//', project_sdk_setup, name="project_sdk_setup_platform"), + + path('/alerts/', project_alerts_setup, name="project_alerts_setup"), + path('/alerts/service/add/', project_messaging_service_add, name="project_messaging_service_add"), + path( + '/alerts/service//edit/', project_messaging_service_edit, + name="project_messaging_service_edit"), ] diff --git a/projects/views.py b/projects/views.py index d0437aa..ed15710 100644 --- a/projects/views.py +++ b/projects/views.py @@ -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, + })