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.
115 lines
3.6 KiB
Python
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}"
|