coulomb-social/coulomb_social/apps/spaces/models.py
tegwick be32432d39 Add Space metadata model and provisional Forgejo content ADR
Introduce tenant-scoped Space/SpaceMembership with list and detail views.
Document markdown-in-Forgejo as content SoR (ADR-0002). Run migrations on
container start so app.coulomb.social picks up the new tables.
2026-08-12 01:30:05 +02:00

115 lines
3.6 KiB
Python

"""Space metadata — content bodies live in Forgejo (markdown), not here."""
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 Forgejo binding pointers only. Long-form pages
are markdown in the bound repository (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)
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)
@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}"