fix: scenario preview rendering and double-encoded ampersands

- bake_scenario_cards() accepts scenario_overrides dict for preview mode
  (bypasses DB lookup when no published_scenario exists)
- preview_article() builds in-memory scenario dict and passes it through
- Fix double-encoded & in locale strings (was & in rendered HTML)
- Fix ruff import sort in _datetimeformat
- Fix migration 0019 minor issue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-24 01:22:25 +01:00
parent 1762188f08
commit 5808ba6490
5 changed files with 46 additions and 14 deletions

View File

@@ -135,7 +135,7 @@ def _validate_table_name(data_table: str) -> None:
def _datetimeformat(value: str, fmt: str = "%Y-%m-%d") -> str:
"""Jinja2 filter: format a date string (or 'now') with strftime."""
from datetime import datetime, UTC
from datetime import UTC, datetime
if value == "now":
dt = datetime.now(UTC)
@@ -499,6 +499,7 @@ async def preview_article(
meta_desc = _render_pattern(config["meta_description_pattern"], ctx)
# Calculator: compute scenario in-memory
scenario_overrides = None
if config["content_type"] == "calculator":
# DuckDB lowercases all column names; build a case-insensitive
# reverse map so "ratepeak" (stored) matches "ratePeak" (DEFAULTS).
@@ -509,12 +510,31 @@ async def preview_article(
if k.lower() in _defaults_ci and v is not None
}
state = validate_state(calc_overrides)
calc(state, lang=lang) # validate state produces valid output
ctx["scenario_slug"] = slug + "-" + str(row[natural_key])
d = calc(state, lang=lang)
scenario_slug = slug + "-" + str(row[natural_key])
ctx["scenario_slug"] = scenario_slug
# Build a fake scenario dict so bake_scenario_cards can render
# without a published_scenarios DB row.
city = row.get("city_name", row.get("city", ""))
scenario_overrides = {
scenario_slug: {
"slug": scenario_slug,
"title": city,
"location": city,
"country": row.get("country", state.get("country", "")),
"venue_type": state.get("venue", "indoor"),
"ownership": state.get("own", "rent"),
"calc_json": json.dumps(d),
"state_json": json.dumps(state),
}
}
body_md = _render_pattern(config["body_template"], ctx)
body_html = mistune.html(body_md)
body_html = await bake_scenario_cards(body_html, lang=lang)
body_html = await bake_scenario_cards(
body_html, lang=lang, scenario_overrides=scenario_overrides,
)
return {
"title": title,

View File

@@ -60,13 +60,21 @@ def is_reserved_path(url_path: str) -> bool:
return any(clean.startswith(p) for p in RESERVED_PREFIXES)
async def bake_scenario_cards(html: str, lang: str = "en") -> str:
"""Replace [scenario:slug] and [scenario:slug:section] markers with rendered HTML."""
async def bake_scenario_cards(
html: str,
lang: str = "en",
scenario_overrides: dict[str, dict] | None = None,
) -> str:
"""Replace [scenario:slug] and [scenario:slug:section] markers with rendered HTML.
scenario_overrides: optional {slug: {calc_json, state_json, ...}} dict
that bypasses the DB lookup (used by preview_article).
"""
matches = list(SCENARIO_RE.finditer(html))
if not matches:
return html
# Batch-fetch all referenced scenarios
# Batch-fetch all referenced scenarios from DB
slugs = list({m.group(1) for m in matches})
placeholders = ",".join("?" * len(slugs))
rows = await fetch_all(
@@ -75,6 +83,10 @@ async def bake_scenario_cards(html: str, lang: str = "en") -> str:
)
scenarios = {row["slug"]: row for row in rows}
# Merge in any overrides (preview mode — no DB row exists yet)
if scenario_overrides:
scenarios.update(scenario_overrides)
for match in reversed(matches):
slug = match.group(1)
section = match.group(2)

View File

@@ -1059,7 +1059,7 @@
"scenario_total_label": "Gesamt",
"scenario_total_capex_label": "Gesamt-CAPEX",
"scenario_monthly_ebitda_label": "Monatliches EBITDA",
"scenario_returns_config_title": "Renditen &amp; Finanzierung",
"scenario_returns_config_title": "Renditen & Finanzierung",
"scenario_return_metrics_title": "Renditekennzahlen",
"scenario_yield_on_cost_label": "Rendite auf Kosten",
"scenario_exit_analysis_title": "Exit-Analyse",
@@ -1070,7 +1070,7 @@
"scenario_loan_amount_label": "Darlehensbetrag",
"scenario_rate_term_label": "Zinssatz / Laufzeit",
"scenario_monthly_payment_label": "Monatliche Rate",
"scenario_revenue_opex_title": "Umsatz &amp; Betriebskosten",
"scenario_revenue_opex_title": "Umsatz & Betriebskosten",
"scenario_revenue_model_title": "Umsatzmodell",
"scenario_weighted_rate_label": "Gewichteter Satz",
"scenario_utilization_target_label": "Auslastungsziel",
@@ -1551,7 +1551,7 @@
"email_quote_verify_body": "Danke f\u00fcr deine Angebotsanfrage. Best\u00e4tige deine E-Mail, um deine Anfrage zu aktivieren und dein {app_name}-Konto zu erstellen.",
"email_quote_verify_project_label": "Dein Projekt:",
"email_quote_verify_urgency": "Verifizierte Anfragen werden von unserem Anbieternetzwerk bevorzugt behandelt.",
"email_quote_verify_btn": "Best\u00e4tigen &amp; Aktivieren \u2192",
"email_quote_verify_btn": "Best\u00e4tigen & Aktivieren \u2192",
"email_quote_verify_expires": "Dieser Link l\u00e4uft in 60 Minuten ab.",
"email_quote_verify_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"email_quote_verify_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",

View File

@@ -1059,7 +1059,7 @@
"scenario_total_label": "Total",
"scenario_total_capex_label": "Total CAPEX",
"scenario_monthly_ebitda_label": "Monthly EBITDA",
"scenario_returns_config_title": "Returns &amp; Financing",
"scenario_returns_config_title": "Returns & Financing",
"scenario_return_metrics_title": "Return Metrics",
"scenario_yield_on_cost_label": "Yield on Cost",
"scenario_exit_analysis_title": "Exit Analysis",
@@ -1070,7 +1070,7 @@
"scenario_loan_amount_label": "Loan Amount",
"scenario_rate_term_label": "Rate / Term",
"scenario_monthly_payment_label": "Monthly Payment",
"scenario_revenue_opex_title": "Revenue &amp; Operating Costs",
"scenario_revenue_opex_title": "Revenue & Operating Costs",
"scenario_revenue_model_title": "Revenue Model",
"scenario_weighted_rate_label": "Weighted Rate",
"scenario_utilization_target_label": "Utilization Target",
@@ -1551,7 +1551,7 @@
"email_quote_verify_body": "Thanks for requesting quotes. Verify your email to activate your quote request and create your {app_name} account.",
"email_quote_verify_project_label": "Your project:",
"email_quote_verify_urgency": "Verified requests get prioritized by our supplier network.",
"email_quote_verify_btn": "Verify &amp; Activate \u2192",
"email_quote_verify_btn": "Verify & Activate \u2192",
"email_quote_verify_expires": "This link expires in 60 minutes.",
"email_quote_verify_fallback": "If the button doesn't work, copy and paste this URL into your browser:",
"email_quote_verify_ignore": "If you didn't request this, you can safely ignore this email.",

View File

@@ -19,7 +19,7 @@ def up(conn):
conn.executemany(
"INSERT OR IGNORE INTO feature_flags (name, enabled, description) VALUES (?, ?, ?)",
[
("markets", 1, "Market/SEO content pages"),
("markets", 0, "Market/SEO content pages"),
("payments", 0, "Paddle billing & checkout"),
("planner_export", 0, "Business plan PDF export"),
("supplier_signup", 0, "Supplier onboarding wizard"),