diff --git a/.gitignore b/.gitignore index ce84097..41e5283 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ scripts/package-lock.json .env.* !.env.example +# Thin content plane (ADR-0004) — local SoR tree +var/ + # Python / Django .venv/ __pycache__/ diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 42d8aa7..2336f23 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/coulomb_social/apps/spaces/admin.py b/coulomb_social/apps/spaces/admin.py index 1f62acd..4c23fc0 100644 --- a/coulomb_social/apps/spaces/admin.py +++ b/coulomb_social/apps/spaces/admin.py @@ -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) diff --git a/coulomb_social/apps/spaces/content.py b/coulomb_social/apps/spaces/content.py index 741dddb..ef7031c 100644 --- a/coulomb_social/apps/spaces/content.py +++ b/coulomb_social/apps/spaces/content.py @@ -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//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, diff --git a/coulomb_social/apps/spaces/migrations/0002_space_visual.py b/coulomb_social/apps/spaces/migrations/0002_space_visual.py new file mode 100644 index 0000000..4638741 --- /dev/null +++ b/coulomb_social/apps/spaces/migrations/0002_space_visual.py @@ -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).", + ), + ), + ] diff --git a/coulomb_social/apps/spaces/models.py b/coulomb_social/apps/spaces/models.py index 853b638..34cd529 100644 --- a/coulomb_social/apps/spaces/models.py +++ b/coulomb_social/apps/spaces/models.py @@ -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" diff --git a/coulomb_social/apps/spaces/pageops.py b/coulomb_social/apps/spaces/pageops.py new file mode 100644 index 0000000..a28a05a --- /dev/null +++ b/coulomb_social/apps/spaces/pageops.py @@ -0,0 +1,331 @@ +"""PageOps — thin filesystem content plane (ADR-0003 / ADR-0004). + +Layout under CONTENT_ROOT: + + spaces// + pages/.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- 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") diff --git a/coulomb_social/apps/spaces/services.py b/coulomb_social/apps/spaces/services.py index 7eb2503..4946019 100644 --- a/coulomb_social/apps/spaces/services.py +++ b/coulomb_social/apps/spaces/services.py @@ -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, + ) diff --git a/coulomb_social/apps/spaces/urls.py b/coulomb_social/apps/spaces/urls.py index a03b63e..3163831 100644 --- a/coulomb_social/apps/spaces/urls.py +++ b/coulomb_social/apps/spaces/urls.py @@ -10,6 +10,30 @@ urlpatterns = [ views.forgejo_content_webhook, name="forgejo_webhook", ), + path("new/", views.space_create, name="create"), + path("/edit/", views.space_edit, name="edit"), + path("/archive/", views.space_archive, name="archive"), path("/refresh/", views.space_refresh, name="refresh"), + path("/pages/new/", views.page_new, name="page_new"), + path( + "/pages//edit/", + views.page_edit, + name="page_edit", + ), + path( + "/pages//copy/", + views.page_copy, + name="page_copy", + ), + path( + "/pages//transfer/", + views.page_transfer, + name="page_transfer", + ), + path( + "/pages//delete/", + views.page_delete, + name="page_delete", + ), path("/", views.space_detail, name="detail"), ] diff --git a/coulomb_social/apps/spaces/views.py b/coulomb_social/apps/spaces/views.py index a2c7dcd..d089266 100644 --- a/coulomb_social/apps/spaces/views.py +++ b/coulomb_social/apps/spaces/views.py @@ -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: diff --git a/coulomb_social/settings/base.py b/coulomb_social/settings/base.py index f1f0927..bb50f89 100644 --- a/coulomb_social/settings/base.py +++ b/coulomb_social/settings/base.py @@ -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//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: ///index.md +# Optional local fixture root for offline tests: ///index.md SPACE_CONTENT_FIXTURE_ROOT = config("SPACE_CONTENT_FIXTURE_ROOT", default="") diff --git a/coulomb_social/settings/test.py b/coulomb_social/settings/test.py index 50d8b18..c491a83 100644 --- a/coulomb_social/settings/test.py +++ b/coulomb_social/settings/test.py @@ -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": { diff --git a/coulomb_social/templates/core/app_home.html b/coulomb_social/templates/core/app_home.html index 4fd4794..516d4e5 100644 --- a/coulomb_social/templates/core/app_home.html +++ b/coulomb_social/templates/core/app_home.html @@ -1,11 +1,16 @@ {% extends "base.html" %} {% block title %}Spaces — {{ site_name }}{% endblock %} {% block content %} -

Spaces

-

- Co-creation spaces for your tenant. - Page content will live as markdown in Forgejo-backed repositories. -

+
+
+

Spaces

+

+ Co-creation spaces for your tenant. Pages live as markdown on the content plane + (local dir+git; Forgejo optional). +

+
+ New space +
{% if spaces %}
    @@ -18,15 +23,20 @@ {% if space.description %}

    {{ space.description }}

    {% endif %} + {% if space.visual %} +

    Visual: {{ space.visual }}

    + {% endif %} {% endfor %}
{% else %}

No spaces yet

-

- Operators can seed a space via Django admin (tenant-matched). - Create-space UI and Forgejo binding land next (T04–T06). +

+ Create a space to start writing pages (Title, Abstract, Visual + markdown body). +

+

+ Create your first space

{% endif %} diff --git a/coulomb_social/templates/spaces/detail.html b/coulomb_social/templates/spaces/detail.html index 0179e39..c2b1492 100644 --- a/coulomb_social/templates/spaces/detail.html +++ b/coulomb_social/templates/spaces/detail.html @@ -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; + } {% endblock %} {% block content %}

← Spaces

-

{{ space.title }}

- {% if space.description %} -

{{ space.description }}

- {% endif %} - - {% if space.has_content_binding %} -
- {% if forgejo_edit_url %} - Edit in Forgejo +
+
+

{{ space.title }}

+ {% if space.description %} +

{{ space.description }}

{% endif %} - {% if forgejo_blob_url %} - View source + {% if space.visual %} + {% endif %} -
- {% csrf_token %} - - -
- - {% endif %} + +
- {% if page.error %} -
-

Content unavailable

-

{{ page.error }}

- {% if space.has_content_binding %} +
+ Edit page + {% if page.slug and page.slug != 'index' %} + Copy + Transfer +
+ {% csrf_token %} + +
+ {% else %} + Copy index + {% endif %} + {% if forgejo_edit_url %} + Edit in Forgejo + {% endif %} + {% if forgejo_blob_url %} + View source + {% endif %} +
+ {% csrf_token %} + + +
+
+ +
+ + +
+ {% if page.error %} +
+

Content unavailable

+

{{ page.error }}

+ {% if space.has_content_binding %} + + {% else %} + + {% endif %} +
+ {% else %} +
+ {{ page.html|safe }} +
{% endif %}
- {% else %} -
- {{ page.html|safe }} -
- - {% endif %} +
{% endblock %} diff --git a/coulomb_social/templates/spaces/form.html b/coulomb_social/templates/spaces/form.html new file mode 100644 index 0000000..4514353 --- /dev/null +++ b/coulomb_social/templates/spaces/form.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}{{ form_title }} — {{ site_name }}{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block content %} +

+ ← Back +

+

{{ form_title }}

+

Title and Abstract describe the space; Visual is a cover path under the space tree (e.g. assets/cover.jpg).

+ +
+ {% csrf_token %} + + + + + + + + + + + + +
+ + Cancel +
+
+ + {% if space %} +
+

Archive space

+

Hides the space from the list. Content tree is soft-archived on disk.

+
+ {% csrf_token %} + +
+
+ {% endif %} +{% endblock %} diff --git a/coulomb_social/templates/spaces/page_copy.html b/coulomb_social/templates/spaces/page_copy.html new file mode 100644 index 0000000..ab62a5f --- /dev/null +++ b/coulomb_social/templates/spaces/page_copy.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Copy page — {{ space.title }} — {{ site_name }}{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block content %} +

+ ← {{ space.title }} +

+

Copy page

+

+ Create an independent copy of {{ page_slug }}. + Relative visual assets are duplicated when present. +

+ +
+ {% csrf_token %} + + + + + + + + + +
+ + Cancel +
+
+{% endblock %} diff --git a/coulomb_social/templates/spaces/page_form.html b/coulomb_social/templates/spaces/page_form.html new file mode 100644 index 0000000..5f6b5ac --- /dev/null +++ b/coulomb_social/templates/spaces/page_form.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block title %}{{ form_title }} — {{ space.title }} — {{ site_name }}{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block content %} +

+ ← {{ space.title }} +

+

{{ form_title }}

+

Markdown body is the source of truth. Title, Abstract, and Visual are stored in frontmatter.

+ +
+ {% csrf_token %} + + + + + {% if doc.slug == 'index' %} + + {% else %} + + {% endif %} + + + + + + + + + + +
+ + Cancel +
+
+{% endblock %} diff --git a/coulomb_social/templates/spaces/page_transfer.html b/coulomb_social/templates/spaces/page_transfer.html new file mode 100644 index 0000000..b96b232 --- /dev/null +++ b/coulomb_social/templates/spaces/page_transfer.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Transfer page — {{ space.title }} — {{ site_name }}{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block content %} +

+ ← {{ space.title }} +

+

Transfer page

+

+ Move {{ page_slug }} 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. +

+ + {% if not all_spaces %} +
+

No other spaces available. Create another space first, then transfer.

+
+ {% else %} +
+ {% csrf_token %} + + + + + + +
+ + Cancel +
+
+ {% endif %} +{% endblock %} diff --git a/docs/capability/model-v0.yaml b/docs/capability/model-v0.yaml index d447e52..7178910 100644 --- a/docs/capability/model-v0.yaml +++ b/docs/capability/model-v0.yaml @@ -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) --- diff --git a/docs/identity/smoke.md b/docs/identity/smoke.md index 03634ca..4a0ea89 100644 --- a/docs/identity/smoke.md +++ b/docs/identity/smoke.md @@ -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//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 | diff --git a/tests/test_content.py b/tests/test_content.py index 2175ba5..c38cd75 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -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 diff --git a/tests/test_pageops.py b/tests/test_pageops.py new file mode 100644 index 0000000..e95ce10 --- /dev/null +++ b/tests/test_pageops.py @@ -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() diff --git a/tests/test_space_crud.py b/tests/test_space_crud.py new file mode 100644 index 0000000..6190ead --- /dev/null +++ b/tests/test_space_crud.py @@ -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 diff --git a/workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md b/workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md index 9bafad8..60eaea1 100644 --- a/workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md +++ b/workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md @@ -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//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.