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 &amp; 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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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 & 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 & 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.",
|
||||
|
||||
@@ -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 & 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 & 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 & 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.",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user