diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f1cca94..e8c0b66 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -23,6 +23,7 @@ | workplan | CSOC-WP-0013 | ready | — | workplans/CSOC-WP-0013-sharing-content-hardening.md | | workplan | CSOC-WP-0014 | active | — | workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md | | workplan | CSOC-WP-0015 | active | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | +| workplan | CSOC-WP-0016 | ready | — | workplans/CSOC-WP-0016-mfa-setup-link.md | | task | CSOC-WP-0001-T01 | done | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md | | task | CSOC-WP-0001-T02 | done | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md | | task | CSOC-WP-0001-T03 | done | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md | @@ -97,6 +98,7 @@ | task | CSOC-WP-0015-T02 | todo | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | | task | CSOC-WP-0015-T03 | wait | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | | task | CSOC-WP-0015-T04 | todo | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | +| task | CSOC-WP-0016-T01 | todo | — | workplans/CSOC-WP-0016-mfa-setup-link.md | | intake | CSOC-IN-0001 | closed | blue | docs/intakes/csoc-residuals.md | | intake | CSOC-IN-0002 | closed | green | docs/intakes/csoc-residuals.md | | intake | CSOC-IN-0003 | open | blue | docs/intakes/csoc-residuals.md | diff --git a/coulomb_social/apps/spaces/admin.py b/coulomb_social/apps/spaces/admin.py index 19990b5..96723cb 100644 --- a/coulomb_social/apps/spaces/admin.py +++ b/coulomb_social/apps/spaces/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.core.exceptions import ValidationError +from django.db import IntegrityError from . import pageops from .models import Space, SpaceMembership @@ -15,7 +17,12 @@ class SpaceAdmin(admin.ModelAdmin): def save_model(self, request, obj, form, change): with pageops.content_write_lock(): obj.full_clean() - super().save_model(request, obj, form, change) + try: + super().save_model(request, obj, form, change) + except IntegrityError as exc: + raise ValidationError( + {"slug": "This content instance already uses that slug"} + ) from exc list_display = ( "title", diff --git a/coulomb_social/apps/spaces/import_tree.py b/coulomb_social/apps/spaces/import_tree.py index f930980..f133610 100644 --- a/coulomb_social/apps/spaces/import_tree.py +++ b/coulomb_social/apps/spaces/import_tree.py @@ -7,7 +7,7 @@ import tempfile from dataclasses import dataclass, field from pathlib import Path -from django.db import transaction +from django.db import IntegrityError, transaction from coulomb_social.apps.members.models import Member @@ -162,12 +162,17 @@ def _import_space_tree( published = False try: with transaction.atomic(): - space, created = Space.objects.update_or_create( - tenant_id=tenant_id, slug=space_slug, - defaults={"title": display_title, "description": abstractor, - "visual": visual, "is_active": True, - "content_root": "pages", "default_branch": "main"}, - ) + try: + space, created = Space.objects.update_or_create( + tenant_id=tenant_id, slug=space_slug, + defaults={"title": display_title, "description": abstractor, + "visual": visual, "is_active": True, + "content_root": "pages", "default_branch": "main"}, + ) + except IntegrityError as exc: + raise pageops.PageOpsError( + "Content slug belongs to another tenant" + ) from exc report.space_created = created report.space_updated = not created for mapped, role in grants: diff --git a/coulomb_social/apps/spaces/migrations/0003_space_slug_global_uniq.py b/coulomb_social/apps/spaces/migrations/0003_space_slug_global_uniq.py new file mode 100644 index 0000000..c0048e9 --- /dev/null +++ b/coulomb_social/apps/spaces/migrations/0003_space_slug_global_uniq.py @@ -0,0 +1,49 @@ +"""Enforce one content slug per instance. + +Forward refuses while any slug, including an inactive row, is stored more than +once. It does not rename, merge, or delete those rows. Reconcile them first, +then migrate again. + +Rollback is `manage.py migrate spaces 0002`. That drops +`spaces_space_slug_global_uniq` only. `spaces_space_tenant_slug_uniq` stays, +and no content or membership rows are rewritten. After rollback, application +checks still reject ordinary collisions; a restored legacy duplicate is denied +on read until the rows are reconciled and this migration is applied again. +""" + +from django.db import migrations, models +from django.db.models import Count + + +def refuse_legacy_slug_collisions(apps, schema_editor): + Space = apps.get_model("spaces", "Space") + duplicates = list( + Space.objects.values("slug") + .annotate(n=Count("pk")) + .filter(n__gt=1) + .order_by("slug") + ) + if duplicates: + listed = ", ".join(row["slug"] for row in duplicates) + raise RuntimeError( + "Refusing spaces_space_slug_global_uniq: slug-keyed content already " + f"has duplicate rows ({listed}). Reconcile or discontinue those rows " + "before migrating. This migration does not rename, merge, or delete them." + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("spaces", "0002_space_visual"), + ] + + operations = [ + migrations.RunPython(refuse_legacy_slug_collisions, migrations.RunPython.noop), + migrations.AddConstraint( + model_name="space", + constraint=models.UniqueConstraint( + fields=("slug",), + name="spaces_space_slug_global_uniq", + ), + ), + ] diff --git a/coulomb_social/apps/spaces/models.py b/coulomb_social/apps/spaces/models.py index 7196334..1b17f95 100644 --- a/coulomb_social/apps/spaces/models.py +++ b/coulomb_social/apps/spaces/models.py @@ -75,6 +75,13 @@ class Space(models.Model): fields=["tenant_id", "slug"], name="spaces_space_tenant_slug_uniq", ), + # Content paths are spaces//, shared by every tenant on this + # instance. Inactive rows keep the slug so a later space cannot + # reuse an archived tree. + models.UniqueConstraint( + fields=["slug"], + name="spaces_space_slug_global_uniq", + ), ] indexes = [ models.Index(fields=["tenant_id", "is_active"]), diff --git a/coulomb_social/apps/spaces/services.py b/coulomb_social/apps/spaces/services.py index 849fd4b..d0bc77d 100644 --- a/coulomb_social/apps/spaces/services.py +++ b/coulomb_social/apps/spaces/services.py @@ -3,7 +3,7 @@ from __future__ import annotations from django.core.exceptions import ValidationError -from django.db import transaction +from django.db import IntegrityError, transaction from django.db.models import Count, Q, QuerySet from coulomb_social.apps.members.models import Member @@ -111,16 +111,21 @@ def create_space( if Space.objects.filter(slug=space_slug).exists(): raise pageops.PageOpsError("A space with this slug already exists in this content instance") - space = Space.objects.create( - tenant_id=member.tenant_id, - slug=space_slug, - title=title, - description=abstractor or "", - visual=visual or "", - created_by=member, - content_root="pages", - default_branch="main", - ) + try: + space = Space.objects.create( + tenant_id=member.tenant_id, + slug=space_slug, + title=title, + description=abstractor or "", + visual=visual or "", + created_by=member, + content_root="pages", + default_branch="main", + ) + except IntegrityError as exc: + raise pageops.PageOpsError( + "A space with this slug already exists in this content instance" + ) from exc SpaceMembership.objects.get_or_create( space=space, member=member, diff --git a/workplans/CSOC-WP-0016-mfa-setup-link.md b/workplans/CSOC-WP-0016-mfa-setup-link.md index 42477a3..6569fa3 100644 --- a/workplans/CSOC-WP-0016-mfa-setup-link.md +++ b/workplans/CSOC-WP-0016-mfa-setup-link.md @@ -11,6 +11,7 @@ created: "2026-10-11" updated: "2026-10-11" flavor: implementation depends_on: [] +state_hub_workstream_id: "bf60ed62-5cbb-5276-80da-6c5dec302a2e" --- Founder finding F1 from the attended MFA run of 2026-10-11 (user-engine @@ -24,6 +25,7 @@ app. id: CSOC-WP-0016-T01 status: todo priority: low +state_hub_task_id: "b8bb27c0-de32-597e-9106-c5657d7ab130" ``` On the signed-in user's session/account details page, add a "Manage authenticator app (optional)"