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

205 lines
7.7 KiB
Python

"""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