Message service backend setup: switch config form per-service in the UI

See #281, which this commit prepares for
This commit is contained in:
Klaas van Schelven
2025-11-26 09:10:14 +01:00
parent e0e4104cae
commit 2a90d6ab1e
8 changed files with 141 additions and 33 deletions

View File

@@ -3,7 +3,7 @@ from django.forms import ModelForm
from .models import MessagingServiceConfig
class MessagingServiceConfigForm(ModelForm):
class MessagingServiceConfigNewForm(ModelForm):
def __init__(self, project, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -19,3 +19,13 @@ class MessagingServiceConfigForm(ModelForm):
if commit:
instance.save()
return instance
class MessagingServiceConfigEditForm(ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Meta:
model = MessagingServiceConfig
fields = ["display_name"]

View File

@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
model_name="messagingserviceconfig",
name="kind",
field=models.CharField(
choices=alerts.models.kind_choices, default="slack", max_length=20
choices=alerts.models.get_alert_service_kind_choices, default="slack", max_length=20
),
),
]

View File

@@ -6,7 +6,7 @@ from .service_backends.mattermost import MattermostBackend
from .service_backends.discord import DiscordBackend
def kind_choices():
def get_alert_service_kind_choices():
# As a callable to avoid non-DB-affecting migrations for adding new kinds.
# Messaging backends don't need translations since they are brand names.
return [
@@ -16,12 +16,22 @@ def kind_choices():
]
def get_alert_service_backend_class(kind):
if kind == "discord":
return DiscordBackend
if kind == "mattermost":
return MattermostBackend
if kind == "slack":
return SlackBackend
raise ValueError(f"Unknown backend kind: {kind}")
class MessagingServiceConfig(models.Model):
project = models.ForeignKey(Project, on_delete=models.DO_NOTHING, 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=kind_choices, max_length=20, default="slack")
kind = models.CharField(choices=get_alert_service_kind_choices, max_length=20, default="slack")
config = models.TextField(blank=False)
@@ -40,13 +50,7 @@ class MessagingServiceConfig(models.Model):
help_text="Error message from the exception")
def get_backend(self):
if self.kind == "discord":
return DiscordBackend(self)
if self.kind == "mattermost":
return MattermostBackend(self)
if self.kind == "slack":
return SlackBackend(self)
raise ValueError(f"Unknown backend kind: {self.kind}")
return get_alert_service_backend_class(self.kind)(self)
def clear_failure_status(self):
"""Clear all failure tracking fields on successful operation"""

View File

@@ -203,7 +203,8 @@ class DiscordBackend:
def __init__(self, service_config):
self.service_config = service_config
def get_form_class(self):
@classmethod
def get_form_class(cls):
return DiscordConfigForm
def send_test_message(self):

View File

@@ -185,7 +185,8 @@ class MattermostBackend:
def __init__(self, service_config):
self.service_config = service_config
def get_form_class(self):
@classmethod
def get_form_class(cls):
return MattermostConfigForm
def send_test_message(self):

View File

@@ -222,7 +222,8 @@ class SlackBackend:
def __init__(self, service_config):
self.service_config = service_config
def get_form_class(self):
@classmethod
def get_form_class(cls):
return SlackConfigForm
def send_test_message(self):

View File

@@ -0,0 +1,79 @@
{% 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 dark:bg-cyan-900 border-2 border-cyan-800 dark:border-cyan-400 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 config_form_kind, config_form in config_forms.items %}
<div
class="config-form-block {% if config_form_kind == selected_config_form_kind %}block{% else %}hidden{% endif %}" data-config-kind="{{ config_form_kind }}">
{% for field in config_form %}
{% tailwind_formfield field %}
{% endfor %}
</div>
{% endfor %}
<button name="action" value="add" class="font-bold text-slate-800 dark:text-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 border-2 bg-cyan-200 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 active:ring-3 rounded-md">Save</button>
<a href="{% url "project_alerts_setup" project_pk=project.pk %}" class="font-bold text-slate-500 dark:text-slate-300 ml-4">Cancel</a>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const kindSelect = document.querySelector('select[name="kind"]');
const blocks = document.querySelectorAll('.config-form-block');
function updateConfigForms() {
const selectedKind = kindSelect.value;
blocks.forEach(block => {
const blockKind = block.dataset.configKind;
const isActive = (blockKind === selectedKind);
block.classList.toggle('hidden', !isActive);
block.classList.toggle('block', isActive);
block.querySelectorAll('input, select, textarea, button').forEach(element => {
element.disabled = !isActive;
});
});
}
updateConfigForms(); // Initialize on page load
kindSelect.addEventListener('change', updateConfigForms);
});
</script>
{% endblock %}

View File

@@ -20,9 +20,8 @@ from bugsink.app_settings import get_settings, CB_ANYBODY, CB_MEMBERS, CB_ADMINS
from bugsink.decorators import login_exempt, atomic_for_request_method
from bugsink.utils import assert_
from alerts.models import MessagingServiceConfig
from alerts.forms import MessagingServiceConfigForm
from alerts.service_backends.slack import SlackConfigForm
from alerts.models import MessagingServiceConfig, get_alert_service_backend_class, get_alert_service_kind_choices
from alerts.forms import MessagingServiceConfigNewForm, MessagingServiceConfigEditForm
from .models import Project, ProjectMembership, ProjectRole, ProjectVisibility
from .forms import ProjectMembershipForm, MyProjectMembershipForm, ProjectMemberInviteForm, ProjectForm
@@ -457,26 +456,35 @@ def project_messaging_service_add(request, project_pk):
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
config_forms = {
kind: get_alert_service_backend_class(kind).get_form_class()()
for (kind, _) in get_alert_service_kind_choices()
}
if request.method == 'POST':
form = MessagingServiceConfigForm(project, request.POST)
config_form = SlackConfigForm(data=request.POST)
form = MessagingServiceConfigNewForm(project, request.POST)
kind = form.data.get('kind') or form.fields['kind'].initial
config_form = get_alert_service_backend_class(kind).get_form_class()(data=request.POST)
config_forms[kind] = config_form
if form.is_valid() and config_form.is_valid():
service = form.save(commit=False)
service.config = json.dumps(config_form.get_config())
service.save()
if form.is_valid():
if 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)
messages.success(request, "Messaging service added successfully.")
return redirect('project_alerts_setup', project_pk=project_pk)
else:
form = MessagingServiceConfigForm(project)
config_form = SlackConfigForm()
form = MessagingServiceConfigNewForm(project)
kind = form.fields['kind'].initial
return render(request, 'projects/project_messaging_service_edit.html', {
return render(request, 'projects/project_messaging_service_new.html', {
'project': project,
'form': form,
'config_form': config_form,
'config_forms': config_forms,
'selected_config_form_kind': kind,
})
@@ -486,10 +494,14 @@ def project_messaging_service_edit(request, project_pk, service_pk):
_check_project_admin(project, request.user)
instance = project.service_configs.get(id=service_pk)
# for editing, we don't allow for changing the kind; although it's probably possible to implement it, it would raise
# questions on "how much are the various configs related (should data be transferred from one config to another).
# and even though "it's possible" simply disallowing greatly simplifies the implementation.
config_form_class = get_alert_service_backend_class(instance.kind).get_form_class()
if request.method == 'POST':
form = MessagingServiceConfigForm(project, request.POST, instance=instance)
config_form = SlackConfigForm(data=request.POST)
form = MessagingServiceConfigEditForm(request.POST, instance=instance)
config_form = config_form_class(data=request.POST)
if form.is_valid() and config_form.is_valid():
service = form.save(commit=False)
@@ -500,8 +512,8 @@ def project_messaging_service_edit(request, project_pk, service_pk):
return redirect('project_alerts_setup', project_pk=project_pk)
else:
form = MessagingServiceConfigForm(project, instance=instance)
config_form = SlackConfigForm(config=json.loads(instance.config))
form = MessagingServiceConfigEditForm(instance=instance)
config_form = config_form_class(config=json.loads(instance.config))
return render(request, 'projects/project_messaging_service_edit.html', {
'project': project,