"""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