feat: add CMS/pSEO engine, feature flags, email log (template v0.17.0 backport) ...
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -176,3 +176,10 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
.claude/worktrees/
|
||||
|
||||
*.duckdb
|
||||
*.duckdb.wal
|
||||
data/
|
||||
.claude/worktrees/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
375
web/scripts/seed_cms_coffee.py
Normal file
375
web/scripts/seed_cms_coffee.py
Normal 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)
|
||||
368
web/src/beanflows/admin/cms_routes.py
Normal file
368
web/src/beanflows/admin/cms_routes.py
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
92
web/src/beanflows/admin/templates/admin/cms.html
Normal file
92
web/src/beanflows/admin/templates/admin/cms.html
Normal 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 %}
|
||||
90
web/src/beanflows/admin/templates/admin/cms_editor.html
Normal file
90
web/src/beanflows/admin/templates/admin/cms_editor.html
Normal 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;">150–160 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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> <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 %}
|
||||
81
web/src/beanflows/admin/templates/admin/cms_templates.html
Normal file
81
web/src/beanflows/admin/templates/admin/cms_templates.html
Normal 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 %}
|
||||
86
web/src/beanflows/admin/templates/admin/emails.html
Normal file
86
web/src/beanflows/admin/templates/admin/emails.html
Normal 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 %}
|
||||
88
web/src/beanflows/admin/templates/admin/flags.html
Normal file
88
web/src/beanflows/admin/templates/admin/flags.html
Normal 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 %}
|
||||
@@ -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
|
||||
|
||||
1
web/src/beanflows/content/__init__.py
Normal file
1
web/src/beanflows/content/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Content package: public article serving for CMS-generated pages.
|
||||
57
web/src/beanflows/content/routes.py
Normal file
57
web/src/beanflows/content/routes.py
Normal 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}")
|
||||
75
web/src/beanflows/content/templates/content/article.html
Normal file
75
web/src/beanflows/content/templates/content/article.html
Normal 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 %}
|
||||
@@ -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
|
||||
@@ -435,3 +450,46 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
|
||||
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
|
||||
1
web/src/beanflows/migrations/0002_feature_flags_cms.py
Normal file
1
web/src/beanflows/migrations/0002_feature_flags_cms.py
Normal file
@@ -0,0 +1 @@
|
||||
# This file was created in the wrong location. See versions/0002_feature_flags_cms.py
|
||||
116
web/src/beanflows/migrations/versions/0002_feature_flags_cms.py
Normal file
116
web/src/beanflows/migrations/versions/0002_feature_flags_cms.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user