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.
196 lines
5.0 KiB
Python
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
|