coulomb-social/coulomb_social/apps/spaces/views.py
tegwick affed64839 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.
2026-08-13 12:46:39 +02:00

537 lines
18 KiB
Python

import hashlib
import hmac
import json
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseForbidden,
HttpResponseNotFound,
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_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,
forgejo_blob_url,
forgejo_edit_url,
forgejo_repo_url,
load_space_page,
)
from .models import Space
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, 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:
return HttpResponseNotFound("Space not found.")
page = request.GET.get("page") or "index"
rendered = load_space_page(space, page)
pages = list_space_pages(space)
return render(
request,
"spaces/detail.html",
{
"principal": principal,
"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),
},
)
@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 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:
return HttpResponseNotFound("Space not found.")
n = clear_content_cache_for_space(space)
messages.success(
request,
f"Content cache cleared ({n} entries). Fresh fetch on this page load.",
)
page = request.POST.get("page") or request.GET.get("page") or "index"
target = reverse("spaces:detail", kwargs={"slug": slug})
if page and page != "index":
target = f"{target}?page={page}"
return redirect(target)
@csrf_exempt
@require_POST
def forgejo_content_webhook(request: HttpRequest) -> JsonResponse:
"""Forgejo/Gitea push webhook → invalidate content cache for matching spaces."""
secret = (getattr(settings, "FORGEJO_WEBHOOK_SECRET", None) or "").strip()
if not secret:
return JsonResponse(
{"ok": False, "error": "webhook not configured"},
status=503,
)
if not _webhook_authorized(request, secret):
return JsonResponse({"ok": False, "error": "unauthorized"}, status=401)
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
return JsonResponse({"ok": False, "error": "invalid json"}, status=400)
owner, repo = _repo_from_push_payload(payload)
if not owner or not repo:
return JsonResponse({"ok": False, "error": "no repository in payload"}, status=400)
cleared = clear_content_cache_for_repo(owner, repo)
matched = Space.objects.filter(
forgejo_owner__iexact=owner,
forgejo_repo__iexact=repo,
is_active=True,
).count()
logger.info(
"Forgejo webhook cache invalidate owner=%s repo=%s cleared=%s spaces=%s",
owner,
repo,
cleared,
matched,
)
return JsonResponse(
{
"ok": True,
"owner": owner,
"repo": repo,
"cache_entries_cleared": cleared,
"spaces_matched": matched,
}
)
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
sig = request.headers.get("X-Gitea-Signature") or request.headers.get(
"X-Hub-Signature-256", ""
)
if sig.startswith("sha256="):
sig = sig[len("sha256=") :]
if sig:
digest = hmac.new(
secret.encode("utf-8"), request.body, hashlib.sha256
).hexdigest()
if hmac.compare_digest(digest, sig):
return True
return False
def _repo_from_push_payload(payload: dict) -> tuple[str, str]:
repo = payload.get("repository") or {}
full = (repo.get("full_name") or "").strip()
if "/" in full:
owner, name = full.split("/", 1)
return owner, name
owner = repo.get("owner") or {}
if isinstance(owner, dict):
owner_name = owner.get("login") or owner.get("username") or ""
else:
owner_name = str(owner)
name = repo.get("name") or ""
return str(owner_name), str(name)