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:
29
shop/migrations/0001_initial.py
Normal file
29
shop/migrations/0001_initial.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-12 19:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Product',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('slug', models.SlugField(max_length=255, unique=True, verbose_name='slug')),
|
||||
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
23
shop/migrations/0002_alter_product_slug_and_more.py
Normal file
23
shop/migrations/0002_alter_product_slug_and_more.py
Normal 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 = [
|
||||
('club', '0008_alter_clubmembership_unique_together_and_more'),
|
||||
('shop', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='product',
|
||||
name='slug',
|
||||
field=models.SlugField(blank=True, max_length=255, verbose_name='slug'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='product',
|
||||
constraint=models.UniqueConstraint(fields=('club', 'slug'), name='unique_product_slug_per_club'),
|
||||
),
|
||||
]
|
||||
@@ -1 +1,17 @@
|
||||
# Create your models here.
|
||||
from django.db import models
|
||||
from django.db.models import UniqueConstraint
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from clubmanager.base import ClubScopedModel
|
||||
|
||||
|
||||
class Product(ClubScopedModel):
|
||||
name = models.CharField(_("name"), max_length=255)
|
||||
slug = models.SlugField(_("slug"), max_length=255, blank=True)
|
||||
|
||||
slug_source = "name"
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
UniqueConstraint(fields=["club", "slug"], name="unique_product_slug_per_club"),
|
||||
]
|
||||
|
||||
@@ -1 +1,39 @@
|
||||
# Create your tests here.
|
||||
from django.test import TestCase
|
||||
|
||||
from club.models import Club
|
||||
|
||||
from .models import Product
|
||||
|
||||
|
||||
class ProductSlugTests(TestCase):
|
||||
def setUp(self):
|
||||
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
|
||||
|
||||
def test_slug_auto_populated_from_name(self):
|
||||
product = Product.objects.create(club=self.club, name="Home Jersey")
|
||||
|
||||
self.assertEqual(product.slug, "home-jersey")
|
||||
|
||||
def test_explicit_slug_is_preserved(self):
|
||||
product = Product.objects.create(club=self.club, name="Home Jersey", slug="custom")
|
||||
|
||||
self.assertEqual(product.slug, "custom")
|
||||
|
||||
def test_slug_is_unique_per_club_with_suffix(self):
|
||||
first = Product.objects.create(club=self.club, name="Home Jersey")
|
||||
second = Product.objects.create(club=self.club, name="Home Jersey")
|
||||
|
||||
self.assertEqual(first.slug, "home-jersey")
|
||||
self.assertEqual(second.slug, "home-jersey-2")
|
||||
|
||||
def test_same_slug_allowed_in_a_different_club(self):
|
||||
other = Club.objects.create(name="Rival FC", slug="rival-fc")
|
||||
here = Product.objects.create(club=self.club, name="Home Jersey")
|
||||
there = Product.objects.create(club=other, name="Home Jersey")
|
||||
|
||||
self.assertEqual(here.slug, there.slug)
|
||||
|
||||
def test_unsluggable_name_falls_back(self):
|
||||
product = Product.objects.create(club=self.club, name="###")
|
||||
|
||||
self.assertEqual(product.slug, "item")
|
||||
|
||||
Reference in New Issue
Block a user