"""SQL row to dataclass converters. One ``row_to_`` 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"], )