Land CSOC-WP-0006 PageOps: member space/page CRUD, copy, transfer

Thin CONTENT_ROOT content plane (ADR-0003/0004) with space visual field,
product UI for Title/Abstract/Visual, page list sidebar, and tests. Update
capability model and smoke checklist; mark WP-0006 finished.
This commit is contained in:
tegwick 2026-08-13 12:46:39 +02:00
parent 00dbb86c93
commit affed64839
24 changed files with 1812 additions and 151 deletions

View file

@ -16,15 +16,29 @@ class SpaceAdmin(admin.ModelAdmin):
"slug",
"tenant_id",
"is_active",
"visual",
"forgejo_owner",
"forgejo_repo",
"updated_at",
)
list_filter = ("tenant_id", "is_active")
search_fields = ("title", "slug", "forgejo_repo")
search_fields = ("title", "slug", "description", "forgejo_repo")
prepopulated_fields = {"slug": ("title",)}
raw_id_fields = ("created_by",)
inlines = [SpaceMembershipInline]
fields = (
"tenant_id",
"slug",
"title",
"description",
"visual",
"created_by",
"forgejo_owner",
"forgejo_repo",
"default_branch",
"content_root",
"is_active",
)
@admin.register(SpaceMembership)

View file

@ -106,7 +106,7 @@ def _title_from_markdown(text: str, fallback: str) -> str:
def load_space_page(space: Space, page: str | None = None) -> RenderedPage:
"""Fetch and render a page for a space. Fail closed with error field set."""
"""Fetch and render a page for a space. Prefer CONTENT_ROOT (ADR-0004)."""
try:
page_slug = normalize_page_slug(page)
path = page_path_for(space, page_slug)
@ -120,24 +120,21 @@ def load_space_page(space: Space, page: str | None = None) -> RenderedPage:
error=exc.message,
)
if not space.has_content_binding:
return RenderedPage(
slug=page_slug,
path=path,
title=space.title,
html="",
source="",
error="This space is not bound to a Forgejo repository yet.",
)
try:
fetched = _fetch(space, path)
html = render_markdown(fetched.text)
title = _title_from_markdown(fetched.text, space.title)
# Prefer frontmatter title when present
from .pageops import parse_frontmatter
meta, body = parse_frontmatter(fetched.text)
display_title = (
str(meta.get("title") or "").strip()
or _title_from_markdown(body or fetched.text, space.title)
)
html = render_markdown(body if meta else fetched.text)
return RenderedPage(
slug=page_slug,
path=path,
title=title,
title=display_title,
html=html,
source=fetched.source,
error=None,
@ -154,6 +151,17 @@ def load_space_page(space: Space, page: str | None = None) -> RenderedPage:
def _fetch(space: Space, path: str) -> FetchedFile:
# ADR-0004: CONTENT_ROOT / spaces/<slug>/pages/…
from . import pageops
try:
local_doc = pageops.read_page(space.slug, Path(path).stem)
if local_doc.exists and local_doc.path:
text = Path(local_doc.path).read_text(encoding="utf-8")
return FetchedFile(path=path, text=text, source="content-plane")
except pageops.PageOpsError:
pass
# Optional local fixture root for offline tests / air-gapped demos
fixture_root = (getattr(settings, "SPACE_CONTENT_FIXTURE_ROOT", None) or "").strip()
if fixture_root:
@ -161,6 +169,11 @@ def _fetch(space: Space, path: str) -> FetchedFile:
if local.is_file():
return FetchedFile(path=path, text=local.read_text(encoding="utf-8"), source="fixture")
if not space.has_content_binding:
raise ContentFetchError(
"No local page on the content plane and no Forgejo binding."
)
cache_key = (
space.forgejo_owner,
space.forgejo_repo,

View file

@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("spaces", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="space",
name="visual",
field=models.CharField(
blank=True,
help_text="Cover/visual path relative to space tree (e.g. assets/cover.jpg).",
max_length=512,
),
),
migrations.AlterField(
model_name="space",
name="description",
field=models.TextField(
blank=True,
help_text="Short description (product label: Abstractor).",
),
),
]

View file

@ -1,4 +1,4 @@
"""Space metadata — content bodies live in Forgejo (markdown), not here."""
"""Space metadata index — page bodies live on the content plane (markdown)."""
from __future__ import annotations
@ -23,15 +23,24 @@ def validate_space_slug(value: str) -> None:
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).
Postgres holds metadata and optional Forgejo binding pointers. Long-form
pages are markdown under CONTENT_ROOT (ADR-0003 / ADR-0004); Forgejo is an
optional read/edit fallback (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)
description = models.TextField(
blank=True,
help_text="Short description (product label: Abstractor).",
)
visual = models.CharField(
max_length=512,
blank=True,
help_text="Cover/visual path relative to space tree (e.g. assets/cover.jpg).",
)
created_by = models.ForeignKey(
"members.Member",
on_delete=models.PROTECT,
@ -80,6 +89,15 @@ class Space(models.Model):
def has_content_binding(self) -> bool:
return bool(self.forgejo_owner and self.forgejo_repo)
@property
def abstractor(self) -> str:
"""Product name for the short description field."""
return self.description
@abstractor.setter
def abstractor(self, value: str) -> None:
self.description = value or ""
@classmethod
def suggest_slug(cls, title: str) -> str:
return slugify(title)[:80] or "space"

View file

@ -0,0 +1,331 @@
"""PageOps — thin filesystem content plane (ADR-0003 / ADR-0004).
Layout under CONTENT_ROOT:
spaces/<space-slug>/
pages/<page-slug>.md
assets/…
Markdown files use YAML-like frontmatter (title, abstractor, visual, …).
The app DB holds Space rows as an index; bodies live on disk.
"""
from __future__ import annotations
import json
import logging
import re
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from django.conf import settings
from django.utils.text import slugify as django_slugify
logger = logging.getLogger(__name__)
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
class PageOpsError(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(message)
@dataclass
class PageDoc:
slug: str
title: str = ""
abstractor: str = ""
visual: str = ""
body: str = ""
meta: dict = field(default_factory=dict)
path: str = ""
@property
def exists(self) -> bool:
return bool(self.path)
def content_root() -> Path:
raw = (getattr(settings, "CONTENT_ROOT", None) or "").strip()
if raw:
return Path(raw).expanduser().resolve()
# Default under project var/ (gitignored)
base = Path(settings.BASE_DIR)
return (base / "var" / "content").resolve()
def space_dir(space_slug: str) -> Path:
return content_root() / "spaces" / space_slug
def pages_dir(space_slug: str) -> Path:
return space_dir(space_slug) / "pages"
def assets_dir(space_slug: str) -> Path:
return space_dir(space_slug) / "assets"
def page_file(space_slug: str, page_slug: str) -> Path:
name = "index.md" if page_slug == "index" else f"{page_slug}.md"
return pages_dir(space_slug) / name
def normalize_slug(value: str, *, allow_index: bool = True) -> str:
s = django_slugify(value or "")[:80]
if not s:
raise PageOpsError("Slug cannot be empty")
if s == "index" and not allow_index:
raise PageOpsError("Slug 'index' is reserved")
if not _SLUG_RE.match(s):
raise PageOpsError("Invalid slug")
return s
def parse_frontmatter(text: str) -> tuple[dict, str]:
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end < 0:
# allow trailing --- at EOF
end = text.find("\n---", 4)
if end < 0 or end + 4 != len(text.rstrip()):
return {}, text
body = ""
fm_raw = text[4:end]
else:
fm_raw = text[4:end]
body = text[end + 5 :]
data: dict = {}
for line in fm_raw.splitlines():
if not line.strip() or line.strip().startswith("#"):
continue
if ":" not in line:
continue
key, raw = line.split(":", 1)
key = key.strip()
raw = raw.strip()
if not key:
continue
if raw in ("true", "false"):
data[key] = raw == "true"
elif raw.startswith('"') or raw.startswith("'"):
try:
data[key] = json.loads(raw.replace("'", '"') if raw.startswith("'") else raw)
except json.JSONDecodeError:
data[key] = raw.strip("\"'")
else:
data[key] = raw
return data, body.lstrip("\n")
def dump_frontmatter(meta: dict, body: str) -> str:
lines = ["---"]
for key, value in meta.items():
if value is None or value == "":
lines.append(f'{key}: ""')
elif isinstance(value, bool):
lines.append(f"{key}: {'true' if value else 'false'}")
elif isinstance(value, (int, float)):
lines.append(f"{key}: {value}")
else:
lines.append(f"{key}: {json.dumps(str(value))}")
lines.append("---")
lines.append("")
lines.append(body.rstrip() + ("\n" if body.strip() else ""))
return "\n".join(lines)
def ensure_space_tree(space_slug: str) -> Path:
root = space_dir(space_slug)
pages_dir(space_slug).mkdir(parents=True, exist_ok=True)
assets_dir(space_slug).mkdir(parents=True, exist_ok=True)
return root
def write_page(
space_slug: str,
page_slug: str,
*,
title: str = "",
abstractor: str = "",
visual: str = "",
body: str = "",
extra_meta: dict | None = None,
) -> PageDoc:
page_slug = normalize_slug(page_slug) if page_slug != "index" else "index"
ensure_space_tree(space_slug)
meta = {
"title": title or page_slug,
"abstractor": abstractor or "",
"visual": visual or "",
"space": space_slug,
}
if extra_meta:
meta.update(extra_meta)
path = page_file(space_slug, page_slug)
path.write_text(dump_frontmatter(meta, body or ""), encoding="utf-8")
return read_page(space_slug, page_slug)
def read_page(space_slug: str, page_slug: str) -> PageDoc:
page_slug = "index" if page_slug in ("", "index") else normalize_slug(page_slug)
path = page_file(space_slug, page_slug)
if not path.is_file():
return PageDoc(slug=page_slug, path="")
text = path.read_text(encoding="utf-8")
meta, body = parse_frontmatter(text)
return PageDoc(
slug=page_slug,
title=str(meta.get("title") or ""),
abstractor=str(meta.get("abstractor") or meta.get("description") or ""),
visual=str(meta.get("visual") or ""),
body=body,
meta=meta,
path=str(path),
)
def list_pages(space_slug: str) -> list[PageDoc]:
pdir = pages_dir(space_slug)
if not pdir.is_dir():
return []
docs: list[PageDoc] = []
for path in sorted(pdir.glob("*.md")):
slug = path.stem
docs.append(read_page(space_slug, slug))
# index first
docs.sort(key=lambda d: (0 if d.slug == "index" else 1, d.title.lower() or d.slug))
return docs
def delete_page(space_slug: str, page_slug: str) -> None:
if page_slug == "index":
raise PageOpsError("Cannot delete the space index page")
path = page_file(space_slug, normalize_slug(page_slug))
if path.is_file():
path.unlink()
def copy_page(
source_space: str,
source_page: str,
dest_space: str,
dest_page: str,
*,
title: str | None = None,
) -> PageDoc:
src = read_page(source_space, source_page)
if not src.exists:
raise PageOpsError("Source page not found")
dest_page = normalize_slug(dest_page)
if page_file(dest_space, dest_page).is_file():
raise PageOpsError("Destination page already exists")
ensure_space_tree(dest_space)
# copy visual asset if relative
visual = src.visual
if visual and not visual.startswith("http") and not visual.startswith("/"):
src_asset = space_dir(source_space) / visual
if src_asset.is_file():
dest_assets = assets_dir(dest_space)
dest_assets.mkdir(parents=True, exist_ok=True)
dest_name = f"{dest_page}-{src_asset.name}"
dest_path = dest_assets / dest_name
shutil.copy2(src_asset, dest_path)
visual = f"assets/{dest_name}"
new_title = title if title is not None else (src.title or dest_page)
if new_title == src.title and source_space == dest_space:
new_title = f"{src.title} (copy)" if src.title else f"{dest_page}"
return write_page(
dest_space,
dest_page,
title=new_title,
abstractor=src.abstractor,
visual=visual,
body=src.body,
extra_meta={k: v for k, v in src.meta.items() if k not in {"title", "abstractor", "visual", "space"}},
)
def transfer_page(
source_space: str,
source_page: str,
dest_space: str,
dest_page: str | None = None,
) -> PageDoc:
dest_page = dest_page or source_page
if source_page == "index":
raise PageOpsError("Cannot transfer the space index page")
doc = copy_page(source_space, source_page, dest_space, dest_page, title=None)
# restore original title (copy_page may suffix)
src = read_page(source_space, source_page)
write_page(
dest_space,
dest_page,
title=src.title,
abstractor=src.abstractor,
visual=doc.visual,
body=src.body,
extra_meta={k: v for k, v in src.meta.items() if k not in {"title", "abstractor", "visual", "space"}},
)
delete_page(source_space, source_page)
return read_page(dest_space, dest_page)
def init_space_content(
space_slug: str,
*,
title: str,
abstractor: str = "",
visual: str = "",
) -> PageDoc:
"""Create tree + index.md for a new space."""
ensure_space_tree(space_slug)
body = f"# {title}\n\n{abstractor}\n" if abstractor else f"# {title}\n"
return write_page(
space_slug,
"index",
title=title,
abstractor=abstractor,
visual=visual,
body=body,
extra_meta={"bubble_type": "space"},
)
def rename_space_tree(old_slug: str, new_slug: str) -> None:
old = space_dir(old_slug)
new = space_dir(new_slug)
if not old.is_dir():
return
if new.exists():
raise PageOpsError("Target space directory already exists")
new.parent.mkdir(parents=True, exist_ok=True)
old.rename(new)
def archive_space_tree(space_slug: str) -> None:
"""Soft-archive: rename tree to .archived-<slug> if present."""
root = space_dir(space_slug)
if not root.is_dir():
return
dest = content_root() / "spaces" / f".archived-{space_slug}"
if dest.exists():
shutil.rmtree(dest)
root.rename(dest)
def local_page_text(space_slug: str, page_slug: str) -> str | None:
"""Raw markdown if present on the content plane."""
path = page_file(space_slug, page_slug if page_slug != "index" else "index")
# handle normalize
try:
doc = read_page(space_slug, page_slug)
except PageOpsError:
return None
if not doc.exists:
return None
return Path(doc.path).read_text(encoding="utf-8")

View file

@ -1,11 +1,13 @@
"""Space queries scoped by tenant membership."""
"""Space queries and PageOps-backed mutations (ADR-0003 / ADR-0004)."""
from __future__ import annotations
from django.db import transaction
from django.db.models import Q, QuerySet
from coulomb_social.apps.members.models import Member
from . import pageops
from .models import Space, SpaceMembership
@ -37,3 +39,159 @@ 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()
@transaction.atomic
def create_space(
member: Member,
*,
title: str,
abstractor: str = "",
slug: str | None = None,
visual: str = "",
) -> Space:
title = (title or "").strip()
if not title:
raise pageops.PageOpsError("Title is required")
space_slug = pageops.normalize_slug(slug or Space.suggest_slug(title))
if Space.objects.filter(tenant_id=member.tenant_id, slug=space_slug).exists():
raise pageops.PageOpsError("A space with this slug already exists")
space = Space.objects.create(
tenant_id=member.tenant_id,
slug=space_slug,
title=title,
description=abstractor or "",
visual=visual or "",
created_by=member,
content_root="pages",
default_branch="main",
)
SpaceMembership.objects.get_or_create(
space=space,
member=member,
defaults={"role": SpaceMembership.Role.OWNER},
)
pageops.init_space_content(
space_slug,
title=title,
abstractor=abstractor or "",
visual=visual or "",
)
return space
@transaction.atomic
def update_space(
member: Member,
space: Space,
*,
title: str | None = None,
abstractor: str | None = None,
visual: str | None = None,
) -> Space:
if get_space_for_member(member, space.slug) is None:
raise pageops.PageOpsError("Space not found")
if title is not None:
space.title = title.strip() or space.title
if abstractor is not None:
space.description = abstractor
if visual is not None:
space.visual = visual
space.save()
# Keep index.md in sync
index = pageops.read_page(space.slug, "index")
body = index.body if index.exists else f"# {space.title}\n"
pageops.write_page(
space.slug,
"index",
title=space.title,
abstractor=space.description,
visual=space.visual,
body=body,
extra_meta={"bubble_type": "space"},
)
return space
@transaction.atomic
def archive_space(member: Member, space: Space) -> Space:
if get_space_for_member(member, space.slug) is None:
raise pageops.PageOpsError("Space not found")
space.is_active = False
space.save(update_fields=["is_active", "updated_at"])
try:
pageops.archive_space_tree(space.slug)
except Exception:
# Tree archive is best-effort
pass
return space
def list_space_pages(space: Space) -> list[pageops.PageDoc]:
return pageops.list_pages(space.slug)
def put_space_page(
member: Member,
space: Space,
page_slug: str,
*,
title: str,
abstractor: str = "",
visual: str = "",
body: str = "",
) -> pageops.PageDoc:
if get_space_for_member(member, space.slug) is None:
raise pageops.PageOpsError("Space not found")
return pageops.write_page(
space.slug,
page_slug,
title=title,
abstractor=abstractor,
visual=visual,
body=body,
)
def copy_space_page(
member: Member,
space: Space,
page_slug: str,
*,
dest_slug: str,
dest_space: Space | None = None,
title: str | None = None,
) -> pageops.PageDoc:
if get_space_for_member(member, space.slug) is None:
raise pageops.PageOpsError("Source space not found")
target = dest_space or space
if get_space_for_member(member, target.slug) is None:
raise pageops.PageOpsError("Destination space not found")
return pageops.copy_page(
space.slug,
page_slug,
target.slug,
dest_slug,
title=title,
)
def transfer_space_page(
member: Member,
space: Space,
page_slug: str,
dest_space: Space,
*,
dest_slug: str | None = None,
) -> pageops.PageDoc:
if get_space_for_member(member, space.slug) is None:
raise pageops.PageOpsError("Source space not found")
if get_space_for_member(member, dest_space.slug) is None:
raise pageops.PageOpsError("Destination space not found")
return pageops.transfer_page(
space.slug,
page_slug,
dest_space.slug,
dest_slug,
)

View file

@ -10,6 +10,30 @@ urlpatterns = [
views.forgejo_content_webhook,
name="forgejo_webhook",
),
path("new/", views.space_create, name="create"),
path("<slug:slug>/edit/", views.space_edit, name="edit"),
path("<slug:slug>/archive/", views.space_archive, name="archive"),
path("<slug:slug>/refresh/", views.space_refresh, name="refresh"),
path("<slug:slug>/pages/new/", views.page_new, name="page_new"),
path(
"<slug:slug>/pages/<slug:page_slug>/edit/",
views.page_edit,
name="page_edit",
),
path(
"<slug:slug>/pages/<slug:page_slug>/copy/",
views.page_copy,
name="page_copy",
),
path(
"<slug:slug>/pages/<slug:page_slug>/transfer/",
views.page_transfer,
name="page_transfer",
),
path(
"<slug:slug>/pages/<slug:page_slug>/delete/",
views.page_delete,
name="page_delete",
),
path("<slug:slug>/", views.space_detail, name="detail"),
]

View file

@ -14,11 +14,13 @@ from django.http import (
JsonResponse,
)
from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_http_methods, require_POST
from coulomb_social.apps.core.principal import build_principal
from . import pageops
from .content import (
clear_content_cache_for_repo,
clear_content_cache_for_space,
@ -28,18 +30,126 @@ from .content import (
load_space_page,
)
from .models import Space
from .services import get_space_for_member, spaces_for_member
from .services import (
archive_space,
copy_space_page,
create_space,
get_space_for_member,
list_space_pages,
put_space_page,
spaces_for_member,
transfer_space_page,
update_space,
)
logger = logging.getLogger(__name__)
def _principal_or_forbid(request: HttpRequest, resource: str):
principal = build_principal(request, authz_resource_id=resource)
if not principal["authz_allow"]:
return None, HttpResponseForbidden(
f"Not authorized ({principal['authz_reason']})."
)
return principal, None
@login_required
@require_http_methods(["GET", "POST"])
def space_create(request: HttpRequest) -> HttpResponse:
principal, forbid = _principal_or_forbid(request, "space_create")
if forbid:
return forbid
member = principal.get("member")
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
abstractor = (request.POST.get("abstractor") or "").strip()
slug = (request.POST.get("slug") or "").strip() or None
visual = (request.POST.get("visual") or "").strip()
try:
space = create_space(
member,
title=title,
abstractor=abstractor,
slug=slug,
visual=visual,
)
messages.success(request, f"Space “{space.title}” created.")
return redirect("spaces:detail", slug=space.slug)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
return render(
request,
"spaces/form.html",
{
"principal": principal,
"display_name": principal["display_name"],
"form_title": "Create space",
"space": None,
"submit_label": "Create space",
},
)
@login_required
@require_http_methods(["GET", "POST"])
def space_edit(request: HttpRequest, slug: str) -> HttpResponse:
principal, forbid = _principal_or_forbid(request, "space_edit")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
if request.method == "POST":
try:
update_space(
member,
space,
title=(request.POST.get("title") or "").strip(),
abstractor=(request.POST.get("abstractor") or "").strip(),
visual=(request.POST.get("visual") or "").strip(),
)
messages.success(request, "Space updated.")
return redirect("spaces:detail", slug=space.slug)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
return render(
request,
"spaces/form.html",
{
"principal": principal,
"display_name": principal["display_name"],
"form_title": f"Edit {space.title}",
"space": space,
"submit_label": "Save",
},
)
@login_required
@require_POST
def space_archive(request: HttpRequest, slug: str) -> HttpResponse:
principal, forbid = _principal_or_forbid(request, "space_archive")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
try:
archive_space(member, space)
messages.success(request, f"Space “{space.title}” archived.")
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
return redirect("core:app_home")
@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']})."
)
principal, forbid = _principal_or_forbid(request, "space_detail")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
@ -47,6 +157,7 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
page = request.GET.get("page") or "index"
rendered = load_space_page(space, page)
pages = list_space_pages(space)
return render(
request,
"spaces/detail.html",
@ -55,6 +166,7 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
"display_name": principal["display_name"],
"space": space,
"page": rendered,
"pages": pages,
"forgejo_repo_url": forgejo_repo_url(space),
"forgejo_edit_url": forgejo_edit_url(space, page),
"forgejo_blob_url": forgejo_blob_url(space, page),
@ -62,13 +174,274 @@ def space_detail(request: HttpRequest, slug: str) -> HttpResponse:
)
@login_required
@require_http_methods(["GET", "POST"])
def page_edit(request: HttpRequest, slug: str, page_slug: str = "index") -> HttpResponse:
principal, forbid = _principal_or_forbid(request, "page_edit")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
if page_slug != "index":
try:
page_slug = pageops.normalize_slug(page_slug)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
return redirect("spaces:detail", slug=slug)
doc = pageops.read_page(space.slug, page_slug)
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
abstractor = (request.POST.get("abstractor") or "").strip()
visual = (request.POST.get("visual") or "").strip()
body = request.POST.get("body") or ""
new_slug = (request.POST.get("slug") or page_slug).strip()
try:
if page_slug == "index":
target_slug = "index"
else:
target_slug = pageops.normalize_slug(new_slug)
if target_slug != page_slug and page_slug != "index":
# rename: write new, delete old
if pageops.page_file(space.slug, target_slug).is_file():
raise pageops.PageOpsError("A page with that slug already exists")
put_space_page(
member,
space,
target_slug,
title=title,
abstractor=abstractor,
visual=visual,
body=body,
)
pageops.delete_page(space.slug, page_slug)
page_slug = target_slug
else:
put_space_page(
member,
space,
target_slug,
title=title,
abstractor=abstractor,
visual=visual,
body=body,
)
page_slug = target_slug
clear_content_cache_for_space(space)
messages.success(request, "Page saved.")
target = reverse("spaces:detail", kwargs={"slug": space.slug})
if page_slug != "index":
target = f"{target}?page={page_slug}"
return redirect(target)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
doc = pageops.PageDoc(
slug=page_slug,
title=title,
abstractor=abstractor,
visual=visual,
body=body,
path=doc.path,
)
return render(
request,
"spaces/page_form.html",
{
"principal": principal,
"display_name": principal["display_name"],
"space": space,
"doc": doc,
"is_new": not doc.exists,
"form_title": "Edit page" if doc.exists else "New page",
},
)
@login_required
@require_http_methods(["GET", "POST"])
def page_new(request: HttpRequest, slug: str) -> HttpResponse:
principal, forbid = _principal_or_forbid(request, "page_new")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
title = ""
abstractor = ""
visual = ""
body = ""
page_slug = ""
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
abstractor = (request.POST.get("abstractor") or "").strip()
visual = (request.POST.get("visual") or "").strip()
body = request.POST.get("body") or ""
raw_slug = (request.POST.get("slug") or title or "page").strip()
try:
page_slug = pageops.normalize_slug(raw_slug, allow_index=False)
if pageops.page_file(space.slug, page_slug).is_file():
raise pageops.PageOpsError("A page with that slug already exists")
put_space_page(
member,
space,
page_slug,
title=title or page_slug,
abstractor=abstractor,
visual=visual,
body=body,
)
clear_content_cache_for_space(space)
messages.success(request, "Page created.")
target = reverse("spaces:detail", kwargs={"slug": space.slug})
return redirect(f"{target}?page={page_slug}")
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
return render(
request,
"spaces/page_form.html",
{
"principal": principal,
"display_name": principal["display_name"],
"space": space,
"doc": pageops.PageDoc(
slug=page_slug,
title=title,
abstractor=abstractor,
visual=visual,
body=body,
),
"is_new": True,
"form_title": "New page",
},
)
@login_required
@require_POST
def page_delete(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse:
principal, forbid = _principal_or_forbid(request, "page_delete")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
try:
if get_space_for_member(member, space.slug) is None:
raise pageops.PageOpsError("Space not found")
pageops.delete_page(space.slug, page_slug)
clear_content_cache_for_space(space)
messages.success(request, f"Page “{page_slug}” deleted.")
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
return redirect("spaces:detail", slug=slug)
@login_required
@require_http_methods(["GET", "POST"])
def page_copy(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse:
principal, forbid = _principal_or_forbid(request, "page_copy")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
all_spaces = list(spaces_for_member(member))
if request.method == "POST":
dest_slug = (request.POST.get("dest_slug") or f"{page_slug}-copy").strip()
dest_space_slug = (request.POST.get("dest_space") or space.slug).strip()
dest_space = get_space_for_member(member, dest_space_slug)
title = (request.POST.get("title") or "").strip() or None
try:
doc = copy_space_page(
member,
space,
page_slug,
dest_slug=dest_slug,
dest_space=dest_space,
title=title,
)
messages.success(request, f"Copied to “{doc.slug}”.")
target = reverse("spaces:detail", kwargs={"slug": dest_space.slug})
if doc.slug != "index":
target = f"{target}?page={doc.slug}"
return redirect(target)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
return render(
request,
"spaces/page_copy.html",
{
"principal": principal,
"display_name": principal["display_name"],
"space": space,
"page_slug": page_slug,
"all_spaces": all_spaces,
"default_dest": f"{page_slug}-copy",
},
)
@login_required
@require_http_methods(["GET", "POST"])
def page_transfer(request: HttpRequest, slug: str, page_slug: str) -> HttpResponse:
principal, forbid = _principal_or_forbid(request, "page_transfer")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
return HttpResponseNotFound("Space not found.")
all_spaces = [s for s in spaces_for_member(member) if s.slug != space.slug]
if request.method == "POST":
dest_space_slug = (request.POST.get("dest_space") or "").strip()
dest_slug = (request.POST.get("dest_slug") or page_slug).strip()
dest_space = get_space_for_member(member, dest_space_slug)
try:
if dest_space is None:
raise pageops.PageOpsError("Choose a destination space")
doc = transfer_space_page(
member,
space,
page_slug,
dest_space,
dest_slug=dest_slug,
)
messages.success(request, f"Moved to {dest_space.title}.")
target = reverse("spaces:detail", kwargs={"slug": dest_space.slug})
if doc.slug != "index":
target = f"{target}?page={doc.slug}"
return redirect(target)
except pageops.PageOpsError as exc:
messages.error(request, exc.message)
return render(
request,
"spaces/page_transfer.html",
{
"principal": principal,
"display_name": principal["display_name"],
"space": space,
"page_slug": page_slug,
"all_spaces": all_spaces,
},
)
@login_required
@require_POST
def space_refresh(request: HttpRequest, slug: str) -> HttpResponse:
"""Drop cache for this space's Forgejo binding and re-show the page."""
principal = build_principal(request, authz_resource_id="space_refresh")
if not principal["authz_allow"]:
return HttpResponseForbidden("Not authorized.")
"""Drop cache for this space and re-show the page."""
principal, forbid = _principal_or_forbid(request, "space_refresh")
if forbid:
return forbid
member = principal.get("member")
space = get_space_for_member(member, slug)
if space is None:
@ -79,8 +452,6 @@ def space_refresh(request: HttpRequest, slug: str) -> HttpResponse:
f"Content cache cleared ({n} entries). Fresh fetch on this page load.",
)
page = request.POST.get("page") or request.GET.get("page") or "index"
from django.urls import reverse
target = reverse("spaces:detail", kwargs={"slug": slug})
if page and page != "index":
target = f"{target}?page={page}"
@ -90,12 +461,7 @@ def space_refresh(request: HttpRequest, slug: str) -> HttpResponse:
@csrf_exempt
@require_POST
def forgejo_content_webhook(request: HttpRequest) -> JsonResponse:
"""Forgejo/Gitea push webhook → invalidate content cache for matching spaces.
Auth: header ``X-Coulomb-Webhook-Secret`` or ``X-Gitea-Signature`` (HMAC-SHA256
of body with FORGEJO_WEBHOOK_SECRET) or shared secret query (discouraged).
Configure in Forgejo: repository → Webhooks → Gitea (JSON) → push events.
"""
"""Forgejo/Gitea push webhook → invalidate content cache for matching spaces."""
secret = (getattr(settings, "FORGEJO_WEBHOOK_SECRET", None) or "").strip()
if not secret:
return JsonResponse(
@ -138,15 +504,10 @@ def forgejo_content_webhook(request: HttpRequest) -> JsonResponse:
)
def list_spaces_context(member) -> dict:
return {"spaces": list(spaces_for_member(member))}
def _webhook_authorized(request: HttpRequest, secret: str) -> bool:
header_secret = request.headers.get("X-Coulomb-Webhook-Secret", "")
if header_secret and hmac.compare_digest(header_secret, secret):
return True
# Gitea/Forgejo HMAC-SHA256 hex of body
sig = request.headers.get("X-Gitea-Signature") or request.headers.get(
"X-Hub-Signature-256", ""
)
@ -167,7 +528,7 @@ def _repo_from_push_payload(payload: dict) -> tuple[str, str]:
if "/" in full:
owner, name = full.split("/", 1)
return owner, name
owner = (repo.get("owner") or {})
owner = repo.get("owner") or {}
if isinstance(owner, dict):
owner_name = owner.get("login") or owner.get("username") or ""
else: