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