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:
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user