feat: SSG-inspired pSEO CMS — git templates + DuckDB direct reads
Replace the old CSV-upload-based CMS with an SSG architecture where templates live in git as .md.jinja files with YAML frontmatter and data comes directly from DuckDB serving tables. Only articles and published_scenarios remain in SQLite for routing/state. - Content module: discover, load, generate, preview functions - Migration 0018: drop article_templates + template_data, recreate articles + published_scenarios without FK references, add template_slug/language/date_modified/seo_head columns - Admin routes: read-only template views with generate/regenerate/preview - SEO pipeline: canonical URLs, hreflang (EN+DE), JSON-LD (Article, FAQPage, BreadcrumbList), Open Graph tags baked at generation time - Example template: city-cost-de.md.jinja for German city market data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"resend>=2.22.0",
|
||||
"weasyprint>=68.1",
|
||||
"duckdb>=1.0.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""
|
||||
Admin domain: role-based admin panel for managing users, tasks, etc.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -828,424 +826,140 @@ async def feedback():
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Article Template Management
|
||||
# Content Templates (read-only — templates live in git as .md.jinja files)
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/templates")
|
||||
@role_required("admin")
|
||||
async def templates():
|
||||
"""List article templates."""
|
||||
template_list = await fetch_all(
|
||||
"SELECT * FROM article_templates ORDER BY created_at DESC"
|
||||
)
|
||||
# Attach data row counts
|
||||
"""List content templates scanned from disk."""
|
||||
from ..content import discover_templates, fetch_template_data
|
||||
|
||||
template_list = discover_templates()
|
||||
|
||||
# Attach DuckDB row counts
|
||||
for t in template_list:
|
||||
count_rows = await fetch_template_data(t["data_table"], limit=501)
|
||||
t["data_count"] = len(count_rows)
|
||||
|
||||
# Count generated articles for this template
|
||||
row = await fetch_one(
|
||||
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ?", (t["id"],)
|
||||
)
|
||||
t["data_count"] = row["cnt"] if row else 0
|
||||
row = await fetch_one(
|
||||
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NOT NULL",
|
||||
(t["id"],),
|
||||
"SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?",
|
||||
(t["slug"],),
|
||||
)
|
||||
t["generated_count"] = row["cnt"] if row else 0
|
||||
|
||||
return await render_template("admin/templates.html", templates=template_list)
|
||||
|
||||
|
||||
@bp.route("/templates/new", methods=["GET", "POST"])
|
||||
@bp.route("/templates/<slug>")
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def template_new():
|
||||
"""Create a new article template."""
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
name = form.get("name", "").strip()
|
||||
template_slug = form.get("slug", "").strip() or slugify(name)
|
||||
content_type = form.get("content_type", "calculator")
|
||||
input_schema = form.get("input_schema", "[]").strip()
|
||||
url_pattern = form.get("url_pattern", "").strip()
|
||||
title_pattern = form.get("title_pattern", "").strip()
|
||||
meta_description_pattern = form.get("meta_description_pattern", "").strip()
|
||||
body_template = form.get("body_template", "").strip()
|
||||
async def template_detail(slug: str):
|
||||
"""Template detail: config (read-only), columns, sample data, actions."""
|
||||
from ..content import fetch_template_data, get_table_columns, load_template
|
||||
|
||||
if not name or not url_pattern or not title_pattern or not body_template:
|
||||
await flash("Name, URL pattern, title pattern, and body template are required.", "error")
|
||||
return await render_template("admin/template_form.html", data=dict(form), editing=False)
|
||||
|
||||
# Validate input_schema is valid JSON
|
||||
try:
|
||||
json.loads(input_schema)
|
||||
except json.JSONDecodeError:
|
||||
await flash("Input schema must be valid JSON.", "error")
|
||||
return await render_template("admin/template_form.html", data=dict(form), editing=False)
|
||||
|
||||
existing = await fetch_one(
|
||||
"SELECT 1 FROM article_templates WHERE slug = ?", (template_slug,)
|
||||
)
|
||||
if existing:
|
||||
await flash(f"Slug '{template_slug}' already exists.", "error")
|
||||
return await render_template("admin/template_form.html", data=dict(form), editing=False)
|
||||
|
||||
template_id = await execute(
|
||||
"""INSERT INTO article_templates
|
||||
(name, slug, content_type, input_schema, url_pattern,
|
||||
title_pattern, meta_description_pattern, body_template)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, template_slug, content_type, input_schema, url_pattern,
|
||||
title_pattern, meta_description_pattern, body_template),
|
||||
)
|
||||
await flash(f"Template '{name}' created.", "success")
|
||||
return redirect(url_for("admin.template_data", template_id=template_id))
|
||||
|
||||
return await render_template("admin/template_form.html", data={}, editing=False)
|
||||
|
||||
|
||||
@bp.route("/templates/<int:template_id>/edit", methods=["GET", "POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def template_edit(template_id: int):
|
||||
"""Edit an article template."""
|
||||
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
||||
if not template:
|
||||
try:
|
||||
config = load_template(slug)
|
||||
except (AssertionError, FileNotFoundError):
|
||||
await flash("Template not found.", "error")
|
||||
return redirect(url_for("admin.templates"))
|
||||
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
name = form.get("name", "").strip()
|
||||
input_schema = form.get("input_schema", "[]").strip()
|
||||
url_pattern = form.get("url_pattern", "").strip()
|
||||
title_pattern = form.get("title_pattern", "").strip()
|
||||
meta_description_pattern = form.get("meta_description_pattern", "").strip()
|
||||
body_template = form.get("body_template", "").strip()
|
||||
columns = await get_table_columns(config["data_table"])
|
||||
sample_rows = await fetch_template_data(config["data_table"], limit=10)
|
||||
|
||||
if not name or not url_pattern or not title_pattern or not body_template:
|
||||
await flash("Name, URL pattern, title pattern, and body template are required.", "error")
|
||||
return await render_template(
|
||||
"admin/template_form.html", data=dict(form), editing=True, template_id=template_id,
|
||||
)
|
||||
|
||||
try:
|
||||
json.loads(input_schema)
|
||||
except json.JSONDecodeError:
|
||||
await flash("Input schema must be valid JSON.", "error")
|
||||
return await render_template(
|
||||
"admin/template_form.html", data=dict(form), editing=True, template_id=template_id,
|
||||
)
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
await execute(
|
||||
"""UPDATE article_templates
|
||||
SET name = ?, input_schema = ?, url_pattern = ?,
|
||||
title_pattern = ?, meta_description_pattern = ?,
|
||||
body_template = ?, updated_at = ?
|
||||
WHERE id = ?""",
|
||||
(name, input_schema, url_pattern, title_pattern,
|
||||
meta_description_pattern, body_template, now, template_id),
|
||||
)
|
||||
await flash("Template updated.", "success")
|
||||
return redirect(url_for("admin.templates"))
|
||||
# Count generated articles
|
||||
row = await fetch_one(
|
||||
"SELECT COUNT(*) as cnt FROM articles WHERE template_slug = ?", (slug,),
|
||||
)
|
||||
generated_count = row["cnt"] if row else 0
|
||||
|
||||
return await render_template(
|
||||
"admin/template_form.html", data=dict(template), editing=True, template_id=template_id,
|
||||
"admin/template_detail.html",
|
||||
config_data=config,
|
||||
columns=columns,
|
||||
sample_rows=sample_rows,
|
||||
generated_count=generated_count,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/templates/<int:template_id>/delete", methods=["POST"])
|
||||
@bp.route("/templates/<slug>/preview/<row_key>")
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def template_delete(template_id: int):
|
||||
"""Delete an article template."""
|
||||
await execute("DELETE FROM article_templates WHERE id = ?", (template_id,))
|
||||
await flash("Template deleted.", "success")
|
||||
return redirect(url_for("admin.templates"))
|
||||
async def template_preview(slug: str, row_key: str):
|
||||
"""Preview a single article rendered from template + DuckDB row."""
|
||||
from ..content import preview_article
|
||||
|
||||
lang = request.args.get("lang", "en")
|
||||
try:
|
||||
result = await preview_article(slug, row_key, lang=lang)
|
||||
except (AssertionError, Exception) as exc:
|
||||
await flash(f"Preview error: {exc}", "error")
|
||||
return redirect(url_for("admin.template_detail", slug=slug))
|
||||
|
||||
# =============================================================================
|
||||
# Template Data Management
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/templates/<int:template_id>/data")
|
||||
@role_required("admin")
|
||||
async def template_data(template_id: int):
|
||||
"""View data rows for a template."""
|
||||
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
||||
if not template:
|
||||
await flash("Template not found.", "error")
|
||||
return redirect(url_for("admin.templates"))
|
||||
|
||||
data_rows = await fetch_all(
|
||||
"""SELECT td.*, a.title as article_title, a.url_path as article_url,
|
||||
ps.slug as scenario_slug
|
||||
FROM template_data td
|
||||
LEFT JOIN articles a ON a.id = td.article_id
|
||||
LEFT JOIN published_scenarios ps ON ps.id = td.scenario_id
|
||||
WHERE td.template_id = ?
|
||||
ORDER BY td.created_at DESC""",
|
||||
(template_id,),
|
||||
)
|
||||
|
||||
# Pre-parse data_json for display in template
|
||||
for row in data_rows:
|
||||
try:
|
||||
row["parsed_data"] = json.loads(row["data_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
row["parsed_data"] = {}
|
||||
|
||||
schema = json.loads(template["input_schema"])
|
||||
return await render_template(
|
||||
"admin/template_data.html",
|
||||
template=template,
|
||||
data_rows=data_rows,
|
||||
schema=schema,
|
||||
"admin/template_preview.html",
|
||||
config={"slug": slug},
|
||||
preview=result,
|
||||
lang=lang,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/templates/<int:template_id>/data/add", methods=["POST"])
|
||||
@bp.route("/templates/<slug>/generate", methods=["GET", "POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def template_data_add(template_id: int):
|
||||
"""Add a single data row."""
|
||||
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
||||
if not template:
|
||||
async def template_generate(slug: str):
|
||||
"""Generate articles from template + DuckDB data."""
|
||||
from ..content import fetch_template_data, generate_articles, load_template
|
||||
|
||||
try:
|
||||
config = load_template(slug)
|
||||
except (AssertionError, FileNotFoundError):
|
||||
await flash("Template not found.", "error")
|
||||
return redirect(url_for("admin.templates"))
|
||||
|
||||
form = await request.form
|
||||
schema = json.loads(template["input_schema"])
|
||||
|
||||
data = {}
|
||||
for field in schema:
|
||||
val = form.get(field["name"], "").strip()
|
||||
if field.get("field_type") in ("number", "float"):
|
||||
try:
|
||||
data[field["name"]] = float(val) if val else 0
|
||||
except ValueError:
|
||||
data[field["name"]] = 0
|
||||
else:
|
||||
data[field["name"]] = val
|
||||
|
||||
await execute(
|
||||
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
|
||||
(template_id, json.dumps(data)),
|
||||
)
|
||||
await flash("Data row added.", "success")
|
||||
return redirect(url_for("admin.template_data", template_id=template_id))
|
||||
|
||||
|
||||
@bp.route("/templates/<int:template_id>/data/upload", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def template_data_upload(template_id: int):
|
||||
"""Bulk upload data rows from CSV."""
|
||||
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
||||
if not template:
|
||||
await flash("Template not found.", "error")
|
||||
return redirect(url_for("admin.templates"))
|
||||
|
||||
files = await request.files
|
||||
csv_file = files.get("csv_file")
|
||||
if not csv_file:
|
||||
await flash("No CSV file uploaded.", "error")
|
||||
return redirect(url_for("admin.template_data", template_id=template_id))
|
||||
|
||||
content = (await csv_file.read()).decode("utf-8-sig")
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
|
||||
rows_added = 0
|
||||
for row in reader:
|
||||
data = {k.strip(): v.strip() for k, v in row.items() if k and v}
|
||||
if data:
|
||||
await execute(
|
||||
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
|
||||
(template_id, json.dumps(data)),
|
||||
)
|
||||
rows_added += 1
|
||||
|
||||
await flash(f"{rows_added} data rows imported from CSV.", "success")
|
||||
return redirect(url_for("admin.template_data", template_id=template_id))
|
||||
|
||||
|
||||
@bp.route("/templates/<int:template_id>/data/<int:data_id>/delete", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def template_data_delete(template_id: int, data_id: int):
|
||||
"""Delete a single data row."""
|
||||
await execute("DELETE FROM template_data WHERE id = ? AND template_id = ?", (data_id, template_id))
|
||||
await flash("Data row deleted.", "success")
|
||||
return redirect(url_for("admin.template_data", template_id=template_id))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Bulk Generation
|
||||
# =============================================================================
|
||||
|
||||
def _render_jinja_string(template_str: str, context: dict) -> str:
|
||||
"""Render a Jinja2 template string with the given context."""
|
||||
from jinja2 import Environment
|
||||
env = Environment()
|
||||
tmpl = env.from_string(template_str)
|
||||
return tmpl.render(**context)
|
||||
|
||||
|
||||
@bp.route("/templates/<int:template_id>/generate", methods=["GET", "POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def template_generate(template_id: int):
|
||||
"""Bulk-generate scenarios + articles from template data."""
|
||||
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
||||
if not template:
|
||||
await flash("Template not found.", "error")
|
||||
return redirect(url_for("admin.templates"))
|
||||
|
||||
pending_count = await fetch_one(
|
||||
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NULL",
|
||||
(template_id,),
|
||||
)
|
||||
pending = pending_count["cnt"] if pending_count else 0
|
||||
data_rows = await fetch_template_data(config["data_table"], limit=501)
|
||||
row_count = len(data_rows)
|
||||
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
start_date_str = form.get("start_date", "")
|
||||
articles_per_day = int(form.get("articles_per_day", 2) or 2)
|
||||
articles_per_day = int(form.get("articles_per_day", 3) or 3)
|
||||
|
||||
if not start_date_str:
|
||||
start_date = date.today()
|
||||
else:
|
||||
start_date = date.fromisoformat(start_date_str)
|
||||
|
||||
assert articles_per_day > 0, "articles_per_day must be positive"
|
||||
|
||||
generated = await _generate_from_template(template, start_date, articles_per_day)
|
||||
generated = await generate_articles(
|
||||
slug, start_date, articles_per_day, limit=500,
|
||||
)
|
||||
await flash(f"Generated {generated} articles with staggered publish dates.", "success")
|
||||
return redirect(url_for("admin.template_data", template_id=template_id))
|
||||
return redirect(url_for("admin.articles"))
|
||||
|
||||
return await render_template(
|
||||
"admin/generate_form.html",
|
||||
template=template,
|
||||
pending_count=pending,
|
||||
config_data=config,
|
||||
row_count=row_count,
|
||||
today=date.today().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
async def _generate_from_template(template: dict, start_date: date, articles_per_day: int) -> int:
|
||||
"""Generate scenarios + articles for all un-generated data rows."""
|
||||
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path
|
||||
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||
@bp.route("/templates/<slug>/regenerate", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def template_regenerate(slug: str):
|
||||
"""Re-generate all articles for a template with fresh DuckDB data."""
|
||||
from ..content import generate_articles, load_template
|
||||
|
||||
data_rows = await fetch_all(
|
||||
"SELECT * FROM template_data WHERE template_id = ? AND article_id IS NULL",
|
||||
(template["id"],),
|
||||
)
|
||||
try:
|
||||
load_template(slug)
|
||||
except (AssertionError, FileNotFoundError):
|
||||
await flash("Template not found.", "error")
|
||||
return redirect(url_for("admin.templates"))
|
||||
|
||||
publish_date = start_date
|
||||
published_today = 0
|
||||
generated = 0
|
||||
|
||||
for row in data_rows:
|
||||
data = json.loads(row["data_json"])
|
||||
|
||||
# Separate calc fields from display fields
|
||||
lang = data.get("language", "en")
|
||||
calc_overrides = {k: v for k, v in data.items() if k in DEFAULTS}
|
||||
state = validate_state(calc_overrides)
|
||||
d = calc(state, lang=lang)
|
||||
|
||||
# Build scenario slug
|
||||
city_slug = data.get("city_slug", str(row["id"]))
|
||||
scenario_slug = template["slug"] + "-" + city_slug
|
||||
|
||||
# Court config label
|
||||
dbl = state.get("dblCourts", 0)
|
||||
sgl = state.get("sglCourts", 0)
|
||||
court_config = f"{dbl} double + {sgl} single"
|
||||
|
||||
# Create published scenario
|
||||
scenario_id = await execute(
|
||||
"""INSERT OR IGNORE INTO published_scenarios
|
||||
(slug, title, subtitle, location, country, venue_type, ownership,
|
||||
court_config, state_json, calc_json, template_data_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
scenario_slug,
|
||||
data.get("city", scenario_slug),
|
||||
data.get("subtitle", ""),
|
||||
data.get("city", ""),
|
||||
data.get("country", state.get("country", "")),
|
||||
state.get("venue", "indoor"),
|
||||
state.get("own", "rent"),
|
||||
court_config,
|
||||
json.dumps(state),
|
||||
json.dumps(d),
|
||||
row["id"],
|
||||
),
|
||||
)
|
||||
|
||||
if not scenario_id:
|
||||
# Slug already exists, fetch existing
|
||||
existing = await fetch_one(
|
||||
"SELECT id FROM published_scenarios WHERE slug = ?", (scenario_slug,)
|
||||
)
|
||||
scenario_id = existing["id"] if existing else None
|
||||
if not scenario_id:
|
||||
continue
|
||||
|
||||
# Fill template patterns
|
||||
data["scenario_slug"] = scenario_slug
|
||||
title = _render_jinja_string(template["title_pattern"], data)
|
||||
url_path = _render_jinja_string(template["url_pattern"], data)
|
||||
article_slug = template["slug"] + "-" + city_slug
|
||||
|
||||
meta_desc = ""
|
||||
if template["meta_description_pattern"]:
|
||||
meta_desc = _render_jinja_string(template["meta_description_pattern"], data)
|
||||
|
||||
# Validate url_path
|
||||
if is_reserved_path(url_path):
|
||||
continue
|
||||
|
||||
# Render body
|
||||
body_md = _render_jinja_string(template["body_template"], data)
|
||||
body_html = mistune.html(body_md)
|
||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||
|
||||
# Write to disk
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
build_path = BUILD_DIR / f"{article_slug}.html"
|
||||
build_path.write_text(body_html)
|
||||
|
||||
# Stagger publish date
|
||||
publish_dt = datetime(publish_date.year, publish_date.month, publish_date.day, 8, 0, 0)
|
||||
|
||||
# Create article
|
||||
article_id = await execute(
|
||||
"""INSERT OR IGNORE INTO articles
|
||||
(url_path, slug, title, meta_description, country, region,
|
||||
status, published_at, template_data_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?)""",
|
||||
(
|
||||
url_path, article_slug, title, meta_desc,
|
||||
data.get("country", ""), data.get("region", ""),
|
||||
publish_dt.isoformat(), row["id"],
|
||||
),
|
||||
)
|
||||
|
||||
if article_id:
|
||||
# Link data row
|
||||
now = datetime.utcnow().isoformat()
|
||||
await execute(
|
||||
"UPDATE template_data SET scenario_id = ?, article_id = ?, updated_at = ? WHERE id = ?",
|
||||
(scenario_id, article_id, now, row["id"]),
|
||||
)
|
||||
generated += 1
|
||||
|
||||
# Stagger dates
|
||||
published_today += 1
|
||||
if published_today >= articles_per_day:
|
||||
published_today = 0
|
||||
publish_date += timedelta(days=1)
|
||||
|
||||
return generated
|
||||
# Use today as start date, keep existing publish dates via upsert
|
||||
generated = await generate_articles(slug, date.today(), articles_per_day=500)
|
||||
await flash(f"Regenerated {generated} articles from fresh data.", "success")
|
||||
return redirect(url_for("admin.template_detail", slug=slug))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1659,46 +1373,31 @@ async def rebuild_all():
|
||||
|
||||
|
||||
async def _rebuild_article(article_id: int):
|
||||
"""Re-render a single article from its source (template+data or markdown)."""
|
||||
"""Re-render a single article from its source."""
|
||||
from ..content.routes import BUILD_DIR, bake_scenario_cards
|
||||
|
||||
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
||||
if not article:
|
||||
return
|
||||
|
||||
if article["template_data_id"]:
|
||||
# Generated article: re-render from template + data
|
||||
td = await fetch_one(
|
||||
"""SELECT td.*, at.body_template, at.title_pattern, at.meta_description_pattern
|
||||
FROM template_data td
|
||||
JOIN article_templates at ON at.id = td.template_id
|
||||
WHERE td.id = ?""",
|
||||
(article["template_data_id"],),
|
||||
)
|
||||
if not td:
|
||||
if article["template_slug"]:
|
||||
# SSG-generated article: regenerate via the content module
|
||||
from ..content import generate_articles, load_template
|
||||
try:
|
||||
load_template(article["template_slug"])
|
||||
except (AssertionError, FileNotFoundError):
|
||||
return
|
||||
|
||||
data = json.loads(td["data_json"])
|
||||
|
||||
# Re-fetch scenario for fresh calc_json
|
||||
if td["scenario_id"]:
|
||||
scenario = await fetch_one(
|
||||
"SELECT slug FROM published_scenarios WHERE id = ?", (td["scenario_id"],)
|
||||
)
|
||||
if scenario:
|
||||
data["scenario_slug"] = scenario["slug"]
|
||||
|
||||
body_md = _render_jinja_string(td["body_template"], data)
|
||||
body_html = mistune.html(body_md)
|
||||
lang = data.get("language", "en")
|
||||
# Regenerate all articles for this template (upserts, so safe)
|
||||
await generate_articles(
|
||||
article["template_slug"], date.today(), articles_per_day=500,
|
||||
)
|
||||
else:
|
||||
# Manual article: re-render from markdown file
|
||||
md_path = Path("data/content/articles") / f"{article['slug']}.md"
|
||||
if not md_path.exists():
|
||||
return
|
||||
body_html = mistune.html(md_path.read_text())
|
||||
lang = "en"
|
||||
|
||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
|
||||
lang = article.get("language", "en") if hasattr(article, "get") else "en"
|
||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}Generate Articles - {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}Generate Articles - {{ config_data.name }} - Admin{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width: 32rem; margin: 0 auto;">
|
||||
<a href="{{ url_for('admin.template_data', template_id=template.id) }}" class="text-sm text-slate">← Back to {{ template.name }}</a>
|
||||
<a href="{{ url_for('admin.template_detail', slug=config_data.slug) }}" class="text-sm text-slate">← Back to {{ config_data.name }}</a>
|
||||
<h1 class="text-2xl mt-4 mb-2">Generate Articles</h1>
|
||||
<p class="text-slate text-sm mb-6">{{ pending_count }} pending data row{{ 's' if pending_count != 1 }} ready to generate.</p>
|
||||
<p class="text-slate text-sm mb-6">
|
||||
{{ row_count }} data row{{ 's' if row_count != 1 }} available in
|
||||
<code>{{ config_data.data_table }}</code>
|
||||
× {{ config_data.languages | length }} language{{ 's' if config_data.languages | length != 1 }}
|
||||
= <strong>{{ row_count * config_data.languages | length }}</strong> articles.
|
||||
</p>
|
||||
|
||||
{% if pending_count == 0 %}
|
||||
{% if row_count == 0 %}
|
||||
<div class="card">
|
||||
<p class="text-slate text-sm">All data rows have already been generated. Add more data rows first.</p>
|
||||
<p class="text-slate text-sm">No data rows found. Run the data pipeline to populate <code>{{ config_data.data_table }}</code>.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" class="card">
|
||||
@@ -25,20 +30,23 @@
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="articles_per_day">Articles Per Day</label>
|
||||
<input type="number" id="articles_per_day" name="articles_per_day" value="2" min="1" max="50" class="form-input" required>
|
||||
<input type="number" id="articles_per_day" name="articles_per_day" value="3" min="1" max="50" class="form-input" required>
|
||||
<p class="form-hint">How many articles to publish per day. Remaining articles get staggered to following days.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="background: var(--color-soft-white); border: 1px dashed var(--color-mid-gray); margin-bottom: 1rem;">
|
||||
<p class="text-sm text-slate">
|
||||
This will generate <strong class="text-navy">{{ pending_count }}</strong> articles
|
||||
over <strong class="text-navy" id="days-estimate">{{ ((pending_count + 1) // 2) }}</strong> days,
|
||||
each with its own financial scenario computed from the data row's input values.
|
||||
This will generate up to <strong class="text-navy">{{ row_count * config_data.languages | length }}</strong> articles
|
||||
({{ row_count }} rows × {{ config_data.languages | length }} languages).
|
||||
Existing articles with the same URL will be updated in-place.
|
||||
{% if config_data.priority_column %}
|
||||
Articles are ordered by <code>{{ config_data.priority_column }}</code> (highest first).
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate {{ pending_count }} articles? This cannot be undone.')">
|
||||
Generate {{ pending_count }} Articles
|
||||
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate articles? Existing articles will be updated.')">
|
||||
Generate Articles
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}Data: {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">← Back to templates</a>
|
||||
<h1 class="text-2xl mt-2">{{ template.name }}</h1>
|
||||
<p class="text-slate text-sm">{{ data_rows | length }} data row{{ 's' if data_rows | length != 1 }} · <span class="mono">{{ template.slug }}</span></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.template_generate', template_id=template.id) }}" class="btn">Generate Articles</a>
|
||||
<a href="{{ url_for('admin.template_edit', template_id=template.id) }}" class="btn-outline">Edit Template</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Add Single Row -->
|
||||
<div class="card mb-6">
|
||||
<h3 class="text-base font-semibold mb-4">Add Data Row</h3>
|
||||
<form method="post" action="{{ url_for('admin.template_data_add', template_id=template.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem;" class="mb-4">
|
||||
{% for field in schema %}
|
||||
<div>
|
||||
<label class="form-label">{{ field.label }}</label>
|
||||
<input type="{{ 'number' if field.get('field_type') in ('number', 'float') else 'text' }}"
|
||||
name="{{ field.name }}" class="form-input"
|
||||
{% if field.get('field_type') == 'float' %}step="any"{% endif %}
|
||||
{% if field.get('required') %}required{% endif %}>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm">Add Row</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- CSV Upload -->
|
||||
<div class="card mb-6">
|
||||
<h3 class="text-base font-semibold mb-4">Bulk Upload (CSV)</h3>
|
||||
<form method="post" action="{{ url_for('admin.template_data_upload', template_id=template.id) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="flex items-end gap-3">
|
||||
<div style="flex: 1;">
|
||||
<label class="form-label" for="csv_file">CSV File</label>
|
||||
<input type="file" id="csv_file" name="csv_file" accept=".csv" class="form-input" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm">Upload</button>
|
||||
</div>
|
||||
<p class="form-hint mt-1">CSV headers should match field names: {{ schema | map(attribute='name') | join(', ') }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Data Rows -->
|
||||
<div class="card">
|
||||
<h3 class="text-base font-semibold mb-4">Data Rows</h3>
|
||||
{% if data_rows %}
|
||||
<div class="scenario-widget__table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
{% for field in schema[:5] %}
|
||||
<th>{{ field.label }}</th>
|
||||
{% endfor %}
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in data_rows %}
|
||||
<tr>
|
||||
<td class="mono text-sm">{{ row.id }}</td>
|
||||
{% for field in schema[:5] %}
|
||||
<td class="text-sm">{{ row.parsed_data.get(field.name, '') }}</td>
|
||||
{% endfor %}
|
||||
<td>
|
||||
{% if row.article_id %}
|
||||
<span class="badge-success">Generated</span>
|
||||
{% if row.article_url %}
|
||||
<a href="{{ row.article_url }}" class="text-xs ml-1">View</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge-warning">Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<form method="post" action="{{ url_for('admin.template_data_delete', template_id=template.id, data_id=row.id) }}" class="m-0" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this data row?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No data rows yet. Add some above or upload a CSV.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
111
web/src/padelnomics/admin/templates/admin/template_detail.html
Normal file
111
web/src/padelnomics/admin/templates/admin/template_detail.html
Normal file
@@ -0,0 +1,111 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}{{ config_data.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">← Templates</a>
|
||||
|
||||
<header class="flex justify-between items-center mt-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl">{{ config_data.name }}</h1>
|
||||
<p class="text-slate text-sm mono">{{ config_data.slug }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a>
|
||||
<form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline" onclick="return confirm('Regenerate all articles for this template with fresh data?')">
|
||||
Regenerate
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# Config section #}
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg mb-4">Configuration (read-only)</h2>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr><td class="font-semibold" style="width:200px">Content Type</td><td><span class="badge">{{ config_data.content_type }}</span></td></tr>
|
||||
<tr><td class="font-semibold">Data Table</td><td class="mono">{{ config_data.data_table }}</td></tr>
|
||||
<tr><td class="font-semibold">Natural Key</td><td class="mono">{{ config_data.natural_key }}</td></tr>
|
||||
<tr><td class="font-semibold">Languages</td><td>{{ config_data.languages | join(', ') }}</td></tr>
|
||||
<tr><td class="font-semibold">URL Pattern</td><td class="mono text-sm">{{ config_data.url_pattern }}</td></tr>
|
||||
<tr><td class="font-semibold">Title Pattern</td><td class="text-sm">{{ config_data.title_pattern }}</td></tr>
|
||||
<tr><td class="font-semibold">Meta Description</td><td class="text-sm">{{ config_data.meta_description_pattern }}</td></tr>
|
||||
<tr><td class="font-semibold">Schema Types</td><td>{{ config_data.schema_type | join(', ') }}</td></tr>
|
||||
{% if config_data.priority_column %}
|
||||
<tr><td class="font-semibold">Priority Column</td><td class="mono">{{ config_data.priority_column }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-slate text-sm mt-4">Edit this template in the repo: <code>content/templates/{{ config_data.slug }}.md.jinja</code></p>
|
||||
</div>
|
||||
|
||||
{# Stats #}
|
||||
<div class="flex gap-4 mb-6">
|
||||
<div class="card" style="flex:1; text-align:center">
|
||||
<div class="text-2xl font-bold">{{ columns | length }}</div>
|
||||
<div class="text-slate text-sm">Columns</div>
|
||||
</div>
|
||||
<div class="card" style="flex:1; text-align:center">
|
||||
<div class="text-2xl font-bold">{{ sample_rows | length }}{% if sample_rows | length >= 10 %}+{% endif %}</div>
|
||||
<div class="text-slate text-sm">Data Rows</div>
|
||||
</div>
|
||||
<div class="card" style="flex:1; text-align:center">
|
||||
<div class="text-2xl font-bold">{{ generated_count }}</div>
|
||||
<div class="text-slate text-sm">Generated</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Columns #}
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg mb-4">Available Columns</h2>
|
||||
{% if columns %}
|
||||
<div style="display:flex; flex-wrap:wrap; gap:8px">
|
||||
{% for col in columns %}
|
||||
<span class="badge" title="{{ col.type }}">{{ col.name }} <span class="text-slate">({{ col.type }})</span></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No columns found. Is the DuckDB table <code>{{ config_data.data_table }}</code> available?</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Sample data #}
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg mb-4">Sample Data (first 10 rows)</h2>
|
||||
{% if sample_rows %}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="table text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for col in columns %}
|
||||
<th>{{ col.name }}</th>
|
||||
{% endfor %}
|
||||
<th>Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in sample_rows %}
|
||||
<tr>
|
||||
{% for col in columns %}
|
||||
<td class="mono" style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">
|
||||
{{ row[col.name] }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td>
|
||||
<a href="{{ url_for('admin.template_preview', slug=config_data.slug, row_key=row[config_data.natural_key]) }}"
|
||||
class="btn-outline btn-sm">Preview</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No data available. Run the data pipeline first.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,70 +0,0 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Template - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width: 48rem; margin: 0 auto;">
|
||||
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">← Back to templates</a>
|
||||
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article Template</h1>
|
||||
|
||||
<form method="post" class="card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||
<div>
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ data.get('name', '') }}" class="form-input" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="slug">Slug</label>
|
||||
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
|
||||
placeholder="auto-generated from name" {% if editing %}readonly{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="content_type">Content Type</label>
|
||||
<select id="content_type" name="content_type" class="form-input" {% if editing %}disabled{% endif %}>
|
||||
<option value="calculator" {% if data.get('content_type') == 'calculator' %}selected{% endif %}>Calculator</option>
|
||||
<option value="map" {% if data.get('content_type') == 'map' %}selected{% endif %}>Map (future)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="input_schema">Input Schema (JSON)</label>
|
||||
<textarea id="input_schema" name="input_schema" rows="6" class="form-input" style="font-family: var(--font-mono); font-size: 0.8125rem;">{{ data.get('input_schema', '[{"name": "city", "label": "City", "field_type": "text", "required": true}, {"name": "city_slug", "label": "City Slug", "field_type": "text", "required": true}, {"name": "country", "label": "Country", "field_type": "text", "required": true}, {"name": "region", "label": "Region", "field_type": "text", "required": false}]') }}</textarea>
|
||||
<p class="form-hint">JSON array of field definitions: [{name, label, field_type, required}]</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="url_pattern">URL Pattern</label>
|
||||
<input type="text" id="url_pattern" name="url_pattern" value="{{ data.get('url_pattern', '') }}"
|
||||
class="form-input" placeholder="/padel-court-cost-{{ '{{' }} city_slug {{ '}}' }}" required>
|
||||
<p class="form-hint">Jinja2 template string. Use {{ '{{' }} variable {{ '}}' }} placeholders from data rows.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="title_pattern">Title Pattern</label>
|
||||
<input type="text" id="title_pattern" name="title_pattern" value="{{ data.get('title_pattern', '') }}"
|
||||
class="form-input" placeholder="Padel Center Cost in {{ '{{' }} city {{ '}}' }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="meta_description_pattern">Meta Description Pattern</label>
|
||||
<input type="text" id="meta_description_pattern" name="meta_description_pattern"
|
||||
value="{{ data.get('meta_description_pattern', '') }}" class="form-input"
|
||||
placeholder="How much does it cost to build a padel center in {{ '{{' }} city {{ '}}' }}?">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="body_template">Body Template (Markdown + Jinja2)</label>
|
||||
<textarea id="body_template" name="body_template" rows="20" class="form-input"
|
||||
style="font-family: var(--font-mono); font-size: 0.8125rem;" required>{{ data.get('body_template', '') }}</textarea>
|
||||
<p class="form-hint">Markdown with {{ '{{' }} variable {{ '}}' }} placeholders. Use [scenario:{{ '{{' }} scenario_slug {{ '}}' }}] to embed financial widgets. Sections: [scenario:slug:capex], [scenario:slug:operating], [scenario:slug:cashflow], [scenario:slug:returns], [scenario:slug:full].</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update{% else %}Create{% endif %} Template</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}Preview - {{ preview.title }} - Admin{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">← Back to template</a>
|
||||
|
||||
<header class="mt-4 mb-6">
|
||||
<h1 class="text-2xl">Article Preview</h1>
|
||||
<p class="text-slate text-sm">Language: {{ lang }} | URL: <code>{{ preview.url_path }}</code></p>
|
||||
</header>
|
||||
|
||||
{# Meta preview #}
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg mb-2">SEO Preview</h2>
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px;">
|
||||
<div style="color: #1a0dab; font-size: 20px; line-height: 1.3;">{{ preview.title }}</div>
|
||||
<div style="color: #006621; font-size: 14px; margin: 2px 0;">{{ preview.url_path }}</div>
|
||||
<div style="color: #545454; font-size: 14px; line-height: 1.58;">{{ preview.meta_description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Rendered article #}
|
||||
<div class="card">
|
||||
<h2 class="text-lg mb-4">Rendered HTML</h2>
|
||||
<div class="prose" style="max-width: none;">
|
||||
{{ preview.html | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,18 +1,15 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}Article Templates - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}Content Templates - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl">Article Templates</h1>
|
||||
<p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.template_new') }}" class="btn">New Template</a>
|
||||
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
|
||||
<h1 class="text-2xl">Content Templates</h1>
|
||||
<p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }} (scanned from git)</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
@@ -23,29 +20,32 @@
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Type</th>
|
||||
<th>Data Table</th>
|
||||
<th>Data Rows</th>
|
||||
<th>Generated</th>
|
||||
<th>Languages</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in templates %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('admin.template_data', template_id=t.id) }}">{{ t.name }}</a></td>
|
||||
<td><a href="{{ url_for('admin.template_detail', slug=t.slug) }}">{{ t.name }}</a></td>
|
||||
<td class="mono text-sm">{{ t.slug }}</td>
|
||||
<td><span class="badge">{{ t.content_type }}</span></td>
|
||||
<td class="mono text-sm">{{ t.data_table }}</td>
|
||||
<td class="mono">{{ t.data_count }}</td>
|
||||
<td class="mono">{{ t.generated_count }}</td>
|
||||
<td class="text-sm">{{ t.languages | join(', ') }}</td>
|
||||
<td class="text-right">
|
||||
<a href="{{ url_for('admin.template_edit', template_id=t.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
<a href="{{ url_for('admin.template_generate', template_id=t.id) }}" class="btn btn-sm">Generate</a>
|
||||
<a href="{{ url_for('admin.template_generate', slug=t.slug) }}" class="btn btn-sm">Generate</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No templates yet. Create one to get started.</p>
|
||||
<p class="text-slate text-sm">No templates found. Add <code>.md.jinja</code> files to <code>content/templates/</code> in the repo.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
"""
|
||||
SSG-inspired pSEO content engine.
|
||||
|
||||
Templates live in git as .md.jinja files with YAML frontmatter.
|
||||
Data comes from DuckDB serving tables. Only articles + published_scenarios
|
||||
are stored in SQLite (routing / application state).
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import mistune
|
||||
import yaml
|
||||
from jinja2 import Environment
|
||||
|
||||
from ..analytics import fetch_analytics
|
||||
from ..core import execute, fetch_one, slugify
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
BUILD_DIR = Path("data/content/_build")
|
||||
|
||||
_REQUIRED_FRONTMATTER = {
|
||||
"name", "slug", "content_type", "data_table",
|
||||
"natural_key", "languages", "url_pattern", "title_pattern",
|
||||
"meta_description_pattern",
|
||||
}
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||
|
||||
# FAQ extraction: **bold question** followed by answer paragraph(s)
|
||||
_FAQ_RE = re.compile(
|
||||
r"\*\*(.+?)\*\*\s*\n((?:(?!\*\*).+\n?)+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
# ── Template discovery & loading ─────────────────────────────────────────────
|
||||
|
||||
def discover_templates() -> list[dict]:
|
||||
"""Scan TEMPLATES_DIR for .md.jinja files, return parsed frontmatter list."""
|
||||
templates = []
|
||||
if not TEMPLATES_DIR.exists():
|
||||
return templates
|
||||
|
||||
for path in sorted(TEMPLATES_DIR.glob("*.md.jinja")):
|
||||
try:
|
||||
config = _parse_frontmatter(path.read_text())
|
||||
config["_path"] = str(path)
|
||||
templates.append(config)
|
||||
except (ValueError, yaml.YAMLError):
|
||||
continue
|
||||
return templates
|
||||
|
||||
|
||||
def load_template(slug: str) -> dict:
|
||||
"""Load a single template by slug. Returns frontmatter + body_template."""
|
||||
path = TEMPLATES_DIR / f"{slug}.md.jinja"
|
||||
assert path.exists(), f"Template not found: {slug}"
|
||||
|
||||
text = path.read_text()
|
||||
config = _parse_frontmatter(text)
|
||||
|
||||
# Everything after the closing --- is the body template
|
||||
match = _FRONTMATTER_RE.match(text)
|
||||
assert match, f"No frontmatter in {slug}"
|
||||
config["body_template"] = text[match.end():]
|
||||
return config
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict:
|
||||
"""Extract YAML frontmatter from a template file."""
|
||||
match = _FRONTMATTER_RE.match(text)
|
||||
if not match:
|
||||
raise ValueError("No YAML frontmatter found")
|
||||
|
||||
config = yaml.safe_load(match.group(1))
|
||||
assert isinstance(config, dict), "Frontmatter must be a YAML mapping"
|
||||
|
||||
missing = _REQUIRED_FRONTMATTER - set(config.keys())
|
||||
assert not missing, f"Missing frontmatter keys: {missing}"
|
||||
|
||||
# Normalize schema_type to list
|
||||
schema_type = config.get("schema_type", "Article")
|
||||
if isinstance(schema_type, str):
|
||||
schema_type = [schema_type]
|
||||
config["schema_type"] = schema_type
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# ── DuckDB data access ───────────────────────────────────────────────────────
|
||||
|
||||
async def get_table_columns(data_table: str) -> list[dict]:
|
||||
"""Query DuckDB information_schema for a serving table's columns."""
|
||||
assert "." in data_table, "data_table must be schema-qualified (e.g. serving.xxx)"
|
||||
schema, table = data_table.split(".", 1)
|
||||
|
||||
rows = await fetch_analytics(
|
||||
"""SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = ? AND table_name = ?
|
||||
ORDER BY ordinal_position""",
|
||||
[schema, table],
|
||||
)
|
||||
return [{"name": r["column_name"], "type": r["data_type"]} for r in rows]
|
||||
|
||||
|
||||
async def fetch_template_data(
|
||||
data_table: str,
|
||||
order_by: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> list[dict]:
|
||||
"""Fetch all rows from a DuckDB serving table."""
|
||||
assert "." in data_table, "data_table must be schema-qualified"
|
||||
_validate_table_name(data_table)
|
||||
|
||||
order_clause = f"ORDER BY {order_by} DESC" if order_by else ""
|
||||
return await fetch_analytics(
|
||||
f"SELECT * FROM {data_table} {order_clause} LIMIT ?",
|
||||
[limit],
|
||||
)
|
||||
|
||||
|
||||
def _validate_table_name(data_table: str) -> None:
|
||||
"""Guard against SQL injection in table names."""
|
||||
assert re.match(r"^[a-z_][a-z0-9_.]*$", data_table), (
|
||||
f"Invalid table name: {data_table}"
|
||||
)
|
||||
|
||||
|
||||
# ── Rendering helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _render_pattern(pattern: str, context: dict) -> str:
|
||||
"""Render a Jinja2 pattern string with context variables."""
|
||||
env = Environment()
|
||||
env.filters["slugify"] = slugify
|
||||
return env.from_string(pattern).render(**context)
|
||||
|
||||
|
||||
def _extract_faq_pairs(markdown: str) -> list[dict]:
|
||||
"""Extract FAQ Q&A pairs from a ## FAQ section in markdown."""
|
||||
# Find the FAQ section
|
||||
faq_start = markdown.find("## FAQ")
|
||||
if faq_start == -1:
|
||||
return []
|
||||
|
||||
# Take content until next ## heading or end
|
||||
rest = markdown[faq_start:]
|
||||
next_h2 = rest.find("\n## ", 1)
|
||||
faq_block = rest[:next_h2] if next_h2 > 0 else rest
|
||||
|
||||
pairs = []
|
||||
for match in _FAQ_RE.finditer(faq_block):
|
||||
question = match.group(1).strip()
|
||||
answer = match.group(2).strip()
|
||||
if question and answer:
|
||||
pairs.append({"question": question, "answer": answer})
|
||||
return pairs
|
||||
|
||||
|
||||
# ── JSON-LD structured data ──────────────────────────────────────────────────
|
||||
|
||||
def build_jsonld(
|
||||
schema_types: list[str],
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
url: str,
|
||||
published_at: str,
|
||||
date_modified: str,
|
||||
language: str,
|
||||
breadcrumbs: list[dict],
|
||||
faq_pairs: list[dict] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Build JSON-LD structured data objects for an article."""
|
||||
objects = []
|
||||
|
||||
# BreadcrumbList — always present
|
||||
objects.append({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": i + 1,
|
||||
"name": bc["name"],
|
||||
"item": bc["url"],
|
||||
}
|
||||
for i, bc in enumerate(breadcrumbs)
|
||||
],
|
||||
})
|
||||
|
||||
# Article
|
||||
if "Article" in schema_types:
|
||||
objects.append({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": title[:110],
|
||||
"description": description[:200],
|
||||
"url": url,
|
||||
"inLanguage": language,
|
||||
"datePublished": published_at,
|
||||
"dateModified": date_modified,
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "Padelnomics",
|
||||
"url": "https://padelnomics.io",
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Padelnomics",
|
||||
"url": "https://padelnomics.io",
|
||||
},
|
||||
})
|
||||
|
||||
# FAQPage
|
||||
if "FAQPage" in schema_types and faq_pairs:
|
||||
objects.append({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": faq["question"],
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": faq["answer"],
|
||||
},
|
||||
}
|
||||
for faq in faq_pairs
|
||||
],
|
||||
})
|
||||
|
||||
return objects
|
||||
|
||||
|
||||
def _build_breadcrumbs(url_path: str, base_url: str) -> list[dict]:
|
||||
"""Build breadcrumb list from URL path segments."""
|
||||
parts = [p for p in url_path.strip("/").split("/") if p]
|
||||
crumbs = [{"name": "Home", "url": base_url + "/"}]
|
||||
for i, part in enumerate(parts):
|
||||
label = part.replace("-", " ").title()
|
||||
path = "/" + "/".join(parts[: i + 1])
|
||||
crumbs.append({"name": label, "url": base_url + path})
|
||||
return crumbs
|
||||
|
||||
|
||||
# ── Article generation pipeline ──────────────────────────────────────────────
|
||||
|
||||
async def generate_articles(
|
||||
slug: str,
|
||||
start_date: date,
|
||||
articles_per_day: int,
|
||||
*,
|
||||
limit: int = 500,
|
||||
base_url: str = "https://padelnomics.io",
|
||||
) -> int:
|
||||
"""
|
||||
Generate articles from a git template + DuckDB data.
|
||||
|
||||
For each row in the DuckDB table x each language:
|
||||
- render patterns (url, title, meta)
|
||||
- create/update published_scenario if calculator type
|
||||
- render body markdown -> HTML
|
||||
- bake scenario cards
|
||||
- inject SEO head (canonical, hreflang, JSON-LD, OG)
|
||||
- write HTML to disk
|
||||
- upsert article row in SQLite
|
||||
|
||||
Returns count of articles generated.
|
||||
"""
|
||||
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||
from .routes import bake_scenario_cards, is_reserved_path
|
||||
|
||||
assert articles_per_day > 0, "articles_per_day must be positive"
|
||||
|
||||
config = load_template(slug)
|
||||
order_by = config.get("priority_column")
|
||||
rows = await fetch_template_data(config["data_table"], order_by=order_by, limit=limit)
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
publish_date = start_date
|
||||
published_today = 0
|
||||
generated = 0
|
||||
now_iso = datetime.now(UTC).isoformat()
|
||||
|
||||
for row in rows:
|
||||
for lang in config["languages"]:
|
||||
# Build render context: row data + language
|
||||
ctx = {**row, "language": lang}
|
||||
|
||||
# Render URL pattern
|
||||
url_path = f"/{lang}" + _render_pattern(config["url_pattern"], ctx)
|
||||
if is_reserved_path(url_path):
|
||||
continue
|
||||
|
||||
title = _render_pattern(config["title_pattern"], ctx)
|
||||
meta_desc = _render_pattern(config["meta_description_pattern"], ctx)
|
||||
article_slug = slug + "-" + lang + "-" + str(row[config["natural_key"]])
|
||||
|
||||
# Calculator content type: create scenario
|
||||
scenario_slug = None
|
||||
if config["content_type"] == "calculator":
|
||||
calc_overrides = {k: v for k, v in row.items() if k in DEFAULTS}
|
||||
state = validate_state(calc_overrides)
|
||||
d = calc(state, lang=lang)
|
||||
|
||||
scenario_slug = slug + "-" + str(row[config["natural_key"]])
|
||||
dbl = state.get("dblCourts", 0)
|
||||
sgl = state.get("sglCourts", 0)
|
||||
court_config = f"{dbl} double + {sgl} single"
|
||||
city = row.get("city_name", row.get("city", ""))
|
||||
country = row.get("country", state.get("country", ""))
|
||||
|
||||
# Upsert published scenario
|
||||
existing = await fetch_one(
|
||||
"SELECT id FROM published_scenarios WHERE slug = ?",
|
||||
(scenario_slug,),
|
||||
)
|
||||
if existing:
|
||||
await execute(
|
||||
"""UPDATE published_scenarios
|
||||
SET state_json = ?, calc_json = ?, updated_at = ?
|
||||
WHERE slug = ?""",
|
||||
(json.dumps(state), json.dumps(d), now_iso, scenario_slug),
|
||||
)
|
||||
else:
|
||||
await execute(
|
||||
"""INSERT INTO published_scenarios
|
||||
(slug, title, location, country, venue_type, ownership,
|
||||
court_config, state_json, calc_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
scenario_slug, city, city, country,
|
||||
state.get("venue", "indoor"),
|
||||
state.get("own", "rent"),
|
||||
court_config,
|
||||
json.dumps(state), json.dumps(d), now_iso,
|
||||
),
|
||||
)
|
||||
|
||||
ctx["scenario_slug"] = scenario_slug
|
||||
|
||||
# Render body template
|
||||
body_md = _render_pattern(config["body_template"], ctx)
|
||||
body_html = mistune.html(body_md)
|
||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||
|
||||
# Extract FAQ pairs for structured data
|
||||
faq_pairs = _extract_faq_pairs(body_md)
|
||||
|
||||
# Build SEO metadata
|
||||
full_url = base_url + url_path
|
||||
publish_dt = datetime(
|
||||
publish_date.year, publish_date.month, publish_date.day,
|
||||
8, 0, 0,
|
||||
).isoformat()
|
||||
|
||||
# Hreflang links
|
||||
hreflang_links = []
|
||||
for alt_lang in config["languages"]:
|
||||
alt_url = f"/{alt_lang}" + _render_pattern(config["url_pattern"], {**row, "language": alt_lang})
|
||||
hreflang_links.append(
|
||||
f'<link rel="alternate" hreflang="{alt_lang}" href="{base_url}{alt_url}" />'
|
||||
)
|
||||
# x-default points to English (or first language)
|
||||
default_lang = "en" if "en" in config["languages"] else config["languages"][0]
|
||||
default_url = f"/{default_lang}" + _render_pattern(config["url_pattern"], {**row, "language": default_lang})
|
||||
hreflang_links.append(
|
||||
f'<link rel="alternate" hreflang="x-default" href="{base_url}{default_url}" />'
|
||||
)
|
||||
|
||||
# JSON-LD
|
||||
breadcrumbs = _build_breadcrumbs(url_path, base_url)
|
||||
jsonld_objects = build_jsonld(
|
||||
config["schema_type"],
|
||||
title=title,
|
||||
description=meta_desc,
|
||||
url=full_url,
|
||||
published_at=publish_dt,
|
||||
date_modified=now_iso,
|
||||
language=lang,
|
||||
breadcrumbs=breadcrumbs,
|
||||
faq_pairs=faq_pairs,
|
||||
)
|
||||
|
||||
# Build SEO head block
|
||||
seo_head = "\n".join([
|
||||
f'<link rel="canonical" href="{full_url}" />',
|
||||
*hreflang_links,
|
||||
f'<meta property="og:title" content="{_escape_attr(title)}" />',
|
||||
f'<meta property="og:description" content="{_escape_attr(meta_desc)}" />',
|
||||
f'<meta property="og:url" content="{full_url}" />',
|
||||
'<meta property="og:type" content="article" />',
|
||||
*[
|
||||
f'<script type="application/ld+json">{json.dumps(obj, ensure_ascii=False)}</script>'
|
||||
for obj in jsonld_objects
|
||||
],
|
||||
])
|
||||
|
||||
# Write HTML to disk
|
||||
build_dir = BUILD_DIR / lang
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / f"{article_slug}.html").write_text(body_html)
|
||||
|
||||
# Upsert article in SQLite
|
||||
existing_article = await fetch_one(
|
||||
"SELECT id FROM articles WHERE url_path = ?", (url_path,),
|
||||
)
|
||||
if existing_article:
|
||||
await execute(
|
||||
"""UPDATE articles
|
||||
SET title = ?, meta_description = ?, template_slug = ?,
|
||||
language = ?, date_modified = ?, updated_at = ?,
|
||||
seo_head = ?
|
||||
WHERE url_path = ?""",
|
||||
(title, meta_desc, slug, lang, now_iso, now_iso, seo_head, url_path),
|
||||
)
|
||||
else:
|
||||
await execute(
|
||||
"""INSERT INTO articles
|
||||
(url_path, slug, title, meta_description, country, region,
|
||||
status, published_at, template_slug, language, date_modified,
|
||||
seo_head, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
url_path, article_slug, title, meta_desc,
|
||||
row.get("country", ""), row.get("region", ""),
|
||||
publish_dt, slug, lang, now_iso, seo_head, now_iso,
|
||||
),
|
||||
)
|
||||
|
||||
generated += 1
|
||||
|
||||
# Stagger dates
|
||||
published_today += 1
|
||||
if published_today >= articles_per_day:
|
||||
published_today = 0
|
||||
publish_date += timedelta(days=1)
|
||||
|
||||
return generated
|
||||
|
||||
|
||||
async def preview_article(
|
||||
slug: str,
|
||||
row_key: str,
|
||||
lang: str = "en",
|
||||
base_url: str = "https://padelnomics.io",
|
||||
) -> dict:
|
||||
"""
|
||||
Render one article in-memory for admin preview.
|
||||
No disk write, no DB insert. Returns {title, url_path, html, meta_description}.
|
||||
"""
|
||||
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||
from .routes import bake_scenario_cards
|
||||
|
||||
config = load_template(slug)
|
||||
|
||||
# Fetch one row by natural key
|
||||
_validate_table_name(config["data_table"])
|
||||
natural_key = config["natural_key"]
|
||||
rows = await fetch_analytics(
|
||||
f"SELECT * FROM {config['data_table']} WHERE {natural_key} = ? LIMIT 1",
|
||||
[row_key],
|
||||
)
|
||||
assert rows, f"No row found for {natural_key}={row_key}"
|
||||
row = rows[0]
|
||||
|
||||
ctx = {**row, "language": lang}
|
||||
|
||||
url_path = f"/{lang}" + _render_pattern(config["url_pattern"], ctx)
|
||||
title = _render_pattern(config["title_pattern"], ctx)
|
||||
meta_desc = _render_pattern(config["meta_description_pattern"], ctx)
|
||||
|
||||
# Calculator: compute scenario in-memory
|
||||
if config["content_type"] == "calculator":
|
||||
calc_overrides = {k: v for k, v in row.items() if k in DEFAULTS}
|
||||
state = validate_state(calc_overrides)
|
||||
calc(state, lang=lang) # validate state produces valid output
|
||||
ctx["scenario_slug"] = slug + "-" + str(row[natural_key])
|
||||
|
||||
body_md = _render_pattern(config["body_template"], ctx)
|
||||
body_html = mistune.html(body_md)
|
||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"url_path": url_path,
|
||||
"meta_description": meta_desc,
|
||||
"html": body_html,
|
||||
}
|
||||
|
||||
|
||||
def _escape_attr(text: str) -> str:
|
||||
"""Escape text for use in HTML attribute values."""
|
||||
return text.replace("&", "&").replace('"', """).replace("<", "<")
|
||||
|
||||
@@ -215,7 +215,12 @@ async def article_page(url_path: str):
|
||||
if not article:
|
||||
abort(404)
|
||||
|
||||
build_path = BUILD_DIR / f"{article['slug']}.html"
|
||||
# SSG articles: language-prefixed build path
|
||||
lang = article["language"] if article.get("language") else "en"
|
||||
build_path = BUILD_DIR / lang / f"{article['slug']}.html"
|
||||
if not build_path.exists():
|
||||
# Fallback: flat build path (legacy manual articles)
|
||||
build_path = BUILD_DIR / f"{article['slug']}.html"
|
||||
if not build_path.exists():
|
||||
abort(404)
|
||||
|
||||
|
||||
64
web/src/padelnomics/content/templates/city-cost-de.md.jinja
Normal file
64
web/src/padelnomics/content/templates/city-cost-de.md.jinja
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: "DE City Padel Costs"
|
||||
slug: city-cost-de
|
||||
content_type: calculator
|
||||
data_table: serving.pseo_city_costs_de
|
||||
natural_key: city_slug
|
||||
languages: [de, en]
|
||||
url_pattern: "/markets/{{ country_name_en | lower | slugify }}/{{ city_slug }}"
|
||||
title_pattern: "Padel in {{ city_name }} — Market Analysis & Costs"
|
||||
meta_description_pattern: "How much does it cost to build a padel center in {{ city_name }}? {{ padel_venue_count }} venues, pricing data & financial model."
|
||||
schema_type: [Article, FAQPage]
|
||||
priority_column: population
|
||||
---
|
||||
|
||||
# Padel in {{ city_name }}
|
||||
|
||||
{{ city_name }} ({{ country_name_en }}) is home to **{{ padel_venue_count }}** padel venues, serving a population of {{ population | int | default(0) }} residents. That gives the city a venue density of {{ venues_per_100k | round(1) }} venues per 100,000 people.
|
||||
|
||||
## Market Overview
|
||||
|
||||
The padel market in {{ city_name }} shows a market score of **{{ market_score | round(1) }}** based on our analysis of venue density, pricing, and occupancy data.
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Venues | {{ padel_venue_count }} |
|
||||
| Venues per 100k | {{ venues_per_100k | round(1) }} |
|
||||
| Market Score | {{ market_score | round(1) }} |
|
||||
| Data Confidence | {{ data_confidence }} |
|
||||
|
||||
## Pricing
|
||||
|
||||
Court rental rates in {{ city_name }}:
|
||||
|
||||
- **Peak hours**: {{ median_peak_rate | round(0) | int }} per hour
|
||||
- **Off-peak hours**: {{ median_offpeak_rate | round(0) | int }} per hour
|
||||
- **Average hourly rate**: {{ median_hourly_rate | round(0) | int }} per hour
|
||||
|
||||
## What Would It Cost to Build?
|
||||
|
||||
Based on current market data for {{ city_name }}, here is what a padel center investment looks like:
|
||||
|
||||
[scenario:{{ scenario_slug }}:capex]
|
||||
|
||||
## Revenue Potential
|
||||
|
||||
[scenario:{{ scenario_slug }}:operating]
|
||||
|
||||
## Financial Returns
|
||||
|
||||
[scenario:{{ scenario_slug }}:returns]
|
||||
|
||||
## FAQ
|
||||
|
||||
**How much does it cost to build a padel center in {{ city_name }}?**
|
||||
Based on our financial model, building a padel center in {{ city_name }} with typical court configurations requires a total investment that depends on venue type (indoor vs outdoor), land costs, and construction standards in {{ country_name_en }}.
|
||||
|
||||
**How many padel courts are there in {{ city_name }}?**
|
||||
{{ city_name }} currently has {{ padel_venue_count }} padel venues. With a population of {{ population | int | default(0) }}, this translates to {{ venues_per_100k | round(1) }} venues per 100,000 residents.
|
||||
|
||||
**Is {{ city_name }} a good location for a padel center?**
|
||||
{{ city_name }} has a market score of {{ market_score | round(1) }} based on our analysis. Factors include current venue density, pricing levels, and estimated occupancy rates.
|
||||
|
||||
**What are typical padel court rental prices in {{ city_name }}?**
|
||||
Peak hour rates average around {{ median_peak_rate | round(0) | int }} per hour, while off-peak rates are approximately {{ median_offpeak_rate | round(0) | int }} per hour.
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Drop old CMS intermediary tables, recreate articles + published_scenarios.
|
||||
|
||||
article_templates and template_data are replaced by git-based .md.jinja
|
||||
template files + direct DuckDB reads. Nothing was published yet so
|
||||
this is a clean-slate migration.
|
||||
|
||||
published_scenarios and articles had FK references to template_data(id).
|
||||
SQLite requires full table recreation to remove FK columns, so we do
|
||||
the standard create-copy-drop-rename dance for both tables.
|
||||
"""
|
||||
|
||||
|
||||
def up(conn):
|
||||
# ── 1. Drop articles FTS triggers + virtual table ──────────────────
|
||||
conn.execute("DROP TRIGGER IF EXISTS articles_ai")
|
||||
conn.execute("DROP TRIGGER IF EXISTS articles_ad")
|
||||
conn.execute("DROP TRIGGER IF EXISTS articles_au")
|
||||
conn.execute("DROP TABLE IF EXISTS articles_fts")
|
||||
|
||||
# ── 2. Drop old intermediary tables ────────────────────────────────
|
||||
conn.execute("DROP TABLE IF EXISTS template_data")
|
||||
conn.execute("DROP TABLE IF EXISTS article_templates")
|
||||
|
||||
# ── 3. Recreate published_scenarios without template_data_id FK ────
|
||||
conn.execute("""
|
||||
CREATE TABLE published_scenarios_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
location TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
venue_type TEXT NOT NULL DEFAULT 'indoor',
|
||||
ownership TEXT NOT NULL DEFAULT 'rent',
|
||||
court_config TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
calc_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO published_scenarios_new
|
||||
(id, slug, title, subtitle, location, country,
|
||||
venue_type, ownership, court_config, state_json, calc_json,
|
||||
created_at, updated_at)
|
||||
SELECT id, slug, title, subtitle, location, country,
|
||||
venue_type, ownership, court_config, state_json, calc_json,
|
||||
created_at, updated_at
|
||||
FROM published_scenarios
|
||||
""")
|
||||
conn.execute("DROP TABLE published_scenarios")
|
||||
conn.execute("ALTER TABLE published_scenarios_new RENAME TO published_scenarios")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pub_scenarios_slug"
|
||||
" ON published_scenarios(slug)"
|
||||
)
|
||||
|
||||
# ── 4. Recreate articles without template_data_id, add SSG columns ─
|
||||
conn.execute("""
|
||||
CREATE TABLE articles_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url_path TEXT UNIQUE NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
meta_description TEXT,
|
||||
country TEXT,
|
||||
region TEXT,
|
||||
og_image_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
published_at TEXT,
|
||||
template_slug TEXT,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
date_modified TEXT,
|
||||
seo_head TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO articles_new
|
||||
(id, url_path, slug, title, meta_description, country, region,
|
||||
og_image_url, status, published_at, created_at, updated_at)
|
||||
SELECT id, url_path, slug, title, meta_description, country, region,
|
||||
og_image_url, status, published_at, created_at, updated_at
|
||||
FROM articles
|
||||
""")
|
||||
conn.execute("DROP TABLE articles")
|
||||
conn.execute("ALTER TABLE articles_new RENAME TO articles")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)"
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_articles_status"
|
||||
" ON articles(status, published_at)"
|
||||
)
|
||||
|
||||
# ── 5. Recreate articles FTS + triggers ────────────────────────────
|
||||
conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
||||
title, meta_description, country, region,
|
||||
content='articles', content_rowid='id'
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
|
||||
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||
END
|
||||
""")
|
||||
@@ -81,43 +81,54 @@ async def _create_article(slug="test-article", url_path="/test-article",
|
||||
)
|
||||
|
||||
|
||||
async def _create_template():
|
||||
"""Insert a template + 3 data rows, return (template_id, data_row_count)."""
|
||||
template_id = await execute(
|
||||
"""INSERT INTO article_templates
|
||||
(name, slug, content_type, input_schema, url_pattern,
|
||||
title_pattern, meta_description_pattern, body_template)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
"City Cost Analysis", "city-cost", "calculator",
|
||||
json.dumps([
|
||||
{"name": "city", "label": "City", "field_type": "text", "required": True},
|
||||
{"name": "city_slug", "label": "Slug", "field_type": "text", "required": True},
|
||||
{"name": "country", "label": "Country", "field_type": "text", "required": True},
|
||||
{"name": "region", "label": "Region", "field_type": "text", "required": False},
|
||||
{"name": "electricity", "label": "Electricity", "field_type": "number", "required": False},
|
||||
]),
|
||||
"/padel-court-cost-{{ city_slug }}",
|
||||
"Padel Center Cost in {{ city }}",
|
||||
"How much does a padel center cost in {{ city }}?",
|
||||
"# Padel in {{ city }}\n\n[scenario:{{ scenario_slug }}]\n\n## CAPEX\n\n[scenario:{{ scenario_slug }}:capex]",
|
||||
),
|
||||
)
|
||||
TEST_TEMPLATE = """\
|
||||
---
|
||||
name: "Test City Analysis"
|
||||
slug: test-city
|
||||
content_type: calculator
|
||||
data_table: serving.test_cities
|
||||
natural_key: city_slug
|
||||
languages: [en]
|
||||
url_pattern: "/markets/{{ country | lower }}/{{ city_slug }}"
|
||||
title_pattern: "Padel in {{ city }}"
|
||||
meta_description_pattern: "Padel costs in {{ city }}"
|
||||
schema_type: Article
|
||||
---
|
||||
# Padel in {{ city }}
|
||||
|
||||
cities = [
|
||||
("Miami", "miami", "US", "North America", 700),
|
||||
("Madrid", "madrid", "ES", "Europe", 500),
|
||||
("Berlin", "berlin", "DE", "Europe", 550),
|
||||
]
|
||||
for city, slug, country, region, elec in cities:
|
||||
await execute(
|
||||
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
|
||||
(template_id, json.dumps({
|
||||
"city": city, "city_slug": slug, "country": country,
|
||||
"region": region, "electricity": elec,
|
||||
})),
|
||||
)
|
||||
return template_id, len(cities)
|
||||
Welcome to {{ city }}.
|
||||
|
||||
[scenario:{{ scenario_slug }}:capex]
|
||||
"""
|
||||
|
||||
TEST_ROWS = [
|
||||
{"city": "Miami", "city_slug": "miami", "country": "US", "region": "North America", "electricity": 700},
|
||||
{"city": "Madrid", "city_slug": "madrid", "country": "ES", "region": "Europe", "electricity": 500},
|
||||
{"city": "Berlin", "city_slug": "berlin", "country": "DE", "region": "Europe", "electricity": 550},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pseo_env(tmp_path, monkeypatch):
|
||||
"""Set up pSEO environment: temp template dir, build dir, mock DuckDB."""
|
||||
import padelnomics.content as content_mod
|
||||
|
||||
tpl_dir = tmp_path / "templates"
|
||||
tpl_dir.mkdir()
|
||||
monkeypatch.setattr(content_mod, "TEMPLATES_DIR", tpl_dir)
|
||||
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
monkeypatch.setattr(content_mod, "BUILD_DIR", build_dir)
|
||||
|
||||
(tpl_dir / "test-city.md.jinja").write_text(TEST_TEMPLATE)
|
||||
|
||||
async def mock_fetch_analytics(query, params=None):
|
||||
return TEST_ROWS
|
||||
|
||||
monkeypatch.setattr(content_mod, "fetch_analytics", mock_fetch_analytics)
|
||||
|
||||
return {"tpl_dir": tpl_dir, "build_dir": build_dir}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@@ -401,22 +412,14 @@ class TestBakeScenarioCards:
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestGenerationPipeline:
|
||||
async def test_generates_correct_count(self, db):
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
template_id, count = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
generated = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
||||
assert generated == count
|
||||
async def test_generates_correct_count(self, db, pseo_env):
|
||||
from padelnomics.content import generate_articles
|
||||
generated = await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||
assert generated == 3 # 3 rows × 1 language
|
||||
|
||||
async def test_staggered_dates_two_per_day(self, db):
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
template_id, _ = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
await _generate_from_template(dict(template), date(2026, 3, 1), 2)
|
||||
async def test_staggered_dates_two_per_day(self, db, pseo_env):
|
||||
from padelnomics.content import generate_articles
|
||||
await generate_articles("test-city", date(2026, 3, 1), 2)
|
||||
|
||||
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
|
||||
assert len(articles) == 3
|
||||
@@ -426,55 +429,39 @@ class TestGenerationPipeline:
|
||||
assert dates[1] == "2026-03-01"
|
||||
assert dates[2] == "2026-03-02"
|
||||
|
||||
async def test_staggered_dates_one_per_day(self, db):
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
template_id, _ = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
await _generate_from_template(dict(template), date(2026, 3, 1), 1)
|
||||
async def test_staggered_dates_one_per_day(self, db, pseo_env):
|
||||
from padelnomics.content import generate_articles
|
||||
await generate_articles("test-city", date(2026, 3, 1), 1)
|
||||
|
||||
articles = await fetch_all("SELECT * FROM articles ORDER BY published_at")
|
||||
dates = sorted({a["published_at"][:10] for a in articles})
|
||||
assert dates == ["2026-03-01", "2026-03-02", "2026-03-03"]
|
||||
|
||||
async def test_article_url_and_title(self, db):
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
template_id, _ = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
||||
async def test_article_url_and_title(self, db, pseo_env):
|
||||
from padelnomics.content import generate_articles
|
||||
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||
|
||||
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'city-cost-miami'")
|
||||
miami = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
|
||||
assert miami is not None
|
||||
assert miami["url_path"] == "/padel-court-cost-miami"
|
||||
assert miami["title"] == "Padel Center Cost in Miami"
|
||||
assert miami["country"] == "US"
|
||||
assert miami["region"] == "North America"
|
||||
assert miami["url_path"] == "/en/markets/us/miami"
|
||||
assert miami["title"] == "Padel in Miami"
|
||||
assert miami["template_slug"] == "test-city"
|
||||
assert miami["language"] == "en"
|
||||
assert miami["status"] == "published"
|
||||
|
||||
async def test_scenario_created_per_row(self, db):
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
template_id, count = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
||||
async def test_scenario_created_per_row(self, db, pseo_env):
|
||||
from padelnomics.content import generate_articles
|
||||
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||
|
||||
scenarios = await fetch_all("SELECT * FROM published_scenarios")
|
||||
assert len(scenarios) == count
|
||||
assert len(scenarios) == 3
|
||||
|
||||
async def test_scenario_has_valid_calc_json(self, db):
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
template_id, _ = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
||||
async def test_scenario_has_valid_calc_json(self, db, pseo_env):
|
||||
from padelnomics.content import generate_articles
|
||||
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||
|
||||
scenario = await fetch_one(
|
||||
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'"
|
||||
"SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
|
||||
)
|
||||
assert scenario is not None
|
||||
d = json.loads(scenario["calc_json"])
|
||||
@@ -483,112 +470,76 @@ class TestGenerationPipeline:
|
||||
assert "irr" in d
|
||||
assert d["capex"] > 0
|
||||
|
||||
async def test_template_data_linked(self, db):
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
template_id, _ = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
||||
|
||||
rows = await fetch_all(
|
||||
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
|
||||
)
|
||||
for row in rows:
|
||||
assert row["article_id"] is not None, f"Row {row['id']} not linked to article"
|
||||
assert row["scenario_id"] is not None, f"Row {row['id']} not linked to scenario"
|
||||
|
||||
async def test_build_files_written(self, db):
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
from padelnomics.content.routes import BUILD_DIR
|
||||
template_id, _ = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
||||
async def test_build_files_written(self, db, pseo_env):
|
||||
from padelnomics.content import generate_articles
|
||||
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||
|
||||
build_dir = pseo_env["build_dir"]
|
||||
articles = await fetch_all("SELECT slug FROM articles")
|
||||
try:
|
||||
for a in articles:
|
||||
build_path = BUILD_DIR / f"{a['slug']}.html"
|
||||
assert build_path.exists(), f"Missing build file: {build_path}"
|
||||
content = build_path.read_text()
|
||||
assert len(content) > 100, f"Build file too small: {build_path}"
|
||||
assert "scenario-widget" in content
|
||||
finally:
|
||||
# Cleanup build files
|
||||
for a in articles:
|
||||
p = BUILD_DIR / f"{a['slug']}.html"
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
for a in articles:
|
||||
build_path = build_dir / "en" / f"{a['slug']}.html"
|
||||
assert build_path.exists(), f"Missing build file: {build_path}"
|
||||
content = build_path.read_text()
|
||||
assert len(content) > 50
|
||||
|
||||
async def test_skips_already_generated(self, db):
|
||||
"""Running generate twice does not duplicate articles."""
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
template_id, count = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
async def test_updates_existing_on_regeneration(self, db, pseo_env):
|
||||
"""Running generate twice updates articles, doesn't duplicate."""
|
||||
from padelnomics.content import generate_articles
|
||||
|
||||
first = await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
||||
assert first == count
|
||||
first = await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||
assert first == 3
|
||||
|
||||
# Second run: all rows already linked → 0 generated
|
||||
second = await _generate_from_template(dict(template), date(2026, 3, 10), 10)
|
||||
assert second == 0
|
||||
second = await generate_articles("test-city", date(2026, 3, 10), 10)
|
||||
assert second == 3 # Updates existing
|
||||
|
||||
articles = await fetch_all("SELECT * FROM articles")
|
||||
assert len(articles) == count
|
||||
assert len(articles) == 3 # No duplicates
|
||||
|
||||
# Cleanup
|
||||
from padelnomics.content.routes import BUILD_DIR
|
||||
for a in articles:
|
||||
p = BUILD_DIR / f"{a['slug']}.html"
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
async def test_calc_overrides_applied(self, db):
|
||||
async def test_calc_overrides_applied(self, db, pseo_env):
|
||||
"""Data row values that match DEFAULTS keys are used as calc overrides."""
|
||||
from padelnomics.admin.routes import _generate_from_template
|
||||
template_id, _ = await _create_template()
|
||||
template = await fetch_one(
|
||||
"SELECT * FROM article_templates WHERE id = ?", (template_id,)
|
||||
)
|
||||
await _generate_from_template(dict(template), date(2026, 3, 1), 10)
|
||||
from padelnomics.content import generate_articles
|
||||
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||
|
||||
# Miami had electricity=700, default is 600
|
||||
scenario = await fetch_one(
|
||||
"SELECT * FROM published_scenarios WHERE slug = 'city-cost-miami'"
|
||||
"SELECT * FROM published_scenarios WHERE slug = 'test-city-miami'"
|
||||
)
|
||||
state = json.loads(scenario["state_json"])
|
||||
assert state["electricity"] == 700
|
||||
|
||||
# Cleanup
|
||||
from padelnomics.content.routes import BUILD_DIR
|
||||
for slug in ("miami", "madrid", "berlin"):
|
||||
p = BUILD_DIR / f"{slug}.html"
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
async def test_seo_head_populated(self, db, pseo_env):
|
||||
from padelnomics.content import generate_articles
|
||||
await generate_articles("test-city", date(2026, 3, 1), 10)
|
||||
|
||||
article = await fetch_one("SELECT * FROM articles WHERE slug = 'test-city-en-miami'")
|
||||
assert article["seo_head"] is not None
|
||||
assert 'rel="canonical"' in article["seo_head"]
|
||||
assert 'application/ld+json' in article["seo_head"]
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Jinja string rendering
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestRenderJinjaString:
|
||||
class TestRenderPattern:
|
||||
def test_simple(self):
|
||||
from padelnomics.admin.routes import _render_jinja_string
|
||||
assert _render_jinja_string("Hello {{ name }}!", {"name": "World"}) == "Hello World!"
|
||||
from padelnomics.content import _render_pattern
|
||||
assert _render_pattern("Hello {{ name }}!", {"name": "World"}) == "Hello World!"
|
||||
|
||||
def test_missing_var_empty(self):
|
||||
from padelnomics.admin.routes import _render_jinja_string
|
||||
result = _render_jinja_string("Hello {{ missing }}!", {})
|
||||
from padelnomics.content import _render_pattern
|
||||
result = _render_pattern("Hello {{ missing }}!", {})
|
||||
assert result == "Hello !"
|
||||
|
||||
def test_url_pattern(self):
|
||||
from padelnomics.admin.routes import _render_jinja_string
|
||||
result = _render_jinja_string("/padel-court-cost-{{ slug }}", {"slug": "miami"})
|
||||
assert result == "/padel-court-cost-miami"
|
||||
from padelnomics.content import _render_pattern
|
||||
result = _render_pattern("/markets/{{ country | lower }}/{{ slug }}", {"country": "US", "slug": "miami"})
|
||||
assert result == "/markets/us/miami"
|
||||
|
||||
def test_slugify_filter(self):
|
||||
from padelnomics.content import _render_pattern
|
||||
result = _render_pattern("{{ name | slugify }}", {"name": "Hello World"})
|
||||
assert result == "hello-world"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@@ -772,75 +723,6 @@ class TestAdminTemplates:
|
||||
resp = await admin_client.get("/admin/templates")
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_template_new_form(self, admin_client):
|
||||
resp = await admin_client.get("/admin/templates/new")
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_template_create(self, admin_client, db):
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
resp = await admin_client.post("/admin/templates/new", form={
|
||||
"csrf_token": "test",
|
||||
"name": "Test Template",
|
||||
"slug": "test-tmpl",
|
||||
"content_type": "calculator",
|
||||
"input_schema": '[{"name":"city","label":"City","field_type":"text","required":true}]',
|
||||
"url_pattern": "/test-{{ city }}",
|
||||
"title_pattern": "Test {{ city }}",
|
||||
"meta_description_pattern": "",
|
||||
"body_template": "# Hello {{ city }}",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
row = await fetch_one("SELECT * FROM article_templates WHERE slug = 'test-tmpl'")
|
||||
assert row is not None
|
||||
assert row["name"] == "Test Template"
|
||||
|
||||
async def test_template_edit(self, admin_client, db):
|
||||
template_id = await execute(
|
||||
"""INSERT INTO article_templates
|
||||
(name, slug, content_type, input_schema, url_pattern,
|
||||
title_pattern, body_template)
|
||||
VALUES ('Edit Me', 'edit-me', 'calculator', '[]',
|
||||
'/edit', 'Edit', '# body')"""
|
||||
)
|
||||
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
resp = await admin_client.post(f"/admin/templates/{template_id}/edit", form={
|
||||
"csrf_token": "test",
|
||||
"name": "Edited",
|
||||
"input_schema": "[]",
|
||||
"url_pattern": "/edit",
|
||||
"title_pattern": "Edited",
|
||||
"body_template": "# edited",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
||||
assert row["name"] == "Edited"
|
||||
|
||||
async def test_template_delete(self, admin_client, db):
|
||||
template_id = await execute(
|
||||
"""INSERT INTO article_templates
|
||||
(name, slug, content_type, input_schema, url_pattern,
|
||||
title_pattern, body_template)
|
||||
VALUES ('Del Me', 'del-me', 'calculator', '[]',
|
||||
'/del', 'Del', '# body')"""
|
||||
)
|
||||
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
resp = await admin_client.post(f"/admin/templates/{template_id}/delete", form={
|
||||
"csrf_token": "test",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
row = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
|
||||
assert row is None
|
||||
|
||||
|
||||
class TestAdminScenarios:
|
||||
@@ -1012,81 +894,6 @@ class TestAdminArticles:
|
||||
assert await fetch_one("SELECT 1 FROM articles WHERE id = ?", (article_id,)) is None
|
||||
|
||||
|
||||
class TestAdminTemplateData:
|
||||
async def test_data_add(self, admin_client, db):
|
||||
template_id, _ = await _create_template()
|
||||
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
resp = await admin_client.post(f"/admin/templates/{template_id}/data/add", form={
|
||||
"csrf_token": "test",
|
||||
"city": "London",
|
||||
"city_slug": "london",
|
||||
"country": "UK",
|
||||
"region": "Europe",
|
||||
"electricity": "650",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
rows = await fetch_all(
|
||||
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
|
||||
)
|
||||
# 3 from _create_template + 1 just added
|
||||
assert len(rows) == 4
|
||||
|
||||
async def test_data_delete(self, admin_client, db):
|
||||
template_id, _ = await _create_template()
|
||||
rows = await fetch_all(
|
||||
"SELECT id FROM template_data WHERE template_id = ?", (template_id,)
|
||||
)
|
||||
data_id = rows[0]["id"]
|
||||
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
resp = await admin_client.post(
|
||||
f"/admin/templates/{template_id}/data/{data_id}/delete",
|
||||
form={"csrf_token": "test"},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
|
||||
remaining = await fetch_all(
|
||||
"SELECT * FROM template_data WHERE template_id = ?", (template_id,)
|
||||
)
|
||||
assert len(remaining) == 2
|
||||
|
||||
|
||||
class TestAdminGenerate:
|
||||
async def test_generate_form(self, admin_client, db):
|
||||
template_id, _ = await _create_template()
|
||||
resp = await admin_client.get(f"/admin/templates/{template_id}/generate")
|
||||
assert resp.status_code == 200
|
||||
html = (await resp.data).decode()
|
||||
assert "3" in html # pending count
|
||||
|
||||
async def test_generate_creates_articles(self, admin_client, db):
|
||||
from padelnomics.content.routes import BUILD_DIR
|
||||
template_id, _ = await _create_template()
|
||||
|
||||
async with admin_client.session_transaction() as sess:
|
||||
sess["csrf_token"] = "test"
|
||||
|
||||
resp = await admin_client.post(f"/admin/templates/{template_id}/generate", form={
|
||||
"csrf_token": "test",
|
||||
"start_date": "2026-04-01",
|
||||
"articles_per_day": "2",
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
|
||||
articles = await fetch_all("SELECT * FROM articles")
|
||||
assert len(articles) == 3
|
||||
|
||||
# Cleanup
|
||||
for a in articles:
|
||||
p = BUILD_DIR / f"{a['slug']}.html"
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user