coulomb-social/coulomb_social/apps/spaces/models.py
tegwick affed64839 Land CSOC-WP-0006 PageOps: member space/page CRUD, copy, transfer
Thin CONTENT_ROOT content plane (ADR-0003/0004) with space visual field,
product UI for Title/Abstract/Visual, page list sidebar, and tests. Update
capability model and smoke checklist; mark WP-0006 finished.
2026-08-13 12:46:39 +02:00

133 lines
4.2 KiB
Python

"""Space metadata index — page bodies live on the content plane (markdown)."""
from __future__ import annotations
import re
import uuid
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.text import slugify
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
def validate_space_slug(value: str) -> None:
if not _SLUG_RE.match(value or ""):
raise ValidationError(
"Slug must be lowercase alphanumeric with single hyphens (e.g. my-space)."
)
class Space(models.Model):
"""Tenant-scoped co-creation space.
Postgres holds metadata and optional Forgejo binding pointers. Long-form
pages are markdown under CONTENT_ROOT (ADR-0003 / ADR-0004); Forgejo is an
optional read/edit fallback (ADR-0002).
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
tenant_id = models.CharField(max_length=64, db_index=True)
slug = models.SlugField(max_length=80, validators=[validate_space_slug])
title = models.CharField(max_length=255)
description = models.TextField(
blank=True,
help_text="Short description (product label: Abstractor).",
)
visual = models.CharField(
max_length=512,
blank=True,
help_text="Cover/visual path relative to space tree (e.g. assets/cover.jpg).",
)
created_by = models.ForeignKey(
"members.Member",
on_delete=models.PROTECT,
related_name="spaces_created",
null=True,
blank=True,
)
# Optional content binding (Forgejo) — empty until T04 wires the read path
forgejo_owner = models.CharField(
max_length=128,
blank=True,
help_text="Forgejo org or user owning the repo (e.g. coulomb).",
)
forgejo_repo = models.CharField(
max_length=128,
blank=True,
help_text="Repository name (e.g. space-my-space).",
)
default_branch = models.CharField(max_length=128, blank=True, default="main")
content_root = models.CharField(
max_length=255,
blank=True,
default="pages",
help_text="Path prefix inside the repo for markdown pages.",
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["tenant_id", "slug"],
name="spaces_space_tenant_slug_uniq",
),
]
indexes = [
models.Index(fields=["tenant_id", "is_active"]),
]
ordering = ["title", "slug"]
def __str__(self) -> str:
return f"{self.title} ({self.slug})@{self.tenant_id}"
@property
def has_content_binding(self) -> bool:
return bool(self.forgejo_owner and self.forgejo_repo)
@property
def abstractor(self) -> str:
"""Product name for the short description field."""
return self.description
@abstractor.setter
def abstractor(self, value: str) -> None:
self.description = value or ""
@classmethod
def suggest_slug(cls, title: str) -> str:
return slugify(title)[:80] or "space"
class SpaceMembership(models.Model):
"""Optional explicit membership; v1 list also allows tenant-wide visibility."""
class Role(models.TextChoices):
OWNER = "owner", "Owner"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
space = models.ForeignKey(Space, on_delete=models.CASCADE, related_name="memberships")
member = models.ForeignKey(
"members.Member",
on_delete=models.CASCADE,
related_name="space_memberships",
)
role = models.CharField(max_length=16, choices=Role.choices, default=Role.MEMBER)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["space", "member"],
name="spaces_membership_space_member_uniq",
),
]
def __str__(self) -> str:
return f"{self.member_id}@{self.space.slug}:{self.role}"