feat: phase 2 content model + cache — SQLite schema, markdown, TTL

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.
This commit is contained in:
2026-04-21 15:40:35 -05:00
parent 28168f57b6
commit 0306f71763
21 changed files with 2055 additions and 108 deletions

View File

@@ -1,25 +1,31 @@
"""Public-facing HTTP routes.
Phase 1 scope:
Phase 2 scope:
- ``GET /`` — blog index (currently empty list from the stub service).
- ``GET /about`` — static placeholder copy.
- ``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.
methods it needs, and delegates rendering to a Jinja template. No HTML
is constructed in Python.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
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
@@ -27,6 +33,9 @@ from app.services.posts import PostService, get_post_service
# 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.
@@ -48,9 +57,10 @@ def home(
) -> HTMLResponse:
"""Render the blog index with any published posts.
In Phase 1 the service returns an empty list, so the template shows a
friendly "no posts yet" state. Phase 2 will populate the list from
SQLite without any changes to this handler.
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.
@@ -66,18 +76,31 @@ def home(
def about(
request: Request,
templates: Jinja2Templates = Depends(get_templates),
pages: PageService = Depends(get_page_service),
) -> HTMLResponse:
"""Render the static About page.
"""Render the About page from the ``pages`` table.
Copy is deliberately generic and does not reveal the farm's street
address (Morrison, TN is mentioned; the physical address is not —
see CLAUDE.md). Head Hen will replace this content via the Phase 4
admin CMS.
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"},
{"active_nav": "about", "page": page},
)