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

@@ -4,6 +4,15 @@ The factory pattern (``create_app``) keeps test setup straightforward and
lets us swap in alternate configurations without module-level side
effects. ``app = create_app()`` at import time is what Uvicorn references
via ``app.main:app``.
Phase 2 additions:
- Build a shared SQLAlchemy :class:`~sqlalchemy.Engine` from
``settings.database_url`` and attach the per-connection
PRAGMA listener (WAL + foreign keys).
- Apply SQL migrations from :mod:`app.models.migrations`.
- Run the idempotent seed (welcome post, About page, system user).
- Instantiate :class:`PostService` and :class:`PageService` and
expose them on ``app.state`` for route-level DI.
"""
from __future__ import annotations
@@ -17,9 +26,13 @@ from fastapi.templating import Jinja2Templates
from app import __version__
from app.config import get_settings
from app.db import build_engine, run_migrations
from app.logging_config import configure_logging
from app.models.seed import run_seed
from app.routes.health import router as health_router
from app.routes.public import router as public_router
from app.services.pages import PageService
from app.services.posts import PostService
# Resolve the package root once so template / static paths stay correct
@@ -33,16 +46,19 @@ _STATIC_DIR: Path = _PACKAGE_ROOT / "static"
def create_app() -> FastAPI:
"""Build and return the FastAPI application.
Responsibilities:
- Load validated configuration via :func:`get_settings`.
- Initialize structured logging *before* any logger is used.
- Instantiate FastAPI with canonical title + version.
- Mount the ``/static`` directory for CSS, JS, and image assets.
- Attach the shared :class:`Jinja2Templates` to ``app.state`` so route
dependencies can retrieve it without a circular import on this
module.
- Register routers (Phase 1: health + public).
- Emit a single ``app_started`` structured log event.
Responsibilities (in strict order):
1. Load validated configuration via :func:`get_settings`.
2. Initialize structured logging *before* any logger is used.
3. Build the SQLAlchemy engine and install the PRAGMA listener.
4. Apply SQL migrations (idempotent — no-op after first boot).
5. Run the seed (idempotent — marked via ``schema_migrations``).
6. Instantiate :class:`PostService` / :class:`PageService` and
attach them to ``app.state`` so route dependencies can resolve
them via ``request.app.state``.
7. Mount static files, attach the shared :class:`Jinja2Templates`,
and register routers.
8. Emit a single ``app_started`` structured log event.
"""
# Parse + validate configuration first so a bad environment fails fast
# with a clear pydantic error before we touch logging / FastAPI.
@@ -52,6 +68,13 @@ def create_app() -> FastAPI:
# very first log line already flows through our processor chain.
configure_logging(settings.app_env)
# --- Database plumbing --------------------------------------------------
# Engine is a process-wide resource. Built here so that migrations
# and seed both run on the same pool/config as the running app.
engine = build_engine(settings.database_url)
run_migrations(engine)
run_seed(engine)
application = FastAPI(
title="Chicken Babies R Us",
version=__version__,
@@ -78,6 +101,13 @@ def create_app() -> FastAPI:
# function defined next to the routes.
application.state.templates = Jinja2Templates(directory=_TEMPLATES_DIR)
# Store the engine + services on ``app.state`` so the
# dependency-injection helpers in :mod:`app.services.*` can find
# them without importing this module (circular-import-safe).
application.state.engine = engine
application.state.post_service = PostService(engine)
application.state.page_service = PageService(engine)
# Register routers. Kept explicit (no dynamic discovery) so the set of
# mounted endpoints is trivially auditable.
application.include_router(health_router)