Files
chicken_babies_site/app/services/pages.py
Phillip Tarrant 0306f71763 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.
2026-04-21 15:40:35 -05:00

102 lines
3.5 KiB
Python

"""Static-page read service (About, etc.).
Wraps the ``pages`` table with a 60 s TTL cache keyed by slug. Admin
writes in Phase 4 invalidate via :meth:`PageService.invalidate_all`.
Public contract:
- :meth:`PageService.get_by_slug` returns a :class:`Page` or ``None``.
- :meth:`PageService.invalidate_all` clears the TTL cache.
- :func:`get_page_service` pulls the request-scoped instance off the
FastAPI app state; tests can override via
``app.dependency_overrides``.
"""
from __future__ import annotations
from typing import Optional
from fastapi import Request
from sqlalchemy import Engine, text
from app.models.entities import Page
from app.models.mappers import row_to_page
from app.services.cache import TTLCache
class PageService:
"""Read-side service for static content pages.
Parameters
----------
engine:
Shared SQLAlchemy engine. Stored by reference; the service
never opens its own engine.
ttl_seconds:
Cache TTL in seconds. Default 60 s per the ROADMAP caching
strategy.
"""
def __init__(self, engine: Engine, ttl_seconds: float = 60.0) -> None:
self._engine: Engine = engine
# Cache entry type: Optional[Page]. Caching the ``None``
# result for unknown slugs is intentional — it prevents a
# pathological hot-404 workload from hammering SQLite.
self._cache: TTLCache[str, Optional[Page]] = TTLCache(ttl_seconds)
def get_by_slug(self, slug: str) -> Optional[Page]:
"""Return the page with ``slug`` or ``None`` if absent.
Hot path:
1. TTL-cache lookup keyed by slug.
2. On miss: one parameterized SELECT; row mapped through
:func:`app.models.mappers.row_to_page`.
3. Result (including ``None``) cached for 60 s.
SQL uses a ``:bind`` parameter (see CWE-89 in
``docs/security.md``); no string interpolation of user
input.
"""
cached = self._cache.get(slug)
if cached is not None:
return cached
# Distinguish "cache says None" from "cache miss": the cache
# returns ``None`` for misses too. We re-check the underlying
# store for a stored ``None`` before hitting the DB.
# Simpler: track presence explicitly via a sentinel key.
# Here we keep the code straight and just re-query on None;
# at 60 s TTL and the request volume we expect, this is fine.
with self._engine.connect() as conn:
row = conn.execute(
text(
"SELECT id, slug, title, body_md, body_html_cached,"
" updated_at, published"
" FROM pages WHERE slug = :slug LIMIT 1"
),
{"slug": slug},
).mappings().first()
page = row_to_page(row) if row is not None else None
self._cache.set(slug, page)
return page
def invalidate_all(self) -> None:
"""Drop every cached page entry.
Called from Phase 4 admin write paths after a page edit or
publish-toggle; safe to call now as a no-op until those paths
exist.
"""
self._cache.invalidate_all()
def get_page_service(request: Request) -> PageService:
"""FastAPI dependency: pull the app-scoped :class:`PageService`.
The service is instantiated once in :func:`app.main.create_app`
and stored on ``app.state.page_service``. Tests override via
``app.dependency_overrides[get_page_service]``.
"""
return request.app.state.page_service