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.
205 lines
7.7 KiB
Python
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
|