Stand up the full SQLite content layer: all 7 tables from the authoritative schema with WAL + foreign-keys enforced per-connection, entity dataclasses plus row mappers, hand-rolled versioned migrations tracked in schema_migrations, and an idempotent Python seed (system user + welcome post + About page). Add a Markdown->HTML service using markdown-it-py with a strict bleach allowlist (tables intentionally omitted on both sides). Add a typed in-process TTLCache[K,V] and wire it into real DB-backed PostService and PageService, both exposing invalidate_all() for Phase 4 admin writes. Rewire / and /about to read from the DB; homepage renders the seeded welcome post, About renders page.title + sanitized body_html_cached. Update the Phase 1 route tests accordingly. Mark Phase 2 complete in docs/ROADMAP.md.
149 lines
5.4 KiB
Python
149 lines
5.4 KiB
Python
"""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"},
|
|
)
|