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:
190
app/models/mappers.py
Normal file
190
app/models/mappers.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""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"],
|
||||
)
|
||||
Reference in New Issue
Block a user