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.
This commit is contained in:
parent
bb6bc6093a
commit
be32432d39
22 changed files with 697 additions and 12 deletions
115
coulomb_social/apps/spaces/models.py
Normal file
115
coulomb_social/apps/spaces/models.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue