"""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}"