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

196 lines
5.0 KiB
Python

"""Canonical persistence-layer dataclasses.
One dataclass per table in the authoritative SQLite schema documented in
``docs/ROADMAP.md`` ("SQLite Schema (authoritative)"). These map 1:1 to
the columns of each table — field names, types, and nullability all
match — so the mapper layer (:mod:`app.models.mappers`) can convert
``sqlalchemy.Row`` objects to dataclass instances with no guesswork.
Design notes
------------
- Dataclasses are *not* frozen. Later phases mutate fields such as
``User.last_login_at`` or ``MagicLinkToken.used_at`` on successful
auth events; freezing would force service code into hand-rolled
copying. Immutability for view-layer projections is still enforced
via ``PostSummary`` in :mod:`app.models.posts`.
- Datetimes are always timezone-aware UTC at the Python boundary. The
SQLite columns are ``TEXT`` holding ISO-8601 strings; conversion
happens only in :mod:`app.models.mappers`, so application code never
sees a naive datetime.
- ``PostStatus`` is a string-valued ``Enum`` to keep JSON/template
rendering trivial while still providing type-level safety.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Optional
class PostStatus(str, Enum):
"""Publication lifecycle for a blog post.
The string values match the ``CHECK`` constraint on
``posts.status`` in the SQLite schema; adding a new value here
would require a migration, so this enum is deliberately small.
"""
DRAFT = "draft"
PUBLISHED = "published"
@dataclass
class User:
"""Admin user row.
Phase 2 seeds a single inactive system user (``id=1``) so the
``posts.author_user_id`` foreign key has something to reference;
real admin users are provisioned in Phase 3's magic-link flow.
"""
id: int
email: str
display_name: str
created_at: datetime
last_login_at: Optional[datetime]
active: bool
@dataclass
class MagicLinkToken:
"""Single-use email-login token.
``token_hash`` stores the SHA-256 of the raw token; the raw token
is emailed to the user and never persisted. ``used_at`` is set
when the token is consumed so we can refuse replay attempts
without deleting the audit row.
"""
id: int
email: str
token_hash: str
created_at: datetime
expires_at: datetime
used_at: Optional[datetime]
request_ip: str
@dataclass
class Session:
"""Authenticated admin session.
``revoked_at`` records logouts without deleting the row, so the
audit log remains complete. ``ip`` / ``user_agent`` are snapshots
from session creation, not live values.
"""
id: int
user_id: int
token_hash: str
created_at: datetime
expires_at: datetime
ip: str
user_agent: str
revoked_at: Optional[datetime]
@dataclass
class Page:
"""Static-ish content page (e.g. About).
``body_html_cached`` is regenerated on write by the Phase 4 admin
flow via the Markdown pipeline and stored here so render time
costs only a SELECT, not a sanitize. See "Caching Strategy" in
``docs/ROADMAP.md``.
"""
id: int
slug: str
title: str
body_md: str
body_html_cached: str
updated_at: datetime
published: bool
@dataclass
class Post:
"""Blog post row.
Mirrors the ``posts`` table exactly. ``body_html_cached`` follows
the same regenerate-on-write convention as :class:`Page`.
"""
id: int
slug: str
title: str
body_md: str
body_html_cached: str
status: PostStatus
published_at: Optional[datetime]
updated_at: datetime
author_user_id: int
@dataclass
class Media:
"""Uploaded image metadata.
``filename`` is the random storage name assigned on upload; the
original client-supplied filename is preserved for display only
and NEVER used to build a filesystem path. ``stored_path`` is
relative to the project root.
"""
id: int
filename: str
original_filename: str
content_type: str
size_bytes: int
stored_path: str
alt_text: str
uploaded_by: int
uploaded_at: datetime
@dataclass
class ContactSubmission:
"""Submission from the public ``/contact`` form.
``handled`` flips true once Head Hen has actioned the submission;
retained indefinitely as part of the contact audit log. No
sensitive fields — by design we only capture what the form asks
for.
"""
id: int
name: str
email: str
message: str
ip: str
user_agent: str
submitted_at: datetime
handled: bool
@dataclass
class AuthEvent:
"""Append-only audit record for auth-related events.
``event_type`` values are one of ``link_requested``,
``link_consumed``, ``session_revoked``, ``rate_limited`` (see
Phase 3). ``detail`` is a JSON string so we can attach
event-specific context without schema churn.
"""
id: int
event_type: str
email: Optional[str]
user_id: Optional[int]
ip: str
user_agent: str
created_at: datetime
detail: str