"""Public-facing HTTP routes. Phase 2 scope: - ``GET /`` — blog index; posts come from :class:`PostService` which now reads the ``posts`` table. - ``GET /about`` — DB-backed; loads the ``about`` row from the ``pages`` table via :class:`PageService` and renders its ``body_html_cached`` directly. - ``GET /contact`` — inert contact form UI + optional ``mailto:`` link. - ``GET /shop`` — "Coming soon" card. Every handler is thin: it resolves its dependencies, calls any service methods it needs, and delegates rendering to a Jinja template. No HTML is constructed in Python. """ from __future__ import annotations import structlog from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from app.config import Settings, get_settings from app.models.entities import Page from app.models.posts import PostSummary from app.services.pages import PageService, get_page_service from app.services.posts import PostService, get_post_service # Module-level router. Mounted without a prefix by ``app.main.create_app`` # so the routes below live at the site root. router: APIRouter = APIRouter(tags=["public"]) # One module-level logger is fine; structlog handles context binding. _log = structlog.get_logger(__name__) def get_templates(request: Request) -> Jinja2Templates: """Return the shared :class:`Jinja2Templates` instance. The singleton is attached to ``app.state.templates`` in :func:`app.main.create_app`. Looking it up via ``request.app.state`` (rather than importing from ``app.main``) avoids an import cycle and keeps the handlers test-friendly — tests can swap out the instance by mutating ``app.state.templates`` before issuing requests. """ return request.app.state.templates @router.get("/", response_class=HTMLResponse, summary="Blog index") def home( request: Request, templates: Jinja2Templates = Depends(get_templates), posts: PostService = Depends(get_post_service), ) -> HTMLResponse: """Render the blog index with any published posts. Phase 2: the service now returns real ``PostSummary`` rows from SQLite. The homepage template still handles the empty-list case gracefully in case a future deployment starts with an unseeded database. """ # Query the service layer for the most recent published posts. The # template handles the empty-list case; we do not branch here. summaries: list[PostSummary] = posts.list_published() return templates.TemplateResponse( request, "public/home.html", {"posts": summaries, "active_nav": "home"}, ) @router.get("/about", response_class=HTMLResponse, summary="About the farm") def about( request: Request, templates: Jinja2Templates = Depends(get_templates), pages: PageService = Depends(get_page_service), ) -> HTMLResponse: """Render the About page from the ``pages`` table. Phase 2 rewires this route: the body comes from ``pages.about`` (seeded at first boot and editable via Phase 4 admin). If the page row is missing — which should not happen after a successful seed — we log the anomaly and return a generic 500 without leaking implementation details (CWE-200). """ page: Page | None = pages.get_by_slug("about") if page is None: # Anomalous: the seed should always have populated this row. # Log with enough context to diagnose without exposing it to # the visitor. _log.error("about_page_missing", slug="about") raise HTTPException( status_code=500, detail="The About page is temporarily unavailable.", ) return templates.TemplateResponse( request, "public/about.html", {"active_nav": "about", "page": page}, ) @router.get("/contact", response_class=HTMLResponse, summary="Contact the farm") def contact( request: Request, templates: Jinja2Templates = Depends(get_templates), settings: Settings = Depends(get_settings), ) -> HTMLResponse: """Render the inert contact page. The form fields are marked ``disabled`` and the form has no ``method`` attribute — it is UI-only. If ``ADMIN_CONTACT_EMAIL`` is configured the template renders a ``mailto:`` link so visitors still have a way to reach the farm before Phase 5 wires up the real POST flow. """ return templates.TemplateResponse( request, "public/contact.html", { "active_nav": "contact", # None when unset; the template hides the mailto link in that # case. We pass the value through settings so tests can # override it without touching environment variables. "contact_email": settings.admin_contact_email, }, ) @router.get("/shop", response_class=HTMLResponse, summary="Shop placeholder") def shop( request: Request, templates: Jinja2Templates = Depends(get_templates), ) -> HTMLResponse: """Render the "Coming soon" placeholder for the future shop. The nav link to ``/shop`` remains enabled so visitors can preview the offering; it is the landing page itself that signals the shop is not yet live. Phase 7 replaces this template with the real catalog. """ return templates.TemplateResponse( request, "public/shop.html", {"active_nav": "shop"}, )