Files
chicken_babies_site/app/services/admin_posts.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

384 lines
14 KiB
Python

"""Admin-side (write) post service.
Mirrors the shape of :class:`app.services.posts.PostService` but for
the admin CRUD path. Responsibilities:
- create / update / delete posts
- toggle publish state
- auto-generate unique slugs from titles on create (draft only)
- re-render Markdown to ``body_html_cached`` on every write
- audit every write via :class:`AuditService` using descriptive
``event_type`` strings
- invalidate both :class:`PostService` and :class:`PageService` caches
so the public site reflects the change immediately
All writes use parameterized SQL (``text(":bind")``). No user input is
ever interpolated into a query string.
The service treats ``author_user_id`` as an immutable field: once a
post is created, edits do NOT reassign authorship, even if a different
admin saves the edit. This matches the single-author ("Head Hen")
reality of the site.
Slug lock-on-publish
--------------------
A slug may only be auto-regenerated on title change while the post is
a draft. Once a post has been published even once, the slug is locked
server-side — callers cannot change it via the update path, even if
they later unpublish the post. This preserves any inbound links that
went live while the post was published.
"""
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 Post, PostStatus
from app.models.mappers import row_to_post
from app.services.audit import AuditService
from app.services.markdown import MarkdownService
from app.services.pages import PageService
from app.services.posts import PostService
from app.services.slugs import ensure_unique, slugify
_log = structlog.get_logger(__name__)
class AdminPostsService:
"""Write-side orchestration for blog posts.
Parameters
----------
engine:
Shared SQLAlchemy engine. Never opens its own.
markdown:
Shared :class:`MarkdownService` used to re-render on every
write so the public read path pays only a single SELECT.
post_service:
The public read-side service. Invalidated after every write so
the home page reflects the change immediately.
page_service:
Same rationale — a post edit doesn't change page content but
we conservatively invalidate to keep cache logic uniform.
audit:
:class:`AuditService` for descriptive admin write events.
"""
def __init__(
self,
engine: Engine,
markdown: MarkdownService,
post_service: PostService,
page_service: PageService,
audit: AuditService,
) -> None:
self._engine: Engine = engine
self._markdown: MarkdownService = markdown
self._post_service: PostService = post_service
self._page_service: PageService = page_service
self._audit: AuditService = audit
# ------------------------------------------------------------------
# Reads (admin dashboard)
# ------------------------------------------------------------------
def list_all(self) -> list[Post]:
"""Return every post, newest-updated-first.
Drafts and published posts are both included; the dashboard
surfaces the status column so Head Hen can work on unpublished
material.
"""
with self._engine.connect() as conn:
rows = (
conn.execute(
text(
"SELECT id, slug, title, body_md, body_html_cached,"
" status, published_at, updated_at, author_user_id"
" FROM posts"
" ORDER BY updated_at DESC, id DESC"
)
)
.mappings()
.all()
)
return [row_to_post(row) for row in rows]
def get_by_id(self, post_id: int) -> Optional[Post]:
"""Return the :class:`Post` for ``post_id`` or ``None`` if absent."""
with self._engine.connect() as conn:
row = conn.execute(
text(
"SELECT id, slug, title, body_md, body_html_cached,"
" status, published_at, updated_at, author_user_id"
" FROM posts WHERE id = :id LIMIT 1"
),
{"id": post_id},
).mappings().first()
return row_to_post(row) if row is not None else None
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def create(
self,
*,
title: str,
body_md: str,
status: PostStatus,
author_id: int,
) -> Post:
"""Insert a new post row and return the loaded :class:`Post`.
Flow
----
1. Slugify the title; ensure uniqueness via the closure over the
DB so concurrent creates cannot collide on the UNIQUE index.
2. Render Markdown to sanitized HTML.
3. If ``status == PUBLISHED`` stamp ``published_at = now``;
otherwise leave NULL.
4. Insert.
5. Audit ``post_created`` (and ``post_published`` when the
initial status is published).
6. Invalidate caches.
"""
clean_title = (title or "").strip()
clean_body = body_md or ""
base_slug = slugify(clean_title)
# The closure escapes the engine so ensure_unique can check
# without opening a long-lived transaction.
unique_slug = ensure_unique(base_slug, self._slug_exists)
body_html = self._markdown.render(clean_body)
now = datetime.now(timezone.utc)
now_iso = now.isoformat()
published_at_iso: Optional[str] = (
now_iso if status is PostStatus.PUBLISHED else None
)
with self._engine.begin() as conn:
result = conn.execute(
text(
"INSERT INTO posts"
" (slug, title, body_md, body_html_cached, status,"
" published_at, updated_at, author_user_id)"
" VALUES (:slug, :title, :body_md, :body_html,"
" :status, :published_at, :updated_at, :author_id)"
),
{
"slug": unique_slug,
"title": clean_title,
"body_md": clean_body,
"body_html": body_html,
"status": status.value,
"published_at": published_at_iso,
"updated_at": now_iso,
"author_id": author_id,
},
)
new_id = int(result.lastrowid) # type: ignore[arg-type]
row = conn.execute(
text(
"SELECT id, slug, title, body_md, body_html_cached,"
" status, published_at, updated_at, author_user_id"
" FROM posts WHERE id = :id"
),
{"id": new_id},
).mappings().first()
if row is None: # pragma: no cover — just inserted
raise RuntimeError("failed to reload just-inserted post row")
post = row_to_post(row)
self._audit.record(
"post_created",
user_id=author_id,
detail={"post_id": post.id, "slug": post.slug, "status": post.status.value},
)
if post.status is PostStatus.PUBLISHED:
self._audit.record(
"post_published",
user_id=author_id,
detail={"post_id": post.id, "slug": post.slug},
)
self._invalidate_caches()
return post
def update(
self,
post_id: int,
*,
title: str,
body_md: str,
actor_user_id: int,
) -> Optional[Post]:
"""Update a post's title + body. Return the refreshed :class:`Post`.
Behavior
--------
- The slug is NEVER regenerated by an update call. While the
post is still a draft the admin may delete + recreate to pick
a new slug; once published the slug is permanent per the
security contract (external links must not break).
- ``author_user_id`` is preserved — this endpoint does not
transfer authorship.
- ``published_at`` is preserved verbatim. Publishing happens via
:meth:`toggle_publish`.
- Always re-renders Markdown so ``body_html_cached`` stays in
sync with ``body_md``.
- Always bumps ``updated_at``.
"""
existing = self.get_by_id(post_id)
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 posts"
" SET title = :title, body_md = :body_md,"
" body_html_cached = :body_html,"
" updated_at = :updated_at"
" WHERE id = :id"
),
{
"title": clean_title,
"body_md": clean_body,
"body_html": body_html,
"updated_at": now_iso,
"id": post_id,
},
)
self._audit.record(
"post_updated",
user_id=actor_user_id,
detail={"post_id": post_id, "slug": existing.slug},
)
self._invalidate_caches()
return self.get_by_id(post_id)
def delete(self, post_id: int, *, actor_user_id: int) -> bool:
"""Delete a post row. Return True if something was deleted.
Media rows uploaded during drafting are NOT cleaned up here —
uploads aren't linked to posts in the schema, and orphan-sweep
is explicitly out of scope per the Phase 4 brief.
"""
existing = self.get_by_id(post_id)
if existing is None:
return False
with self._engine.begin() as conn:
conn.execute(
text("DELETE FROM posts WHERE id = :id"),
{"id": post_id},
)
self._audit.record(
"post_deleted",
user_id=actor_user_id,
detail={"post_id": post_id, "slug": existing.slug},
)
self._invalidate_caches()
return True
def toggle_publish(self, post_id: int, *, actor_user_id: int) -> Optional[Post]:
"""Flip draft ↔ published. Return the updated post, or ``None``.
Contract (see Phase 4 brief constraint 7):
- Draft → Published: set ``published_at = now`` ONLY if it was
previously NULL. If the post was once published, unpublished,
and is now being re-published we preserve the original
publish timestamp so the public list ordering stays stable.
- Published → Draft: status flips, ``published_at`` is preserved.
"""
existing = self.get_by_id(post_id)
if existing is None:
return None
now_iso = datetime.now(timezone.utc).isoformat()
if existing.status is PostStatus.PUBLISHED:
new_status = PostStatus.DRAFT
# Preserve existing published_at on unpublish. No event_type
# branch yet — we emit post_unpublished below.
published_at_iso: Optional[str] = (
existing.published_at.isoformat()
if existing.published_at is not None
else None
)
event_type = "post_unpublished"
else:
new_status = PostStatus.PUBLISHED
# First-publish stamp. Preserve any prior published_at so
# re-publish doesn't renumber the post on the front page.
if existing.published_at is None:
published_at_iso = now_iso
else:
published_at_iso = existing.published_at.isoformat()
event_type = "post_published"
with self._engine.begin() as conn:
conn.execute(
text(
"UPDATE posts"
" SET status = :status,"
" published_at = :published_at,"
" updated_at = :updated_at"
" WHERE id = :id"
),
{
"status": new_status.value,
"published_at": published_at_iso,
"updated_at": now_iso,
"id": post_id,
},
)
self._audit.record(
event_type,
user_id=actor_user_id,
detail={"post_id": post_id, "slug": existing.slug},
)
self._invalidate_caches()
return self.get_by_id(post_id)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _slug_exists(self, candidate: str) -> bool:
"""Return True if a row with ``slug = candidate`` is already present."""
with self._engine.connect() as conn:
row = conn.execute(
text("SELECT 1 FROM posts WHERE slug = :s LIMIT 1"),
{"s": candidate},
).first()
return row is not None
def _invalidate_caches(self) -> None:
"""Drop both the post and page read-side caches.
Post invalidation is strictly required; page invalidation is
defensive — the schemas are separate, but keeping cache
invalidation uniform makes it obvious Phase 4 writes never
leave a stale public read.
"""
self._post_service.invalidate_all()
self._page_service.invalidate_all()
def get_admin_posts_service(request): # pragma: no cover — trivial
"""FastAPI dependency — pull the service off ``app.state``."""
return request.app.state.admin_posts_service