"""Admin CMS routes — dashboard, post CRUD, About edit, media upload. These handlers all live behind :func:`require_admin`. Mutating endpoints additionally pull a CSRF dependency (:func:`require_csrf_form` or :func:`require_csrf_header`) so the double-submit cookie is verified before any state change. Each handler does the absolute minimum: pull services off the app state, call their methods, translate the result into an HTTP response. No business logic, no SQL. Routing map ----------- - ``GET /admin`` — dashboard. - ``GET /admin/posts/new`` — create form. - ``POST /admin/posts`` — create handler (CSRF). - ``GET /admin/posts/{id}/edit`` — edit form. - ``POST /admin/posts/{id}`` — update (CSRF). - ``POST /admin/posts/{id}/delete`` — delete (CSRF). - ``POST /admin/posts/{id}/publish`` — publish toggle (CSRF). - ``GET /admin/pages/about/edit`` — About edit form. - ``POST /admin/pages/about`` — About update (CSRF). - ``POST /admin/media/upload`` — multipart upload (header CSRF). - ``POST /admin/preview`` — Markdown → HTML preview (header CSRF). """ from __future__ import annotations from typing import Optional import structlog from fastapi import ( APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, ) from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates from app.dependencies.auth import require_admin from app.dependencies.csrf import require_csrf_form, require_csrf_header from app.models.entities import PostStatus, User from app.services.admin_pages import AdminPagesService from app.services.admin_posts import AdminPostsService from app.services.markdown import MarkdownService from app.services.media import MediaRejectedError, MediaService router: APIRouter = APIRouter(tags=["admin-cms"]) _log = structlog.get_logger(__name__) # --------------------------------------------------------------------------- # DI helpers # --------------------------------------------------------------------------- def _get_templates(request: Request) -> Jinja2Templates: """Return the app-scoped :class:`Jinja2Templates`.""" return request.app.state.templates def _get_admin_posts(request: Request) -> AdminPostsService: """Return the app-scoped :class:`AdminPostsService`.""" return request.app.state.admin_posts_service def _get_admin_pages(request: Request) -> AdminPagesService: """Return the app-scoped :class:`AdminPagesService`.""" return request.app.state.admin_pages_service def _get_media(request: Request) -> MediaService: """Return the app-scoped :class:`MediaService`.""" return request.app.state.media_service def _get_markdown(request: Request) -> MarkdownService: """Return the app-scoped :class:`MarkdownService`.""" return request.app.state.markdown_service def _get_csrf_token_for_template(request: Request) -> str: """Return the CSRF token to embed in the rendered admin templates. The middleware (see :mod:`app.main`) sets ``request.state.csrf_token`` on every admin GET after ensuring the cookie is in sync. Handlers pull it from request state and pass it into the template context. """ return getattr(request.state, "csrf_token", "") or "" # --------------------------------------------------------------------------- # GET /admin — dashboard # --------------------------------------------------------------------------- @router.get("/admin", response_class=HTMLResponse, summary="Admin dashboard") def admin_dashboard( request: Request, user: User = Depends(require_admin), templates: Jinja2Templates = Depends(_get_templates), admin_posts: AdminPostsService = Depends(_get_admin_posts), admin_pages: AdminPagesService = Depends(_get_admin_pages), ) -> HTMLResponse: """Render the dashboard: posts list + About edit link. Posts are sorted newest-updated-first and include both drafts and published posts — the admin table surfaces the status badge. """ posts = admin_posts.list_all() about = admin_pages.get_about() # Optional flash from PRG query param — we keep it minimal. msg = request.query_params.get("msg") or "" return templates.TemplateResponse( request, "admin/dashboard.html", { "user": user, "posts": posts, "about": about, "msg": msg, "csrf_token": _get_csrf_token_for_template(request), }, ) # --------------------------------------------------------------------------- # GET /admin/posts/new # --------------------------------------------------------------------------- @router.get( "/admin/posts/new", response_class=HTMLResponse, summary="Admin: new post form", ) def admin_post_new_form( request: Request, user: User = Depends(require_admin), templates: Jinja2Templates = Depends(_get_templates), ) -> HTMLResponse: """Render the empty create form. No post id → the form POSTs to ``/admin/posts``. Slug is not shown because we auto-generate it from the title on create. """ return templates.TemplateResponse( request, "admin/post_form.html", { "user": user, "post": None, "form": {"title": "", "body_md": "", "status": PostStatus.DRAFT.value}, "errors": {}, "csrf_token": _get_csrf_token_for_template(request), }, ) # --------------------------------------------------------------------------- # POST /admin/posts — create # --------------------------------------------------------------------------- @router.post("/admin/posts", summary="Admin: create post") def admin_post_create( request: Request, title: str = Form(default=""), body_md: str = Form(default=""), status: str = Form(default=PostStatus.DRAFT.value), user: User = Depends(require_admin), templates: Jinja2Templates = Depends(_get_templates), admin_posts: AdminPostsService = Depends(_get_admin_posts), _csrf: None = Depends(require_csrf_form), ) -> Response: """Handle the new-post submission and redirect to the dashboard. Minimal validation: - title must be non-empty after strip - status must be a valid :class:`PostStatus` value On validation error we re-render the form with the submitted values so Head Hen doesn't retype. """ errors: dict[str, str] = {} clean_title = (title or "").strip() if not clean_title: errors["title"] = "Title is required." try: status_enum = PostStatus(status) except ValueError: errors["status"] = "Invalid status." status_enum = PostStatus.DRAFT if errors: return templates.TemplateResponse( request, "admin/post_form.html", { "user": user, "post": None, "form": { "title": title, "body_md": body_md, "status": status_enum.value, }, "errors": errors, "csrf_token": _get_csrf_token_for_template(request), }, status_code=400, ) admin_posts.create( title=clean_title, body_md=body_md or "", status=status_enum, author_id=user.id, ) return RedirectResponse(url="/admin?msg=created", status_code=303) # --------------------------------------------------------------------------- # GET /admin/posts/{id}/edit # --------------------------------------------------------------------------- @router.get( "/admin/posts/{post_id}/edit", response_class=HTMLResponse, summary="Admin: edit post form", ) def admin_post_edit_form( request: Request, post_id: int, user: User = Depends(require_admin), templates: Jinja2Templates = Depends(_get_templates), admin_posts: AdminPostsService = Depends(_get_admin_posts), ) -> HTMLResponse: """Render the edit form for an existing post.""" post = admin_posts.get_by_id(post_id) if post is None: raise HTTPException(status_code=404, detail="Post not found") return templates.TemplateResponse( request, "admin/post_form.html", { "user": user, "post": post, "form": { "title": post.title, "body_md": post.body_md, "status": post.status.value, }, "errors": {}, "csrf_token": _get_csrf_token_for_template(request), }, ) # --------------------------------------------------------------------------- # POST /admin/posts/{id} — update # --------------------------------------------------------------------------- @router.post("/admin/posts/{post_id}", summary="Admin: update post") def admin_post_update( request: Request, post_id: int, title: str = Form(default=""), body_md: str = Form(default=""), user: User = Depends(require_admin), templates: Jinja2Templates = Depends(_get_templates), admin_posts: AdminPostsService = Depends(_get_admin_posts), _csrf: None = Depends(require_csrf_form), ) -> Response: """Apply title + body edits to an existing post. Slug changes are not permitted via this path — server-side enforcement of the "slug lock on publish" policy (see :class:`AdminPostsService`). """ existing = admin_posts.get_by_id(post_id) if existing is None: raise HTTPException(status_code=404, detail="Post not found") clean_title = (title or "").strip() errors: dict[str, str] = {} if not clean_title: errors["title"] = "Title is required." if errors: return templates.TemplateResponse( request, "admin/post_form.html", { "user": user, "post": existing, "form": { "title": title, "body_md": body_md, "status": existing.status.value, }, "errors": errors, "csrf_token": _get_csrf_token_for_template(request), }, status_code=400, ) admin_posts.update( post_id, title=clean_title, body_md=body_md or "", actor_user_id=user.id, ) return RedirectResponse(url="/admin?msg=saved", status_code=303) # --------------------------------------------------------------------------- # POST /admin/posts/{id}/delete # --------------------------------------------------------------------------- @router.post("/admin/posts/{post_id}/delete", summary="Admin: delete post") def admin_post_delete( request: Request, post_id: int, user: User = Depends(require_admin), admin_posts: AdminPostsService = Depends(_get_admin_posts), _csrf: None = Depends(require_csrf_form), ) -> Response: """Hard-delete a post row.""" deleted = admin_posts.delete(post_id, actor_user_id=user.id) if not deleted: raise HTTPException(status_code=404, detail="Post not found") return RedirectResponse(url="/admin?msg=deleted", status_code=303) # --------------------------------------------------------------------------- # POST /admin/posts/{id}/publish — publish/unpublish toggle # --------------------------------------------------------------------------- @router.post( "/admin/posts/{post_id}/publish", summary="Admin: toggle publish state" ) def admin_post_toggle_publish( request: Request, post_id: int, user: User = Depends(require_admin), admin_posts: AdminPostsService = Depends(_get_admin_posts), _csrf: None = Depends(require_csrf_form), ) -> Response: """Flip draft ↔ published.""" updated = admin_posts.toggle_publish(post_id, actor_user_id=user.id) if updated is None: raise HTTPException(status_code=404, detail="Post not found") # Friendly-ish flash so the admin sees the result of the toggle. msg = "published" if updated.status is PostStatus.PUBLISHED else "unpublished" return RedirectResponse(url=f"/admin?msg={msg}", status_code=303) # --------------------------------------------------------------------------- # GET /admin/pages/about/edit # --------------------------------------------------------------------------- @router.get( "/admin/pages/about/edit", response_class=HTMLResponse, summary="Admin: edit About page", ) def admin_about_edit_form( request: Request, user: User = Depends(require_admin), templates: Jinja2Templates = Depends(_get_templates), admin_pages: AdminPagesService = Depends(_get_admin_pages), ) -> HTMLResponse: """Render the About-page edit form.""" page = admin_pages.get_about() if page is None: raise HTTPException(status_code=500, detail="About page missing") return templates.TemplateResponse( request, "admin/page_form.html", { "user": user, "page": page, "form": {"title": page.title, "body_md": page.body_md}, "errors": {}, "csrf_token": _get_csrf_token_for_template(request), }, ) # --------------------------------------------------------------------------- # POST /admin/pages/about — update # --------------------------------------------------------------------------- @router.post("/admin/pages/about", summary="Admin: update About page") def admin_about_update( request: Request, title: str = Form(default=""), body_md: str = Form(default=""), user: User = Depends(require_admin), templates: Jinja2Templates = Depends(_get_templates), admin_pages: AdminPagesService = Depends(_get_admin_pages), _csrf: None = Depends(require_csrf_form), ) -> Response: """Apply edits to the About page (slug is fixed).""" clean_title = (title or "").strip() errors: dict[str, str] = {} if not clean_title: errors["title"] = "Title is required." if errors: page = admin_pages.get_about() return templates.TemplateResponse( request, "admin/page_form.html", { "user": user, "page": page, "form": {"title": title, "body_md": body_md}, "errors": errors, "csrf_token": _get_csrf_token_for_template(request), }, status_code=400, ) admin_pages.update_about( title=clean_title, body_md=body_md or "", actor_user_id=user.id, ) return RedirectResponse(url="/admin?msg=saved", status_code=303) # --------------------------------------------------------------------------- # POST /admin/media/upload # --------------------------------------------------------------------------- @router.post("/admin/media/upload", summary="Admin: upload image") async def admin_media_upload( request: Request, file: UploadFile = File(...), alt_text: str = Form(default=""), user: User = Depends(require_admin), media: MediaService = Depends(_get_media), _csrf: None = Depends(require_csrf_header), ) -> JSONResponse: """Validate and store an uploaded image. Response JSON is small by design — the drag-drop JS only needs a URL to splice into the Markdown source as ``![alt](url)``. """ data = await file.read() try: record = media.save_upload( original_filename=file.filename or "", data=data, uploaded_by=user.id, alt_text=alt_text or "", ) except MediaRejectedError as exc: return JSONResponse( {"error": str(exc)}, status_code=400, ) return JSONResponse( { "id": record.id, "url": media.public_url(record), "alt": record.alt_text, "filename": record.filename, "size_bytes": record.size_bytes, } ) # --------------------------------------------------------------------------- # POST /admin/preview # --------------------------------------------------------------------------- @router.post( "/admin/preview", response_class=HTMLResponse, summary="Admin: Markdown preview", ) def admin_preview( request: Request, markdown: str = Form(default=""), user: User = Depends(require_admin), md: MarkdownService = Depends(_get_markdown), _csrf: None = Depends(require_csrf_header), ) -> HTMLResponse: """Render ``markdown`` through the sanitizer and return an HTML fragment. The fragment is NOT wrapped in a full page — it is ``innerHTML``-safe output from the same pipeline that stores ``body_html_cached``. The route reuses :class:`MarkdownService` so preview output exactly matches what will eventually be served to the public. """ rendered: str = md.render(markdown or "") return HTMLResponse(content=rendered) # --------------------------------------------------------------------------- # Optional aliases for backward-compat imports # --------------------------------------------------------------------------- # If another module (e.g. tests) imports ``router`` from here, the # attribute name stays stable. _ = router # silence "not used" in linters that don't pick up FastAPI magic # Avoid "Optional unused" when the type is only referenced via Depends. _Optional = Optional # pragma: no cover