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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue