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>
384 lines
14 KiB
Python
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
|