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
|
|
@ -36,12 +36,14 @@ WORKDIR /app
|
|||
COPY --from=deps /app/.venv /app/.venv
|
||||
COPY manage.py pyproject.toml ./
|
||||
COPY coulomb_social ./coulomb_social
|
||||
COPY scripts/docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||
|
||||
RUN chown -R appuser:appuser /app
|
||||
RUN chmod +x /app/docker-entrypoint.sh \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')" || exit 1
|
||||
|
||||
CMD ["gunicorn", "coulomb_social.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"]
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ The rebuild is deliberately **product-faithful first**: UI and content parity ma
|
|||
- `workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md` — **done**: identity shell on app.coulomb.social
|
||||
- `workplans/CSOC-WP-0004-app-shell-and-space-content.md` — **current path**: leave login shell; spaces + Forgejo markdown
|
||||
- `docs/adr/ADR-0001-netkingdom-identity.md` — accepted identity decision
|
||||
- `docs/adr/ADR-0002-space-content-forgejo-markdown.md` — space content as markdown in Forgejo
|
||||
- `workplans/CSOC-WP-0001-bubble-io-exit-assessment.md` — Bubble inventory/migration **after** product foundation
|
||||
- `workplans/CSOC-WP-0003-self-registration-and-assurance.md` — public registration when NetKingdom mail path lands
|
||||
- `the-custodian/docs/coulomb-social-rebuild-seed.md` — original workplan seed (CUST-WP-0058-T08)
|
||||
|
|
|
|||
3
SCOPE.md
3
SCOPE.md
|
|
@ -68,7 +68,8 @@ coulomb.social connects people around shared projects and complementary capabili
|
|||
- Status: **parallel hosts** — identity live; product content under construction
|
||||
- **`https://app.coulomb.social`**: Railiance rebuild (OIDC, shell); spaces/content next
|
||||
- **`https://coulomb.social`**: Bubble.io full product until cutover decision
|
||||
- Active path: **`CSOC-WP-0004`** (app shell + Forgejo markdown spaces)
|
||||
- Active path: **`CSOC-WP-0004`** (spaces metadata + Forgejo markdown content)
|
||||
- Content ADR: `docs/adr/ADR-0002-space-content-forgejo-markdown.md`
|
||||
- Deferred: bulk Bubble migration (`CSOC-WP-0001`); public self-registration (`CSOC-WP-0003` / NK)
|
||||
- Prior art: design extract; CSOC-WP-0002 identity acceptance (2026-08-10)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
|
|
|
|||
123
docs/adr/ADR-0002-space-content-forgejo-markdown.md
Normal file
123
docs/adr/ADR-0002-space-content-forgejo-markdown.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# ADR-0002 — Space content as markdown in Forgejo
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| Status | **Provisional** (accepted for T04 vertical slice; revisit write path) |
|
||||
| Date | 2026-08-12 |
|
||||
| Deciders | bernd |
|
||||
| Workplan | CSOC-WP-0004-T03 |
|
||||
|
||||
## Context
|
||||
|
||||
coulomb.social co-creation **spaces** need durable page/artefact content.
|
||||
Bubble stores opaque page graphs. The rebuild should:
|
||||
|
||||
- keep content **agent- and human-editable** in git,
|
||||
- version and review changes like normal software,
|
||||
- avoid making Postgres the system of record for long-form prose,
|
||||
- align with Forgejo already hosting fleet code on railiance01.
|
||||
|
||||
Identity and space **metadata** stay in the app DB (`Space`, memberships).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Canonical content form
|
||||
|
||||
- **Markdown** (CommonMark + limited GFM: tables, fenced code, images).
|
||||
- Static assets (images, attachments) live beside markdown under a content root.
|
||||
- Postgres stores **metadata and binding only** — never full page bodies as SoR.
|
||||
|
||||
### 2. Repository layout (v1)
|
||||
|
||||
**One Forgejo repository per space** under a dedicated org (recommended:
|
||||
`coulomb-spaces` or tenant-scoped org later).
|
||||
|
||||
```text
|
||||
<forgejo_owner>/<forgejo_repo>/
|
||||
README.md # optional space intro
|
||||
pages/
|
||||
index.md # default landing page
|
||||
<page-slug>.md
|
||||
assets/ # images etc. referenced from pages
|
||||
```
|
||||
|
||||
| Field on `Space` | Meaning |
|
||||
|------------------|---------|
|
||||
| `forgejo_owner` | org/user |
|
||||
| `forgejo_repo` | repo name |
|
||||
| `default_branch` | usually `main` |
|
||||
| `content_root` | default `pages` |
|
||||
|
||||
**Branch policy (v1):** app reads from `default_branch` only. PRs for review
|
||||
are a human/Forgejo workflow; the app does not merge PRs in v1.
|
||||
|
||||
**Monorepo alternative (deferred):** single repo with `spaces/<slug>/…` —
|
||||
rejected for v1 to keep permissions and migration packages simple.
|
||||
|
||||
### 3. Read path (T04)
|
||||
|
||||
1. App resolves `Space` by tenant + slug (authz already enforced).
|
||||
2. If binding incomplete → empty state (no silent Bubble fetch).
|
||||
3. Fetch file via **Forgejo raw/contents API** (preferred) or shallow cache:
|
||||
- HTTP GET with service token from env/OpenBao.
|
||||
- Cache rendered HTML or raw markdown in memory/disk with short TTL +
|
||||
optional webhook invalidation later.
|
||||
4. Render markdown → HTML with a locked-down sanitizer (no raw script).
|
||||
5. Fail closed on 404/403/network errors with operator-visible reason.
|
||||
|
||||
### 4. Write path (T05 — provisional)
|
||||
|
||||
**v1 preference:** **edit in Forgejo** (web UI or git) + app refresh.
|
||||
|
||||
- Lowest security surface (no app-held write credentials required if public-read
|
||||
internal repos use deploy token read-only).
|
||||
- Optional later: in-app editor → commit as bot user via API.
|
||||
|
||||
**Not in v1:** bidirectional live sync with Bubble.
|
||||
|
||||
### 5. Mapping from Bubble (later CSOC-WP-0001)
|
||||
|
||||
| Bubble concept | Target |
|
||||
|----------------|--------|
|
||||
| Space / room-like container | `Space` row + Forgejo repo |
|
||||
| Page / chunk prose | `pages/<slug>.md` |
|
||||
| Attachments | `assets/…` |
|
||||
| Permissions | `SpaceMembership` + NetKingdom groups (refine later) |
|
||||
|
||||
Export scripts should emit markdown files + a manifest JSON for binding fields.
|
||||
|
||||
### 6. Secrets
|
||||
|
||||
| Secret | Storage |
|
||||
|--------|---------|
|
||||
| Forgejo API token (read, later write) | K8s Secret / OpenBao — env e.g. `FORGEJO_TOKEN` |
|
||||
| Forgejo base URL | non-secret config e.g. `FORGEJO_BASE_URL=https://forgejo.coulomb.social` |
|
||||
|
||||
Never commit tokens. Never render tokens in Session details.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Content is git-native and agent-friendly.
|
||||
- Clear boundary: app = membership + UX; Forgejo = document history.
|
||||
- Migration can ship repos per space without rewriting history into SQL.
|
||||
|
||||
**Negative / follow-ups**
|
||||
|
||||
- Need Forgejo org, tokens, and network reachability from the app pod.
|
||||
- Offline/local dev needs a stub or fixture markdown path.
|
||||
- Search and cross-space queries need a separate index later.
|
||||
|
||||
## Open questions (non-blocking for T04)
|
||||
|
||||
1. Org name: `coulomb-spaces` vs per-tenant org.
|
||||
2. Private repos only vs public read for some community spaces.
|
||||
3. When to add webhook-driven cache purge.
|
||||
4. Whether user OAuth to Forgejo is required for in-app write (T05).
|
||||
|
||||
## References
|
||||
|
||||
- CSOC-WP-0004, Space model in `coulomb_social.apps.spaces`
|
||||
- ADR-0001 NetKingdom identity
|
||||
- Host posture: `docs/deploy.md` (app.coulomb.social)
|
||||
4
scripts/docker-entrypoint.sh
Executable file
4
scripts/docker-entrypoint.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
python manage.py migrate --noinput
|
||||
exec gunicorn coulomb_social.wsgi:application --bind 0.0.0.0:8000 --workers 2
|
||||
111
tests/test_spaces.py
Normal file
111
tests/test_spaces.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from coulomb_social.apps.members.models import Member, User
|
||||
from coulomb_social.apps.spaces.models import Space, SpaceMembership
|
||||
from coulomb_social.apps.spaces.services import spaces_for_member
|
||||
|
||||
|
||||
def _login(client, settings, *, subject: str, tenant: str, name: str):
|
||||
settings.DEBUG = True
|
||||
settings.OIDC_ENABLED = False
|
||||
settings.DEFAULT_TENANT_ID = tenant
|
||||
return client.post(
|
||||
reverse("identity:dev_login"),
|
||||
{
|
||||
"subject": subject,
|
||||
"issuer": "https://local.dev/issuer",
|
||||
"name": name,
|
||||
"tenant": tenant,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_spaces_list_empty_for_tenant(client, settings):
|
||||
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
|
||||
r = client.get(reverse("core:app_home"))
|
||||
assert r.status_code == 200
|
||||
assert b"No spaces yet" in r.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_spaces_list_shows_same_tenant_only(client, settings):
|
||||
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
|
||||
member = Member.objects.get(subject="u1")
|
||||
Space.objects.create(
|
||||
tenant_id="tenant:a",
|
||||
slug="alpha",
|
||||
title="Alpha Space",
|
||||
created_by=member,
|
||||
)
|
||||
Space.objects.create(
|
||||
tenant_id="tenant:b",
|
||||
slug="beta",
|
||||
title="Beta Space",
|
||||
)
|
||||
|
||||
r = client.get(reverse("core:app_home"))
|
||||
assert r.status_code == 200
|
||||
assert b"Alpha Space" in r.content
|
||||
assert b"Beta Space" not in r.content
|
||||
assert b"No spaces yet" not in r.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_space_detail_tenant_isolation(client, settings):
|
||||
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
|
||||
Space.objects.create(tenant_id="tenant:b", slug="secret", title="Secret")
|
||||
r = client.get(reverse("spaces:detail", kwargs={"slug": "secret"}))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_space_detail_ok(client, settings):
|
||||
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
|
||||
member = Member.objects.get(subject="u1")
|
||||
Space.objects.create(
|
||||
tenant_id="tenant:a",
|
||||
slug="lab",
|
||||
title="Lab",
|
||||
description="Research lab",
|
||||
created_by=member,
|
||||
forgejo_owner="coulomb",
|
||||
forgejo_repo="space-lab",
|
||||
)
|
||||
r = client.get(reverse("spaces:detail", kwargs={"slug": "lab"}))
|
||||
assert r.status_code == 200
|
||||
assert b"Lab" in r.content
|
||||
assert b"coulomb/space-lab" in r.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_membership_narrows_visibility(client, settings):
|
||||
_login(client, settings, subject="u1", tenant="tenant:a", name="User One")
|
||||
member = Member.objects.get(subject="u1")
|
||||
other_user = User.objects.create_user(username="other")
|
||||
other = Member.objects.create(
|
||||
tenant_id="tenant:a",
|
||||
user=other_user,
|
||||
user_engine_user_id="usr_other",
|
||||
issuer="https://local.dev/issuer",
|
||||
subject="other-sub",
|
||||
display_name="Other",
|
||||
)
|
||||
open_space = Space.objects.create(
|
||||
tenant_id="tenant:a", slug="open", title="Open", created_by=other
|
||||
)
|
||||
closed = Space.objects.create(
|
||||
tenant_id="tenant:a", slug="closed", title="Closed", created_by=other
|
||||
)
|
||||
# Any membership in tenant switches mode to membership-scoped
|
||||
SpaceMembership.objects.create(
|
||||
space=closed, member=other, role=SpaceMembership.Role.OWNER
|
||||
)
|
||||
SpaceMembership.objects.create(
|
||||
space=open_space, member=member, role=SpaceMembership.Role.MEMBER
|
||||
)
|
||||
|
||||
visible = list(spaces_for_member(member).values_list("slug", flat=True))
|
||||
assert "open" in visible
|
||||
assert "closed" not in visible
|
||||
|
|
@ -75,7 +75,7 @@ dump removed from home body. Deploy with next image for app.coulomb.social.
|
|||
|
||||
```task
|
||||
id: CSOC-WP-0004-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5fd91718-151d-4216-acf2-2104a45cddf9"
|
||||
```
|
||||
|
|
@ -92,11 +92,14 @@ Migrations + admin + minimal list/detail routes behind auth.
|
|||
**Done when:** authenticated user can list zero-or-more spaces from DB; tests cover
|
||||
tenant isolation basics.
|
||||
|
||||
2026-08-12: `spaces` app — `Space` + `SpaceMembership`, tenant-scoped list on
|
||||
`/app/`, detail `/app/spaces/<slug>/`, admin seed path. Tests cover isolation.
|
||||
|
||||
## T03 — Content model ADR: markdown + Forgejo
|
||||
|
||||
```task
|
||||
id: CSOC-WP-0004-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "628243e0-732f-42c1-b4e3-9b8cd82ea530"
|
||||
```
|
||||
|
|
@ -113,6 +116,9 @@ Write `docs/adr/ADR-0002-space-content-forgejo-markdown.md` deciding:
|
|||
**Done when:** ADR accepted (or explicitly provisional with open questions listed)
|
||||
and linked from INTENT/SCOPE.
|
||||
|
||||
2026-08-12: Provisional ADR-0002 committed; linked from INTENT/SCOPE. One repo
|
||||
per space, `pages/` root, Forgejo API read for T04; write-in-Forgejo for T05.
|
||||
|
||||
## T04 — Read path: render space markdown from bound repo
|
||||
|
||||
```task
|
||||
|
|
@ -170,6 +176,7 @@ names, smoke checklist on app.coulomb.social. Update `docs/deploy.md` and
|
|||
id: CSOC-WP-0004-T07
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "8487443a-dfd2-4220-a90b-bca2b5b04c4c"
|
||||
```
|
||||
|
||||
Keep the current principal card fields available as **detail information**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue