Files
chicken_babies_site/app/models/mappers.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

191 lines
6.3 KiB
Python

"""SQL row to dataclass converters.
One ``row_to_<entity>`` function per table. All functions accept a
mapping-like object (``sqlalchemy.Row``, :class:`sqlite3.Row`, or plain
``dict``) and return the corresponding dataclass from
:mod:`app.models.entities`.
Boundary responsibilities handled here (so service code never has to):
- Parse ISO-8601 ``TEXT`` columns into timezone-aware :class:`datetime`
instances (always UTC).
- Coerce SQLite ``INTEGER`` booleans (``0`` / ``1``) into real ``bool``.
- Translate ``posts.status`` strings into :class:`PostStatus` members.
Anything that isn't safe to assume (e.g. that ``published_at`` might be
NULL) is handled explicitly via :func:`_parse_optional_datetime`.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Mapping, Optional
from app.models.entities import (
AuthEvent,
ContactSubmission,
MagicLinkToken,
Media,
Page,
Post,
PostStatus,
Session,
User,
)
def _parse_datetime(value: str) -> datetime:
"""Parse a stored ISO-8601 string into a timezone-aware UTC datetime.
All write paths use :func:`datetime.now` with ``tz=timezone.utc``
and serialize via ``.isoformat()``, so the stored strings always
include an offset. We still call ``astimezone(timezone.utc)`` to
normalize anything that sneaks through with a different offset —
an inexpensive belt-and-braces guard.
"""
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
# Defensive: legacy rows (none exist yet) or a bad write path.
# Treat as UTC rather than raising; we never intentionally
# persist naive datetimes.
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _parse_optional_datetime(value: Optional[str]) -> Optional[datetime]:
"""Return ``None`` for NULL rows; otherwise parse as UTC.
Thin wrapper around :func:`_parse_datetime` kept for readability at
call sites that deal with nullable columns.
"""
if value is None:
return None
return _parse_datetime(value)
def _as_bool(value: Any) -> bool:
"""Coerce a SQLite INTEGER column into a Python ``bool``.
SQLite stores booleans as ``0`` / ``1`` integers. ``bool(0) is
False`` and ``bool(1) is True`` both behave correctly; this
wrapper exists so the intent is explicit at the mapper boundary
rather than relying on implicit truthiness.
"""
return bool(value)
def row_to_user(row: Mapping[str, Any]) -> User:
"""Map a ``users`` row to :class:`User`."""
return User(
id=int(row["id"]),
email=row["email"],
display_name=row["display_name"],
created_at=_parse_datetime(row["created_at"]),
last_login_at=_parse_optional_datetime(row["last_login_at"]),
active=_as_bool(row["active"]),
)
def row_to_magic_link_token(row: Mapping[str, Any]) -> MagicLinkToken:
"""Map a ``magic_link_tokens`` row to :class:`MagicLinkToken`."""
return MagicLinkToken(
id=int(row["id"]),
email=row["email"],
token_hash=row["token_hash"],
created_at=_parse_datetime(row["created_at"]),
expires_at=_parse_datetime(row["expires_at"]),
used_at=_parse_optional_datetime(row["used_at"]),
request_ip=row["request_ip"],
)
def row_to_session(row: Mapping[str, Any]) -> Session:
"""Map a ``sessions`` row to :class:`Session`."""
return Session(
id=int(row["id"]),
user_id=int(row["user_id"]),
token_hash=row["token_hash"],
created_at=_parse_datetime(row["created_at"]),
expires_at=_parse_datetime(row["expires_at"]),
ip=row["ip"],
user_agent=row["user_agent"],
revoked_at=_parse_optional_datetime(row["revoked_at"]),
)
def row_to_page(row: Mapping[str, Any]) -> Page:
"""Map a ``pages`` row to :class:`Page`."""
return Page(
id=int(row["id"]),
slug=row["slug"],
title=row["title"],
body_md=row["body_md"],
body_html_cached=row["body_html_cached"],
updated_at=_parse_datetime(row["updated_at"]),
published=_as_bool(row["published"]),
)
def row_to_post(row: Mapping[str, Any]) -> Post:
"""Map a ``posts`` row to :class:`Post`.
``status`` goes through the :class:`PostStatus` constructor which
enforces the same set the ``CHECK`` constraint does; a value that
somehow bypassed the constraint would raise ``ValueError`` here
rather than silently flowing into business logic.
"""
return Post(
id=int(row["id"]),
slug=row["slug"],
title=row["title"],
body_md=row["body_md"],
body_html_cached=row["body_html_cached"],
status=PostStatus(row["status"]),
published_at=_parse_optional_datetime(row["published_at"]),
updated_at=_parse_datetime(row["updated_at"]),
author_user_id=int(row["author_user_id"]),
)
def row_to_media(row: Mapping[str, Any]) -> Media:
"""Map a ``media`` row to :class:`Media`."""
return Media(
id=int(row["id"]),
filename=row["filename"],
original_filename=row["original_filename"],
content_type=row["content_type"],
size_bytes=int(row["size_bytes"]),
stored_path=row["stored_path"],
alt_text=row["alt_text"],
uploaded_by=int(row["uploaded_by"]),
uploaded_at=_parse_datetime(row["uploaded_at"]),
)
def row_to_contact_submission(row: Mapping[str, Any]) -> ContactSubmission:
"""Map a ``contact_submissions`` row to :class:`ContactSubmission`."""
return ContactSubmission(
id=int(row["id"]),
name=row["name"],
email=row["email"],
message=row["message"],
ip=row["ip"],
user_agent=row["user_agent"],
submitted_at=_parse_datetime(row["submitted_at"]),
handled=_as_bool(row["handled"]),
)
def row_to_auth_event(row: Mapping[str, Any]) -> AuthEvent:
"""Map an ``auth_events`` row to :class:`AuthEvent`."""
return AuthEvent(
id=int(row["id"]),
event_type=row["event_type"],
email=row["email"],
user_id=int(row["user_id"]) if row["user_id"] is not None else None,
ip=row["ip"],
user_agent=row["user_agent"],
created_at=_parse_datetime(row["created_at"]),
detail=row["detail"],
)