feat: add CMS/pSEO engine, feature flags, email log (template v0.17.0 backport) ...

This commit is contained in:
Deeman
2026-02-26 02:43:10 +01:00
parent 3ae8c7e98a
commit 3629783bbf
21 changed files with 1810 additions and 8 deletions

View File

@@ -1,10 +1,16 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
_commit: v0.4.0
_commit: v0.17.0
_src_path: git@gitlab.com:deemanone/materia_saas_boilerplate.master.git
author_email: hendrik@beanflows.coffee
author_name: Hendrik Deeman
base_url: https://beanflows.coffee
business_model: saas
description: Commodity analytics for coffee traders
enable_cms: true
enable_daas: true
enable_directory: false
enable_i18n: false
enable_leads: false
payment_provider: paddle
project_name: BeanFlows
project_slug: beanflows

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
seed_cms_coffee.py — Seed coffee commodity CMS article templates.
Creates:
1. Article templates (Jinja2 body_template + URL/title patterns)
2. Template data rows (one per country/commodity/year combo)
pulled from the DuckDB serving layer when available.
Usage (from web/ directory):
uv run python scripts/seed_cms_coffee.py [--db data/app.db] [--dry-run]
After running this, go to /admin/cms to bulk-generate the articles.
"""
import argparse
import json
import os
import sqlite3
import sys
from pathlib import Path
# ── Config ────────────────────────────────────────────────────────────────────
DB_DEFAULT = "data/app.db"
SERVING_DB = os.getenv("SERVING_DUCKDB_PATH", "")
# ── Article templates ─────────────────────────────────────────────────────────
TEMPLATES = [
{
"name": "coffee-country-overview",
"url_pattern": "/coffee/{{ country_slug }}",
"title_pattern": "{{ country_name }} Coffee Production & Trade — BeanFlows",
"meta_description_pattern": (
"USDA PSD supply/demand data for {{ country_name }} coffee: "
"production, exports, imports, ending stocks, and market trends."
),
"body_template": """\
<h2>{{ country_name }} Coffee Overview</h2>
<p>
{{ country_name }} is {% if rank <= 5 %}one of the world's top coffee-producing nations
{% else %}a notable player in the global coffee market{% endif %}.
In {{ latest_year }}, total production reached
<strong>{{ "{:,}".format(production_bags|int) }} 60-kg bags</strong>.
</p>
<h3>Supply & Demand Snapshot ({{ latest_year }})</h3>
<table>
<thead>
<tr><th>Metric</th><th>Value (60-kg bags)</th></tr>
</thead>
<tbody>
<tr><td>Production</td><td>{{ "{:,}".format(production_bags|int) }}</td></tr>
<tr><td>Exports</td><td>{{ "{:,}".format(exports_bags|int) }}</td></tr>
<tr><td>Domestic Consumption</td><td>{{ "{:,}".format(domestic_consumption_bags|int) }}</td></tr>
<tr><td>Ending Stocks</td><td>{{ "{:,}".format(ending_stocks_bags|int) }}</td></tr>
</tbody>
</table>
<h3>Production Trend</h3>
<p>
Over the past decade, {{ country_name }}'s coffee output has shown
{% if production_trend == 'up' %}a <strong>rising trend</strong>
{% elif production_trend == 'down' %}a <strong>declining trend</strong>
{% else %}relatively <strong>stable</strong> production
{% endif %}.
Year-on-year change in {{ latest_year }}: <strong>{{ production_yoy_pct }}%</strong>.
</p>
<h3>Key Export Markets</h3>
<p>
{{ country_name }} primarily exports to international commodity markets,
with volumes settled against the ICE Coffee C futures contract (KC=F).
Track live price data and warehouse stocks on the
<a href="/dashboard/positioning">BeanFlows positioning dashboard</a>.
</p>
<p><em>Data source: USDA PSD Online, updated {{ data_vintage }}.</em></p>
""",
},
{
"name": "coffee-global-market-year",
"url_pattern": "/coffee/market/{{ market_year }}",
"title_pattern": "Global Coffee Market {{ market_year }} — Supply, Demand & Stocks",
"meta_description_pattern": (
"Global coffee supply and demand balance for {{ market_year }}: "
"USDA PSD production, consumption, trade, and ending stocks data."
),
"body_template": """\
<h2>Global Coffee Market {{ market_year }}</h2>
<p>
The <strong>{{ market_year }}</strong> global coffee marketing year ran from
October {{ market_year|int - 1 }} through September {{ market_year }}.
World production totalled
<strong>{{ "{:,}".format(world_production_bags|int) }} million 60-kg bags</strong>.
</p>
<h3>World Supply & Demand Balance</h3>
<table>
<thead>
<tr><th>Metric</th><th>Million 60-kg Bags</th></tr>
</thead>
<tbody>
<tr><td>Opening Stocks</td><td>{{ "%.1f"|format(beginning_stocks_m|float) }}</td></tr>
<tr><td>Production</td><td>{{ "%.1f"|format(production_m|float) }}</td></tr>
<tr><td>Total Supply</td><td>{{ "%.1f"|format(total_supply_m|float) }}</td></tr>
<tr><td>Consumption</td><td>{{ "%.1f"|format(consumption_m|float) }}</td></tr>
<tr><td>Ending Stocks</td><td>{{ "%.1f"|format(ending_stocks_m|float) }}</td></tr>
<tr><td>Stock-to-Use Ratio</td><td>{{ "%.1f"|format(stu_pct|float) }}%</td></tr>
</tbody>
</table>
<h3>Supply/Demand Balance</h3>
<p>
The {{ market_year }} marketing year ended with a
{% if balance >= 0 %}<strong>surplus</strong> of {{ "%.1f"|format(balance|float) }}M bags
{% else %}<strong>deficit</strong> of {{ "%.1f"|format((balance|float)|abs) }}M bags
{% endif %}.
The stock-to-use ratio of <strong>{{ "%.1f"|format(stu_pct|float) }}%</strong> indicates
{% if stu_pct|float > 25 %}comfortable{% elif stu_pct|float > 18 %}adequate{% else %}tight{% endif %}
global supply conditions.
</p>
<p>
Explore live supply & demand charts and price data on
<a href="/dashboard/supply">BeanFlows Supply Dashboard</a>.
</p>
<p><em>Data source: USDA PSD Online, updated {{ data_vintage }}.</em></p>
""",
},
]
# ── Data generation ───────────────────────────────────────────────────────────
def fetch_country_data_from_duckdb() -> list[dict]:
"""Pull top coffee-producing countries from DuckDB serving layer."""
if not SERVING_DB or not Path(SERVING_DB).exists():
print(f" Serving DB not found at {SERVING_DB!r} — using placeholder countries")
return []
try:
import duckdb
conn = duckdb.connect(SERVING_DB, read_only=True)
rows = conn.execute("""
WITH latest AS (
SELECT MAX(market_year) AS max_year
FROM serving.commodity_metrics
WHERE commodity_code = 711100 AND country_code IS NOT NULL
),
ranked AS (
SELECT country_name, country_code, market_year,
production * 1000 AS production_bags,
exports * 1000 AS exports_bags,
domestic_consumption * 1000 AS domestic_consumption_bags,
ending_stocks * 1000 AS ending_stocks_bags,
production_yoy_pct,
ROW_NUMBER() OVER (ORDER BY production DESC) AS rank
FROM serving.commodity_metrics, latest
WHERE commodity_code = 711100
AND country_code IS NOT NULL
AND market_year = latest.max_year
AND production > 0
)
SELECT * FROM ranked LIMIT 30
""").fetchall()
cols = [d[0] for d in conn.execute("""
WITH latest AS (SELECT MAX(market_year) AS max_year FROM serving.commodity_metrics
WHERE commodity_code = 711100 AND country_code IS NOT NULL)
SELECT country_name, country_code, market_year, production * 1000,
exports * 1000, domestic_consumption * 1000, ending_stocks * 1000,
production_yoy_pct, 1 FROM serving.commodity_metrics, latest LIMIT 0
""").description or []]
return [dict(zip(["country_name","country_code","market_year","production_bags",
"exports_bags","domestic_consumption_bags","ending_stocks_bags",
"production_yoy_pct","rank"], row)) for row in rows]
except Exception as e:
print(f" DuckDB error: {e} — using placeholder countries")
return []
def fetch_global_year_data_from_duckdb() -> list[dict]:
"""Pull global supply/demand summary per market year."""
if not SERVING_DB or not Path(SERVING_DB).exists():
return []
try:
import duckdb
conn = duckdb.connect(SERVING_DB, read_only=True)
rows = conn.execute("""
SELECT market_year,
beginning_stocks * 1000 AS beginning_stocks_bags,
production * 1000 AS world_production_bags,
total_supply * 1000 AS total_supply_bags,
domestic_consumption * 1000 AS consumption_bags,
ending_stocks * 1000 AS ending_stocks_bags,
production / NULLIF(total_distribution, 0) * 1000 AS beginning_stocks_m,
production AS production_m,
total_supply AS total_supply_m,
domestic_consumption AS consumption_m,
ending_stocks AS ending_stocks_m,
supply_demand_balance AS balance,
stock_to_use_ratio_pct AS stu_pct
FROM serving.commodity_metrics
WHERE commodity_code = 711100 AND country_name = 'Global'
ORDER BY market_year DESC
LIMIT 10
""").fetchall()
cols = ["market_year","beginning_stocks_bags","world_production_bags",
"total_supply_bags","consumption_bags","ending_stocks_bags",
"beginning_stocks_m","production_m","total_supply_m","consumption_m",
"ending_stocks_m","balance","stu_pct"]
return [dict(zip(cols, row)) for row in rows]
except Exception as e:
print(f" DuckDB error (global): {e}")
return []
PLACEHOLDER_COUNTRIES = [
{"country_name": "Brazil", "country_code": "BR", "rank": 1},
{"country_name": "Vietnam", "country_code": "VN", "rank": 2},
{"country_name": "Colombia", "country_code": "CO", "rank": 3},
{"country_name": "Indonesia", "country_code": "ID", "rank": 4},
{"country_name": "Ethiopia", "country_code": "ET", "rank": 5},
{"country_name": "Honduras", "country_code": "HN", "rank": 6},
{"country_name": "India", "country_code": "IN", "rank": 7},
{"country_name": "Uganda", "country_code": "UG", "rank": 8},
{"country_name": "Mexico", "country_code": "MX", "rank": 9},
{"country_name": "Peru", "country_code": "PE", "rank": 10},
]
def slug(name: str) -> str:
return name.lower().replace(" ", "-").replace(",", "").replace("'", "")
# ── Main ──────────────────────────────────────────────────────────────────────
def run(db_path: str, dry_run: bool = False):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
now = __import__("datetime").datetime.utcnow().isoformat()
data_vintage = __import__("datetime").date.today().strftime("%B %Y")
inserted_templates = 0
inserted_data_rows = 0
for tmpl in TEMPLATES:
existing = conn.execute(
"SELECT id FROM article_templates WHERE name = ?", (tmpl["name"],)
).fetchone()
if existing:
tmpl_id = existing["id"]
print(f" Template '{tmpl['name']}' already exists (id={tmpl_id})")
else:
if dry_run:
print(f" [dry-run] Would insert template: {tmpl['name']}")
tmpl_id = -1
else:
cursor = conn.execute(
"""INSERT INTO article_templates
(name, slug, url_pattern, title_pattern, meta_description_pattern,
body_template, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(tmpl["name"], tmpl["name"], tmpl["url_pattern"], tmpl["title_pattern"],
tmpl["meta_description_pattern"], tmpl["body_template"], now),
)
tmpl_id = cursor.lastrowid
inserted_templates += 1
print(f" Inserted template: {tmpl['name']} (id={tmpl_id})")
# Seed data rows per template
if tmpl["name"] == "coffee-country-overview":
countries = fetch_country_data_from_duckdb() or [
{**c, "latest_year": 2024, "production_bags": 0,
"exports_bags": 0, "domestic_consumption_bags": 0,
"ending_stocks_bags": 0, "production_yoy_pct": 0,
"production_trend": "stable"}
for c in PLACEHOLDER_COUNTRIES
]
for c in countries:
country_slug = slug(c["country_name"])
data = {
"country_name": c["country_name"],
"country_code": c.get("country_code", ""),
"country_slug": country_slug,
"latest_year": c.get("market_year", 2024),
"production_bags": c.get("production_bags", 0),
"exports_bags": c.get("exports_bags", 0),
"domestic_consumption_bags": c.get("domestic_consumption_bags", 0),
"ending_stocks_bags": c.get("ending_stocks_bags", 0),
"production_yoy_pct": round(c.get("production_yoy_pct") or 0, 1),
"production_trend": (
"up" if (c.get("production_yoy_pct") or 0) > 2
else "down" if (c.get("production_yoy_pct") or 0) < -2
else "stable"
),
"rank": c.get("rank", 99),
"data_vintage": data_vintage,
}
exists = conn.execute(
"SELECT id FROM template_data WHERE template_id = ? "
"AND json_extract(data_json, '$.country_code') = ?",
(tmpl_id, c.get("country_code", "")),
).fetchone()
if not exists and not dry_run and tmpl_id > 0:
conn.execute(
"INSERT INTO template_data (template_id, data_json, created_at) VALUES (?, ?, ?)",
(tmpl_id, json.dumps(data), now),
)
inserted_data_rows += 1
elif dry_run:
print(f" [dry-run] Would insert data row: {c['country_name']}")
elif tmpl["name"] == "coffee-global-market-year":
years_data = fetch_global_year_data_from_duckdb()
if not years_data:
years_data = [{"market_year": y} for y in range(2020, 2025)]
for y in years_data:
data = {
"market_year": y["market_year"],
"world_production_bags": y.get("world_production_bags", 0),
"beginning_stocks_m": round(y.get("beginning_stocks_m") or 0, 1),
"production_m": round(y.get("production_m") or 0, 1),
"total_supply_m": round(y.get("total_supply_m") or 0, 1),
"consumption_m": round(y.get("consumption_m") or 0, 1),
"ending_stocks_m": round(y.get("ending_stocks_m") or 0, 1),
"balance": round(y.get("balance") or 0, 2),
"stu_pct": round(y.get("stu_pct") or 0, 1),
"data_vintage": data_vintage,
}
exists = conn.execute(
"SELECT id FROM template_data WHERE template_id = ? "
"AND json_extract(data_json, '$.market_year') = ?",
(tmpl_id, y["market_year"]),
).fetchone()
if not exists and not dry_run and tmpl_id > 0:
conn.execute(
"INSERT INTO template_data (template_id, data_json, created_at) VALUES (?, ?, ?)",
(tmpl_id, json.dumps(data), now),
)
inserted_data_rows += 1
elif dry_run:
print(f" [dry-run] Would insert data row: market_year={y['market_year']}")
if not dry_run:
conn.commit()
conn.close()
print(f"\nDone — inserted {inserted_templates} templates, {inserted_data_rows} data rows.")
if not dry_run:
print("Next: go to /admin/cms → pSEO Templates → Bulk Generate")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Seed coffee CMS templates")
parser.add_argument("--db", default=DB_DEFAULT, help=f"SQLite DB path (default: {DB_DEFAULT})")
parser.add_argument("--dry-run", action="store_true", help="Print what would be inserted, don't write")
args = parser.parse_args()
db = Path(args.db)
if not db.exists():
print(f"DB not found at {db}. Run migrations first: uv run python -m beanflows.migrations.migrate")
sys.exit(1)
print(f"Seeding coffee CMS content into {db}...")
run(str(db), dry_run=args.dry_run)

View File

@@ -0,0 +1,368 @@
"""
CMS / pSEO admin: article management, template CRUD, programmatic generation.
"""
import json
from datetime import datetime
from functools import wraps
from pathlib import Path
from quart import Blueprint, flash, g, redirect, render_template, request, url_for
from ..core import execute, fetch_all, fetch_one, csrf_protect
bp = Blueprint(
"cms",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
url_prefix="/admin/cms",
)
def admin_required(f):
@wraps(f)
async def decorated(*args, **kwargs):
if "admin" not in (g.get("user") or {}).get("roles", []):
await flash("Admin access required.", "error")
from quart import redirect as _redirect, url_for as _url_for
return _redirect(_url_for("auth.login"))
return await f(*args, **kwargs)
return decorated
# =============================================================================
# Article helpers
# =============================================================================
async def list_articles(limit: int = 200) -> list[dict]:
return await fetch_all(
"""SELECT a.*, at.name AS template_name
FROM articles a
LEFT JOIN template_data td ON td.id = a.template_data_id
LEFT JOIN article_templates at ON at.id = td.template_id
ORDER BY a.created_at DESC LIMIT ?""",
(limit,),
)
async def get_article(article_id: int) -> dict | None:
return await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
async def list_article_templates() -> list[dict]:
return await fetch_all(
"""SELECT at.*, COUNT(td.id) AS data_count,
COUNT(td.article_id) AS generated_count
FROM article_templates at
LEFT JOIN template_data td ON td.template_id = at.id
GROUP BY at.id ORDER BY at.created_at DESC"""
)
async def get_article_template(template_id: int) -> dict | None:
return await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
async def list_template_data(template_id: int) -> list[dict]:
return await fetch_all(
"SELECT * FROM template_data WHERE template_id = ? ORDER BY id",
(template_id,),
)
async def generate_article_from_data(data_row: dict, tmpl: dict) -> int | None:
"""Generate (or regenerate) a single article from a template_data row."""
from jinja2 import Environment, BaseLoader
try:
data = json.loads(data_row["data_json"])
except (json.JSONDecodeError, KeyError):
return None
env = Environment(loader=BaseLoader())
def _render(pattern: str) -> str:
try:
return env.from_string(pattern).render(**data)
except Exception:
return pattern
url_path = "/" + _render(tmpl["url_pattern"]).strip("/")
slug = url_path.strip("/").replace("/", "-")
title = _render(tmpl["title_pattern"])
meta_description = _render(tmpl.get("meta_description_pattern") or "")
now = datetime.utcnow().isoformat()
existing = await fetch_one("SELECT id FROM articles WHERE url_path = ?", (url_path,))
if existing:
article_id = existing["id"]
await execute(
"""UPDATE articles
SET title = ?, meta_description = ?, status = 'published',
published_at = COALESCE(published_at, ?),
template_data_id = ?, updated_at = ?
WHERE id = ?""",
(title, meta_description, now, data_row["id"], now, article_id),
)
else:
article_id = await execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, status, published_at,
template_data_id, created_at)
VALUES (?, ?, ?, ?, 'published', ?, ?, ?)""",
(url_path, slug, title, meta_description, now, data_row["id"], now),
)
await execute(
"UPDATE template_data SET article_id = ? WHERE id = ?",
(article_id, data_row["id"]),
)
return article_id
# =============================================================================
# Article routes
# =============================================================================
@bp.route("/")
@admin_required
async def cms_index():
articles = await list_articles()
return await render_template(
"admin/cms.html",
articles=articles,
published_count=sum(1 for a in articles if a["status"] == "published"),
draft_count=sum(1 for a in articles if a["status"] == "draft"),
)
@bp.route("/new", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def cms_new():
if request.method == "POST":
form = await request.form
url_path = ("/" + form.get("url_path", "").strip("/")).rstrip("/") or "/"
slug = url_path.strip("/").replace("/", "-") or "untitled"
title = (form.get("title") or "").strip()
meta_description = (form.get("meta_description") or "").strip()
status = form.get("status", "draft")
now = datetime.utcnow().isoformat()
published_at = now if status == "published" else None
article_id = await execute(
"""INSERT INTO articles (url_path, slug, title, meta_description, status, published_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(url_path, slug, title, meta_description, status, published_at, now),
)
await flash(f"Article '{title}' created.", "success")
return redirect(url_for("cms.cms_edit", article_id=article_id))
return await render_template("admin/cms_editor.html", article=None)
@bp.route("/<int:article_id>/edit", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def cms_edit(article_id: int):
article = await get_article(article_id)
if not article:
await flash("Article not found.", "error")
return redirect(url_for("cms.cms_index"))
if request.method == "POST":
form = await request.form
url_path = ("/" + form.get("url_path", "").strip("/")).rstrip("/") or "/"
slug = url_path.strip("/").replace("/", "-") or f"article-{article_id}"
title = (form.get("title") or "").strip()
meta_description = (form.get("meta_description") or "").strip()
og_image_url = (form.get("og_image_url") or "").strip()
status = form.get("status", "draft")
now = datetime.utcnow().isoformat()
published_at = article["published_at"] or (now if status == "published" else None)
await execute(
"""UPDATE articles
SET url_path = ?, slug = ?, title = ?, meta_description = ?,
og_image_url = ?, status = ?, published_at = ?, updated_at = ?
WHERE id = ?""",
(url_path, slug, title, meta_description, og_image_url or None,
status, published_at, now, article_id),
)
await flash("Article saved.", "success")
return redirect(url_for("cms.cms_edit", article_id=article_id))
article = await get_article(article_id)
return await render_template("admin/cms_editor.html", article=article)
@bp.route("/<int:article_id>/publish", methods=["POST"])
@admin_required
@csrf_protect
async def cms_publish(article_id: int):
now = datetime.utcnow().isoformat()
await execute(
"UPDATE articles SET status = 'published', published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ?",
(now, now, article_id),
)
await flash("Article published.", "success")
return redirect(url_for("cms.cms_index"))
@bp.route("/<int:article_id>/unpublish", methods=["POST"])
@admin_required
@csrf_protect
async def cms_unpublish(article_id: int):
await execute(
"UPDATE articles SET status = 'draft', updated_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), article_id),
)
await flash("Article moved to draft.", "success")
return redirect(url_for("cms.cms_index"))
@bp.route("/<int:article_id>/delete", methods=["POST"])
@admin_required
@csrf_protect
async def cms_delete(article_id: int):
await execute("DELETE FROM articles WHERE id = ?", (article_id,))
await flash("Article deleted.", "success")
return redirect(url_for("cms.cms_index"))
# =============================================================================
# Article template routes
# =============================================================================
@bp.route("/templates")
@admin_required
async def template_list():
templates = await list_article_templates()
return await render_template("admin/cms_templates.html", templates=templates)
@bp.route("/templates/new", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def template_new():
if request.method == "POST":
form = await request.form
name = (form.get("name") or "").strip()
slug = (form.get("slug") or name.lower().replace(" ", "-")).strip()
url_pattern = (form.get("url_pattern") or "").strip()
title_pattern = (form.get("title_pattern") or "").strip()
meta_description_pattern = (form.get("meta_description_pattern") or "").strip()
body_template = (form.get("body_template") or "").strip()
now = datetime.utcnow().isoformat()
template_id = await execute(
"""INSERT INTO article_templates
(name, slug, url_pattern, title_pattern, meta_description_pattern, body_template, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, slug, url_pattern, title_pattern, meta_description_pattern or None,
body_template, now),
)
await flash(f"Template '{name}' created.", "success")
return redirect(url_for("cms.template_data_view", template_id=template_id))
return await render_template("admin/cms_template_editor.html", template=None)
@bp.route("/templates/<int:template_id>/edit", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def template_edit(template_id: int):
tmpl = await get_article_template(template_id)
if not tmpl:
await flash("Template not found.", "error")
return redirect(url_for("cms.template_list"))
if request.method == "POST":
form = await request.form
now = datetime.utcnow().isoformat()
await execute(
"""UPDATE article_templates
SET name = ?, url_pattern = ?, title_pattern = ?,
meta_description_pattern = ?, body_template = ?, updated_at = ?
WHERE id = ?""",
(
(form.get("name") or tmpl["name"]).strip(),
(form.get("url_pattern") or "").strip(),
(form.get("title_pattern") or "").strip(),
(form.get("meta_description_pattern") or "").strip() or None,
(form.get("body_template") or "").strip(),
now,
template_id,
),
)
await flash("Template saved.", "success")
return redirect(url_for("cms.template_data_view", template_id=template_id))
return await render_template("admin/cms_template_editor.html", template=tmpl)
@bp.route("/templates/<int:template_id>")
@admin_required
async def template_data_view(template_id: int):
tmpl = await get_article_template(template_id)
if not tmpl:
await flash("Template not found.", "error")
return redirect(url_for("cms.template_list"))
data_rows = await list_template_data(template_id)
return await render_template(
"admin/cms_template_data.html",
template=tmpl,
data_rows=data_rows,
)
@bp.route("/templates/<int:template_id>/data/add", methods=["POST"])
@admin_required
@csrf_protect
async def template_data_add(template_id: int):
form = await request.form
data_json = (form.get("data_json") or "{}").strip()
try:
json.loads(data_json)
except json.JSONDecodeError:
await flash("Invalid JSON.", "error")
return redirect(url_for("cms.template_data_view", template_id=template_id))
await execute(
"INSERT INTO template_data (template_id, data_json, created_at) VALUES (?, ?, ?)",
(template_id, data_json, datetime.utcnow().isoformat()),
)
await flash("Data row added.", "success")
return redirect(url_for("cms.template_data_view", template_id=template_id))
@bp.route("/templates/<int:template_id>/generate", methods=["POST"])
@admin_required
@csrf_protect
async def template_bulk_generate(template_id: int):
tmpl = await get_article_template(template_id)
if not tmpl:
await flash("Template not found.", "error")
return redirect(url_for("cms.template_list"))
pending = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ? AND article_id IS NULL",
(template_id,),
)
generated = sum(1 for row in pending if await generate_article_from_data(row, tmpl))
await flash(f"Generated {generated} article(s) from {len(pending)} pending row(s).", "success")
return redirect(url_for("cms.template_data_view", template_id=template_id))
@bp.route("/templates/<int:template_id>/regenerate", methods=["POST"])
@admin_required
@csrf_protect
async def template_regenerate_all(template_id: int):
tmpl = await get_article_template(template_id)
if not tmpl:
await flash("Template not found.", "error")
return redirect(url_for("cms.template_list"))
all_rows = await list_template_data(template_id)
regenerated = sum(1 for row in all_rows if await generate_article_from_data(row, tmpl))
await flash(f"Regenerated {regenerated} article(s).", "success")
return redirect(url_for("cms.template_data_view", template_id=template_id))

View File

@@ -367,3 +367,62 @@ async def waitlist():
entries=entries,
total=len(entries),
)
# =============================================================================
# Feature Flags
# =============================================================================
@bp.route("/flags")
@admin_required
async def flags():
"""Feature flags admin."""
flag_list = await fetch_all("SELECT * FROM feature_flags ORDER BY name")
return await render_template("admin/flags.html", flags=flag_list)
@bp.route("/flags/create", methods=["POST"])
@admin_required
@csrf_protect
async def flag_create():
"""Create a feature flag."""
form = await request.form
name = (form.get("name") or "").strip().lower().replace(" ", "_")
description = (form.get("description") or "").strip()
if not name:
await flash("Flag name is required.", "error")
return redirect(url_for("admin.flags"))
await execute(
"INSERT OR IGNORE INTO feature_flags (name, enabled, description, updated_at) VALUES (?, 0, ?, ?)",
(name, description or None, datetime.utcnow().isoformat()),
)
await flash(f"Flag '{name}' created.", "success")
return redirect(url_for("admin.flags"))
@bp.route("/flags/toggle", methods=["POST"])
@admin_required
@csrf_protect
async def flag_toggle():
"""Toggle a feature flag on/off."""
form = await request.form
name = (form.get("name") or "").strip()
await execute(
"UPDATE feature_flags SET enabled = NOT enabled, updated_at = ? WHERE name = ?",
(datetime.utcnow().isoformat(), name),
)
return redirect(url_for("admin.flags"))
# =============================================================================
# Email Log
# =============================================================================
@bp.route("/emails")
@admin_required
async def emails():
"""Email log admin."""
log = await fetch_all(
"SELECT * FROM email_log ORDER BY created_at DESC LIMIT 500"
)
return await render_template("admin/emails.html", log=log)

View File

@@ -319,6 +319,48 @@
Waitlist
</a>
</div>
<div class="admin-nav-section">
<div class="admin-nav-label">Content</div>
<a href="{{ url_for('cms.cms_index') }}"
class="admin-nav-item {% if admin_page == 'cms' %}active{% endif %}">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
Articles
</a>
<a href="{{ url_for('cms.template_list') }}"
class="admin-nav-item {% if admin_page == 'cms_templates' %}active{% endif %}">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>
</svg>
pSEO Templates
</a>
</div>
<div class="admin-nav-section">
<div class="admin-nav-label">System</div>
<a href="{{ url_for('admin.flags') }}"
class="admin-nav-item {% if admin_page == 'flags' %}active{% endif %}">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/>
<line x1="4" y1="22" x2="4" y2="15"/>
</svg>
Feature Flags
</a>
<a href="{{ url_for('admin.emails') }}"
class="admin-nav-item {% if admin_page == 'emails' %}active{% endif %}">
<svg class="admin-nav-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
Email Log
</a>
</div>
</nav>
<!-- Footer links -->

View File

@@ -0,0 +1,92 @@
{% set admin_page = "cms" %}
{% extends "admin/base_admin.html" %}
{% block title %}CMS Articles — {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}CMS Articles{% endblock %}
{% block admin_content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<h2 class="text-xl" style="margin:0;">Articles</h2>
<p class="text-sm text-slate" style="margin:4px 0 0;">
{{ published_count }} published · {{ draft_count }} draft
</p>
</div>
<div style="display:flex;gap:8px;">
<a href="{{ url_for('cms.template_list') }}" class="btn-outline btn-sm">pSEO Templates</a>
<a href="{{ url_for('cms.cms_new') }}" class="btn btn-sm">+ New Article</a>
</div>
</div>
<div class="card" style="padding:0;overflow:hidden;">
{% if articles %}
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>URL Path</th>
<th>Template</th>
<th>Status</th>
<th>Published</th>
<th></th>
</tr>
</thead>
<tbody>
{% for article in articles %}
<tr>
<td>
<a href="{{ url_for('cms.cms_edit', article_id=article.id) }}" class="text-sm" style="font-weight:500;">
{{ article.title }}
</a>
</td>
<td>
<a href="{{ article.url_path }}" target="_blank" class="mono text-sm" style="color:#64748B;">
{{ article.url_path }}
</a>
</td>
<td>
{% if article.template_name %}
<span class="text-sm text-slate">{{ article.template_name }}</span>
{% else %}
<span class="text-slate text-sm"></span>
{% endif %}
</td>
<td>
{% if article.status == 'published' %}
<span class="badge-success">published</span>
{% else %}
<span style="display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600;background:#F1F5F9;color:#64748B;">draft</span>
{% endif %}
</td>
<td class="mono text-sm">{{ article.published_at[:10] if article.published_at else "—" }}</td>
<td>
<div style="display:flex;gap:6px;">
<a href="{{ url_for('cms.cms_edit', article_id=article.id) }}" class="btn-outline btn-sm">Edit</a>
{% if article.status == 'published' %}
<form method="post" action="{{ url_for('cms.cms_unpublish', article_id=article.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Unpublish</button>
</form>
{% else %}
<form method="post" action="{{ url_for('cms.cms_publish', article_id=article.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm">Publish</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:40px;text-align:center;">
<p class="text-slate text-sm">No articles yet. Use pSEO Templates to bulk-generate coffee market pages.</p>
<div style="display:flex;gap:10px;justify-content:center;margin-top:12px;">
<a href="{{ url_for('cms.template_list') }}" class="btn btn-sm">pSEO Templates</a>
<a href="{{ url_for('cms.cms_new') }}" class="btn-outline btn-sm">New Article</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% set admin_page = "cms" %}
{% extends "admin/base_admin.html" %}
{% block title %}{% if article %}Edit Article{% else %}New Article{% endif %} — Admin{% endblock %}
{% block topbar_title %}{% if article %}Edit Article{% else %}New Article{% endif %}{% endblock %}
{% block admin_content %}
<div style="margin-bottom:16px;">
<a href="{{ url_for('cms.cms_index') }}" style="font-size:13px;color:#64748B;text-decoration:none;">
← Articles
</a>
</div>
<div class="card" style="padding:20px;max-width:720px;">
<form method="post"
action="{{ url_for('cms.cms_edit', article_id=article.id) if article else url_for('cms.cms_new') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display:grid;gap:16px;">
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">Title *</label>
<input type="text" name="title" value="{{ article.title if article else '' }}" required
placeholder="Article title"
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:14px;">
</div>
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">
URL Path *
<span style="font-weight:400;color:#94A3B8;font-size:11px;">e.g. /coffee/brazil</span>
</label>
<input type="text" name="url_path" value="{{ article.url_path if article else '' }}" required
placeholder="/coffee/country-name"
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:14px;font-family:monospace;">
</div>
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">
Meta Description
<span style="font-weight:400;color:#94A3B8;font-size:11px;">150160 chars</span>
</label>
<textarea name="meta_description" maxlength="300" rows="2"
placeholder="Short description for search engines"
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:13px;resize:vertical;">{{ article.meta_description if article else '' }}</textarea>
</div>
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">OG Image URL</label>
<input type="url" name="og_image_url" value="{{ article.og_image_url if article else '' }}"
placeholder="https://..."
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:13px;">
</div>
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">Status</label>
<select name="status"
style="border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:13px;background:#fff;">
<option value="draft" {% if not article or article.status == 'draft' %}selected{% endif %}>Draft</option>
<option value="published" {% if article and article.status == 'published' %}selected{% endif %}>Published</option>
</select>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:10px;align-items:center;">
<button type="submit" class="btn">Save</button>
{% if article %}
<a href="{{ article.url_path }}" target="_blank" class="btn-outline btn-sm">View →</a>
<form method="post" action="{{ url_for('cms.cms_delete', article_id=article.id) }}"
class="m-0" onsubmit="return confirm('Delete this article?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" style="color:#EF4444;border-color:#EF4444;">Delete</button>
</form>
{% endif %}
</div>
</form>
{% if article %}
<div style="margin-top:24px;padding-top:16px;border-top:1px solid #E2E8F0;">
<p class="text-sm text-slate" style="margin:0 0 8px;">
<strong style="color:#0F172A;">Body content:</strong>
{% if article.template_data_id %}
Programmatically generated from pSEO template data.
{% else %}
Hand-written article (no template linked).
{% endif %}
</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% set admin_page = "cms" %}
{% extends "admin/base_admin.html" %}
{% block title %}{{ template.name }} Data — Admin{% endblock %}
{% block topbar_title %}{{ template.name }}{% endblock %}
{% block admin_content %}
<div style="margin-bottom:16px;">
<a href="{{ url_for('cms.template_list') }}" style="font-size:13px;color:#64748B;text-decoration:none;">
← Templates
</a>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<h2 class="text-xl" style="margin:0;">{{ template.name }}</h2>
<p class="text-sm text-slate" style="margin:4px 0 0;">
URL: <code class="mono">{{ template.url_pattern }}</code> ·
{{ data_rows | length }} rows ·
{{ data_rows | selectattr('article_id') | list | length }} generated
</p>
</div>
<div style="display:flex;gap:8px;">
<a href="{{ url_for('cms.template_edit', template_id=template.id) }}" class="btn-outline btn-sm">Edit Template</a>
<form method="post" action="{{ url_for('cms.template_bulk_generate', template_id=template.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm">Generate Pending</button>
</form>
<form method="post" action="{{ url_for('cms.template_regenerate_all', template_id=template.id) }}" class="m-0"
onsubmit="return confirm('Regenerate all {{ data_rows | length }} articles?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Regenerate All</button>
</form>
</div>
</div>
<!-- Add data row -->
<div class="card" style="padding:16px;margin-bottom:16px;">
<p class="text-sm" style="font-weight:600;margin:0 0 8px;">Add data row</p>
<form method="post" action="{{ url_for('cms.template_data_add', template_id=template.id) }}"
style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="flex:1;min-width:300px;">
<label class="text-sm" style="display:block;margin-bottom:4px;color:#475569;">JSON data</label>
<input type="text" name="data_json" placeholder='{"country_name": "Brazil", "country_slug": "brazil"}' required
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:6px 10px;font-size:13px;font-family:monospace;">
</div>
<button type="submit" class="btn btn-sm">Add</button>
</form>
</div>
<!-- Data rows table -->
<div class="card" style="padding:0;overflow:hidden;">
{% if data_rows %}
<table class="table" style="font-size:12px;">
<thead>
<tr>
<th>#</th>
<th>Data</th>
<th>Article</th>
<th>Added</th>
</tr>
</thead>
<tbody>
{% for row in data_rows %}
<tr>
<td class="mono text-sm" style="color:#94A3B8;">{{ row.id }}</td>
<td>
<code style="font-size:11px;color:#475569;max-width:400px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
title="{{ row.data_json }}">{{ row.data_json }}</code>
</td>
<td>
{% if row.article_id %}
<span class="badge-success">generated #{{ row.article_id }}</span>
{% else %}
<span style="font-size:11px;color:#F59E0B;">pending</span>
{% endif %}
</td>
<td class="mono text-sm">{{ row.created_at[:10] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:40px;text-align:center;">
<p class="text-slate text-sm">No data rows yet.</p>
<p class="text-slate text-sm" style="margin-top:6px;">
Run <code class="mono">uv run python scripts/seed_cms_coffee.py</code> to populate from DuckDB.
</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,82 @@
{% set admin_page = "cms" %}
{% extends "admin/base_admin.html" %}
{% block title %}{% if template %}Edit Template{% else %}New Template{% endif %} — Admin{% endblock %}
{% block topbar_title %}{% if template %}Edit Template{% else %}New Template{% endif %}{% endblock %}
{% block admin_content %}
<div style="margin-bottom:16px;">
<a href="{{ url_for('cms.template_list') }}" style="font-size:13px;color:#64748B;text-decoration:none;">
← Templates
</a>
</div>
<div class="card" style="padding:20px;max-width:800px;">
<form method="post"
action="{{ url_for('cms.template_edit', template_id=template.id) if template else url_for('cms.template_new') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display:grid;gap:16px;">
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">Template Name *</label>
<input type="text" name="name" value="{{ template.name if template else '' }}" required
placeholder="e.g. Coffee country guide"
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:14px;">
</div>
{% if not template %}
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">
Slug *
<span style="font-weight:400;color:#94A3B8;font-size:11px;">unique identifier</span>
</label>
<input type="text" name="slug" required
placeholder="coffee-country-guide"
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:14px;font-family:monospace;">
</div>
{% endif %}
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">
URL Pattern *
<span style="font-weight:400;color:#94A3B8;font-size:11px;">Jinja2 vars, e.g. /coffee/{{ "{{" }}country_slug{{ "}}" }}</span>
</label>
<input type="text" name="url_pattern" value="{{ template.url_pattern if template else '' }}" required
placeholder="/coffee/{{ '{{' }}country_slug{{ '}}' }}"
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:14px;font-family:monospace;">
</div>
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">
Title Pattern *
</label>
<input type="text" name="title_pattern" value="{{ template.title_pattern if template else '' }}" required
placeholder="{{ '{{' }}country_name{{ '}}' }} Coffee Production — BeanFlows"
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:14px;font-family:monospace;">
</div>
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">Meta Description Pattern</label>
<input type="text" name="meta_description_pattern"
value="{{ template.meta_description_pattern if template else '' }}"
placeholder="USDA PSD data for {{ '{{' }}country_name{{ '}}' }} coffee"
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:8px 12px;font-size:13px;font-family:monospace;">
</div>
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;font-weight:500;">
Body Template
<span style="font-weight:400;color:#94A3B8;font-size:11px;">Jinja2 HTML rendered at request time</span>
</label>
<textarea name="body_template" rows="14" spellcheck="false"
placeholder="<h2>About {{ '{{' }}country_name{{ '}}' }}</h2>&#10;<p>{{ '{{' }}description{{ '}}' }}</p>"
style="width:100%;box-sizing:border-box;border:1px solid #CBD5E1;border-radius:6px;padding:10px 12px;font-size:12px;font-family:monospace;resize:vertical;background:#FAFAFA;">{{ template.body_template if template else '' }}</textarea>
</div>
</div>
<div style="margin-top:20px;">
<button type="submit" class="btn">Save Template</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% set admin_page = "cms" %}
{% extends "admin/base_admin.html" %}
{% block title %}Article Templates — {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}Article Templates{% endblock %}
{% block admin_content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<h2 class="text-xl" style="margin:0;">Article Templates</h2>
<p class="text-sm text-slate" style="margin:4px 0 0;">
Jinja2 templates for programmatic article generation (pSEO).
</p>
</div>
<div style="display:flex;gap:8px;">
<a href="{{ url_for('cms.cms_index') }}" class="btn-outline btn-sm">Articles</a>
<a href="{{ url_for('cms.template_new') }}" class="btn btn-sm">+ New Template</a>
</div>
</div>
<div class="card" style="padding:0;overflow:hidden;">
{% if templates %}
<table class="table">
<thead>
<tr>
<th>Template</th>
<th>URL Pattern</th>
<th>Data Rows</th>
<th>Generated</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for tmpl in templates %}
<tr>
<td>
<a href="{{ url_for('cms.template_data_view', template_id=tmpl.id) }}" style="font-weight:500;">
{{ tmpl.name }}
</a>
</td>
<td>
<code class="mono text-sm" style="color:#64748B;">{{ tmpl.url_pattern }}</code>
</td>
<td class="mono text-sm">{{ tmpl.data_count }}</td>
<td>
<span class="text-sm">{{ tmpl.generated_count }}</span>
{% if tmpl.generated_count < tmpl.data_count %}
<span style="font-size:11px;color:#F59E0B;margin-left:4px;">
{{ tmpl.data_count - tmpl.generated_count }} pending
</span>
{% endif %}
</td>
<td class="mono text-sm">{{ tmpl.created_at[:10] }}</td>
<td>
<div style="display:flex;gap:6px;">
<a href="{{ url_for('cms.template_edit', template_id=tmpl.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('cms.template_bulk_generate', template_id=tmpl.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm"
{% if tmpl.data_count == tmpl.generated_count %}disabled style="opacity:0.5;"{% endif %}>
Generate
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:40px;text-align:center;">
<p class="text-slate text-sm">No article templates yet.</p>
<p class="text-slate text-sm" style="margin-top:8px;">
Run <code class="mono">uv run python scripts/seed_cms_coffee.py</code> to seed coffee market templates.
</p>
<a href="{{ url_for('cms.template_new') }}" class="btn btn-sm" style="margin-top:12px;">Create template manually</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% set admin_page = "emails" %}
{% extends "admin/base_admin.html" %}
{% block title %}Email Log - {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}Email Log{% endblock %}
{% block admin_content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<h2 class="text-xl" style="margin:0;">Email Log</h2>
<p class="text-sm text-slate" style="margin:4px 0 0;">
Audit trail of all outgoing transactional emails. Latest {{ log | length }} entries.
</p>
</div>
<div style="display:flex;gap:8px;font-size:12px;color:#64748B;">
{% set sent_count = log | selectattr('status', 'equalto', 'sent') | list | length %}
{% set error_count = log | selectattr('status', 'equalto', 'error') | list | length %}
{% if sent_count > 0 %}
<span style="background:#ECFDF5;color:#059669;padding:3px 10px;border-radius:20px;font-weight:600;">{{ sent_count }} sent</span>
{% endif %}
{% if error_count > 0 %}
<span style="background:#FEF2F2;color:#EF4444;padding:3px 10px;border-radius:20px;font-weight:600;">{{ error_count }} failed</span>
{% endif %}
</div>
</div>
<div class="card" style="padding:0;overflow:hidden;">
{% if log %}
<table class="table">
<thead>
<tr>
<th>Recipient</th>
<th>Subject</th>
<th>Template</th>
<th>Status</th>
<th>Provider ID</th>
<th>Sent</th>
</tr>
</thead>
<tbody>
{% for entry in log %}
<tr{% if entry.status == 'error' %} style="background:#FEF2F2;"{% endif %}>
<td class="mono text-sm">{{ entry.recipient }}</td>
<td>
<span class="text-sm" style="color:#1E293B;">{{ entry.subject }}</span>
{% if entry.error %}
<p style="margin:2px 0 0;font-size:11px;color:#EF4444;">{{ entry.error }}</p>
{% endif %}
</td>
<td>
{% if entry.template %}
<code style="font-size:11px;color:#64748B;background:#F8FAFC;padding:1px 5px;border-radius:3px;">{{ entry.template }}</code>
{% else %}
<span class="text-slate text-sm"></span>
{% endif %}
</td>
<td>
{% if entry.status == 'sent' %}
<span class="badge-success">sent</span>
{% elif entry.status == 'dev_skip' %}
<span style="display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600;background:#FEF9C3;color:#854D0E;">dev</span>
{% elif entry.status == 'error' %}
<span style="display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600;background:#FEE2E2;color:#EF4444;">error</span>
{% else %}
<span style="display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600;background:#F1F5F9;color:#64748B;">{{ entry.status }}</span>
{% endif %}
</td>
<td>
{% if entry.provider_id %}
<code class="mono" style="font-size:11px;color:#64748B;">{{ entry.provider_id[:16] }}…</code>
{% else %}
<span class="text-slate text-sm"></span>
{% endif %}
</td>
<td class="mono text-sm">{{ entry.created_at[:16] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:40px;text-align:center;">
<p class="text-slate text-sm">No emails logged yet.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,88 @@
{% set admin_page = "flags" %}
{% extends "admin/base_admin.html" %}
{% block title %}Feature Flags - {{ config.APP_NAME }} Admin{% endblock %}
{% block topbar_title %}Feature Flags{% endblock %}
{% block admin_content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<div>
<h2 class="text-xl" style="margin:0;">Feature Flags</h2>
<p class="text-sm text-slate" style="margin:4px 0 0;">
DB-backed toggles. Changes take effect immediately — no restart needed.
</p>
</div>
<button onclick="document.getElementById('create-flag-form').classList.toggle('open')" class="btn btn-sm">
+ New Flag
</button>
</div>
<div id="create-flag-form" class="card" style="display:none;margin-bottom:16px;padding:16px;">
<style>#create-flag-form.open { display:block; }</style>
<p class="text-sm" style="font-weight:600;margin:0 0 12px;">Create feature flag</p>
<form method="post" action="{{ url_for('admin.flag_create') }}" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;color:#475569;">Name <span style="color:#94A3B8;font-size:11px;">(snake_case)</span></label>
<input type="text" name="name" placeholder="e.g. dark_mode" required
style="border:1px solid #CBD5E1;border-radius:6px;padding:6px 10px;font-size:13px;width:200px;">
</div>
<div>
<label class="text-sm" style="display:block;margin-bottom:4px;color:#475569;">Description <span style="color:#94A3B8;font-size:11px;">(optional)</span></label>
<input type="text" name="description" placeholder="What does this flag do?"
style="border:1px solid #CBD5E1;border-radius:6px;padding:6px 10px;font-size:13px;width:280px;">
</div>
<button type="submit" class="btn btn-sm">Create</button>
</form>
</div>
<div class="card" style="padding:0;overflow:hidden;">
{% if flags %}
<table class="table">
<thead>
<tr>
<th>Flag</th>
<th>Description</th>
<th>State</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{% for flag in flags %}
<tr>
<td>
<code style="font-size:12px;color:#0F172A;background:#F1F5F9;padding:2px 6px;border-radius:4px;">
{{ flag.name }}
</code>
</td>
<td><span class="text-sm text-slate">{{ flag.description or "—" }}</span></td>
<td>
{% if flag.enabled %}
<span class="badge-success">enabled</span>
{% else %}
<span style="display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;background:#F1F5F9;color:#64748B;">disabled</span>
{% endif %}
</td>
<td class="mono text-sm">{{ flag.updated_at[:16] }}</td>
<td>
<form method="post" action="{{ url_for('admin.flag_toggle') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="name" value="{{ flag.name }}">
<button type="submit" class="btn-outline btn-sm"
style="{% if flag.enabled %}color:#EF4444;border-color:#EF4444;{% endif %}">
{{ "Disable" if flag.enabled else "Enable" }}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding:40px;text-align:center;">
<p class="text-slate text-sm">No feature flags yet. Create one above.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -126,10 +126,12 @@ def create_app() -> Quart:
return result, status_code
# Register blueprints
from .admin.cms_routes import bp as cms_bp
from .admin.routes import bp as admin_bp
from .api.routes import bp as api_bp
from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp
from .content.routes import bp as content_bp
from .dashboard.routes import bp as dashboard_bp
from .public.routes import bp as public_bp
@@ -138,6 +140,8 @@ def create_app() -> Quart:
app.register_blueprint(dashboard_bp)
app.register_blueprint(billing_bp)
app.register_blueprint(api_bp, url_prefix="/api/v1")
app.register_blueprint(content_bp)
app.register_blueprint(cms_bp)
app.register_blueprint(admin_bp)
# Request ID tracking

View File

@@ -0,0 +1 @@
# Content package: public article serving for CMS-generated pages.

View File

@@ -0,0 +1,57 @@
"""
Content domain: public article serving for CMS-generated coffee market pages.
"""
import json
from pathlib import Path
from quart import Blueprint, abort, render_template
from ..core import fetch_one
bp = Blueprint(
"content",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
)
async def _serve_article(url_path: str):
"""Fetch article by url_path and render it with its body template."""
article = await fetch_one(
"""SELECT a.*, td.data_json, at.body_template
FROM articles a
LEFT JOIN template_data td ON td.id = a.template_data_id
LEFT JOIN article_templates at ON at.id = td.template_id
WHERE a.url_path = ? AND a.status = 'published'""",
(url_path,),
)
if not article:
abort(404)
body_html = ""
if article.get("body_template") and article.get("data_json"):
try:
from jinja2 import Environment, BaseLoader
data = json.loads(article["data_json"])
env = Environment(loader=BaseLoader())
body_html = env.from_string(article["body_template"]).render(**data)
except Exception:
body_html = "<p>Content temporarily unavailable.</p>"
return await render_template(
"content/article.html",
article=article,
body_html=body_html,
)
@bp.route("/coffee/market/<int:year>")
async def market_year_article(year: int):
"""Serve a CMS article at /coffee/market/<year>."""
return await _serve_article(f"/coffee/market/{year}")
@bp.route("/coffee/<slug>")
async def country_article(slug: str):
"""Serve a CMS article at /coffee/<country-slug>."""
return await _serve_article(f"/coffee/{slug}")

View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}{{ article.title }}{% endblock %}
{% block head %}
<meta name="description" content="{{ article.meta_description or '' }}">
{% if article.og_image_url %}
<meta property="og:image" content="{{ article.og_image_url }}">
{% endif %}
<meta property="og:title" content="{{ article.title }}">
<meta property="og:description" content="{{ article.meta_description or '' }}">
<meta property="og:type" content="article">
<link rel="canonical" href="{{ config.BASE_URL }}{{ article.url_path }}">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": {{ article.title | tojson }},
"description": {{ (article.meta_description or '') | tojson }},
"datePublished": {{ (article.published_at or article.created_at)[:10] | tojson }},
"dateModified": {{ (article.updated_at or article.published_at or article.created_at)[:10] | tojson }},
"publisher": {
"@type": "Organization",
"name": {{ config.APP_NAME | tojson }},
"url": {{ config.BASE_URL | tojson }}
}
}
</script>
{% endblock %}
{% block content %}
<div class="container-page" style="max-width:760px;margin:0 auto;padding:40px 20px;">
<!-- Breadcrumb -->
<nav style="font-size:13px;color:#78716C;margin-bottom:24px;">
<a href="{{ url_for('public.landing') }}" style="color:#78716C;text-decoration:none;">Home</a>
<span style="margin:0 6px;"></span>
<a href="/coffee" style="color:#78716C;text-decoration:none;">Coffee</a>
<span style="margin:0 6px;"></span>
<span>{{ article.title }}</span>
</nav>
<!-- Article header -->
<header style="margin-bottom:32px;">
<h1 style="font-size:2rem;font-weight:700;line-height:1.2;color:#1C1917;margin:0 0 12px;">
{{ article.title }}
</h1>
{% if article.meta_description %}
<p style="font-size:1.05rem;color:#57534E;line-height:1.6;margin:0 0 16px;">
{{ article.meta_description }}
</p>
{% endif %}
<p style="font-size:12px;color:#A8A29E;margin:0;">
Published {{ article.published_at[:10] if article.published_at else "" }}
· Data source: USDA PSD Online
</p>
</header>
<!-- Article body (rendered from template) -->
<article class="prose" style="line-height:1.7;color:#292524;">
{{ body_html | safe }}
</article>
<!-- CTA footer -->
<div style="margin-top:48px;padding:24px;background:#FDF8F4;border:1px solid #E8DFD5;border-radius:12px;">
<p style="margin:0 0 12px;font-weight:600;color:#1C1917;">Track live coffee market data</p>
<p style="margin:0 0 16px;font-size:14px;color:#57534E;">
BeanFlows provides daily-updated supply & demand analytics, CFTC positioning data,
and ICE warehouse stocks for coffee traders.
</p>
<a href="{{ url_for('auth.signup') }}" class="btn">Get Started Free →</a>
</div>
</div>
{% endblock %}

View File

@@ -175,26 +175,41 @@ EMAIL_ADDRESSES = {
async def send_email(
to: str, subject: str, html: str, text: str = None, from_addr: str = None
to: str, subject: str, html: str, text: str = None,
from_addr: str = None, template: str = None,
) -> bool:
"""Send email via Resend SDK."""
"""Send email via Resend SDK and log to email_log table."""
if not config.RESEND_API_KEY:
print(f"[EMAIL] Would send to {to}: {subject}")
await execute(
"INSERT INTO email_log (recipient, subject, template, status, created_at) VALUES (?, ?, ?, 'dev_skip', ?)",
(to, subject, template, datetime.utcnow().isoformat()),
)
return True
resend.api_key = config.RESEND_API_KEY
provider_id = None
error_msg = None
try:
resend.Emails.send({
result = resend.Emails.send({
"from": from_addr or config.EMAIL_FROM,
"to": to,
"subject": subject,
"html": html,
"text": text or html,
})
return True
provider_id = result.get("id") if isinstance(result, dict) else None
except Exception as e:
error_msg = str(e)
print(f"[EMAIL] Error sending to {to}: {e}")
return False
await execute(
"""INSERT INTO email_log (recipient, subject, template, status, provider_id, error, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(to, subject, template, "error" if error_msg else "sent",
provider_id, error_msg, datetime.utcnow().isoformat()),
)
return error_msg is None
# =============================================================================
# CSRF Protection
@@ -434,4 +449,47 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
return response
return wrapper
return decorator
# =============================================================================
# Feature Flags (DB-backed, admin-toggleable)
# =============================================================================
async def is_flag_enabled(name: str, default: bool = False) -> bool:
"""Check if a feature flag is enabled. Falls back to default if not found."""
row = await fetch_one("SELECT enabled FROM feature_flags WHERE name = ?", (name,))
if row is None:
return default
return bool(row["enabled"])
async def set_flag(name: str, enabled: bool, description: str = None) -> None:
"""Create or update a feature flag."""
await execute(
"""INSERT INTO feature_flags (name, enabled, description, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled,
updated_at = excluded.updated_at""",
(name, int(enabled), description, datetime.utcnow().isoformat()),
)
async def get_all_flags() -> list[dict]:
"""Return all feature flags ordered by name."""
return await fetch_all("SELECT * FROM feature_flags ORDER BY name")
def feature_gate(flag_name: str, fallback_template: str, **extra_context):
"""Gate a route behind a feature flag; renders fallback on GET, 403 on POST."""
def decorator(f):
@wraps(f)
async def decorated(*args, **kwargs):
if not await is_flag_enabled(flag_name):
if request.method == "GET":
ctx = {k: v() if callable(v) else v for k, v in extra_context.items()}
return await render_template(fallback_template, **ctx)
return {"error": "Feature not available"}, 403
return await f(*args, **kwargs)
return decorated
return decorator

View File

@@ -0,0 +1 @@
# This file was created in the wrong location. See versions/0002_feature_flags_cms.py

View File

@@ -0,0 +1,116 @@
"""
Migration 0002 — feature_flags, email_log, and CMS tables.
Adds:
- feature_flags: DB-backed feature toggles (admin UI, no restart)
- email_log: append-only audit trail of all outgoing emails
- article_templates: Jinja2 body templates for pSEO generation
- template_data: JSON data rows for bulk article generation
- articles: CMS article records with FTS5 search
"""
SQL_DDL = """\
CREATE TABLE IF NOT EXISTS feature_flags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
description TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_feature_flags_name ON feature_flags(name);
CREATE TABLE IF NOT EXISTS email_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
template TEXT,
status TEXT NOT NULL DEFAULT 'sent',
provider_id TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_email_log_recipient ON email_log(recipient);
CREATE INDEX IF NOT EXISTS idx_email_log_created ON email_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_email_log_status ON email_log(status);
CREATE TABLE IF NOT EXISTS article_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
url_pattern TEXT NOT NULL,
title_pattern TEXT NOT NULL,
meta_description_pattern TEXT,
body_template TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_article_templates_slug ON article_templates(slug);
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_path TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
meta_description TEXT,
og_image_url TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT,
template_data_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path);
CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at);
CREATE TABLE IF NOT EXISTS template_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL REFERENCES article_templates(id),
data_json TEXT NOT NULL,
article_id INTEGER REFERENCES articles(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_template_data_template ON template_data(template_id);
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title,
meta_description,
content='articles',
content_rowid='id'
)"""
# Triggers are kept separate because split(";") would break BEGIN...END blocks.
SQL_TRIGGERS = [
"""\
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, meta_description)
VALUES (new.id, new.title, new.meta_description);
END""",
"""\
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description)
VALUES ('delete', old.id, old.title, old.meta_description);
END""",
"""\
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description)
VALUES ('delete', old.id, old.title, old.meta_description);
INSERT INTO articles_fts(rowid, title, meta_description)
VALUES (new.id, new.title, new.meta_description);
END""",
]
def up(conn):
for statement in SQL_DDL.split(";"):
stmt = statement.strip()
if stmt:
conn.execute(stmt)
for trigger in SQL_TRIGGERS:
conn.execute(trigger)

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from quart import Blueprint, render_template, request, g, make_response
from ..core import config, execute, check_rate_limit, csrf_protect
from ..core import config, execute, fetch_all, check_rate_limit, csrf_protect
# Blueprint with its own template folder
bp = Blueprint(
@@ -117,6 +117,7 @@ async def sitemap_xml():
f" </url>\n"
)
today = datetime.utcnow().date().isoformat()
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
xml += url_entry(f"{base}/", priority="1.0", changefreq="daily")
@@ -126,7 +127,27 @@ async def sitemap_xml():
xml += url_entry(f"{base}/terms", priority="0.3", changefreq="yearly")
xml += url_entry(f"{base}/privacy", priority="0.3", changefreq="yearly")
xml += url_entry(f"{base}/imprint", priority="0.2", changefreq="yearly")
# Add dynamic BeanFlows entries here (e.g. public commodity pages)
# CMS articles
try:
articles = await fetch_all(
"SELECT url_path, updated_at, published_at FROM articles WHERE status = 'published' ORDER BY published_at DESC LIMIT 500"
)
except Exception:
articles = []
for article in articles:
lastmod = (article.get("updated_at") or article.get("published_at") or today)[:10]
loc = f"{base}{article['url_path']}"
xml += (
f" <url>\n"
f" <loc>{loc}</loc>\n"
f" <lastmod>{lastmod}</lastmod>\n"
f" <changefreq>monthly</changefreq>\n"
f" <priority>0.6</priority>\n"
f" </url>\n"
)
xml += "</urlset>"
response = await make_response(xml)