diff --git a/.gitignore b/.gitignore index f56d31d..7cab796 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,10 @@ cython_debug/ # PyPI configuration file .pypirc +.claude/worktrees/ + +*.duckdb +*.duckdb.wal +data/ +.claude/worktrees/ + diff --git a/web/.copier-answers.yml b/web/.copier-answers.yml index 840dfc8..e0d2ce9 100644 --- a/web/.copier-answers.yml +++ b/web/.copier-answers.yml @@ -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 diff --git a/web/scripts/seed_cms_coffee.py b/web/scripts/seed_cms_coffee.py new file mode 100644 index 0000000..4ef33df --- /dev/null +++ b/web/scripts/seed_cms_coffee.py @@ -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": """\ +

{{ country_name }} Coffee Overview

+ +

+ {{ 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 + {{ "{:,}".format(production_bags|int) }} 60-kg bags. +

+ +

Supply & Demand Snapshot ({{ latest_year }})

+ + + + + + + + + + +
MetricValue (60-kg bags)
Production{{ "{:,}".format(production_bags|int) }}
Exports{{ "{:,}".format(exports_bags|int) }}
Domestic Consumption{{ "{:,}".format(domestic_consumption_bags|int) }}
Ending Stocks{{ "{:,}".format(ending_stocks_bags|int) }}
+ +

Production Trend

+

+ Over the past decade, {{ country_name }}'s coffee output has shown + {% if production_trend == 'up' %}a rising trend + {% elif production_trend == 'down' %}a declining trend + {% else %}relatively stable production + {% endif %}. + Year-on-year change in {{ latest_year }}: {{ production_yoy_pct }}%. +

+ +

Key Export Markets

+

+ {{ 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 + BeanFlows positioning dashboard. +

+ +

Data source: USDA PSD Online, updated {{ data_vintage }}.

+""", + }, + { + "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": """\ +

Global Coffee Market {{ market_year }}

+ +

+ The {{ market_year }} global coffee marketing year ran from + October {{ market_year|int - 1 }} through September {{ market_year }}. + World production totalled + {{ "{:,}".format(world_production_bags|int) }} million 60-kg bags. +

+ +

World Supply & Demand Balance

+ + + + + + + + + + + + +
MetricMillion 60-kg Bags
Opening Stocks{{ "%.1f"|format(beginning_stocks_m|float) }}
Production{{ "%.1f"|format(production_m|float) }}
Total Supply{{ "%.1f"|format(total_supply_m|float) }}
Consumption{{ "%.1f"|format(consumption_m|float) }}
Ending Stocks{{ "%.1f"|format(ending_stocks_m|float) }}
Stock-to-Use Ratio{{ "%.1f"|format(stu_pct|float) }}%
+ +

Supply/Demand Balance

+

+ The {{ market_year }} marketing year ended with a + {% if balance >= 0 %}surplus of {{ "%.1f"|format(balance|float) }}M bags + {% else %}deficit of {{ "%.1f"|format((balance|float)|abs) }}M bags + {% endif %}. + The stock-to-use ratio of {{ "%.1f"|format(stu_pct|float) }}% indicates + {% if stu_pct|float > 25 %}comfortable{% elif stu_pct|float > 18 %}adequate{% else %}tight{% endif %} + global supply conditions. +

+ +

+ Explore live supply & demand charts and price data on + BeanFlows Supply Dashboard. +

+ +

Data source: USDA PSD Online, updated {{ data_vintage }}.

+""", + }, +] + + +# ── 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) diff --git a/web/src/beanflows/admin/cms_routes.py b/web/src/beanflows/admin/cms_routes.py new file mode 100644 index 0000000..b19a5b4 --- /dev/null +++ b/web/src/beanflows/admin/cms_routes.py @@ -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("//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("//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("//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("//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//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/") +@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//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//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//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)) diff --git a/web/src/beanflows/admin/routes.py b/web/src/beanflows/admin/routes.py index ce559cf..31c6900 100644 --- a/web/src/beanflows/admin/routes.py +++ b/web/src/beanflows/admin/routes.py @@ -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) diff --git a/web/src/beanflows/admin/templates/admin/base_admin.html b/web/src/beanflows/admin/templates/admin/base_admin.html index 57248d5..8fc9798 100644 --- a/web/src/beanflows/admin/templates/admin/base_admin.html +++ b/web/src/beanflows/admin/templates/admin/base_admin.html @@ -319,6 +319,48 @@ Waitlist + + + + diff --git a/web/src/beanflows/admin/templates/admin/cms.html b/web/src/beanflows/admin/templates/admin/cms.html new file mode 100644 index 0000000..cb1e6e3 --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/cms.html @@ -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 %} +
+
+

Articles

+

+ {{ published_count }} published · {{ draft_count }} draft +

+
+ +
+ +
+ {% if articles %} + + + + + + + + + + + + + {% for article in articles %} + + + + + + + + + {% endfor %} + +
TitleURL PathTemplateStatusPublished
+ + {{ article.title }} + + + + {{ article.url_path }} + + + {% if article.template_name %} + {{ article.template_name }} + {% else %} + + {% endif %} + + {% if article.status == 'published' %} + published + {% else %} + draft + {% endif %} + {{ article.published_at[:10] if article.published_at else "—" }} +
+ Edit + {% if article.status == 'published' %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+
+ {% else %} +
+

No articles yet. Use pSEO Templates to bulk-generate coffee market pages.

+ +
+ {% endif %} +
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/cms_editor.html b/web/src/beanflows/admin/templates/admin/cms_editor.html new file mode 100644 index 0000000..445368d --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/cms_editor.html @@ -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 %} + + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + {% if article %} + View → + + + + + {% endif %} +
+ + + {% if article %} +
+

+ Body content: + {% if article.template_data_id %} + Programmatically generated from pSEO template data. + {% else %} + Hand-written article (no template linked). + {% endif %} +

+
+ {% endif %} +
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/cms_template_data.html b/web/src/beanflows/admin/templates/admin/cms_template_data.html new file mode 100644 index 0000000..2f7b5f7 --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/cms_template_data.html @@ -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 %} + + +
+
+

{{ template.name }}

+

+ URL: {{ template.url_pattern }} · + {{ data_rows | length }} rows · + {{ data_rows | selectattr('article_id') | list | length }} generated +

+
+
+ Edit Template +
+ + +
+
+ + +
+
+
+ + +
+

Add data row

+
+ +
+ + +
+ +
+
+ + +
+ {% if data_rows %} + + + + + + + + + + + {% for row in data_rows %} + + + + + + + {% endfor %} + +
#DataArticleAdded
{{ row.id }} + {{ row.data_json }} + + {% if row.article_id %} + generated #{{ row.article_id }} + {% else %} + pending + {% endif %} + {{ row.created_at[:10] }}
+ {% else %} +
+

No data rows yet.

+

+ Run uv run python scripts/seed_cms_coffee.py to populate from DuckDB. +

+
+ {% endif %} +
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/cms_template_editor.html b/web/src/beanflows/admin/templates/admin/cms_template_editor.html new file mode 100644 index 0000000..927a457 --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/cms_template_editor.html @@ -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 %} + + +
+
+ + +
+
+ + +
+ + {% if not template %} +
+ + +
+ {% endif %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/cms_templates.html b/web/src/beanflows/admin/templates/admin/cms_templates.html new file mode 100644 index 0000000..63ef918 --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/cms_templates.html @@ -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 %} +
+
+

Article Templates

+

+ Jinja2 templates for programmatic article generation (pSEO). +

+
+ +
+ +
+ {% if templates %} + + + + + + + + + + + + + {% for tmpl in templates %} + + + + + + + + + {% endfor %} + +
TemplateURL PatternData RowsGeneratedCreated
+ + {{ tmpl.name }} + + + {{ tmpl.url_pattern }} + {{ tmpl.data_count }} + {{ tmpl.generated_count }} + {% if tmpl.generated_count < tmpl.data_count %} + + {{ tmpl.data_count - tmpl.generated_count }} pending + + {% endif %} + {{ tmpl.created_at[:10] }} +
+ Edit +
+ + +
+
+
+ {% else %} +
+

No article templates yet.

+

+ Run uv run python scripts/seed_cms_coffee.py to seed coffee market templates. +

+ Create template manually +
+ {% endif %} +
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/emails.html b/web/src/beanflows/admin/templates/admin/emails.html new file mode 100644 index 0000000..eeeb017 --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/emails.html @@ -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 %} +
+
+

Email Log

+

+ Audit trail of all outgoing transactional emails. Latest {{ log | length }} entries. +

+
+
+ {% set sent_count = log | selectattr('status', 'equalto', 'sent') | list | length %} + {% set error_count = log | selectattr('status', 'equalto', 'error') | list | length %} + {% if sent_count > 0 %} + {{ sent_count }} sent + {% endif %} + {% if error_count > 0 %} + {{ error_count }} failed + {% endif %} +
+
+ +
+ {% if log %} + + + + + + + + + + + + + {% for entry in log %} + + + + + + + + + {% endfor %} + +
RecipientSubjectTemplateStatusProvider IDSent
{{ entry.recipient }} + {{ entry.subject }} + {% if entry.error %} +

{{ entry.error }}

+ {% endif %} +
+ {% if entry.template %} + {{ entry.template }} + {% else %} + + {% endif %} + + {% if entry.status == 'sent' %} + sent + {% elif entry.status == 'dev_skip' %} + dev + {% elif entry.status == 'error' %} + error + {% else %} + {{ entry.status }} + {% endif %} + + {% if entry.provider_id %} + {{ entry.provider_id[:16] }}… + {% else %} + + {% endif %} + {{ entry.created_at[:16] }}
+ {% else %} +
+

No emails logged yet.

+
+ {% endif %} +
+{% endblock %} diff --git a/web/src/beanflows/admin/templates/admin/flags.html b/web/src/beanflows/admin/templates/admin/flags.html new file mode 100644 index 0000000..b6fac80 --- /dev/null +++ b/web/src/beanflows/admin/templates/admin/flags.html @@ -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 %} +
+
+

Feature Flags

+

+ DB-backed toggles. Changes take effect immediately — no restart needed. +

+
+ +
+ + + +
+ {% if flags %} + + + + + + + + + + + + {% for flag in flags %} + + + + + + + + {% endfor %} + +
FlagDescriptionStateUpdated
+ + {{ flag.name }} + + {{ flag.description or "—" }} + {% if flag.enabled %} + enabled + {% else %} + disabled + {% endif %} + {{ flag.updated_at[:16] }} +
+ + + +
+
+ {% else %} +
+

No feature flags yet. Create one above.

+
+ {% endif %} +
+{% endblock %} diff --git a/web/src/beanflows/app.py b/web/src/beanflows/app.py index 546b2f9..6dab9ee 100644 --- a/web/src/beanflows/app.py +++ b/web/src/beanflows/app.py @@ -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 diff --git a/web/src/beanflows/content/__init__.py b/web/src/beanflows/content/__init__.py new file mode 100644 index 0000000..97234b9 --- /dev/null +++ b/web/src/beanflows/content/__init__.py @@ -0,0 +1 @@ +# Content package: public article serving for CMS-generated pages. diff --git a/web/src/beanflows/content/routes.py b/web/src/beanflows/content/routes.py new file mode 100644 index 0000000..bbd15e6 --- /dev/null +++ b/web/src/beanflows/content/routes.py @@ -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 = "

Content temporarily unavailable.

" + + return await render_template( + "content/article.html", + article=article, + body_html=body_html, + ) + + +@bp.route("/coffee/market/") +async def market_year_article(year: int): + """Serve a CMS article at /coffee/market/.""" + return await _serve_article(f"/coffee/market/{year}") + + +@bp.route("/coffee/") +async def country_article(slug: str): + """Serve a CMS article at /coffee/.""" + return await _serve_article(f"/coffee/{slug}") diff --git a/web/src/beanflows/content/templates/content/article.html b/web/src/beanflows/content/templates/content/article.html new file mode 100644 index 0000000..1500e30 --- /dev/null +++ b/web/src/beanflows/content/templates/content/article.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% block title %}{{ article.title }}{% endblock %} + +{% block head %} + + {% if article.og_image_url %} + + {% endif %} + + + + + +{% endblock %} + +{% block content %} +
+ + + + + +
+

+ {{ article.title }} +

+ {% if article.meta_description %} +

+ {{ article.meta_description }} +

+ {% endif %} +

+ Published {{ article.published_at[:10] if article.published_at else "" }} + · Data source: USDA PSD Online +

+
+ + +
+ {{ body_html | safe }} +
+ + +
+

Track live coffee market data

+

+ BeanFlows provides daily-updated supply & demand analytics, CFTC positioning data, + and ICE warehouse stocks for coffee traders. +

+ Get Started Free → +
+ +
+{% endblock %} diff --git a/web/src/beanflows/core.py b/web/src/beanflows/core.py index cdf765b..e9b5c0d 100644 --- a/web/src/beanflows/core.py +++ b/web/src/beanflows/core.py @@ -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 \ No newline at end of file diff --git a/web/src/beanflows/migrations/0002_feature_flags_cms.py b/web/src/beanflows/migrations/0002_feature_flags_cms.py new file mode 100644 index 0000000..c78ea0d --- /dev/null +++ b/web/src/beanflows/migrations/0002_feature_flags_cms.py @@ -0,0 +1 @@ +# This file was created in the wrong location. See versions/0002_feature_flags_cms.py diff --git a/web/src/beanflows/migrations/versions/0002_feature_flags_cms.py b/web/src/beanflows/migrations/versions/0002_feature_flags_cms.py new file mode 100644 index 0000000..3cd41ad --- /dev/null +++ b/web/src/beanflows/migrations/versions/0002_feature_flags_cms.py @@ -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) diff --git a/web/src/beanflows/public/routes.py b/web/src/beanflows/public/routes.py index 7afd75e..dbc8344 100644 --- a/web/src/beanflows/public/routes.py +++ b/web/src/beanflows/public/routes.py @@ -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" \n" ) + today = datetime.utcnow().date().isoformat() xml = '\n' xml += '\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" \n" + f" {loc}\n" + f" {lastmod}\n" + f" monthly\n" + f" 0.6\n" + f" \n" + ) + xml += "" response = await make_response(xml)