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:
tegwick 2026-08-12 01:30:05 +02:00
parent bb6bc6093a
commit be32432d39
22 changed files with 697 additions and 12 deletions

View file

@ -2,6 +2,8 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from coulomb_social.apps.spaces.services import spaces_for_member
from .principal import build_principal
@ -19,8 +21,8 @@ def app_home(request: HttpRequest) -> HttpResponse:
f"Not authorized to view shell ({principal['authz_reason']})."
)
# Spaces list is empty until CSOC-WP-0004-T02; keep the product empty-state.
spaces: list[dict] = []
member = principal.get("member")
spaces = list(spaces_for_member(member))
return render(
request,
"core/app_home.html",

View file

View file

@ -0,0 +1,34 @@
from django.contrib import admin
from .models import Space, SpaceMembership
class SpaceMembershipInline(admin.TabularInline):
model = SpaceMembership
extra = 0
raw_id_fields = ("member",)
@admin.register(Space)
class SpaceAdmin(admin.ModelAdmin):
list_display = (
"title",
"slug",
"tenant_id",
"is_active",
"forgejo_owner",
"forgejo_repo",
"updated_at",
)
list_filter = ("tenant_id", "is_active")
search_fields = ("title", "slug", "forgejo_repo")
prepopulated_fields = {"slug": ("title",)}
raw_id_fields = ("created_by",)
inlines = [SpaceMembershipInline]
@admin.register(SpaceMembership)
class SpaceMembershipAdmin(admin.ModelAdmin):
list_display = ("space", "member", "role", "created_at")
list_filter = ("role",)
raw_id_fields = ("space", "member")

View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class SpacesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "coulomb_social.apps.spaces"
label = "spaces"

View file

@ -0,0 +1,150 @@
# Generated by Django 6.1 on 2026-08-11 23:29
import coulomb_social.apps.spaces.models
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("members", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Space",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("tenant_id", models.CharField(db_index=True, max_length=64)),
(
"slug",
models.SlugField(
max_length=80,
validators=[
coulomb_social.apps.spaces.models.validate_space_slug
],
),
),
("title", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"forgejo_owner",
models.CharField(
blank=True,
help_text="Forgejo org or user owning the repo (e.g. coulomb).",
max_length=128,
),
),
(
"forgejo_repo",
models.CharField(
blank=True,
help_text="Repository name (e.g. space-my-space).",
max_length=128,
),
),
(
"default_branch",
models.CharField(blank=True, default="main", max_length=128),
),
(
"content_root",
models.CharField(
blank=True,
default="pages",
help_text="Path prefix inside the repo for markdown pages.",
max_length=255,
),
),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="spaces_created",
to="members.member",
),
),
],
options={
"ordering": ["title", "slug"],
},
),
migrations.CreateModel(
name="SpaceMembership",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"role",
models.CharField(
choices=[
("owner", "Owner"),
("member", "Member"),
("viewer", "Viewer"),
],
default="member",
max_length=16,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"member",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="space_memberships",
to="members.member",
),
),
(
"space",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="memberships",
to="spaces.space",
),
),
],
),
migrations.AddIndex(
model_name="space",
index=models.Index(
fields=["tenant_id", "is_active"], name="spaces_spac_tenant__c3a447_idx"
),
),
migrations.AddConstraint(
model_name="space",
constraint=models.UniqueConstraint(
fields=("tenant_id", "slug"), name="spaces_space_tenant_slug_uniq"
),
),
migrations.AddConstraint(
model_name="spacemembership",
constraint=models.UniqueConstraint(
fields=("space", "member"), name="spaces_membership_space_member_uniq"
),
),
]

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

View file

@ -0,0 +1,39 @@
"""Space queries scoped by tenant membership."""
from __future__ import annotations
from django.db.models import Q, QuerySet
from coulomb_social.apps.members.models import Member
from .models import Space, SpaceMembership
def spaces_for_member(member: Member | None) -> QuerySet[Space]:
"""Spaces visible to this member (same tenant, active).
Explicit SpaceMembership grants access; if none exist for the tenant yet,
all active tenant spaces are listed (operator-seeded MVP). Once any
memberships exist for the tenant, only membership + created_by apply.
"""
if member is None:
return Space.objects.none()
qs = Space.objects.filter(tenant_id=member.tenant_id, is_active=True)
tenant_has_memberships = SpaceMembership.objects.filter(
space__tenant_id=member.tenant_id
).exists()
if not tenant_has_memberships:
return qs.order_by("title", "slug")
return (
qs.filter(Q(memberships__member=member) | Q(created_by=member))
.distinct()
.order_by("title", "slug")
)
def get_space_for_member(member: Member | None, slug: str) -> Space | None:
if member is None:
return None
return spaces_for_member(member).filter(slug=slug).first()

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = "spaces"
urlpatterns = [
path("<slug:slug>/", views.space_detail, name="detail"),
]

View file

@ -0,0 +1,34 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden, HttpResponseNotFound
from django.shortcuts import render
from coulomb_social.apps.core.principal import build_principal
from .services import get_space_for_member, spaces_for_member
@login_required
def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
principal = build_principal(request, authz_resource_id="space_detail")
if not principal["authz_allow"]:
return HttpResponseForbidden(
f"Not authorized to view spaces ({principal['authz_reason']})."
)
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
return render(
request,
"spaces/detail.html",
{
"principal": principal,
"display_name": principal["display_name"],
"space": space,
},
)
# used by core.app_home — re-export list helper
def list_spaces_context(member) -> dict:
return {"spaces": list(spaces_for_member(member))}

View file

@ -21,6 +21,7 @@ INSTALLED_APPS = [
"coulomb_social.apps.core",
"coulomb_social.apps.members",
"coulomb_social.apps.identity",
"coulomb_social.apps.spaces",
]
MIDDLEWARE = [

View file

@ -4,15 +4,20 @@
<h1>Spaces</h1>
<p class="muted">
Co-creation spaces for your tenant.
Content will live as markdown in Forgejo-backed repositories.
Page content will live as markdown in Forgejo-backed repositories.
</p>
{% if spaces %}
<ul class="space-list" style="list-style:none;padding:0;margin:1.5rem 0 0;">
{% for space in spaces %}
<li class="card" style="margin-top:0.75rem;">
<strong>{{ space.title }}</strong>
<span class="muted"> · {{ space.slug }}</span>
<a href="{% url 'spaces:detail' space.slug %}" style="color:inherit;text-decoration:none;">
<strong>{{ space.title }}</strong>
<span class="muted"> · {{ space.slug }}</span>
</a>
{% if space.description %}
<p class="muted" style="margin:0.35rem 0 0;font-size:0.95rem;">{{ space.description }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
@ -20,8 +25,8 @@
<div class="card">
<h2 style="margin-top:0;">No spaces yet</h2>
<p class="muted" style="margin-bottom:0;">
Spaces and Forgejo-backed content land in the next steps
(CSOC-WP-0004-T02+). Use the profile menu for session diagnostics.
Operators can seed a space via Django admin (tenant-matched).
Create-space UI and Forgejo binding land next (T04–T06).
</p>
</div>
{% endif %}

View file

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}{{ space.title }} — {{ site_name }}{% endblock %}
{% block content %}
<p class="muted" style="margin:0 0 0.5rem;">
<a href="{% url 'core:app_home' %}">← Spaces</a>
</p>
<h1>{{ space.title }}</h1>
{% if space.description %}
<p class="muted">{{ space.description }}</p>
{% endif %}
<div class="card">
<h2 style="margin-top:0;">Space metadata</h2>
<dl>
<dt>Slug</dt><dd>{{ space.slug }}</dd>
<dt>Tenant</dt><dd>{{ space.tenant_id }}</dd>
<dt>Content</dt>
<dd>
{% if space.has_content_binding %}
{{ space.forgejo_owner }}/{{ space.forgejo_repo }}
@ {{ space.default_branch }}
· root <code>{{ space.content_root }}</code>
{% else %}
Not bound to a Forgejo repo yet (CSOC-WP-0004-T04).
{% endif %}
</dd>
</dl>
</div>
<div class="card">
<h2 style="margin-top:0;">Pages</h2>
<p class="muted" style="margin-bottom:0;">
Markdown page rendering from Forgejo lands in T04. This view is metadata only.
</p>
</div>
{% endblock %}

View file

@ -5,5 +5,6 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("healthz", include("coulomb_social.apps.core.urls_health")),
path("auth/", include("coulomb_social.apps.identity.urls")),
path("app/spaces/", include("coulomb_social.apps.spaces.urls")),
path("", include("coulomb_social.apps.core.urls")),
]