feat: phase 4 admin CMS — dashboard, editor, media, CSRF
Head Hen CMS end-to-end: dashboard lists all posts (drafts + published), Markdown editor with live preview + drag-drop image upload, Pillow media pipeline re-encoding every upload to JPEG, post CRUD + publish toggle + hard delete, About page edit, and double-submit CSRF cookie enforced on every admin mutating endpoint (Phase 3's TODO markers resolved). Slug auto-generated on create and server-locked once a post has been published. Unpublish preserves `published_at` so re-publish keeps original date ordering. Every admin write invalidates the read-side Post/Page TTL caches and records an `auth_events` audit row. CSRF middleware is narrow by design — issues/refreshes the `cb_csrf` cookie only on `GET /admin*`, and mutating endpoints opt in via `require_csrf_form` or `require_csrf_header` Depends. Public routes, healthz, and pre-auth login stay untouched. 64 new tests cover slugs, CSRF, media, admin posts/pages services, and end-to-end CMS routes. Tests never mock the DB — real temp SQLite files per the CLAUDE.md mandate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
124
app/services/admin_pages.py
Normal file
124
app/services/admin_pages.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Admin-side (write) page service.
|
||||
|
||||
The public site only has one editable page — "About" — so this
|
||||
service is intentionally narrower than :class:`AdminPostsService`. The
|
||||
slug is a fixed literal (``"about"``) and cannot be changed through
|
||||
the admin. Only the title and body may be edited.
|
||||
|
||||
Every write:
|
||||
|
||||
- re-renders Markdown → sanitized HTML into ``body_html_cached`` so
|
||||
the public read path stays a single SELECT.
|
||||
- bumps ``updated_at``.
|
||||
- emits an ``AuditService`` ``page_updated`` event.
|
||||
- invalidates the public :class:`PageService` (and, defensively, the
|
||||
:class:`PostService`) cache so the next request sees the new copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import Page
|
||||
from app.models.mappers import row_to_page
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# The single editable page's slug. Hard-coded here (not injected) so
|
||||
# the CLI contract is impossible to misuse — there is no way to point
|
||||
# this service at a different slug.
|
||||
ABOUT_SLUG: str = "about"
|
||||
|
||||
|
||||
class AdminPagesService:
|
||||
"""Write-side service for the About page."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
markdown: MarkdownService,
|
||||
page_service: PageService,
|
||||
post_service: PostService,
|
||||
audit: AuditService,
|
||||
) -> None:
|
||||
self._engine: Engine = engine
|
||||
self._markdown: MarkdownService = markdown
|
||||
self._page_service: PageService = page_service
|
||||
self._post_service: PostService = post_service
|
||||
self._audit: AuditService = audit
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reads
|
||||
# ------------------------------------------------------------------
|
||||
def get_about(self) -> Optional[Page]:
|
||||
"""Return the current About page row, or ``None`` if absent."""
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" updated_at, published"
|
||||
" FROM pages WHERE slug = :slug LIMIT 1"
|
||||
),
|
||||
{"slug": ABOUT_SLUG},
|
||||
).mappings().first()
|
||||
return row_to_page(row) if row is not None else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Writes
|
||||
# ------------------------------------------------------------------
|
||||
def update_about(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
body_md: str,
|
||||
actor_user_id: int,
|
||||
) -> Optional[Page]:
|
||||
"""Update the About page's title + body.
|
||||
|
||||
Slug is immutable — the admin form does not expose it.
|
||||
"""
|
||||
existing = self.get_about()
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
clean_title = (title or "").strip()
|
||||
clean_body = body_md or ""
|
||||
body_html = self._markdown.render(clean_body)
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with self._engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE pages"
|
||||
" SET title = :title, body_md = :body_md,"
|
||||
" body_html_cached = :body_html,"
|
||||
" updated_at = :updated_at"
|
||||
" WHERE slug = :slug"
|
||||
),
|
||||
{
|
||||
"title": clean_title,
|
||||
"body_md": clean_body,
|
||||
"body_html": body_html,
|
||||
"updated_at": now_iso,
|
||||
"slug": ABOUT_SLUG,
|
||||
},
|
||||
)
|
||||
|
||||
self._audit.record(
|
||||
"page_updated",
|
||||
user_id=actor_user_id,
|
||||
detail={"slug": ABOUT_SLUG},
|
||||
)
|
||||
self._page_service.invalidate_all()
|
||||
self._post_service.invalidate_all()
|
||||
return self.get_about()
|
||||
Reference in New Issue
Block a user