feat: auto-populate slug fields on save

Add clubmanager.base.unique_slugify(instance, value, scope=...): slugify a
source value, truncate to the field's max_length, and append -2/-3/... to
stay unique within a scope. ClubScopedModel gains a slug_source hook that
fills a blank slug (unique per club) on save.

Wire it up so every SlugField auto-populates from its natural source when
left blank (explicit values are always kept):
- shop.Product.slug   <- name   (per club)
- formbuilder.Form.slug <- title (per club)
- formbuilder.Field.key <- label (per form)
Club.slug already auto-populated; refactor it onto the shared helper.

Also fix shop.Product.slug's multi-tenancy bug: it was globally unique
(unique=True); make it unique per club like the others. Migrations added.
Full suite at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:41:59 +02:00
parent 57f20fe544
commit 54aace8abb
9 changed files with 220 additions and 18 deletions

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.6 on 2026-07-12 19:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('formbuilder', '0003_fix_tenant_uniqueness'),
]
operations = [
migrations.AlterField(
model_name='field',
name='key',
field=models.SlugField(blank=True, verbose_name='key'),
),
migrations.AlterField(
model_name='form',
name='slug',
field=models.SlugField(blank=True, verbose_name='slug'),
),
]

View File

@@ -2,15 +2,17 @@ from django.db import models
from django.db.models import UniqueConstraint
from django.utils.translation import gettext_lazy as _
from clubmanager.base import ClubScopedModel, UUIDModel
from clubmanager.base import ClubScopedModel, UUIDModel, unique_slugify
from members.models import Member
class Form(ClubScopedModel):
title = models.CharField(_("title"), max_length=255)
slug = models.SlugField(_("slug"))
slug = models.SlugField(_("slug"), blank=True)
description = models.TextField(_("description"), blank=True)
slug_source = "title"
is_active = models.BooleanField(_("is active?"), default=True)
login_required = models.BooleanField(_("login required?"), default=False)
@@ -43,7 +45,7 @@ class Field(UUIDModel):
FILE = "file", _("file")
form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name="fields", verbose_name=_("form"))
key = models.SlugField(_("key"))
key = models.SlugField(_("key"), blank=True)
label = models.CharField(_("label"), max_length=255)
field_type = models.CharField(_("field type"), max_length=255, choices=FieldType.choices, default=FieldType.TEXT)
required = models.BooleanField(_("required?"), default=True)
@@ -60,6 +62,11 @@ class Field(UUIDModel):
UniqueConstraint(fields=["form", "key"], name="unique_field_key_per_form"),
]
def save(self, *args, **kwargs):
if not self.key:
self.key = unique_slugify(self, self.label, slug_field="key", scope={"form": self.form})
super().save(*args, **kwargs)
def __str__(self):
return self.label

View File

@@ -76,6 +76,51 @@ class ModelTests(FormbuilderTestBase):
self.name.delete()
class FormSlugTests(FormbuilderTestBase):
def test_slug_auto_populated_from_title(self):
form = Form.objects.create(club=self.club, title="Registration Form")
self.assertEqual(form.slug, "registration-form")
def test_explicit_slug_is_preserved(self):
form = Form.objects.create(club=self.club, title="Registration", slug="reg")
self.assertEqual(form.slug, "reg")
def test_slug_is_unique_per_club_with_suffix(self):
first = Form.objects.create(club=self.club, title="Registration")
second = Form.objects.create(club=self.club, title="Registration")
self.assertEqual(first.slug, "registration")
self.assertEqual(second.slug, "registration-2")
class FieldKeyTests(FormbuilderTestBase):
def test_key_auto_populated_from_label(self):
field = Field.objects.create(form=self.form, label="First Name", order=5)
self.assertEqual(field.key, "first-name")
def test_explicit_key_is_preserved(self):
field = Field.objects.create(form=self.form, key="fn", label="First Name", order=5)
self.assertEqual(field.key, "fn")
def test_key_is_unique_per_form_with_suffix(self):
first = Field.objects.create(form=self.form, label="First Name", order=5)
second = Field.objects.create(form=self.form, label="First Name", order=6)
self.assertEqual(first.key, "first-name")
self.assertEqual(second.key, "first-name-2")
def test_same_key_allowed_in_a_different_form(self):
other_form = Form.objects.create(club=self.club, title="Other", slug="other")
here = Field.objects.create(form=self.form, label="First Name", order=5)
there = Field.objects.create(form=other_form, label="First Name", order=1)
self.assertEqual(here.key, there.key)
class FieldChoicesTests(FormbuilderTestBase):
def test_string_options(self):
self.assertEqual(field_choices(self.size), [("S", "S"), ("M", "M"), ("L", "L")])