add programmatic SEO content engine with article generation pipeline and tests
Adds a complete content generation system for producing SEO articles at scale with embedded financial scenario widgets. Includes DB schema (published_scenarios, article_templates, template_data, articles with FTS5), bulk generation pipeline with staggered publish dates, admin CRUD for templates/scenarios/articles, public markets hub with HTMX filtering, catch-all article serving from pre-rendered static HTML, sitemap integration, and 94 pytest tests covering the full stack. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
54
CHANGELOG.md
54
CHANGELOG.md
@@ -6,6 +6,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added — Programmatic SEO: Content Generation Engine
|
||||||
|
- **Database migration 0010** — `published_scenarios`, `article_templates`,
|
||||||
|
`template_data`, `articles` tables with FTS5 full-text search on articles;
|
||||||
|
content-sync triggers for INSERT/UPDATE/DELETE
|
||||||
|
- **Article template system** — parameterized content recipes with
|
||||||
|
`input_schema` (JSON field definitions), `url_pattern`, `title_pattern`,
|
||||||
|
`body_template` (Markdown + Jinja2); supports multiple content types
|
||||||
|
(only `calculator` built now)
|
||||||
|
- **Template data rows** — per-city/region input data that feeds the
|
||||||
|
generation pipeline; manual add or bulk CSV upload
|
||||||
|
- **Bulk generation pipeline** — `POST /admin/templates/<id>/generate`
|
||||||
|
computes financial scenarios from data rows via `validate_state()` +
|
||||||
|
`calc()`, renders Markdown with baked scenario cards, writes static HTML
|
||||||
|
to `data/content/_build/`, creates articles with staggered publish dates
|
||||||
|
- **Published scenarios** — standalone financial widgets with pre-computed
|
||||||
|
`calc_json`; embeddable in articles via `[scenario:slug]` markers with
|
||||||
|
section variants (`:capex`, `:operating`, `:cashflow`, `:returns`, `:full`)
|
||||||
|
- **Scenario widget templates** — 6 partial templates (summary, CAPEX
|
||||||
|
breakdown, revenue & OPEX, 5-year projection, returns & financing,
|
||||||
|
full combined) with dark navy header bar, Commit Mono numbers, responsive
|
||||||
|
metric grids, and "Try with your own numbers" CTA
|
||||||
|
- **Static file rendering** — articles rendered to `data/content/_build/`
|
||||||
|
as pre-baked HTML; DB stores metadata only, no body content in DB
|
||||||
|
- **Content blueprint** — catch-all route serves published articles by
|
||||||
|
`url_path`; registered last to avoid path collisions
|
||||||
|
- **Markets hub** (`/markets`) — public search + country/region filter page
|
||||||
|
for discovering articles; HTMX live filtering via FTS5 queries
|
||||||
|
- **Admin template CRUD** — list, create, edit, delete article templates;
|
||||||
|
view/add/upload/delete data rows; bulk generation form with start date
|
||||||
|
and articles-per-day controls
|
||||||
|
- **Admin scenario CRUD** — list, create (with curated calculator form),
|
||||||
|
edit + recalculate, delete, preview all widget sections
|
||||||
|
- **Admin article management** — list with status badges (draft/published/
|
||||||
|
scheduled), create manual articles (Markdown textarea), edit, delete,
|
||||||
|
publish/unpublish toggle, rebuild single or all articles
|
||||||
|
- **Rebuild system** — `POST /admin/articles/<id>/rebuild` and
|
||||||
|
`/admin/rebuild-all` re-render article HTML from source (template+data
|
||||||
|
or markdown file) with fresh scenario `calc_json`
|
||||||
|
- **Article detail page** — SEO meta tags (description, og:title,
|
||||||
|
og:description, og:type=article, og:image, canonical), article
|
||||||
|
typography styles, bottom CTA linking to planner
|
||||||
|
- **Scenario widget CSS** — `.scenario-widget` component styles in
|
||||||
|
`input.css` with responsive table scroll and metric grid collapse;
|
||||||
|
`.article-body` typography for headings, paragraphs, lists, blockquotes,
|
||||||
|
tables, code blocks
|
||||||
|
- **`slugify()` utility** — added to `core.py` for URL-safe slug generation
|
||||||
|
- **`mistune` dependency** — Markdown → HTML rendering for articles
|
||||||
|
- **Sitemap** — `/markets` and all published articles added to
|
||||||
|
`/sitemap.xml` (only articles with `published_at <= now`)
|
||||||
|
- **Footer** — "Markets" link added to Product column
|
||||||
|
- **Admin dashboard** — Templates, Scenarios, Articles quick-link buttons
|
||||||
|
- **Path collision prevention** — `RESERVED_PREFIXES` validation rejects
|
||||||
|
article `url_path` values that conflict with existing routes
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Dev setup script** (`scripts/dev_setup.sh`) — interactive bootstrap that
|
- **Dev setup script** (`scripts/dev_setup.sh`) — interactive bootstrap that
|
||||||
checks prerequisites, installs deps, creates `.env` with auto-generated
|
checks prerequisites, installs deps, creates `.env` with auto-generated
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"jinja2>=3.1.0",
|
"jinja2>=3.1.0",
|
||||||
"hypercorn>=0.17.0",
|
"hypercorn>=0.17.0",
|
||||||
"paddle-python-sdk>=1.13.0",
|
"paddle-python-sdk>=1.13.0",
|
||||||
|
"mistune>=3.0.0",
|
||||||
"resend>=2.22.0",
|
"resend>=2.22.0",
|
||||||
"weasyprint>=68.1",
|
"weasyprint>=68.1",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Admin domain: password-protected admin panel for managing users, tasks, etc.
|
Admin domain: password-protected admin panel for managing users, tasks, etc.
|
||||||
"""
|
"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import mistune
|
||||||
from quart import Blueprint, flash, redirect, render_template, request, session, url_for
|
from quart import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..core import config, csrf_protect, execute, fetch_all, fetch_one, transaction
|
from ..core import config, csrf_protect, execute, execute_many, fetch_all, fetch_one, slugify, transaction
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -851,3 +855,836 @@ async def feedback():
|
|||||||
LIMIT 200"""
|
LIMIT 200"""
|
||||||
)
|
)
|
||||||
return await render_template("admin/feedback.html", feedback_list=feedback_list)
|
return await render_template("admin/feedback.html", feedback_list=feedback_list)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Article Template Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/templates")
|
||||||
|
@admin_required
|
||||||
|
async def templates():
|
||||||
|
"""List article templates."""
|
||||||
|
template_list = await fetch_all(
|
||||||
|
"SELECT * FROM article_templates ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
# Attach data row counts
|
||||||
|
for t in template_list:
|
||||||
|
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"],),
|
||||||
|
)
|
||||||
|
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"])
|
||||||
|
@admin_required
|
||||||
|
@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()
|
||||||
|
|
||||||
|
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"])
|
||||||
|
@admin_required
|
||||||
|
@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:
|
||||||
|
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()
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/template_form.html", data=dict(template), editing=True, template_id=template_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/templates/<int:template_id>/delete", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@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"))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Template Data Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/templates/<int:template_id>/data")
|
||||||
|
@admin_required
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/templates/<int:template_id>/data/add", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@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:
|
||||||
|
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"])
|
||||||
|
@admin_required
|
||||||
|
@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"])
|
||||||
|
@admin_required
|
||||||
|
@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"])
|
||||||
|
@admin_required
|
||||||
|
@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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
await flash(f"Generated {generated} articles with staggered publish dates.", "success")
|
||||||
|
return redirect(url_for("admin.template_data", template_id=template_id))
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/generate_form.html",
|
||||||
|
template=template,
|
||||||
|
pending_count=pending,
|
||||||
|
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 bake_scenario_cards, is_reserved_path, BUILD_DIR
|
||||||
|
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||||
|
|
||||||
|
data_rows = await fetch_all(
|
||||||
|
"SELECT * FROM template_data WHERE template_id = ? AND article_id IS NULL",
|
||||||
|
(template["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
calc_overrides = {k: v for k, v in data.items() if k in DEFAULTS}
|
||||||
|
state = validate_state(calc_overrides)
|
||||||
|
d = calc(state)
|
||||||
|
|
||||||
|
# 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 = 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)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Published Scenario Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SCENARIO_FORM_FIELDS = [
|
||||||
|
"title", "slug", "subtitle", "location", "country",
|
||||||
|
"venue", "own", "dblCourts", "sglCourts",
|
||||||
|
"ratePeak", "rateOffPeak", "rateSingle", "peakPct", "hoursPerDay", "utilTarget",
|
||||||
|
"rentSqm", "electricity", "heating", "staff", "insurance", "maintenance", "cleaning", "marketing",
|
||||||
|
"courtCostDbl", "courtCostSgl", "hallCostSqm", "landPriceSqm", "contingencyPct", "fitout",
|
||||||
|
"loanPct", "interestRate", "loanTerm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/scenarios")
|
||||||
|
@admin_required
|
||||||
|
async def scenarios():
|
||||||
|
"""List published scenarios."""
|
||||||
|
scenario_list = await fetch_all(
|
||||||
|
"SELECT * FROM published_scenarios ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
return await render_template("admin/scenarios.html", scenarios=scenario_list)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/scenarios/new", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def scenario_new():
|
||||||
|
"""Create a published scenario manually."""
|
||||||
|
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
title = form.get("title", "").strip()
|
||||||
|
scenario_slug = form.get("slug", "").strip() or slugify(title)
|
||||||
|
subtitle = form.get("subtitle", "").strip()
|
||||||
|
location = form.get("location", "").strip()
|
||||||
|
country = form.get("country", "").strip()
|
||||||
|
|
||||||
|
if not title or not location or not country:
|
||||||
|
await flash("Title, location, and country are required.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/scenario_form.html", data=dict(form), editing=False, defaults=DEFAULTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build calc state from form
|
||||||
|
calc_overrides = {}
|
||||||
|
for key in DEFAULTS:
|
||||||
|
val = form.get(key, "")
|
||||||
|
if val != "":
|
||||||
|
calc_overrides[key] = val
|
||||||
|
|
||||||
|
state = validate_state(calc_overrides)
|
||||||
|
d = calc(state)
|
||||||
|
|
||||||
|
dbl = state.get("dblCourts", 0)
|
||||||
|
sgl = state.get("sglCourts", 0)
|
||||||
|
court_config = f"{dbl} double + {sgl} single"
|
||||||
|
|
||||||
|
scenario_id = await execute(
|
||||||
|
"""INSERT INTO published_scenarios
|
||||||
|
(slug, title, subtitle, location, country, venue_type, ownership,
|
||||||
|
court_config, state_json, calc_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
scenario_slug, title, subtitle, location, country,
|
||||||
|
state.get("venue", "indoor"), state.get("own", "rent"),
|
||||||
|
court_config, json.dumps(state), json.dumps(d),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await flash(f"Scenario '{title}' created.", "success")
|
||||||
|
return redirect(url_for("admin.scenarios"))
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/scenario_form.html", data={}, editing=False, defaults=DEFAULTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/scenarios/<int:scenario_id>/edit", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def scenario_edit(scenario_id: int):
|
||||||
|
"""Edit a published scenario."""
|
||||||
|
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||||
|
|
||||||
|
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
|
||||||
|
if not scenario:
|
||||||
|
await flash("Scenario not found.", "error")
|
||||||
|
return redirect(url_for("admin.scenarios"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
title = form.get("title", "").strip()
|
||||||
|
subtitle = form.get("subtitle", "").strip()
|
||||||
|
location = form.get("location", "").strip()
|
||||||
|
country = form.get("country", "").strip()
|
||||||
|
|
||||||
|
calc_overrides = {}
|
||||||
|
for key in DEFAULTS:
|
||||||
|
val = form.get(key, "")
|
||||||
|
if val != "":
|
||||||
|
calc_overrides[key] = val
|
||||||
|
|
||||||
|
state = validate_state(calc_overrides)
|
||||||
|
d = calc(state)
|
||||||
|
|
||||||
|
dbl = state.get("dblCourts", 0)
|
||||||
|
sgl = state.get("sglCourts", 0)
|
||||||
|
court_config = f"{dbl} double + {sgl} single"
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"""UPDATE published_scenarios
|
||||||
|
SET title = ?, subtitle = ?, location = ?, country = ?,
|
||||||
|
venue_type = ?, ownership = ?, court_config = ?,
|
||||||
|
state_json = ?, calc_json = ?, updated_at = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(
|
||||||
|
title, subtitle, location, country,
|
||||||
|
state.get("venue", "indoor"), state.get("own", "rent"),
|
||||||
|
court_config, json.dumps(state), json.dumps(d), now, scenario_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await flash("Scenario updated and recalculated.", "success")
|
||||||
|
return redirect(url_for("admin.scenarios"))
|
||||||
|
|
||||||
|
# Merge scenario metadata + state for the form
|
||||||
|
state = json.loads(scenario["state_json"])
|
||||||
|
data = {
|
||||||
|
"title": scenario["title"],
|
||||||
|
"slug": scenario["slug"],
|
||||||
|
"subtitle": scenario["subtitle"] or "",
|
||||||
|
"location": scenario["location"],
|
||||||
|
"country": scenario["country"],
|
||||||
|
**state,
|
||||||
|
}
|
||||||
|
return await render_template(
|
||||||
|
"admin/scenario_form.html", data=data, editing=True, scenario_id=scenario_id, defaults=DEFAULTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/scenarios/<int:scenario_id>/delete", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def scenario_delete(scenario_id: int):
|
||||||
|
"""Delete a published scenario."""
|
||||||
|
await execute("DELETE FROM published_scenarios WHERE id = ?", (scenario_id,))
|
||||||
|
await flash("Scenario deleted.", "success")
|
||||||
|
return redirect(url_for("admin.scenarios"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/scenarios/<int:scenario_id>/preview")
|
||||||
|
@admin_required
|
||||||
|
async def scenario_preview(scenario_id: int):
|
||||||
|
"""Preview a rendered scenario card."""
|
||||||
|
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
|
||||||
|
if not scenario:
|
||||||
|
await flash("Scenario not found.", "error")
|
||||||
|
return redirect(url_for("admin.scenarios"))
|
||||||
|
|
||||||
|
d = json.loads(scenario["calc_json"])
|
||||||
|
s = json.loads(scenario["state_json"])
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/scenario_preview.html", scenario=scenario, d=d, s=s,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Article Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/articles")
|
||||||
|
@admin_required
|
||||||
|
async def articles():
|
||||||
|
"""List all articles."""
|
||||||
|
article_list = await fetch_all(
|
||||||
|
"SELECT * FROM articles ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
return await render_template("admin/articles.html", articles=article_list)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/articles/new", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def article_new():
|
||||||
|
"""Create a manual article."""
|
||||||
|
from ..content.routes import bake_scenario_cards, is_reserved_path, BUILD_DIR
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
title = form.get("title", "").strip()
|
||||||
|
article_slug = form.get("slug", "").strip() or slugify(title)
|
||||||
|
url_path = form.get("url_path", "").strip() or ("/" + article_slug)
|
||||||
|
meta_description = form.get("meta_description", "").strip()
|
||||||
|
og_image_url = form.get("og_image_url", "").strip()
|
||||||
|
country = form.get("country", "").strip()
|
||||||
|
region = form.get("region", "").strip()
|
||||||
|
body = form.get("body", "").strip()
|
||||||
|
status = form.get("status", "draft")
|
||||||
|
published_at = form.get("published_at", "").strip()
|
||||||
|
|
||||||
|
if not title or not body:
|
||||||
|
await flash("Title and body are required.", "error")
|
||||||
|
return await render_template("admin/article_form.html", data=dict(form), editing=False)
|
||||||
|
|
||||||
|
if is_reserved_path(url_path):
|
||||||
|
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
||||||
|
return await render_template("admin/article_form.html", data=dict(form), editing=False)
|
||||||
|
|
||||||
|
# Render markdown → HTML with scenario cards baked in
|
||||||
|
body_html = mistune.html(body)
|
||||||
|
body_html = await bake_scenario_cards(body_html)
|
||||||
|
|
||||||
|
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
build_path = BUILD_DIR / f"{article_slug}.html"
|
||||||
|
build_path.write_text(body_html)
|
||||||
|
|
||||||
|
# Save markdown source
|
||||||
|
md_dir = Path("data/content/articles")
|
||||||
|
md_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(md_dir / f"{article_slug}.md").write_text(body)
|
||||||
|
|
||||||
|
pub_dt = published_at or datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
article_id = await execute(
|
||||||
|
"""INSERT INTO articles
|
||||||
|
(url_path, slug, title, meta_description, og_image_url,
|
||||||
|
country, region, status, published_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(url_path, article_slug, title, meta_description, og_image_url,
|
||||||
|
country, region, status, pub_dt),
|
||||||
|
)
|
||||||
|
await flash(f"Article '{title}' created.", "success")
|
||||||
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
|
return await render_template("admin/article_form.html", data={}, editing=False)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def article_edit(article_id: int):
|
||||||
|
"""Edit a manual article."""
|
||||||
|
from ..content.routes import bake_scenario_cards, is_reserved_path, BUILD_DIR
|
||||||
|
|
||||||
|
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
||||||
|
if not article:
|
||||||
|
await flash("Article not found.", "error")
|
||||||
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
title = form.get("title", "").strip()
|
||||||
|
url_path = form.get("url_path", "").strip()
|
||||||
|
meta_description = form.get("meta_description", "").strip()
|
||||||
|
og_image_url = form.get("og_image_url", "").strip()
|
||||||
|
country = form.get("country", "").strip()
|
||||||
|
region = form.get("region", "").strip()
|
||||||
|
body = form.get("body", "").strip()
|
||||||
|
status = form.get("status", article["status"])
|
||||||
|
published_at = form.get("published_at", "").strip()
|
||||||
|
|
||||||
|
if is_reserved_path(url_path):
|
||||||
|
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-render if body provided
|
||||||
|
if body:
|
||||||
|
body_html = mistune.html(body)
|
||||||
|
body_html = await bake_scenario_cards(body_html)
|
||||||
|
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
|
||||||
|
|
||||||
|
md_dir = Path("data/content/articles")
|
||||||
|
md_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(md_dir / f"{article['slug']}.md").write_text(body)
|
||||||
|
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
pub_dt = published_at or article["published_at"]
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"""UPDATE articles
|
||||||
|
SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?,
|
||||||
|
country = ?, region = ?, status = ?, published_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(title, url_path, meta_description, og_image_url,
|
||||||
|
country, region, status, pub_dt, now, article_id),
|
||||||
|
)
|
||||||
|
await flash("Article updated.", "success")
|
||||||
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
|
# Load markdown source if available
|
||||||
|
md_path = Path("data/content/articles") / f"{article['slug']}.md"
|
||||||
|
body = md_path.read_text() if md_path.exists() else ""
|
||||||
|
|
||||||
|
data = {**dict(article), "body": body}
|
||||||
|
return await render_template(
|
||||||
|
"admin/article_form.html", data=data, editing=True, article_id=article_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/articles/<int:article_id>/delete", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def article_delete(article_id: int):
|
||||||
|
"""Delete an article."""
|
||||||
|
article = await fetch_one("SELECT slug FROM articles WHERE id = ?", (article_id,))
|
||||||
|
if article:
|
||||||
|
# Clean up files
|
||||||
|
from ..content.routes import BUILD_DIR
|
||||||
|
build_path = BUILD_DIR / f"{article['slug']}.html"
|
||||||
|
if build_path.exists():
|
||||||
|
build_path.unlink()
|
||||||
|
md_path = Path("data/content/articles") / f"{article['slug']}.md"
|
||||||
|
if md_path.exists():
|
||||||
|
md_path.unlink()
|
||||||
|
|
||||||
|
await execute("DELETE FROM articles WHERE id = ?", (article_id,))
|
||||||
|
await flash("Article deleted.", "success")
|
||||||
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/articles/<int:article_id>/publish", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def article_publish(article_id: int):
|
||||||
|
"""Toggle article status between draft and published."""
|
||||||
|
article = await fetch_one("SELECT status FROM articles WHERE id = ?", (article_id,))
|
||||||
|
if not article:
|
||||||
|
await flash("Article not found.", "error")
|
||||||
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
|
new_status = "published" if article["status"] == "draft" else "draft"
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
await execute(
|
||||||
|
"UPDATE articles SET status = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(new_status, now, article_id),
|
||||||
|
)
|
||||||
|
await flash(f"Article status changed to {new_status}.", "success")
|
||||||
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/articles/<int:article_id>/rebuild", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def article_rebuild(article_id: int):
|
||||||
|
"""Re-render an article's HTML from source."""
|
||||||
|
await _rebuild_article(article_id)
|
||||||
|
await flash("Article rebuilt.", "success")
|
||||||
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/rebuild-all", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
@csrf_protect
|
||||||
|
async def rebuild_all():
|
||||||
|
"""Re-render all articles."""
|
||||||
|
articles = await fetch_all("SELECT id FROM articles")
|
||||||
|
count = 0
|
||||||
|
for a in articles:
|
||||||
|
await _rebuild_article(a["id"])
|
||||||
|
count += 1
|
||||||
|
await flash(f"Rebuilt {count} articles.", "success")
|
||||||
|
return redirect(url_for("admin.articles"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _rebuild_article(article_id: int):
|
||||||
|
"""Re-render a single article from its source (template+data or markdown)."""
|
||||||
|
from ..content.routes import bake_scenario_cards, BUILD_DIR
|
||||||
|
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
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())
|
||||||
|
|
||||||
|
body_html = await bake_scenario_cards(body_html)
|
||||||
|
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<div style="max-width: 48rem; margin: 0 auto;">
|
||||||
|
<a href="{{ url_for('admin.articles') }}" class="text-sm text-slate">← Back to articles</a>
|
||||||
|
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article</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="title">Title</label>
|
||||||
|
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}" 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 title" {% if editing %}readonly{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="url_path">URL Path</label>
|
||||||
|
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}" class="form-input"
|
||||||
|
placeholder="e.g. /padel-court-cost-miami">
|
||||||
|
<p class="form-hint">Defaults to /slug. Must not conflict with existing routes.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="meta_description">Meta Description</label>
|
||||||
|
<input type="text" id="meta_description" name="meta_description" value="{{ data.get('meta_description', '') }}"
|
||||||
|
class="form-input" maxlength="160">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="country">Country</label>
|
||||||
|
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}" class="form-input"
|
||||||
|
placeholder="e.g. US">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="region">Region</label>
|
||||||
|
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}" class="form-input"
|
||||||
|
placeholder="e.g. North America">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="og_image_url">OG Image URL</label>
|
||||||
|
<input type="text" id="og_image_url" name="og_image_url" value="{{ data.get('og_image_url', '') }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="body">Body (Markdown)</label>
|
||||||
|
<textarea id="body" name="body" rows="20" class="form-input"
|
||||||
|
style="font-family: var(--font-mono); font-size: 0.8125rem;" {% if not editing %}required{% endif %}>{{ data.get('body', '') }}</textarea>
|
||||||
|
<p class="form-hint">Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="status">Status</label>
|
||||||
|
<select id="status" name="status" class="form-input">
|
||||||
|
<option value="draft" {% if data.get('status') == 'draft' %}selected{% endif %}>Draft</option>
|
||||||
|
<option value="published" {% if data.get('status') == 'published' %}selected{% endif %}>Published</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="published_at">Publish Date</label>
|
||||||
|
<input type="datetime-local" id="published_at" name="published_at"
|
||||||
|
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}" class="form-input">
|
||||||
|
<p class="form-hint">Leave blank for now. Future date = scheduled.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<header class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">Articles</h1>
|
||||||
|
<p class="text-slate text-sm">{{ articles | length }} article{{ 's' if articles | length != 1 }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="{{ url_for('admin.article_new') }}" class="btn">New Article</a>
|
||||||
|
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display: inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn-outline" onclick="return confirm('Rebuild all articles?')">Rebuild All</button>
|
||||||
|
</form>
|
||||||
|
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{% if articles %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Published</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in articles %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.title }}</td>
|
||||||
|
<td class="mono text-sm">{{ a.url_path }}</td>
|
||||||
|
<td>
|
||||||
|
{% if a.status == 'published' %}
|
||||||
|
{% if a.published_at and a.published_at > now.isoformat() %}
|
||||||
|
<span class="badge-warning">Scheduled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-success">Published</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">Draft</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono text-sm">{{ a.published_at[:10] if a.published_at else '-' }}</td>
|
||||||
|
<td class="text-sm">{% if a.template_data_id %}Generated{% else %}Manual{% endif %}</td>
|
||||||
|
<td class="text-right" style="white-space: nowrap;">
|
||||||
|
<form method="post" action="{{ url_for('admin.article_publish', article_id=a.id) }}" class="m-0" style="display: inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn-outline btn-sm">{% if a.status == 'published' %}Unpublish{% else %}Publish{% endif %}</button>
|
||||||
|
</form>
|
||||||
|
<a href="{{ url_for('admin.article_edit', article_id=a.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||||
|
<form method="post" action="{{ url_for('admin.article_rebuild', article_id=a.id) }}" class="m-0" style="display: inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn-outline btn-sm">Rebuild</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('admin.article_delete', article_id=a.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 article?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate text-sm">No articles yet. Create one or generate from a template.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Generate Articles - {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% if pending_count == 0 %}
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-slate text-sm">All data rows have already been generated. Add more data rows first.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" class="card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="start_date">Start Date</label>
|
||||||
|
<input type="date" id="start_date" name="start_date" value="{{ today }}" class="form-input" required>
|
||||||
|
<p class="form-hint">First batch of articles will be published on this date.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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.
|
||||||
|
</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>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Links -->
|
<!-- Quick Links -->
|
||||||
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem" class="mb-10">
|
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem" class="mb-4">
|
||||||
<a href="{{ url_for('admin.leads') }}" class="btn text-center">Leads</a>
|
<a href="{{ url_for('admin.leads') }}" class="btn text-center">Leads</a>
|
||||||
<a href="{{ url_for('admin.suppliers') }}" class="btn text-center">Suppliers</a>
|
<a href="{{ url_for('admin.suppliers') }}" class="btn text-center">Suppliers</a>
|
||||||
<a href="{{ url_for('admin.users') }}" class="btn-outline text-center">All Users</a>
|
<a href="{{ url_for('admin.users') }}" class="btn-outline text-center">All Users</a>
|
||||||
@@ -102,6 +102,11 @@
|
|||||||
<a href="{{ url_for('admin.feedback') }}" class="btn-outline text-center">Feedback</a>
|
<a href="{{ url_for('admin.feedback') }}" class="btn-outline text-center">Feedback</a>
|
||||||
<a href="{{ url_for('dashboard.index') }}" class="btn-outline text-center">View as User</a>
|
<a href="{{ url_for('dashboard.index') }}" class="btn-outline text-center">View as User</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem" class="mb-10">
|
||||||
|
<a href="{{ url_for('admin.templates') }}" class="btn-outline text-center">Templates</a>
|
||||||
|
<a href="{{ url_for('admin.scenarios') }}" class="btn-outline text-center">Scenarios</a>
|
||||||
|
<a href="{{ url_for('admin.articles') }}" class="btn-outline text-center">Articles</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<!-- Recent Users -->
|
<!-- Recent Users -->
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Scenario - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<div style="max-width: 48rem; margin: 0 auto;">
|
||||||
|
<a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">← Back to scenarios</a>
|
||||||
|
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Published Scenario</h1>
|
||||||
|
|
||||||
|
<form method="post" class="card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<h3 class="text-base font-semibold mb-3">Metadata</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Title</label>
|
||||||
|
<input type="text" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Slug</label>
|
||||||
|
<input type="text" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
|
||||||
|
placeholder="auto-generated" {% if editing %}readonly{% endif %}>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Subtitle</label>
|
||||||
|
<input type="text" name="subtitle" value="{{ data.get('subtitle', '') }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Location</label>
|
||||||
|
<input type="text" name="location" value="{{ data.get('location', '') }}" class="form-input" required placeholder="e.g. Miami">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Country</label>
|
||||||
|
<input type="text" name="country" value="{{ data.get('country', '') }}" class="form-input" required placeholder="e.g. US">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Venue -->
|
||||||
|
<h3 class="text-base font-semibold mb-3">Venue</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 1rem;" class="mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Venue Type</label>
|
||||||
|
<select name="venue" class="form-input">
|
||||||
|
<option value="indoor" {% if data.get('venue') == 'indoor' %}selected{% endif %}>Indoor</option>
|
||||||
|
<option value="outdoor" {% if data.get('venue') == 'outdoor' %}selected{% endif %}>Outdoor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Ownership</label>
|
||||||
|
<select name="own" class="form-input">
|
||||||
|
<option value="rent" {% if data.get('own') == 'rent' %}selected{% endif %}>Rent</option>
|
||||||
|
<option value="buy" {% if data.get('own') == 'buy' %}selected{% endif %}>Buy</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Double Courts</label>
|
||||||
|
<input type="number" name="dblCourts" value="{{ data.get('dblCourts', defaults.dblCourts) }}" class="form-input" min="0">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Single Courts</label>
|
||||||
|
<input type="number" name="sglCourts" value="{{ data.get('sglCourts', defaults.sglCourts) }}" class="form-input" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pricing -->
|
||||||
|
<h3 class="text-base font-semibold mb-3">Pricing</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;" class="mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Peak Rate (€/hr)</label>
|
||||||
|
<input type="number" name="ratePeak" value="{{ data.get('ratePeak', defaults.ratePeak) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Off-Peak Rate (€/hr)</label>
|
||||||
|
<input type="number" name="rateOffPeak" value="{{ data.get('rateOffPeak', defaults.rateOffPeak) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Single Rate (€/hr)</label>
|
||||||
|
<input type="number" name="rateSingle" value="{{ data.get('rateSingle', defaults.rateSingle) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Peak %</label>
|
||||||
|
<input type="number" name="peakPct" value="{{ data.get('peakPct', defaults.peakPct) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Hours/Day</label>
|
||||||
|
<input type="number" name="hoursPerDay" value="{{ data.get('hoursPerDay', defaults.hoursPerDay) }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Utilization Target (%)</label>
|
||||||
|
<input type="number" name="utilTarget" value="{{ data.get('utilTarget', defaults.utilTarget) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OPEX -->
|
||||||
|
<h3 class="text-base font-semibold mb-3">Operating Costs (monthly)</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem;" class="mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Rent/m²</label>
|
||||||
|
<input type="number" name="rentSqm" value="{{ data.get('rentSqm', defaults.rentSqm) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Electricity</label>
|
||||||
|
<input type="number" name="electricity" value="{{ data.get('electricity', defaults.electricity) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Heating</label>
|
||||||
|
<input type="number" name="heating" value="{{ data.get('heating', defaults.heating) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Staff</label>
|
||||||
|
<input type="number" name="staff" value="{{ data.get('staff', defaults.staff) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Insurance</label>
|
||||||
|
<input type="number" name="insurance" value="{{ data.get('insurance', defaults.insurance) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Maintenance</label>
|
||||||
|
<input type="number" name="maintenance" value="{{ data.get('maintenance', defaults.maintenance) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Cleaning</label>
|
||||||
|
<input type="number" name="cleaning" value="{{ data.get('cleaning', defaults.cleaning) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Marketing</label>
|
||||||
|
<input type="number" name="marketing" value="{{ data.get('marketing', defaults.marketing) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CAPEX -->
|
||||||
|
<h3 class="text-base font-semibold mb-3">Capital Costs</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;" class="mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Court Cost (dbl)</label>
|
||||||
|
<input type="number" name="courtCostDbl" value="{{ data.get('courtCostDbl', defaults.courtCostDbl) }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Court Cost (sgl)</label>
|
||||||
|
<input type="number" name="courtCostSgl" value="{{ data.get('courtCostSgl', defaults.courtCostSgl) }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Hall Cost/m²</label>
|
||||||
|
<input type="number" name="hallCostSqm" value="{{ data.get('hallCostSqm', defaults.hallCostSqm) }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Land Price/m²</label>
|
||||||
|
<input type="number" name="landPriceSqm" value="{{ data.get('landPriceSqm', defaults.landPriceSqm) }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Contingency (%)</label>
|
||||||
|
<input type="number" name="contingencyPct" value="{{ data.get('contingencyPct', defaults.contingencyPct) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Fit-out</label>
|
||||||
|
<input type="number" name="fitout" value="{{ data.get('fitout', defaults.fitout) }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Financing -->
|
||||||
|
<h3 class="text-base font-semibold mb-3">Financing</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;" class="mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Loan %</label>
|
||||||
|
<input type="number" name="loanPct" value="{{ data.get('loanPct', defaults.loanPct) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Interest Rate (%)</label>
|
||||||
|
<input type="number" name="interestRate" value="{{ data.get('interestRate', defaults.interestRate) }}" class="form-input" step="any">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Loan Term (years)</label>
|
||||||
|
<input type="number" name="loanTerm" value="{{ data.get('loanTerm', defaults.loanTerm) }}" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update & Recalculate{% else %}Create Scenario{% endif %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Preview: {{ scenario.title }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<div style="max-width: 48rem; margin: 0 auto;">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">← Back to scenarios</a>
|
||||||
|
<a href="{{ url_for('admin.scenario_edit', scenario_id=scenario.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl mb-2">{{ scenario.title }}</h1>
|
||||||
|
{% if scenario.subtitle %}
|
||||||
|
<p class="text-slate mb-6">{{ scenario.subtitle }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2 class="text-lg mb-4">Summary</h2>
|
||||||
|
{% include "partials/scenario_summary.html" %}
|
||||||
|
|
||||||
|
<h2 class="text-lg mb-4 mt-8">CAPEX Breakdown</h2>
|
||||||
|
{% include "partials/scenario_capex.html" %}
|
||||||
|
|
||||||
|
<h2 class="text-lg mb-4 mt-8">Revenue & Operating Costs</h2>
|
||||||
|
{% include "partials/scenario_operating.html" %}
|
||||||
|
|
||||||
|
<h2 class="text-lg mb-4 mt-8">Cash Flow Projection</h2>
|
||||||
|
{% include "partials/scenario_cashflow.html" %}
|
||||||
|
|
||||||
|
<h2 class="text-lg mb-4 mt-8">Returns & Financing</h2>
|
||||||
|
{% include "partials/scenario_returns.html" %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<header class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">Published Scenarios</h1>
|
||||||
|
<p class="text-slate text-sm">{{ scenarios | length }} scenario{{ 's' if scenarios | length != 1 }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="{{ url_for('admin.scenario_new') }}" class="btn">New Scenario</a>
|
||||||
|
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{% if scenarios %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Config</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in scenarios %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.title }}</td>
|
||||||
|
<td class="mono text-sm">{{ s.slug }}</td>
|
||||||
|
<td>{{ s.location }}, {{ s.country }}</td>
|
||||||
|
<td class="text-sm">{{ s.venue_type | capitalize }} · {{ s.court_config }}</td>
|
||||||
|
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
|
||||||
|
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||||
|
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.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 scenario?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate text-sm">No published scenarios yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Data: {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Template - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Article Templates - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{% if templates %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Data Rows</th>
|
||||||
|
<th>Generated</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 class="mono text-sm">{{ t.slug }}</td>
|
||||||
|
<td><span class="badge">{{ t.content_type }}</span></td>
|
||||||
|
<td class="mono">{{ t.data_count }}</td>
|
||||||
|
<td class="mono">{{ t.generated_count }}</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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate text-sm">No templates yet. Create one to get started.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -80,6 +80,7 @@ def create_app() -> Quart:
|
|||||||
from .admin.routes import bp as admin_bp
|
from .admin.routes import bp as admin_bp
|
||||||
from .auth.routes import bp as auth_bp
|
from .auth.routes import bp as auth_bp
|
||||||
from .billing.routes import bp as billing_bp
|
from .billing.routes import bp as billing_bp
|
||||||
|
from .content.routes import bp as content_bp
|
||||||
from .dashboard.routes import bp as dashboard_bp
|
from .dashboard.routes import bp as dashboard_bp
|
||||||
from .directory.routes import bp as directory_bp
|
from .directory.routes import bp as directory_bp
|
||||||
from .leads.routes import bp as leads_bp
|
from .leads.routes import bp as leads_bp
|
||||||
@@ -96,6 +97,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(directory_bp)
|
app.register_blueprint(directory_bp)
|
||||||
app.register_blueprint(suppliers_bp)
|
app.register_blueprint(suppliers_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(content_bp) # last — catch-all route
|
||||||
|
|
||||||
# Request ID tracking
|
# Request ID tracking
|
||||||
setup_request_id(app)
|
setup_request_id(app)
|
||||||
|
|||||||
0
padelnomics/src/padelnomics/content/__init__.py
Normal file
0
padelnomics/src/padelnomics/content/__init__.py
Normal file
204
padelnomics/src/padelnomics/content/routes.py
Normal file
204
padelnomics/src/padelnomics/content/routes.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
Content domain: public article serving, markets hub, scenario widget rendering.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from markupsafe import Markup
|
||||||
|
from quart import Blueprint, abort, render_template, request
|
||||||
|
|
||||||
|
from ..core import fetch_all, fetch_one
|
||||||
|
|
||||||
|
bp = Blueprint(
|
||||||
|
"content",
|
||||||
|
__name__,
|
||||||
|
template_folder=str(Path(__file__).parent / "templates"),
|
||||||
|
)
|
||||||
|
|
||||||
|
BUILD_DIR = Path("data/content/_build")
|
||||||
|
|
||||||
|
RESERVED_PREFIXES = (
|
||||||
|
"/admin", "/auth", "/planner", "/billing", "/dashboard",
|
||||||
|
"/directory", "/leads", "/suppliers", "/health",
|
||||||
|
"/sitemap", "/static", "/markets", "/features",
|
||||||
|
"/terms", "/privacy", "/about", "/feedback",
|
||||||
|
)
|
||||||
|
|
||||||
|
SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]')
|
||||||
|
|
||||||
|
SECTION_TEMPLATES = {
|
||||||
|
None: "partials/scenario_summary.html",
|
||||||
|
"capex": "partials/scenario_capex.html",
|
||||||
|
"operating": "partials/scenario_operating.html",
|
||||||
|
"cashflow": "partials/scenario_cashflow.html",
|
||||||
|
"returns": "partials/scenario_returns.html",
|
||||||
|
"full": "partials/scenario_full.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Standalone Jinja2 env for baking scenario cards into static HTML.
|
||||||
|
# Does not require a Quart app context.
|
||||||
|
_TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||||
|
_bake_env = Environment(loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=True)
|
||||||
|
|
||||||
|
|
||||||
|
def is_reserved_path(url_path: str) -> bool:
|
||||||
|
"""Check if a url_path starts with a reserved prefix."""
|
||||||
|
clean = "/" + url_path.strip("/")
|
||||||
|
return any(clean.startswith(p) for p in RESERVED_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
async def bake_scenario_cards(html: str) -> str:
|
||||||
|
"""Replace [scenario:slug] and [scenario:slug:section] markers with rendered HTML."""
|
||||||
|
matches = list(SCENARIO_RE.finditer(html))
|
||||||
|
if not matches:
|
||||||
|
return html
|
||||||
|
|
||||||
|
# Batch-fetch all referenced scenarios
|
||||||
|
slugs = list({m.group(1) for m in matches})
|
||||||
|
placeholders = ",".join("?" * len(slugs))
|
||||||
|
rows = await fetch_all(
|
||||||
|
f"SELECT * FROM published_scenarios WHERE slug IN ({placeholders})",
|
||||||
|
tuple(slugs),
|
||||||
|
)
|
||||||
|
scenarios = {row["slug"]: row for row in rows}
|
||||||
|
|
||||||
|
for match in reversed(matches):
|
||||||
|
slug = match.group(1)
|
||||||
|
section = match.group(2)
|
||||||
|
|
||||||
|
scenario = scenarios.get(slug)
|
||||||
|
if not scenario:
|
||||||
|
continue
|
||||||
|
|
||||||
|
template_name = SECTION_TEMPLATES.get(section)
|
||||||
|
if not template_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
calc_data = json.loads(scenario["calc_json"])
|
||||||
|
state_data = json.loads(scenario["state_json"])
|
||||||
|
|
||||||
|
tmpl = _bake_env.get_template(template_name)
|
||||||
|
card_html = tmpl.render(scenario=scenario, d=calc_data, s=state_data)
|
||||||
|
html = html[:match.start()] + card_html + html[match.end():]
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Markets Hub
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/markets")
|
||||||
|
async def markets():
|
||||||
|
"""Hub page: search + country/region filter for articles."""
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
country = request.args.get("country", "")
|
||||||
|
region = request.args.get("region", "")
|
||||||
|
|
||||||
|
countries = await fetch_all(
|
||||||
|
"""SELECT DISTINCT country FROM articles
|
||||||
|
WHERE country IS NOT NULL AND country != ''
|
||||||
|
AND status = 'published' AND published_at <= datetime('now')
|
||||||
|
ORDER BY country"""
|
||||||
|
)
|
||||||
|
regions = await fetch_all(
|
||||||
|
"""SELECT DISTINCT region FROM articles
|
||||||
|
WHERE region IS NOT NULL AND region != ''
|
||||||
|
AND status = 'published' AND published_at <= datetime('now')
|
||||||
|
ORDER BY region"""
|
||||||
|
)
|
||||||
|
|
||||||
|
articles = await _filter_articles(q, country, region)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"markets.html",
|
||||||
|
articles=articles,
|
||||||
|
countries=[c["country"] for c in countries],
|
||||||
|
regions=[r["region"] for r in regions],
|
||||||
|
current_q=q,
|
||||||
|
current_country=country,
|
||||||
|
current_region=region,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/markets/results")
|
||||||
|
async def market_results():
|
||||||
|
"""HTMX partial: filtered article cards."""
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
country = request.args.get("country", "")
|
||||||
|
region = request.args.get("region", "")
|
||||||
|
|
||||||
|
articles = await _filter_articles(q, country, region)
|
||||||
|
return await render_template("partials/market_results.html", articles=articles)
|
||||||
|
|
||||||
|
|
||||||
|
async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
|
||||||
|
"""Query published articles with optional FTS + country/region filters."""
|
||||||
|
if q:
|
||||||
|
# FTS query
|
||||||
|
wheres = ["articles_fts MATCH ?"]
|
||||||
|
params: list = [q]
|
||||||
|
if country:
|
||||||
|
wheres.append("a.country = ?")
|
||||||
|
params.append(country)
|
||||||
|
if region:
|
||||||
|
wheres.append("a.region = ?")
|
||||||
|
params.append(region)
|
||||||
|
where = " AND ".join(wheres)
|
||||||
|
return await fetch_all(
|
||||||
|
f"""SELECT a.* FROM articles a
|
||||||
|
JOIN articles_fts ON articles_fts.rowid = a.id
|
||||||
|
WHERE {where}
|
||||||
|
AND a.status = 'published' AND a.published_at <= datetime('now')
|
||||||
|
ORDER BY a.published_at DESC
|
||||||
|
LIMIT 100""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
wheres = ["status = 'published'", "published_at <= datetime('now')"]
|
||||||
|
params = []
|
||||||
|
if country:
|
||||||
|
wheres.append("country = ?")
|
||||||
|
params.append(country)
|
||||||
|
if region:
|
||||||
|
wheres.append("region = ?")
|
||||||
|
params.append(region)
|
||||||
|
where = " AND ".join(wheres)
|
||||||
|
return await fetch_all(
|
||||||
|
f"""SELECT * FROM articles WHERE {where}
|
||||||
|
ORDER BY published_at DESC LIMIT 100""",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Catch-all Article Serving (must be registered LAST)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@bp.route("/<path:url_path>")
|
||||||
|
async def article_page(url_path: str):
|
||||||
|
"""Serve a published article by its url_path."""
|
||||||
|
clean_path = "/" + url_path.strip("/")
|
||||||
|
|
||||||
|
article = await fetch_one(
|
||||||
|
"""SELECT * FROM articles
|
||||||
|
WHERE url_path = ? AND status = 'published'
|
||||||
|
AND published_at <= datetime('now')""",
|
||||||
|
(clean_path,),
|
||||||
|
)
|
||||||
|
if not article:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
build_path = BUILD_DIR / f"{article['slug']}.html"
|
||||||
|
if not build_path.exists():
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
body_html = build_path.read_text()
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"article_detail.html",
|
||||||
|
article=article,
|
||||||
|
body_html=Markup(body_html),
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ article.title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<meta name="description" content="{{ article.meta_description or '' }}">
|
||||||
|
<meta property="og:title" content="{{ article.title }}">
|
||||||
|
<meta property="og:description" content="{{ article.meta_description or '' }}">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
{% if article.og_image_url %}
|
||||||
|
<meta property="og:image" content="{{ article.og_image_url }}">
|
||||||
|
{% endif %}
|
||||||
|
<link rel="canonical" href="{{ config.BASE_URL }}{{ article.url_path }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<article class="article-content" style="max-width: 48rem; margin: 0 auto;">
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-3xl mb-2">{{ article.title }}</h1>
|
||||||
|
<p class="text-sm text-slate">
|
||||||
|
{% if article.published_at %}{{ article.published_at[:10] }} · {% endif %}Padelnomics Research
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="article-body">
|
||||||
|
{{ body_html }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-12 pt-8 border-t border-light-gray">
|
||||||
|
<div class="card" style="text-align: center; padding: 2rem;">
|
||||||
|
<h3 class="text-xl mb-2">Run Your Own Numbers</h3>
|
||||||
|
<p class="text-slate text-sm mb-4">Use our free financial planner to model a padel center with your own assumptions.</p>
|
||||||
|
<a href="{{ url_for('planner.index') }}" class="btn">Open the Planner</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
65
padelnomics/src/padelnomics/content/templates/markets.html
Normal file
65
padelnomics/src/padelnomics/content/templates/markets.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Padel Markets - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<meta name="description" content="Padel court cost analysis and market data for cities worldwide. Real financial scenarios with local data.">
|
||||||
|
<meta property="og:title" content="Padel Markets - {{ config.APP_NAME }}">
|
||||||
|
<meta property="og:description" content="Explore padel court costs, revenue projections, and investment returns by city.">
|
||||||
|
<link rel="canonical" href="{{ config.BASE_URL }}/markets">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-3xl mb-2">Padel Markets</h1>
|
||||||
|
<p class="text-slate">Cost analysis and financial projections for padel centers worldwide.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-8">
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr auto auto; gap: 1rem; align-items: end;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="market-q">Search</label>
|
||||||
|
<input type="text" id="market-q" name="q" value="{{ current_q }}" placeholder="Search articles..."
|
||||||
|
class="form-input"
|
||||||
|
hx-get="{{ url_for('content.market_results') }}"
|
||||||
|
hx-target="#market-results"
|
||||||
|
hx-trigger="input changed delay:300ms"
|
||||||
|
hx-include="#market-country, #market-region">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="market-country">Country</label>
|
||||||
|
<select id="market-country" name="country" class="form-input"
|
||||||
|
hx-get="{{ url_for('content.market_results') }}"
|
||||||
|
hx-target="#market-results"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#market-q, #market-region">
|
||||||
|
<option value="">All Countries</option>
|
||||||
|
{% for c in countries %}
|
||||||
|
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="market-region">Region</label>
|
||||||
|
<select id="market-region" name="region" class="form-input"
|
||||||
|
hx-get="{{ url_for('content.market_results') }}"
|
||||||
|
hx-target="#market-results"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="#market-q, #market-country">
|
||||||
|
<option value="">All Regions</option>
|
||||||
|
{% for r in regions %}
|
||||||
|
<option value="{{ r }}" {% if r == current_region %}selected{% endif %}>{{ r }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div id="market-results">
|
||||||
|
{% include "partials/market_results.html" %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% if articles %}
|
||||||
|
<div class="grid-3">
|
||||||
|
{% for article in articles %}
|
||||||
|
<a href="{{ article.url_path }}" class="card" style="text-decoration: none; display: block;">
|
||||||
|
<h3 class="text-base font-semibold text-navy mb-1">{{ article.title }}</h3>
|
||||||
|
{% if article.meta_description %}
|
||||||
|
<p class="text-sm text-slate mb-3" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">{{ article.meta_description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if article.country %}
|
||||||
|
<span class="badge">{{ article.country }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if article.region %}
|
||||||
|
<span class="text-xs text-slate">{{ article.region }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if article.published_at %}
|
||||||
|
<span class="text-xs text-slate ml-auto mono">{{ article.published_at[:10] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="text-slate text-sm">No articles found. Try adjusting your filters.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<div class="scenario-widget scenario-capex">
|
||||||
|
<div class="scenario-widget__header">
|
||||||
|
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||||
|
<span class="scenario-widget__config">Investment Breakdown</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__body">
|
||||||
|
<div class="scenario-widget__table-wrap">
|
||||||
|
<table class="scenario-widget__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th class="text-right">Amount</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in d.capexItems %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td class="text-right mono">€{{ "{:,.0f}".format(item.amount) }}</td>
|
||||||
|
<td class="text-sm text-slate">{{ item.info }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Total CAPEX</strong></td>
|
||||||
|
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.capex) }}</strong></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metrics" style="margin-top: 1rem;">
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Per Court</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capexPerCourt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Per m²</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capexPerSqm) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">LTV</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ltv * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metrics">
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Equity</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.equity) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Loan</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.loanAmount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Total</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capex) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__cta">
|
||||||
|
<a href="/planner/">Try with your own numbers →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<div class="scenario-widget scenario-cashflow">
|
||||||
|
<div class="scenario-widget__header">
|
||||||
|
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||||
|
<span class="scenario-widget__config">{{ s.holdYears }}-Year Projection</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__body">
|
||||||
|
<div class="scenario-widget__table-wrap">
|
||||||
|
<table class="scenario-widget__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
{% for a in d.annuals %}
|
||||||
|
<th class="text-right">Year {{ a.year }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Revenue</td>
|
||||||
|
{% for a in d.annuals %}
|
||||||
|
<td class="text-right mono">€{{ "{:,.0f}".format(a.revenue) }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>EBITDA</td>
|
||||||
|
{% for a in d.annuals %}
|
||||||
|
<td class="text-right mono">€{{ "{:,.0f}".format(a.ebitda) }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Debt Service</td>
|
||||||
|
{% for a in d.annuals %}
|
||||||
|
<td class="text-right mono">€{{ "{:,.0f}".format(a.ds) }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Net Cash Flow</strong></td>
|
||||||
|
{% for a in d.annuals %}
|
||||||
|
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(a.ncf) }}</strong></td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Cumulative NCF</td>
|
||||||
|
{% set cum = namespace(total=-d.capex) %}
|
||||||
|
{% for a in d.annuals %}
|
||||||
|
{% set cum.total = cum.total + a.ncf %}
|
||||||
|
<td class="text-right mono {% if cum.total >= 0 %}text-accent{% else %}text-danger{% endif %}">€{{ "{:,.0f}".format(cum.total) }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td>DSCR</td>
|
||||||
|
{% for entry in d.dscr %}
|
||||||
|
<td class="text-right mono">{{ "{:.2f}".format(entry.dscr) }}x</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__cta">
|
||||||
|
<a href="/planner/">Try with your own numbers →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{% include "partials/scenario_summary.html" %}
|
||||||
|
{% include "partials/scenario_capex.html" %}
|
||||||
|
{% include "partials/scenario_operating.html" %}
|
||||||
|
{% include "partials/scenario_cashflow.html" %}
|
||||||
|
{% include "partials/scenario_returns.html" %}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<div class="scenario-widget scenario-operating">
|
||||||
|
<div class="scenario-widget__header">
|
||||||
|
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||||
|
<span class="scenario-widget__config">Revenue & Operating Costs</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__body">
|
||||||
|
<h4 class="scenario-widget__section-title">Revenue Model</h4>
|
||||||
|
<div class="scenario-widget__metrics">
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Weighted Rate</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:.0f}".format(d.weightedRate) }}/hr</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Utilization Target</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ s.utilTarget }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Booked Hours/mo</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:,.0f}".format(d.bookedHoursMonth) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="scenario-widget__section-title">Monthly OPEX</h4>
|
||||||
|
<div class="scenario-widget__table-wrap">
|
||||||
|
<table class="scenario-widget__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th class="text-right">Monthly</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in d.opexItems %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td class="text-right mono">€{{ "{:,.0f}".format(item.amount) }}</td>
|
||||||
|
<td class="text-sm text-slate">{{ item.info }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Total Monthly OPEX</strong></td>
|
||||||
|
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.opex) }}</strong></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="scenario-widget__section-title">Monthly Summary</h4>
|
||||||
|
<div class="scenario-widget__table-wrap">
|
||||||
|
<table class="scenario-widget__table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Gross Revenue</td>
|
||||||
|
<td class="text-right mono">€{{ "{:,.0f}".format(d.grossRevMonth) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Booking Fees</td>
|
||||||
|
<td class="text-right mono">-€{{ "{:,.0f}".format(d.feeDeduction) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Net Revenue</td>
|
||||||
|
<td class="text-right mono">€{{ "{:,.0f}".format(d.netRevMonth) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Operating Costs</td>
|
||||||
|
<td class="text-right mono">-€{{ "{:,.0f}".format(d.opex) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>EBITDA</strong></td>
|
||||||
|
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.ebitdaMonth) }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Debt Service</td>
|
||||||
|
<td class="text-right mono">-€{{ "{:,.0f}".format(d.monthlyPayment) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Net Cash Flow</strong></td>
|
||||||
|
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.netCFMonth) }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__cta">
|
||||||
|
<a href="/planner/">Try with your own numbers →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<div class="scenario-widget scenario-returns">
|
||||||
|
<div class="scenario-widget__header">
|
||||||
|
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||||
|
<span class="scenario-widget__config">Returns & Financing</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__body">
|
||||||
|
<h4 class="scenario-widget__section-title">Return Metrics</h4>
|
||||||
|
<div class="scenario-widget__metrics">
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">IRR ({{ s.holdYears }}yr)</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.irr * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">MOIC</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:.2f}".format(d.moic) }}x</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Payback</span>
|
||||||
|
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} months{% else %}N/A{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metrics">
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Cash-on-Cash</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.cashOnCash * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Yield on Cost</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.yieldOnCost * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">EBITDA Margin</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ebitdaMargin * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="scenario-widget__section-title">Exit Analysis</h4>
|
||||||
|
<div class="scenario-widget__metrics">
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Exit Value ({{ s.exitMultiple }}x EBITDA)</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.exitValue) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Remaining Loan</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.remainingLoan) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Net Exit</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.netExit) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="scenario-widget__section-title">Financing</h4>
|
||||||
|
<div class="scenario-widget__metrics">
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Loan Amount</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.loanAmount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Rate / Term</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ s.interestRate }}% / {{ s.loanTerm }}yr</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Monthly Payment</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.monthlyPayment) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__cta">
|
||||||
|
<a href="/planner/">Try with your own numbers →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<div class="scenario-widget scenario-summary">
|
||||||
|
<div class="scenario-widget__header">
|
||||||
|
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||||
|
<span class="scenario-widget__config">{{ scenario.venue_type | capitalize }} · {{ scenario.court_config }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__body">
|
||||||
|
<div class="scenario-widget__metrics">
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Total CAPEX</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capex) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Monthly EBITDA</span>
|
||||||
|
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.ebitdaMonth) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">IRR ({{ s.holdYears }}yr)</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.irr * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metrics">
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Payback</span>
|
||||||
|
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} months{% else %}N/A{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">Cash-on-Cash</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.cashOnCash * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__metric">
|
||||||
|
<span class="scenario-widget__metric-label">EBITDA Margin</span>
|
||||||
|
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ebitdaMargin * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-widget__cta">
|
||||||
|
<a href="/planner/">Try with your own numbers →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,7 +4,9 @@ Core infrastructure: database, config, email, and shared utilities.
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import unicodedata
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -373,3 +375,15 @@ async def get_all_paddle_prices() -> dict[str, str]:
|
|||||||
"""Load all Paddle price IDs as a {key: price_id} dict."""
|
"""Load all Paddle price IDs as a {key: price_id} dict."""
|
||||||
rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products")
|
rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products")
|
||||||
return {r["key"]: r["paddle_price_id"] for r in rows}
|
return {r["key"]: r["paddle_price_id"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Text Utilities
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def slugify(text: str, max_length_chars: int = 80) -> str:
|
||||||
|
"""Convert text to URL-safe slug."""
|
||||||
|
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||||
|
text = re.sub(r"[^\w\s-]", "", text.lower())
|
||||||
|
text = re.sub(r"[-\s]+", "-", text).strip("-")
|
||||||
|
return text[:max_length_chars]
|
||||||
|
|||||||
@@ -316,3 +316,102 @@ CREATE TABLE IF NOT EXISTS feedback (
|
|||||||
is_read INTEGER NOT NULL DEFAULT 0,
|
is_read INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Content / Programmatic SEO
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Published scenarios (generated financial widgets for articles)
|
||||||
|
CREATE TABLE IF NOT EXISTS published_scenarios (
|
||||||
|
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,
|
||||||
|
template_data_id INTEGER REFERENCES template_data(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pub_scenarios_slug ON published_scenarios(slug);
|
||||||
|
|
||||||
|
-- Article templates (content recipes)
|
||||||
|
CREATE TABLE IF NOT EXISTS article_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'calculator',
|
||||||
|
input_schema TEXT NOT NULL,
|
||||||
|
url_pattern TEXT NOT NULL,
|
||||||
|
title_pattern TEXT NOT NULL,
|
||||||
|
meta_description_pattern TEXT,
|
||||||
|
body_template TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_templates_slug ON article_templates(slug);
|
||||||
|
|
||||||
|
-- Template data (per-city/region input rows)
|
||||||
|
CREATE TABLE IF NOT EXISTS template_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
template_id INTEGER NOT NULL REFERENCES article_templates(id),
|
||||||
|
data_json TEXT NOT NULL,
|
||||||
|
scenario_id INTEGER REFERENCES published_scenarios(id),
|
||||||
|
article_id INTEGER REFERENCES articles(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_data_template ON template_data(template_id);
|
||||||
|
|
||||||
|
-- Articles (generated or manual)
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url_path TEXT UNIQUE NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
meta_description TEXT,
|
||||||
|
country TEXT,
|
||||||
|
region TEXT,
|
||||||
|
og_image_url TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
published_at TEXT,
|
||||||
|
template_data_id INTEGER REFERENCES template_data(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at);
|
||||||
|
|
||||||
|
-- FTS5 full-text search for articles
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
||||||
|
title, meta_description, country, region,
|
||||||
|
content='articles', content_rowid='id'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Keep FTS in sync with articles table
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Programmatic SEO: article templates, template data, published scenarios, articles + FTS."""
|
||||||
|
|
||||||
|
|
||||||
|
def up(conn):
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS published_scenarios (
|
||||||
|
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,
|
||||||
|
template_data_id INTEGER REFERENCES template_data(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_pub_scenarios_slug ON published_scenarios(slug)")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS article_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'calculator',
|
||||||
|
input_schema TEXT NOT NULL,
|
||||||
|
url_pattern TEXT NOT NULL,
|
||||||
|
title_pattern TEXT NOT NULL,
|
||||||
|
meta_description_pattern TEXT,
|
||||||
|
body_template TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_article_templates_slug ON article_templates(slug)")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS template_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
template_id INTEGER NOT NULL REFERENCES article_templates(id),
|
||||||
|
data_json TEXT NOT NULL,
|
||||||
|
scenario_id INTEGER REFERENCES published_scenarios(id),
|
||||||
|
article_id INTEGER REFERENCES articles(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_template_data_template ON template_data(template_id)")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url_path TEXT UNIQUE NOT NULL,
|
||||||
|
slug TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
meta_description TEXT,
|
||||||
|
country TEXT,
|
||||||
|
region TEXT,
|
||||||
|
og_image_url TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
published_at TEXT,
|
||||||
|
template_data_id INTEGER REFERENCES template_data(id),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
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)")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
||||||
|
title, meta_description, country, region,
|
||||||
|
content='articles', content_rowid='id'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# FTS sync triggers
|
||||||
|
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
|
||||||
|
""")
|
||||||
@@ -107,7 +107,18 @@ async def sitemap():
|
|||||||
f"{base}/billing/pricing",
|
f"{base}/billing/pricing",
|
||||||
f"{base}/terms",
|
f"{base}/terms",
|
||||||
f"{base}/privacy",
|
f"{base}/privacy",
|
||||||
|
f"{base}/markets",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Add published articles (only those with published_at <= now)
|
||||||
|
articles = await fetch_all(
|
||||||
|
"""SELECT url_path FROM articles
|
||||||
|
WHERE status = 'published' AND published_at <= datetime('now')
|
||||||
|
ORDER BY published_at DESC"""
|
||||||
|
)
|
||||||
|
for article in articles:
|
||||||
|
urls.append(f"{base}{article['url_path']}")
|
||||||
|
|
||||||
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
for url in urls:
|
for url in urls:
|
||||||
|
|||||||
@@ -280,4 +280,115 @@
|
|||||||
.htmx-request.htmx-indicator {
|
.htmx-request.htmx-indicator {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Scenario Widgets ── */
|
||||||
|
.scenario-widget {
|
||||||
|
@apply border border-light-gray rounded-2xl overflow-hidden mb-6 bg-white shadow-sm;
|
||||||
|
}
|
||||||
|
.scenario-widget__header {
|
||||||
|
@apply flex justify-between items-center px-4 py-3 text-sm font-semibold text-white;
|
||||||
|
background: var(--color-navy);
|
||||||
|
}
|
||||||
|
.scenario-widget__location {
|
||||||
|
@apply uppercase tracking-wider text-xs;
|
||||||
|
}
|
||||||
|
.scenario-widget__config {
|
||||||
|
@apply text-xs font-normal opacity-80;
|
||||||
|
}
|
||||||
|
.scenario-widget__body {
|
||||||
|
@apply p-4;
|
||||||
|
}
|
||||||
|
.scenario-widget__metrics {
|
||||||
|
@apply grid gap-4 mb-4;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.scenario-widget__metrics {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scenario-widget__metric {
|
||||||
|
@apply text-center;
|
||||||
|
}
|
||||||
|
.scenario-widget__metric-label {
|
||||||
|
@apply block text-xs text-slate mb-0.5;
|
||||||
|
}
|
||||||
|
.scenario-widget__metric-value {
|
||||||
|
@apply block text-lg font-bold font-mono text-navy;
|
||||||
|
}
|
||||||
|
.scenario-widget__section-title {
|
||||||
|
@apply text-sm font-semibold text-charcoal mb-2 mt-4 pb-1 border-b border-light-gray;
|
||||||
|
}
|
||||||
|
.scenario-widget__table-wrap {
|
||||||
|
@apply overflow-x-auto;
|
||||||
|
}
|
||||||
|
.scenario-widget__table {
|
||||||
|
@apply w-full text-sm;
|
||||||
|
}
|
||||||
|
.scenario-widget__table th {
|
||||||
|
@apply text-left px-3 py-1.5 text-xs font-semibold text-slate uppercase tracking-wider border-b border-light-gray;
|
||||||
|
}
|
||||||
|
.scenario-widget__table td {
|
||||||
|
@apply px-3 py-1.5 border-b border-light-gray text-slate-dark;
|
||||||
|
}
|
||||||
|
.scenario-widget__table tfoot td,
|
||||||
|
.scenario-widget__table tfoot th {
|
||||||
|
@apply border-t-2 border-light-gray font-semibold;
|
||||||
|
}
|
||||||
|
.scenario-widget__cta {
|
||||||
|
@apply px-4 py-3 text-center border-t border-light-gray bg-soft-white;
|
||||||
|
}
|
||||||
|
.scenario-widget__cta a {
|
||||||
|
@apply text-sm font-semibold text-electric hover:text-electric-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Article Body Typography ── */
|
||||||
|
.article-body h2 {
|
||||||
|
@apply text-2xl mt-10 mb-4;
|
||||||
|
}
|
||||||
|
.article-body h3 {
|
||||||
|
@apply text-xl mt-8 mb-3;
|
||||||
|
}
|
||||||
|
.article-body h4 {
|
||||||
|
@apply text-lg mt-6 mb-2;
|
||||||
|
}
|
||||||
|
.article-body p {
|
||||||
|
@apply text-base leading-relaxed mb-4 text-slate-dark;
|
||||||
|
}
|
||||||
|
.article-body ul, .article-body ol {
|
||||||
|
@apply mb-4 pl-6;
|
||||||
|
}
|
||||||
|
.article-body ul {
|
||||||
|
@apply list-disc;
|
||||||
|
}
|
||||||
|
.article-body ol {
|
||||||
|
@apply list-decimal;
|
||||||
|
}
|
||||||
|
.article-body li {
|
||||||
|
@apply mb-1 text-slate-dark;
|
||||||
|
}
|
||||||
|
.article-body blockquote {
|
||||||
|
@apply border-l-4 border-electric pl-4 italic text-slate my-6;
|
||||||
|
}
|
||||||
|
.article-body table {
|
||||||
|
@apply w-full text-sm mb-6;
|
||||||
|
}
|
||||||
|
.article-body table th {
|
||||||
|
@apply text-left px-3 py-2 text-xs font-semibold text-slate uppercase border-b-2 border-light-gray;
|
||||||
|
}
|
||||||
|
.article-body table td {
|
||||||
|
@apply px-3 py-2 border-b border-light-gray text-slate-dark;
|
||||||
|
}
|
||||||
|
.article-body code {
|
||||||
|
@apply font-mono text-sm bg-light-gray px-1 py-0.5 rounded;
|
||||||
|
}
|
||||||
|
.article-body pre {
|
||||||
|
@apply bg-navy text-white p-4 rounded-xl mb-4 overflow-x-auto;
|
||||||
|
}
|
||||||
|
.article-body pre code {
|
||||||
|
@apply bg-transparent p-0;
|
||||||
|
}
|
||||||
|
.article-body a {
|
||||||
|
@apply text-electric underline hover:text-electric-hover;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@
|
|||||||
<ul class="space-y-1 text-sm">
|
<ul class="space-y-1 text-sm">
|
||||||
<li><a href="{{ url_for('planner.index') }}">Planner</a></li>
|
<li><a href="{{ url_for('planner.index') }}">Planner</a></li>
|
||||||
<li><a href="{{ url_for('directory.index') }}">Supplier Directory</a></li>
|
<li><a href="{{ url_for('directory.index') }}">Supplier Directory</a></li>
|
||||||
|
<li><a href="{{ url_for('content.markets') }}">Markets</a></li>
|
||||||
<li><a href="{{ url_for('public.suppliers') }}">For Suppliers</a></li>
|
<li><a href="{{ url_for('public.suppliers') }}">For Suppliers</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1102
padelnomics/tests/test_content.py
Normal file
1102
padelnomics/tests/test_content.py
Normal file
File diff suppressed because it is too large
Load Diff
11
padelnomics/uv.lock
generated
11
padelnomics/uv.lock
generated
@@ -633,6 +633,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mistune"
|
||||||
|
version = "3.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
version = "26.0"
|
||||||
@@ -664,6 +673,7 @@ dependencies = [
|
|||||||
{ name = "hypercorn" },
|
{ name = "hypercorn" },
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
|
{ name = "mistune" },
|
||||||
{ name = "paddle-python-sdk" },
|
{ name = "paddle-python-sdk" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "quart" },
|
{ name = "quart" },
|
||||||
@@ -688,6 +698,7 @@ requires-dist = [
|
|||||||
{ name = "hypercorn", specifier = ">=0.17.0" },
|
{ name = "hypercorn", specifier = ">=0.17.0" },
|
||||||
{ name = "itsdangerous", specifier = ">=2.1.0" },
|
{ name = "itsdangerous", specifier = ">=2.1.0" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||||
|
{ name = "mistune", specifier = ">=3.0.0" },
|
||||||
{ name = "paddle-python-sdk", specifier = ">=1.13.0" },
|
{ name = "paddle-python-sdk", specifier = ">=1.13.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
{ name = "quart", specifier = ">=0.19.0" },
|
{ name = "quart", specifier = ">=0.19.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user