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
|
|
@ -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",
|
||||
|
|
|
|||
0
coulomb_social/apps/spaces/__init__.py
Normal file
0
coulomb_social/apps/spaces/__init__.py
Normal file
34
coulomb_social/apps/spaces/admin.py
Normal file
34
coulomb_social/apps/spaces/admin.py
Normal 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")
|
||||
7
coulomb_social/apps/spaces/apps.py
Normal file
7
coulomb_social/apps/spaces/apps.py
Normal 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"
|
||||
150
coulomb_social/apps/spaces/migrations/0001_initial.py
Normal file
150
coulomb_social/apps/spaces/migrations/0001_initial.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
0
coulomb_social/apps/spaces/migrations/__init__.py
Normal file
0
coulomb_social/apps/spaces/migrations/__init__.py
Normal file
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}"
|
||||
39
coulomb_social/apps/spaces/services.py
Normal file
39
coulomb_social/apps/spaces/services.py
Normal 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()
|
||||
3
coulomb_social/apps/spaces/tests.py
Normal file
3
coulomb_social/apps/spaces/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
coulomb_social/apps/spaces/urls.py
Normal file
9
coulomb_social/apps/spaces/urls.py
Normal 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"),
|
||||
]
|
||||
34
coulomb_social/apps/spaces/views.py
Normal file
34
coulomb_social/apps/spaces/views.py
Normal 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))}
|
||||
|
|
@ -21,6 +21,7 @@ INSTALLED_APPS = [
|
|||
"coulomb_social.apps.core",
|
||||
"coulomb_social.apps.members",
|
||||
"coulomb_social.apps.identity",
|
||||
"coulomb_social.apps.spaces",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
36
coulomb_social/templates/spaces/detail.html
Normal file
36
coulomb_social/templates/spaces/detail.html
Normal 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 %}
|
||||
|
|
@ -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")),
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue