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:
204
app/models/seed.py
Normal file
204
app/models/seed.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Idempotent seed data for first-run databases.
|
||||
|
||||
Creates the minimum content needed so the public site is not blank
|
||||
before an admin exists:
|
||||
|
||||
- System seed user (``users.id = 1``). Inactive and not on the
|
||||
``ADMIN_EMAILS`` allowlist — cannot log in. Exists only so
|
||||
``posts.author_user_id`` has a foreign-key target.
|
||||
- Welcome blog post (``slug = 'welcome-to-the-farm'``).
|
||||
- About page (``slug = 'about'``) ported from the Phase 1 static copy.
|
||||
|
||||
Idempotency is enforced two ways:
|
||||
|
||||
1. A marker row in ``schema_migrations`` (``version = 'seed_001'``)
|
||||
— if present, the whole seed is a no-op.
|
||||
2. As a belt-and-braces guard, each INSERT is gated by ``INSERT OR
|
||||
IGNORE`` on a unique key (``users.email``, ``posts.slug``,
|
||||
``pages.slug``) so a partially-applied seed never duplicates.
|
||||
|
||||
Running this twice is safe and logs ``seed_skipped`` on the second
|
||||
boot, which the Phase 2 verification run depends on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.services.markdown import MarkdownService
|
||||
|
||||
|
||||
_log = structlog.get_logger(__name__)
|
||||
|
||||
# Marker row used to short-circuit the seed on subsequent boots.
|
||||
# Namespaced with the ``seed_`` prefix so it cannot collide with a
|
||||
# real migration file name.
|
||||
_SEED_MARKER: str = "seed_001"
|
||||
|
||||
|
||||
# --- Content --------------------------------------------------------------
|
||||
#
|
||||
# The About body is a Markdown translation of the Phase 1
|
||||
# ``app/templates/public/about.html`` narrative. Kept close to the
|
||||
# original wording so returning visitors see familiar copy; Head Hen
|
||||
# rewrites via the Phase 4 admin.
|
||||
#
|
||||
# The welcome post is three short paragraphs: a greeting, a Morrison,
|
||||
# TN mention (no street address — per CLAUDE.md), and a teaser of what
|
||||
# future updates will cover.
|
||||
_WELCOME_POST_TITLE: str = "Welcome to the Farm"
|
||||
_WELCOME_POST_SLUG: str = "welcome-to-the-farm"
|
||||
_WELCOME_POST_MD: str = (
|
||||
"Hi there, and thanks for stopping by Chicken Babies R Us! "
|
||||
"We're a small family farm and we're glad you found us.\n\n"
|
||||
"We're based in Morrison, Tennessee, tucked into the rolling "
|
||||
"hills of the middle part of the state. Our flock is growing, "
|
||||
"our waterfowl are loud, and our coffee cups are never quite "
|
||||
"empty.\n\n"
|
||||
"Check back soon for updates on hatching plans, new chicks and "
|
||||
"ducklings, fresh-egg availability, and whatever the geese "
|
||||
"decided to get into this week."
|
||||
)
|
||||
|
||||
_ABOUT_PAGE_TITLE: str = "About the Farm"
|
||||
_ABOUT_PAGE_SLUG: str = "about"
|
||||
_ABOUT_PAGE_MD: str = (
|
||||
"Chicken Babies R Us is a small family farm tucked into the "
|
||||
"rolling hills of Morrison, Tennessee. What started as a "
|
||||
"handful of chicks in a backyard brooder has grown into a flock "
|
||||
"of chickens, ducks, and geese that keep us busy (and "
|
||||
"entertained) year round.\n\n"
|
||||
"The operation is run by Head Hen — the chief wrangler, egg "
|
||||
"gatherer, waterfowl-whisperer, and unofficial chicken "
|
||||
"photographer. She handles the day-to-day care of the birds "
|
||||
"and does most of the writing you'll find on this site. Expect "
|
||||
"updates on hatching plans, new arrivals, the occasional coop "
|
||||
"mishap, and whatever the geese decided to get into this "
|
||||
"week.\n\n"
|
||||
"We're a hobby farm at heart, not a commercial one, which "
|
||||
"means we can take the time to know our birds and raise them "
|
||||
"the way we think they ought to be raised. If you're curious "
|
||||
"about what we've got going on — or just want to say hello — "
|
||||
"pop over to the contact page."
|
||||
)
|
||||
|
||||
# Seed user constants. ``active=0`` + the local-only email keep this
|
||||
# user out of any real auth flow. Phase 3's magic-link issuer MUST
|
||||
# refuse to issue links for non-allowlisted or inactive emails;
|
||||
# Phase 3 tests assert that behavior directly.
|
||||
_SEED_USER_ID: int = 1
|
||||
_SEED_USER_EMAIL: str = "seed@chickenbabies.local"
|
||||
_SEED_USER_DISPLAY: str = "Head Hen"
|
||||
|
||||
|
||||
def run_seed(engine: Engine) -> bool:
|
||||
"""Populate the database with first-run content, if not already done.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
engine:
|
||||
SQLAlchemy engine. Must already have had migrations applied
|
||||
(this function does not create tables).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` when seed rows were inserted on this call, ``False``
|
||||
when the marker was already present (no-op). Useful for
|
||||
verification scripts and tests that need to assert
|
||||
``seed_skipped`` on second boot.
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
markdown = MarkdownService()
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Short-circuit via the migration-tracker marker. Cheaper than
|
||||
# counting rows and survives the edge case of a manually
|
||||
# wiped posts/pages table that we wouldn't want to reseed
|
||||
# automatically.
|
||||
marker_row = conn.execute(
|
||||
text(
|
||||
"SELECT version FROM schema_migrations WHERE version = :v"
|
||||
),
|
||||
{"v": _SEED_MARKER},
|
||||
).first()
|
||||
if marker_row is not None:
|
||||
_log.info("seed_skipped", marker=_SEED_MARKER)
|
||||
return False
|
||||
|
||||
# --- Seed user ------------------------------------------------
|
||||
# The explicit id=1 pin keeps the ``posts.author_user_id``
|
||||
# foreign key stable even if a future migration renumbers.
|
||||
# Inline comment below repeats the intent for anyone reading
|
||||
# the DB directly.
|
||||
conn.execute(
|
||||
text(
|
||||
# seed artifact; not a real admin — see Phase 3 for real users
|
||||
"INSERT OR IGNORE INTO users"
|
||||
" (id, email, display_name, created_at, last_login_at, active)"
|
||||
" VALUES (:id, :email, :display_name, :created_at, NULL, 0)"
|
||||
),
|
||||
{
|
||||
"id": _SEED_USER_ID,
|
||||
"email": _SEED_USER_EMAIL,
|
||||
"display_name": _SEED_USER_DISPLAY,
|
||||
"created_at": now_iso,
|
||||
},
|
||||
)
|
||||
|
||||
# --- Welcome post --------------------------------------------
|
||||
welcome_html = markdown.render(_WELCOME_POST_MD)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT OR IGNORE INTO posts"
|
||||
" (slug, title, body_md, body_html_cached, status,"
|
||||
" published_at, updated_at, author_user_id)"
|
||||
" VALUES (:slug, :title, :body_md, :body_html,"
|
||||
" 'published', :published_at, :updated_at, :author_id)"
|
||||
),
|
||||
{
|
||||
"slug": _WELCOME_POST_SLUG,
|
||||
"title": _WELCOME_POST_TITLE,
|
||||
"body_md": _WELCOME_POST_MD,
|
||||
"body_html": welcome_html,
|
||||
"published_at": now_iso,
|
||||
"updated_at": now_iso,
|
||||
"author_id": _SEED_USER_ID,
|
||||
},
|
||||
)
|
||||
|
||||
# --- About page ----------------------------------------------
|
||||
about_html = markdown.render(_ABOUT_PAGE_MD)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT OR IGNORE INTO pages"
|
||||
" (slug, title, body_md, body_html_cached, updated_at,"
|
||||
" published)"
|
||||
" VALUES (:slug, :title, :body_md, :body_html,"
|
||||
" :updated_at, 1)"
|
||||
),
|
||||
{
|
||||
"slug": _ABOUT_PAGE_SLUG,
|
||||
"title": _ABOUT_PAGE_TITLE,
|
||||
"body_md": _ABOUT_PAGE_MD,
|
||||
"body_html": about_html,
|
||||
"updated_at": now_iso,
|
||||
},
|
||||
)
|
||||
|
||||
# --- Marker ---------------------------------------------------
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (version, applied_at)"
|
||||
" VALUES (:v, :t)"
|
||||
),
|
||||
{"v": _SEED_MARKER, "t": now_iso},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
_log.info("seed_applied", marker=_SEED_MARKER)
|
||||
return True
|
||||
Reference in New Issue
Block a user