Files
chicken_babies_site/app/services/admin_pages.py
Phillip Tarrant 9a8506970c 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>
2026-04-21 20:42:01 -05:00

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()