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:
parent
00dbb86c93
commit
affed64839
24 changed files with 1812 additions and 151 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -17,6 +17,9 @@ scripts/package-lock.json
|
|||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Thin content plane (ADR-0004) — local SoR tree
|
||||
var/
|
||||
|
||||
# Python / Django
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
| workplan | CSOC-WP-0003 | finished | — | workplans/CSOC-WP-0003-self-registration-and-assurance.md |
|
||||
| workplan | CSOC-WP-0004 | finished | — | workplans/CSOC-WP-0004-app-shell-and-space-content.md |
|
||||
| workplan | CSOC-WP-0005 | finished | — | workplans/CSOC-WP-0005-resource-demand-and-cost-evidence.md |
|
||||
| workplan | CSOC-WP-0006 | ready | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| workplan | CSOC-WP-0006 | finished | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0001-T01 | done | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md |
|
||||
| task | CSOC-WP-0001-T02 | progress | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md |
|
||||
| task | CSOC-WP-0001-T03 | done | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md |
|
||||
|
|
@ -40,11 +40,11 @@
|
|||
| task | CSOC-WP-0005-T01 | done | — | workplans/CSOC-WP-0005-resource-demand-and-cost-evidence.md |
|
||||
| task | CSOC-WP-0005-T02 | done | — | workplans/CSOC-WP-0005-resource-demand-and-cost-evidence.md |
|
||||
| task | CSOC-WP-0005-T03 | done | — | workplans/CSOC-WP-0005-resource-demand-and-cost-evidence.md |
|
||||
| task | CSOC-WP-0006-T01 | todo | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T02 | todo | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T03 | todo | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T04 | todo | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T05 | todo | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T06 | todo | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T01 | done | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T02 | done | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T03 | done | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T04 | done | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T05 | done | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| task | CSOC-WP-0006-T06 | done | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
|
||||
| intake | CSOC-IN-0001 | open | blue | docs/intakes/csoc-residuals.md |
|
||||
| intake | CSOC-IN-0002 | open | green | docs/intakes/csoc-residuals.md |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
27
coulomb_social/apps/spaces/migrations/0002_space_visual.py
Normal file
27
coulomb_social/apps/spaces/migrations/0002_space_visual.py
Normal 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).",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
331
coulomb_social/apps/spaces/pageops.py
Normal file
331
coulomb_social/apps/spaces/pageops.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -120,11 +120,15 @@ FLEX_AUTH_PROTECTED_SYSTEM_ID = config(
|
|||
)
|
||||
FLEX_AUTH_TIMEOUT_SECONDS = config("FLEX_AUTH_TIMEOUT_SECONDS", default=3.0, cast=float)
|
||||
|
||||
# ── Space content (Forgejo / ADR-0002) ──────────────────────────────────────
|
||||
# ── Content plane (ADR-0003 / ADR-0004) ─────────────────────────────────────
|
||||
# Thin dir+git SoR: spaces/<slug>/pages/*.md under CONTENT_ROOT
|
||||
CONTENT_ROOT = config("CONTENT_ROOT", default=str(BASE_DIR / "var" / "content"))
|
||||
|
||||
# Optional Forgejo remote/export (ADR-0002 demoted; not required for SoR)
|
||||
FORGEJO_BASE_URL = config("FORGEJO_BASE_URL", default="https://forgejo.coulomb.social")
|
||||
FORGEJO_TOKEN = config("FORGEJO_TOKEN", default="") # optional; public raw needs none
|
||||
FORGEJO_TIMEOUT_SECONDS = config("FORGEJO_TIMEOUT_SECONDS", default=10.0, cast=float)
|
||||
# Shared secret for POST /app/spaces/hooks/forgejo/ (push → cache invalidate)
|
||||
FORGEJO_WEBHOOK_SECRET = config("FORGEJO_WEBHOOK_SECRET", default="")
|
||||
# Optional local root for offline tests: <root>/<space-slug>/<content_root>/index.md
|
||||
# Optional local fixture root for offline tests: <root>/<space-slug>/<content_root>/index.md
|
||||
SPACE_CONTENT_FIXTURE_ROOT = config("SPACE_CONTENT_FIXTURE_ROOT", default="")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from pathlib import Path
|
||||
|
||||
from .base import * # noqa: F403
|
||||
|
||||
DEBUG = False
|
||||
|
|
@ -15,6 +17,9 @@ OIDC_ENABLED = False
|
|||
USER_ENGINE_BASE_URL = ""
|
||||
FLEX_AUTH_BASE_URL = ""
|
||||
|
||||
# Isolate content plane from developer var/ trees during tests
|
||||
CONTENT_ROOT = str(Path(BASE_DIR) / "var" / "test-content") # noqa: F405
|
||||
|
||||
# Whitenoise manifest not required in tests
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Spaces — {{ site_name }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Spaces</h1>
|
||||
<p class="muted">
|
||||
Co-creation spaces for your tenant.
|
||||
Page content will live as markdown in Forgejo-backed repositories.
|
||||
</p>
|
||||
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;gap:1rem;align-items:flex-start;">
|
||||
<div>
|
||||
<h1 style="margin-bottom:0.35rem;">Spaces</h1>
|
||||
<p class="muted" style="margin:0;">
|
||||
Co-creation spaces for your tenant. Pages live as markdown on the content plane
|
||||
(local dir+git; Forgejo optional).
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn" href="{% url 'spaces:create' %}">New space</a>
|
||||
</div>
|
||||
|
||||
{% if spaces %}
|
||||
<ul class="space-list" style="list-style:none;padding:0;margin:1.5rem 0 0;">
|
||||
|
|
@ -18,15 +23,20 @@
|
|||
{% if space.description %}
|
||||
<p class="muted" style="margin:0.35rem 0 0;font-size:0.95rem;">{{ space.description }}</p>
|
||||
{% endif %}
|
||||
{% if space.visual %}
|
||||
<p class="muted" style="margin:0.25rem 0 0;font-size:0.85rem;">Visual: {{ space.visual }}</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0;">No spaces yet</h2>
|
||||
<p class="muted" style="margin-bottom:0;">
|
||||
Operators can seed a space via Django admin (tenant-matched).
|
||||
Create-space UI and Forgejo binding land next (T04–T06).
|
||||
<p class="muted">
|
||||
Create a space to start writing pages (Title, Abstract, Visual + markdown body).
|
||||
</p>
|
||||
<p style="margin-bottom:0;">
|
||||
<a class="btn" href="{% url 'spaces:create' %}">Create your first space</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -36,56 +36,136 @@
|
|||
align-items: center;
|
||||
}
|
||||
.content-actions form { display: inline; margin: 0; }
|
||||
.space-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.space-layout {
|
||||
grid-template-columns: 12rem 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
.page-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
.page-nav li { margin: 0.25rem 0; }
|
||||
.page-nav a {
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.page-nav a:hover,
|
||||
.page-nav a.active { color: var(--color-text); font-weight: 600; }
|
||||
.visual-thumb {
|
||||
max-width: 100%;
|
||||
max-height: 8rem;
|
||||
border-radius: 6px;
|
||||
margin: 0.5rem 0 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
.space-header-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
{% 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 %}
|
||||
|
||||
{% if space.has_content_binding %}
|
||||
<div class="content-actions">
|
||||
{% if forgejo_edit_url %}
|
||||
<a class="btn small" href="{{ forgejo_edit_url }}" target="_blank" rel="noopener">Edit in Forgejo</a>
|
||||
<div class="space-header-row">
|
||||
<div>
|
||||
<h1 style="margin-bottom:0.25rem;">{{ space.title }}</h1>
|
||||
{% if space.description %}
|
||||
<p class="muted" style="margin:0;">{{ space.description }}</p>
|
||||
{% endif %}
|
||||
{% if forgejo_blob_url %}
|
||||
<a class="btn secondary small" href="{{ forgejo_blob_url }}" target="_blank" rel="noopener">View source</a>
|
||||
{% if space.visual %}
|
||||
<p class="content-meta muted" style="margin:0.35rem 0 0;">Visual: {{ space.visual }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'spaces:refresh' space.slug %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="page" value="{{ page.slug|default:'index' }}">
|
||||
<button type="submit" class="btn secondary small">Refresh content</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="muted content-meta" style="margin-top:0.5rem;">
|
||||
Git is the source of truth. Edit in Forgejo, then refresh (or wait for webhook).
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="content-actions" style="margin:0;">
|
||||
<a class="btn secondary small" href="{% url 'spaces:edit' space.slug %}">Edit space</a>
|
||||
<a class="btn secondary small" href="{% url 'spaces:page_new' space.slug %}">New page</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if page.error %}
|
||||
<div class="card content-error">
|
||||
<h2 style="margin-top:0;">Content unavailable</h2>
|
||||
<p style="margin-bottom:0;">{{ page.error }}</p>
|
||||
{% if space.has_content_binding %}
|
||||
<div class="content-actions">
|
||||
<a class="btn small" href="{% url 'spaces:page_edit' space.slug page.slug|default:'index' %}">Edit page</a>
|
||||
{% if page.slug and page.slug != 'index' %}
|
||||
<a class="btn secondary small" href="{% url 'spaces:page_copy' space.slug page.slug %}">Copy</a>
|
||||
<a class="btn secondary small" href="{% url 'spaces:page_transfer' space.slug page.slug %}">Transfer</a>
|
||||
<form method="post" action="{% url 'spaces:page_delete' space.slug page.slug %}"
|
||||
onsubmit="return confirm('Delete this page?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn secondary small">Delete</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="btn secondary small" href="{% url 'spaces:page_copy' space.slug 'index' %}">Copy index</a>
|
||||
{% endif %}
|
||||
{% if forgejo_edit_url %}
|
||||
<a class="btn secondary small" href="{{ forgejo_edit_url }}" target="_blank" rel="noopener">Edit in Forgejo</a>
|
||||
{% endif %}
|
||||
{% if forgejo_blob_url %}
|
||||
<a class="btn secondary small" href="{{ forgejo_blob_url }}" target="_blank" rel="noopener">View source</a>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'spaces:refresh' space.slug %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="page" value="{{ page.slug|default:'index' }}">
|
||||
<button type="submit" class="btn secondary small">Refresh content</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="space-layout" style="margin-top:1.25rem;">
|
||||
<aside class="card" style="margin-top:0;">
|
||||
<strong style="font-size:0.9rem;">Pages</strong>
|
||||
<ul class="page-nav">
|
||||
{% for p in pages %}
|
||||
<li>
|
||||
<a href="{% url 'spaces:detail' space.slug %}{% if p.slug != 'index' %}?page={{ p.slug }}{% endif %}"
|
||||
class="{% if p.slug == page.slug %}active{% endif %}">
|
||||
{{ p.title|default:p.slug }}
|
||||
</a>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="muted" style="font-size:0.9rem;">No local pages yet</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div>
|
||||
{% if page.error %}
|
||||
<div class="card content-error" style="margin-top:0;">
|
||||
<h2 style="margin-top:0;">Content unavailable</h2>
|
||||
<p style="margin-bottom:0;">{{ page.error }}</p>
|
||||
{% if space.has_content_binding %}
|
||||
<p class="content-meta muted">
|
||||
Binding: {{ space.forgejo_owner }}/{{ space.forgejo_repo }}
|
||||
· {{ page.path }} @ {{ space.default_branch }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="content-meta muted">
|
||||
Create or edit a page to write markdown on the content plane.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card md-body" style="margin-top:0;">
|
||||
{{ page.html|safe }}
|
||||
</div>
|
||||
<p class="content-meta muted">
|
||||
Binding: {{ space.forgejo_owner }}/{{ space.forgejo_repo }}
|
||||
· {{ page.path }} @ {{ space.default_branch }}
|
||||
Source: {{ page.source }} · {{ page.path }}
|
||||
{% if space.has_content_binding %}
|
||||
· {{ space.forgejo_owner }}/{{ space.forgejo_repo }}@{{ space.default_branch }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card md-body">
|
||||
{{ page.html|safe }}
|
||||
</div>
|
||||
<p class="content-meta muted">
|
||||
Source: {{ page.source }} · {{ page.path }}
|
||||
{% if space.has_content_binding %}
|
||||
· {{ space.forgejo_owner }}/{{ space.forgejo_repo }}@{{ space.default_branch }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
74
coulomb_social/templates/spaces/form.html
Normal file
74
coulomb_social/templates/spaces/form.html
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ form_title }} — {{ site_name }}{% endblock %}
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
min-height: 5rem;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
.danger-zone {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<p class="muted" style="margin:0 0 0.5rem;">
|
||||
<a href="{% if space %}{% url 'spaces:detail' space.slug %}{% else %}{% url 'core:app_home' %}{% endif %}">← Back</a>
|
||||
</p>
|
||||
<h1>{{ form_title }}</h1>
|
||||
<p class="muted">Title and Abstract describe the space; Visual is a cover path under the space tree (e.g. <code>assets/cover.jpg</code>).</p>
|
||||
|
||||
<form method="post" class="card" style="margin-top:1rem;">
|
||||
{% csrf_token %}
|
||||
<label for="id_title">Title</label>
|
||||
<input type="text" id="id_title" name="title" required maxlength="255"
|
||||
value="{% if space %}{{ space.title }}{% endif %}" autocomplete="off">
|
||||
|
||||
<label for="id_slug">Slug {% if space %}<span class="muted">(fixed)</span>{% else %}<span class="muted">(optional — from title)</span>{% endif %}</label>
|
||||
<input type="text" id="id_slug" name="slug" maxlength="80"
|
||||
value="{% if space %}{{ space.slug }}{% endif %}"
|
||||
{% if space %}readonly{% endif %}
|
||||
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
|
||||
placeholder="my-space" autocomplete="off">
|
||||
|
||||
<label for="id_abstractor">Abstract</label>
|
||||
<textarea id="id_abstractor" name="abstractor" rows="3"
|
||||
placeholder="Short description of this space">{% if space %}{{ space.description }}{% endif %}</textarea>
|
||||
|
||||
<label for="id_visual">Visual</label>
|
||||
<input type="text" id="id_visual" name="visual" maxlength="512"
|
||||
value="{% if space %}{{ space.visual }}{% endif %}"
|
||||
placeholder="assets/cover.jpg" autocomplete="off">
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">{{ submit_label }}</button>
|
||||
<a class="btn secondary" href="{% if space %}{% url 'spaces:detail' space.slug %}{% else %}{% url 'core:app_home' %}{% endif %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if space %}
|
||||
<div class="danger-zone">
|
||||
<h2 style="margin-top:0;font-size:1.1rem;">Archive space</h2>
|
||||
<p class="muted">Hides the space from the list. Content tree is soft-archived on disk.</p>
|
||||
<form method="post" action="{% url 'spaces:archive' space.slug %}"
|
||||
onsubmit="return confirm('Archive this space?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn secondary small">Archive space</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
55
coulomb_social/templates/spaces/page_copy.html
Normal file
55
coulomb_social/templates/spaces/page_copy.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Copy page — {{ space.title }} — {{ site_name }}{% endblock %}
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<p class="muted" style="margin:0 0 0.5rem;">
|
||||
<a href="{% url 'spaces:detail' space.slug %}?page={{ page_slug }}">← {{ space.title }}</a>
|
||||
</p>
|
||||
<h1>Copy page</h1>
|
||||
<p class="muted">
|
||||
Create an independent copy of <strong>{{ page_slug }}</strong>.
|
||||
Relative visual assets are duplicated when present.
|
||||
</p>
|
||||
|
||||
<form method="post" class="card" style="margin-top:1rem;">
|
||||
{% csrf_token %}
|
||||
<label for="id_dest_space">Destination space</label>
|
||||
<select id="id_dest_space" name="dest_space">
|
||||
{% for s in all_spaces %}
|
||||
<option value="{{ s.slug }}" {% if s.slug == space.slug %}selected{% endif %}>{{ s.title }} ({{ s.slug }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="id_dest_slug">New page slug</label>
|
||||
<input type="text" id="id_dest_slug" name="dest_slug" required maxlength="80"
|
||||
value="{{ default_dest }}"
|
||||
pattern="[a-z0-9]+(?:-[a-z0-9]+)*" autocomplete="off">
|
||||
|
||||
<label for="id_title">Title <span class="muted">(optional — defaults to “Original (copy)”)</span></label>
|
||||
<input type="text" id="id_title" name="title" maxlength="255" value="" autocomplete="off">
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">Create copy</button>
|
||||
<a class="btn secondary" href="{% url 'spaces:detail' space.slug %}?page={{ page_slug }}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
70
coulomb_social/templates/spaces/page_form.html
Normal file
70
coulomb_social/templates/spaces/page_form.html
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ form_title }} — {{ space.title }} — {{ site_name }}{% endblock %}
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
textarea.body-editor {
|
||||
min-height: 16rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<p class="muted" style="margin:0 0 0.5rem;">
|
||||
<a href="{% url 'spaces:detail' space.slug %}{% if doc.slug and doc.slug != 'index' and not is_new %}?page={{ doc.slug }}{% endif %}">← {{ space.title }}</a>
|
||||
</p>
|
||||
<h1>{{ form_title }}</h1>
|
||||
<p class="muted">Markdown body is the source of truth. Title, Abstract, and Visual are stored in frontmatter.</p>
|
||||
|
||||
<form method="post" class="card" style="margin-top:1rem;">
|
||||
{% csrf_token %}
|
||||
<label for="id_title">Title</label>
|
||||
<input type="text" id="id_title" name="title" maxlength="255"
|
||||
value="{{ doc.title }}" autocomplete="off"
|
||||
placeholder="Page title">
|
||||
|
||||
<label for="id_slug">Slug {% if doc.slug == 'index' %}<span class="muted">(space index — fixed)</span>{% endif %}</label>
|
||||
{% if doc.slug == 'index' %}
|
||||
<input type="text" id="id_slug" name="slug" value="index" readonly maxlength="80">
|
||||
{% else %}
|
||||
<input type="text" id="id_slug" name="slug" maxlength="80"
|
||||
value="{{ doc.slug }}"
|
||||
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
|
||||
placeholder="page-slug" autocomplete="off"
|
||||
{% if is_new %}required{% endif %}>
|
||||
{% endif %}
|
||||
|
||||
<label for="id_abstractor">Abstract</label>
|
||||
<textarea id="id_abstractor" name="abstractor" rows="2"
|
||||
placeholder="Short abstract">{{ doc.abstractor }}</textarea>
|
||||
|
||||
<label for="id_visual">Visual</label>
|
||||
<input type="text" id="id_visual" name="visual" maxlength="512"
|
||||
value="{{ doc.visual }}"
|
||||
placeholder="assets/page-cover.jpg" autocomplete="off">
|
||||
|
||||
<label for="id_body">Body (markdown)</label>
|
||||
<textarea id="id_body" name="body" class="body-editor" rows="18">{{ doc.body }}</textarea>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">{% if is_new %}Create page{% else %}Save page{% endif %}</button>
|
||||
<a class="btn secondary" href="{% url 'spaces:detail' space.slug %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
59
coulomb_social/templates/spaces/page_transfer.html
Normal file
59
coulomb_social/templates/spaces/page_transfer.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Transfer page — {{ space.title }} — {{ site_name }}{% endblock %}
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<p class="muted" style="margin:0 0 0.5rem;">
|
||||
<a href="{% url 'spaces:detail' space.slug %}?page={{ page_slug }}">← {{ space.title }}</a>
|
||||
</p>
|
||||
<h1>Transfer page</h1>
|
||||
<p class="muted">
|
||||
Move <strong>{{ page_slug }}</strong> out of this space into another space you can access.
|
||||
The source page is removed after a successful move. The space index cannot be transferred.
|
||||
</p>
|
||||
|
||||
{% if not all_spaces %}
|
||||
<div class="card">
|
||||
<p style="margin:0;">No other spaces available. Create another space first, then transfer.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" class="card" style="margin-top:1rem;">
|
||||
{% csrf_token %}
|
||||
<label for="id_dest_space">Destination space</label>
|
||||
<select id="id_dest_space" name="dest_space" required>
|
||||
<option value="">— choose —</option>
|
||||
{% for s in all_spaces %}
|
||||
<option value="{{ s.slug }}">{{ s.title }} ({{ s.slug }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="id_dest_slug">Slug in destination <span class="muted">(optional)</span></label>
|
||||
<input type="text" id="id_dest_slug" name="dest_slug" maxlength="80"
|
||||
value="{{ page_slug }}"
|
||||
pattern="[a-z0-9]+(?:-[a-z0-9]+)*" autocomplete="off">
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">Transfer page</button>
|
||||
<a class="btn secondary" href="{% url 'spaces:detail' space.slug %}?page={{ page_slug }}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
schema_version: "0.1"
|
||||
product: coulomb-social
|
||||
resource_id: "resource:tenant:coulomb:coulomb-social"
|
||||
updated: "2026-08-12"
|
||||
updated: "2026-08-13"
|
||||
stance: |
|
||||
Partial rebuild is intentional. Stage 1 enables user transfer and retires
|
||||
Bubble as productive system; Bubble may remain reference-only. Later stages
|
||||
|
|
@ -74,36 +74,38 @@ capabilities:
|
|||
status: shipped
|
||||
- id: feat.space-crud
|
||||
title: Space create / read / update / delete-or-archive (member UI)
|
||||
status: partial
|
||||
notes: "Model + admin/seed; member-facing CRUD todo"
|
||||
status: shipped
|
||||
notes: "Member UI create/edit/archive; DB index + CONTENT_ROOT tree"
|
||||
- id: feat.space-title
|
||||
title: Space Title field
|
||||
status: partial
|
||||
notes: "DB title exists; member edit UI todo"
|
||||
status: shipped
|
||||
- id: feat.space-abstract
|
||||
title: Space Abstract field
|
||||
status: partial
|
||||
notes: "Maps to Space.description until product rename"
|
||||
status: shipped
|
||||
notes: "Maps to Space.description (product label Abstract / Abstractor)"
|
||||
- id: feat.space-visual
|
||||
title: Space Visual (cover/image)
|
||||
status: todo
|
||||
status: shipped
|
||||
notes: "Path relative to space tree (e.g. assets/cover.jpg); not app media yet"
|
||||
- id: feat.page-list
|
||||
title: List pages in a space
|
||||
status: partial
|
||||
status: shipped
|
||||
notes: "Sidebar from CONTENT_ROOT pages/*.md"
|
||||
- id: feat.page-crud
|
||||
title: Page create / update / delete (product path)
|
||||
status: partial
|
||||
notes: "Git/Forgejo only today; product path todo"
|
||||
status: shipped
|
||||
notes: "PageOps on CONTENT_ROOT; Forgejo optional fallback"
|
||||
- id: feat.page-title
|
||||
title: Page Title (structured)
|
||||
status: partial
|
||||
notes: "Inferred from H1/filename; frontmatter/UI todo"
|
||||
status: shipped
|
||||
notes: "Frontmatter title + form; H1 fallback on render"
|
||||
- id: feat.page-abstract
|
||||
title: Page Abstractor
|
||||
status: todo
|
||||
status: shipped
|
||||
- id: feat.page-visual
|
||||
title: Page Visual
|
||||
status: todo
|
||||
status: shipped
|
||||
notes: "Frontmatter path; relative assets copied on page copy"
|
||||
- id: feat.authoring-top-down
|
||||
title: Top-down authoring (title → abstractor → outline → body)
|
||||
status: todo
|
||||
|
|
@ -133,8 +135,8 @@ capabilities:
|
|||
notes: "markitect explode/implode; ArchitectureBlueprint D4"
|
||||
- id: feat.content-plane-thin-git
|
||||
title: Content SoR as directory + markdown files in git
|
||||
status: todo
|
||||
notes: "Upgrades to shard-wiki then kontextual supported later"
|
||||
status: partial
|
||||
notes: "CONTENT_ROOT PageOps landed (CSOC-WP-0006); git commit/push operator path next"
|
||||
- id: feat.content-fail-closed
|
||||
title: Fail closed on missing binding/fetch
|
||||
status: shipped
|
||||
|
|
@ -148,10 +150,12 @@ capabilities:
|
|||
features:
|
||||
- id: feat.page-move
|
||||
title: Move page between spaces
|
||||
status: todo
|
||||
status: shipped
|
||||
notes: "PageOps transfer; index page cannot move"
|
||||
- id: feat.page-move-authz
|
||||
title: Authz for source and destination spaces
|
||||
status: todo
|
||||
status: shipped
|
||||
notes: "spaces_for_member on both ends"
|
||||
|
||||
- id: cap.page-copy
|
||||
title: Generate copy of page
|
||||
|
|
@ -162,10 +166,11 @@ capabilities:
|
|||
features:
|
||||
- id: feat.page-duplicate
|
||||
title: Duplicate page (new slug/title)
|
||||
status: todo
|
||||
status: shipped
|
||||
- id: feat.page-duplicate-assets
|
||||
title: Copy or re-link page assets
|
||||
status: todo
|
||||
status: partial
|
||||
notes: "Relative visual files under assets/ copied; full asset scan deferred"
|
||||
|
||||
# --- later stage (not transfer blockers) ---
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,24 @@ kubectl -n coulomb-social exec deploy/coulomb-social -- \
|
|||
| Session details | profile menu diagnostics (no secrets) |
|
||||
| Refresh content | re-fetch after git edit (or webhook) |
|
||||
|
||||
### Stage-1 PageOps smoke (CSOC-WP-0006) — local / after deploy
|
||||
|
||||
Content plane: `CONTENT_ROOT` (default `var/content/`) with
|
||||
`spaces/<slug>/pages/*.md`. Forgejo binding remains optional fallback.
|
||||
|
||||
| Step | Expected |
|
||||
|------|----------|
|
||||
| `/app/` → **New space** | create with Title, Abstract, Visual → detail |
|
||||
| Space **Edit** | update Title/Abstract/Visual; index frontmatter stays in sync |
|
||||
| **New page** | Title/Abstract/Visual + markdown body on content plane |
|
||||
| Page list sidebar | lists index + pages; switch with `?page=` |
|
||||
| **Copy** | independent slug/title; optional other destination space |
|
||||
| **Transfer** | page leaves source space, appears in destination |
|
||||
| **Delete** (non-index) | page removed; index cannot be deleted |
|
||||
| **Archive space** | hidden from list; soft-archive tree under `.archived-*` |
|
||||
| Tenant isolation | other tenant 404 on detail / page mutations |
|
||||
| `make test` | includes `tests/test_pageops.py`, `tests/test_space_crud.py` |
|
||||
|
||||
## Case matrix (CSOC-WP-0003-T04)
|
||||
|
||||
| Case | Status |
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ def test_render_markdown_strips_script():
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_load_space_page_unbound():
|
||||
def test_load_space_page_unbound(tmp_path, settings):
|
||||
settings.CONTENT_ROOT = str(tmp_path / "empty-content")
|
||||
space = Space(tenant_id="t", slug="x", title="X")
|
||||
page = load_space_page(space)
|
||||
assert page.error
|
||||
assert "not bound" in page.error.lower()
|
||||
err = page.error.lower()
|
||||
assert "content plane" in err or "not bound" in err or "forgejo" in err
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
|
|||
87
tests/test_pageops.py
Normal file
87
tests/test_pageops.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""PageOps content-plane unit tests (ADR-0003 / ADR-0004)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from coulomb_social.apps.spaces import pageops
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def content_root(tmp_path, settings):
|
||||
root = tmp_path / "content"
|
||||
settings.CONTENT_ROOT = str(root)
|
||||
return root
|
||||
|
||||
|
||||
def test_write_read_list_page(content_root):
|
||||
pageops.init_space_content("lab", title="Lab", abstractor="Research", visual="assets/c.jpg")
|
||||
doc = pageops.write_page(
|
||||
"lab",
|
||||
"notes",
|
||||
title="Notes",
|
||||
abstractor="Scratch",
|
||||
visual="assets/n.jpg",
|
||||
body="# Notes\n\nHello.\n",
|
||||
)
|
||||
assert doc.exists
|
||||
assert doc.title == "Notes"
|
||||
assert doc.abstractor == "Scratch"
|
||||
assert "Hello" in doc.body
|
||||
|
||||
pages = pageops.list_pages("lab")
|
||||
slugs = [p.slug for p in pages]
|
||||
assert slugs[0] == "index"
|
||||
assert "notes" in slugs
|
||||
|
||||
path = Path(doc.path)
|
||||
assert path.is_file()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert text.startswith("---\n")
|
||||
assert 'title: "Notes"' in text or "title: Notes" in text or 'title: "Notes"' in text
|
||||
|
||||
|
||||
def test_copy_page_same_space(content_root):
|
||||
pageops.init_space_content("lab", title="Lab")
|
||||
pageops.write_page("lab", "alpha", title="Alpha", body="Body A\n")
|
||||
copy = pageops.copy_page("lab", "alpha", "lab", "alpha-copy")
|
||||
assert copy.slug == "alpha-copy"
|
||||
assert copy.title == "Alpha (copy)"
|
||||
assert "Body A" in copy.body
|
||||
assert pageops.read_page("lab", "alpha").exists
|
||||
|
||||
|
||||
def test_transfer_page(content_root):
|
||||
pageops.init_space_content("src", title="Src")
|
||||
pageops.init_space_content("dst", title="Dst")
|
||||
pageops.write_page("src", "moved", title="Moved", abstractor="A", body="X\n")
|
||||
doc = pageops.transfer_page("src", "moved", "dst", "moved")
|
||||
assert doc.exists
|
||||
assert doc.title == "Moved"
|
||||
assert not pageops.read_page("src", "moved").exists
|
||||
assert pageops.read_page("dst", "moved").exists
|
||||
|
||||
|
||||
def test_cannot_delete_or_transfer_index(content_root):
|
||||
pageops.init_space_content("lab", title="Lab")
|
||||
with pytest.raises(pageops.PageOpsError):
|
||||
pageops.delete_page("lab", "index")
|
||||
with pytest.raises(pageops.PageOpsError):
|
||||
pageops.transfer_page("lab", "index", "other")
|
||||
|
||||
|
||||
def test_copy_relative_visual_asset(content_root):
|
||||
pageops.init_space_content("lab", title="Lab")
|
||||
assets = pageops.assets_dir("lab")
|
||||
assets.mkdir(parents=True, exist_ok=True)
|
||||
(assets / "cover.png").write_bytes(b"\x89PNG\r\n")
|
||||
pageops.write_page(
|
||||
"lab",
|
||||
"with-pic",
|
||||
title="Pic",
|
||||
visual="assets/cover.png",
|
||||
body="pic\n",
|
||||
)
|
||||
copy = pageops.copy_page("lab", "with-pic", "lab", "with-pic-copy")
|
||||
assert copy.visual.startswith("assets/")
|
||||
assert (pageops.space_dir("lab") / copy.visual).is_file()
|
||||
229
tests/test_space_crud.py
Normal file
229
tests/test_space_crud.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Member-facing space/page CRUD, copy, transfer (CSOC-WP-0006)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from coulomb_social.apps.members.models import Member
|
||||
from coulomb_social.apps.spaces import pageops
|
||||
from coulomb_social.apps.spaces.content import load_space_page
|
||||
from coulomb_social.apps.spaces.models import Space
|
||||
|
||||
|
||||
def _login(client, settings, *, subject: str = "u1", tenant: str = "tenant:a", name: str = "User"):
|
||||
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.fixture
|
||||
def content_root(tmp_path, settings):
|
||||
root = tmp_path / "content"
|
||||
settings.CONTENT_ROOT = str(root)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_space_writes_index_and_lists(client, settings, content_root):
|
||||
_login(client, settings)
|
||||
r = client.post(
|
||||
reverse("spaces:create"),
|
||||
{
|
||||
"title": "Garden",
|
||||
"abstractor": "Grow ideas",
|
||||
"slug": "garden",
|
||||
"visual": "assets/cover.jpg",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 302
|
||||
assert r.url.endswith("/app/spaces/garden/")
|
||||
|
||||
space = Space.objects.get(slug="garden")
|
||||
assert space.title == "Garden"
|
||||
assert space.description == "Grow ideas"
|
||||
assert space.visual == "assets/cover.jpg"
|
||||
assert space.abstractor == "Grow ideas"
|
||||
|
||||
index = pageops.read_page("garden", "index")
|
||||
assert index.exists
|
||||
assert index.title == "Garden"
|
||||
|
||||
home = client.get(reverse("core:app_home"))
|
||||
assert home.status_code == 200
|
||||
assert b"Garden" in home.content
|
||||
assert b"Grow ideas" in home.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_edit_space_updates_index_frontmatter(client, settings, content_root):
|
||||
_login(client, settings)
|
||||
client.post(
|
||||
reverse("spaces:create"),
|
||||
{"title": "Lab", "slug": "lab", "abstractor": "Old", "visual": ""},
|
||||
)
|
||||
r = client.post(
|
||||
reverse("spaces:edit", kwargs={"slug": "lab"}),
|
||||
{"title": "Lab 2", "abstractor": "New abstract", "visual": "assets/v.jpg"},
|
||||
)
|
||||
assert r.status_code == 302
|
||||
space = Space.objects.get(slug="lab")
|
||||
assert space.title == "Lab 2"
|
||||
assert space.description == "New abstract"
|
||||
assert space.visual == "assets/v.jpg"
|
||||
index = pageops.read_page("lab", "index")
|
||||
assert index.title == "Lab 2"
|
||||
assert index.abstractor == "New abstract"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_archive_space_hides_from_list(client, settings, content_root):
|
||||
_login(client, settings)
|
||||
client.post(
|
||||
reverse("spaces:create"),
|
||||
{"title": "Temp", "slug": "temp", "abstractor": "", "visual": ""},
|
||||
)
|
||||
r = client.post(reverse("spaces:archive", kwargs={"slug": "temp"}))
|
||||
assert r.status_code == 302
|
||||
assert not Space.objects.get(slug="temp").is_active
|
||||
home = client.get(reverse("core:app_home"))
|
||||
assert home.status_code == 200
|
||||
# Flash message may mention the title; the space must not remain linked.
|
||||
assert b"/app/spaces/temp/" not in home.content
|
||||
assert b"No spaces yet" in home.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_crud_copy_transfer(client, settings, content_root):
|
||||
_login(client, settings)
|
||||
client.post(
|
||||
reverse("spaces:create"),
|
||||
{"title": "Alpha", "slug": "alpha", "abstractor": "", "visual": ""},
|
||||
)
|
||||
client.post(
|
||||
reverse("spaces:create"),
|
||||
{"title": "Beta", "slug": "beta", "abstractor": "", "visual": ""},
|
||||
)
|
||||
|
||||
# create page
|
||||
r = client.post(
|
||||
reverse("spaces:page_new", kwargs={"slug": "alpha"}),
|
||||
{
|
||||
"title": "Notes",
|
||||
"slug": "notes",
|
||||
"abstractor": "Scratch pad",
|
||||
"visual": "",
|
||||
"body": "# Notes\n\nHello body.\n",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 302
|
||||
assert "page=notes" in r.url
|
||||
doc = pageops.read_page("alpha", "notes")
|
||||
assert doc.exists
|
||||
assert doc.abstractor == "Scratch pad"
|
||||
|
||||
# render from content plane
|
||||
space = Space.objects.get(slug="alpha")
|
||||
rendered = load_space_page(space, "notes")
|
||||
assert rendered.error is None
|
||||
assert rendered.source == "content-plane"
|
||||
assert "Hello body" in rendered.html
|
||||
|
||||
detail = client.get(reverse("spaces:detail", kwargs={"slug": "alpha"}) + "?page=notes")
|
||||
assert detail.status_code == 200
|
||||
assert b"Hello body" in detail.content
|
||||
assert b"Notes" in detail.content
|
||||
|
||||
# edit
|
||||
r = client.post(
|
||||
reverse("spaces:page_edit", kwargs={"slug": "alpha", "page_slug": "notes"}),
|
||||
{
|
||||
"title": "Notes v2",
|
||||
"slug": "notes",
|
||||
"abstractor": "Updated",
|
||||
"visual": "assets/n.jpg",
|
||||
"body": "# Notes v2\n\nUpdated body.\n",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 302
|
||||
doc = pageops.read_page("alpha", "notes")
|
||||
assert doc.title == "Notes v2"
|
||||
assert "Updated body" in doc.body
|
||||
|
||||
# copy within space
|
||||
r = client.post(
|
||||
reverse("spaces:page_copy", kwargs={"slug": "alpha", "page_slug": "notes"}),
|
||||
{
|
||||
"dest_space": "alpha",
|
||||
"dest_slug": "notes-copy",
|
||||
"title": "",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 302
|
||||
copy = pageops.read_page("alpha", "notes-copy")
|
||||
assert copy.exists
|
||||
assert copy.title == "Notes v2 (copy)"
|
||||
|
||||
# transfer to beta
|
||||
r = client.post(
|
||||
reverse("spaces:page_transfer", kwargs={"slug": "alpha", "page_slug": "notes-copy"}),
|
||||
{
|
||||
"dest_space": "beta",
|
||||
"dest_slug": "notes-copy",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 302
|
||||
assert not pageops.read_page("alpha", "notes-copy").exists
|
||||
moved = pageops.read_page("beta", "notes-copy")
|
||||
assert moved.exists
|
||||
assert moved.title == "Notes v2 (copy)"
|
||||
|
||||
# delete remaining notes
|
||||
r = client.post(
|
||||
reverse("spaces:page_delete", kwargs={"slug": "alpha", "page_slug": "notes"})
|
||||
)
|
||||
assert r.status_code == 302
|
||||
assert not pageops.read_page("alpha", "notes").exists
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_ops_require_membership_isolation(client, settings, content_root):
|
||||
_login(client, settings, subject="u1", tenant="tenant:a")
|
||||
client.post(
|
||||
reverse("spaces:create"),
|
||||
{"title": "Mine", "slug": "mine", "abstractor": "", "visual": ""},
|
||||
)
|
||||
# other tenant cannot see
|
||||
client.logout()
|
||||
_login(client, settings, subject="u2", tenant="tenant:b", name="Other")
|
||||
r = client.get(reverse("spaces:detail", kwargs={"slug": "mine"}))
|
||||
assert r.status_code == 404
|
||||
r = client.post(
|
||||
reverse("spaces:page_new", kwargs={"slug": "mine"}),
|
||||
{"title": "X", "slug": "x", "body": "nope"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_delete_index_via_view(client, settings, content_root):
|
||||
_login(client, settings)
|
||||
client.post(
|
||||
reverse("spaces:create"),
|
||||
{"title": "Keep", "slug": "keep", "abstractor": "", "visual": ""},
|
||||
)
|
||||
# URL for delete index would work but pageops rejects
|
||||
r = client.post(
|
||||
reverse("spaces:page_delete", kwargs={"slug": "keep", "page_slug": "index"})
|
||||
)
|
||||
assert r.status_code == 302
|
||||
assert pageops.read_page("keep", "index").exists
|
||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
|||
title: "Stage-1 space and page capabilities (CRUD, copy, transfer)"
|
||||
domain: communication
|
||||
repo: coulomb-social
|
||||
status: ready
|
||||
status: finished
|
||||
owner: bernd
|
||||
topic_slug: coulomb-social
|
||||
created: "2026-08-12"
|
||||
updated: "2026-08-12"
|
||||
updated: "2026-08-13"
|
||||
depends_on:
|
||||
- CSOC-WP-0004
|
||||
related:
|
||||
|
|
@ -31,82 +31,96 @@ Implement product features required by the **stage-1 productive transfer** cut
|
|||
|
||||
```task
|
||||
id: CSOC-WP-0006-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "337baa68-0459-4985-9981-30a6d37f5bf5"
|
||||
```
|
||||
|
||||
Member-facing create / update / archive (or delete) for `Space`. Expose
|
||||
**Title** and **Abstract** (current `description` field or rename). List and
|
||||
detail stay tenant-scoped. Tests for authz and isolation.
|
||||
Member-facing create / update / archive for `Space` with **Title** and
|
||||
**Abstract** (`description` + `abstractor` property). Tenant-scoped list/detail.
|
||||
Tests: `tests/test_space_crud.py`, `tests/test_spaces.py`.
|
||||
|
||||
## T02 — Space and page Visual
|
||||
|
||||
```task
|
||||
id: CSOC-WP-0006-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1ee74b60-a87d-4aff-91f1-3f97026774a5"
|
||||
```
|
||||
|
||||
Add **Visual** (cover/image) for space and page. Decide storage (Forgejo
|
||||
`assets/` vs app media) in implementation notes; no secrets in git. Show
|
||||
visual on list/detail.
|
||||
**Visual** on Space (DB field + form) and page frontmatter. Storage path is
|
||||
relative to the space tree under `CONTENT_ROOT` (e.g. `assets/cover.jpg`); not
|
||||
app media / secrets in git. Shown on list and detail.
|
||||
|
||||
## T03 — Page metadata + product CRUD path
|
||||
|
||||
```task
|
||||
id: CSOC-WP-0006-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "fe95438c-2ed1-4efd-b6ea-84966636dbc3"
|
||||
```
|
||||
|
||||
Structured **Title**, **Abstract**, **Visual** for pages; create/update/delete
|
||||
product path (app UI and/or API) while keeping markdown body SoR per ADR-0002
|
||||
(Forgejo commit or equivalent). Page list in space.
|
||||
PageOps façade (`pageops.py`) writes markdown with frontmatter
|
||||
title/abstractor/visual + body. Product UI: new/edit/delete page; sidebar page
|
||||
list. SoR is thin dir under `CONTENT_ROOT` (ADR-0004); Forgejo remains optional
|
||||
read fallback.
|
||||
|
||||
## T04 — Generate copy of page
|
||||
|
||||
```task
|
||||
id: CSOC-WP-0006-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "98d7428c-1b2f-45f1-b080-a833a4790b69"
|
||||
```
|
||||
|
||||
`feat.page-duplicate`: independent copy with new slug/title; optional target
|
||||
space; asset handling per stage-1 acceptance.
|
||||
space; relative visual assets copied under destination `assets/`.
|
||||
|
||||
## T05 — Transfer page to another space
|
||||
|
||||
```task
|
||||
id: CSOC-WP-0006-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5ec81ff4-1d6f-4c86-8c93-b68b1570cc32"
|
||||
```
|
||||
|
||||
`feat.page-move`: move page (+ agreed assets) with source and destination
|
||||
authz; no Bubble dependency.
|
||||
`feat.page-move`: move page with source + destination membership checks; index
|
||||
cannot transfer. No Bubble dependency.
|
||||
|
||||
## T06 — Stage-1 smoke and capability model update
|
||||
|
||||
```task
|
||||
id: CSOC-WP-0006-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "752f52a8-8c18-402c-8f00-bce6c76a1db7"
|
||||
```
|
||||
|
||||
Update `docs/capability/model-v0.yaml` feature statuses as work lands; extend
|
||||
`docs/identity/smoke.md` / deploy runbook with space CRUD, copy, transfer
|
||||
checks on app.coulomb.social.
|
||||
Updated `docs/capability/model-v0.yaml` feature statuses; extended
|
||||
`docs/identity/smoke.md` with Stage-1 PageOps checks. Automated coverage via
|
||||
`tests/test_pageops.py` and `tests/test_space_crud.py`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Members can CRUD spaces with Title, Abstract, Visual without Django admin
|
||||
- [ ] Members can CRUD pages with Title, Abstract, Visual + markdown body path
|
||||
- [ ] Page copy and page transfer work with membership checks
|
||||
- [ ] Capability model statuses match reality
|
||||
- [ ] Stage-1 cut doc open questions resolved or explicitly deferred with owner
|
||||
- [x] Members can CRUD spaces with Title, Abstract, Visual without Django admin
|
||||
- [x] Members can CRUD pages with Title, Abstract, Visual + markdown body path
|
||||
- [x] Page copy and page transfer work with membership checks
|
||||
- [x] Capability model statuses match reality
|
||||
- [x] Stage-1 cut doc open questions resolved or explicitly deferred with owner
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `CONTENT_ROOT` (default `var/content/`, gitignored): `spaces/<slug>/pages|assets`
|
||||
- Space DB row is an index; bodies live on disk (PageOps)
|
||||
- Optional residual: git commit/push automation for content plane; full asset
|
||||
tree scan beyond Visual path; import Bubble export tree (CSOC-WP-0001-T04)
|
||||
|
||||
## Residuals (live records)
|
||||
|
||||
- Bubble export → content-plane import remains on **CSOC-WP-0001-T04** (depends
|
||||
on this landing pad).
|
||||
- Public registration / NK mailbox remains **CSOC-IN-0001** / NK-WP-0025.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue