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>
125 lines
4.0 KiB
Python
125 lines
4.0 KiB
Python
"""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()
|