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.
191 lines
6.3 KiB
Python
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"],
|
|
)
|