fix(merge): resolve conflict in bake_scenario_cards — pass lang + t(lang)
Combines master's get_translations() injection with the worktree's lang parameter so German articles render translated scenario card labels. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- content: `scripts/seed_content.py` — seeds two article templates (EN + DE) and 18 cities × 2 language rows into the database; run with `uv run python -m padelnomics.scripts.seed_content --generate` to produce 36 pre-built SEO articles covering Germany (8 cities), USA (6 cities), and UK (4 cities); each city has realistic per-market overrides for rates, rent, utilities, permits, and court configuration so the financial model produces genuinely unique output per article
|
||||
- content: EN template (`city-padel-cost-en`) at `/padel-cost/{{ city_slug }}` and DE template (`city-padel-cost-de`) at `/padel-kosten/{{ city_slug }}` with Jinja2 Markdown bodies embedding `[scenario:slug:section]` cards for summary, CAPEX, operating, cashflow, and returns
|
||||
|
||||
### Fixed
|
||||
- content: `bake_scenario_cards()` now accepts a `lang` parameter and passes it to scenario partial templates; previously `lang` was always `undefined`, causing all cards to render with English labels even for German articles
|
||||
- admin: `_generate_from_template()` extracts `language` from data row and passes it to `calc()` and `bake_scenario_cards()` so German scenario cards use translated CAPEX/OPEX item names
|
||||
- admin: `_generate_from_template()` now derives `article_slug` as `{template_slug}-{city_slug}` instead of bare `city_slug`; bare slugs caused UNIQUE constraint collisions when multiple templates generated articles for the same city
|
||||
- admin: `_rebuild_article()` passes `lang` from data row (or `"en"` for manual articles) to `bake_scenario_cards()` so rebuilt articles render correct language labels
|
||||
- content: removed unused `g` import from `content/routes.py`
|
||||
|
||||
### Changed
|
||||
- planner: full HTMX refactor — replaced 847-line SPA `planner.js` with server-rendered Jinja2 tab partials; planner now uses `hx-post /planner/calculate` + form state; all tab content (CAPEX, Operating, Cash Flow, Returns, Metrics) rendered server-side; Chart.js data embedded as `<script type="application/json">` tags, re-initialized on `htmx:afterSettle`; new `planner.js` is ~200 lines (chart init, slider sync, toggle management, wizard nav, scenario save/load)
|
||||
- planner/i18n: merged `_PLANNER_TRANSLATIONS` (~200 keys × 2 languages) into `_TRANSLATIONS`; deleted `get_planner_translations()` and `window.__PADELNOMICS_LOCALE__`; all planner strings now via standard `{{ t.key }}` Jinja2 template variables; adding a new language = one section in `_TRANSLATIONS`
|
||||
|
||||
@@ -1136,9 +1136,10 @@ async def _generate_from_template(template: dict, start_date: date, articles_per
|
||||
data = json.loads(row["data_json"])
|
||||
|
||||
# Separate calc fields from display fields
|
||||
lang = data.get("language", "en")
|
||||
calc_overrides = {k: v for k, v in data.items() if k in DEFAULTS}
|
||||
state = validate_state(calc_overrides)
|
||||
d = calc(state)
|
||||
d = calc(state, lang=lang)
|
||||
|
||||
# Build scenario slug
|
||||
city_slug = data.get("city_slug", str(row["id"]))
|
||||
@@ -1183,7 +1184,7 @@ async def _generate_from_template(template: dict, start_date: date, articles_per
|
||||
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
|
||||
article_slug = template["slug"] + "-" + city_slug
|
||||
|
||||
meta_desc = ""
|
||||
if template["meta_description_pattern"]:
|
||||
@@ -1196,7 +1197,7 @@ async def _generate_from_template(template: dict, start_date: date, articles_per
|
||||
# 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)
|
||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||
|
||||
# Write to disk
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1638,13 +1639,15 @@ async def _rebuild_article(article_id: int):
|
||||
|
||||
body_md = _render_jinja_string(td["body_template"], data)
|
||||
body_html = mistune.html(body_md)
|
||||
lang = data.get("language", "en")
|
||||
else:
|
||||
# Manual article: re-render from markdown file
|
||||
md_path = Path("data/content/articles") / f"{article['slug']}.md"
|
||||
if not md_path.exists():
|
||||
return
|
||||
body_html = mistune.html(md_path.read_text())
|
||||
lang = "en"
|
||||
|
||||
body_html = await bake_scenario_cards(body_html)
|
||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from markupsafe import Markup
|
||||
from quart import Blueprint, abort, g, render_template, request
|
||||
from quart import Blueprint, abort, render_template, request
|
||||
|
||||
from ..core import capture_waitlist_email, config, csrf_protect, fetch_all, fetch_one, waitlist_gate
|
||||
from ..i18n import get_translations
|
||||
@@ -60,7 +60,7 @@ 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) -> str:
|
||||
async def bake_scenario_cards(html: str, lang: str = "en") -> str:
|
||||
"""Replace [scenario:slug] and [scenario:slug:section] markers with rendered HTML."""
|
||||
matches = list(SCENARIO_RE.finditer(html))
|
||||
if not matches:
|
||||
@@ -91,11 +91,9 @@ async def bake_scenario_cards(html: str) -> str:
|
||||
state_data = json.loads(scenario["state_json"])
|
||||
|
||||
tmpl = _bake_env.get_template(template_name)
|
||||
# Baking is always in the EN admin context; t and lang are required
|
||||
# by scenario card templates for translated labels.
|
||||
card_html = tmpl.render(
|
||||
scenario=scenario, d=calc_data, s=state_data,
|
||||
lang="en", t=get_translations("en"),
|
||||
lang=lang, t=get_translations(lang),
|
||||
)
|
||||
html = html[:match.start()] + card_html + html[match.end():]
|
||||
|
||||
|
||||
887
padelnomics/src/padelnomics/scripts/seed_content.py
Normal file
887
padelnomics/src/padelnomics/scripts/seed_content.py
Normal file
@@ -0,0 +1,887 @@
|
||||
"""
|
||||
Seed programmatic SEO content: article templates + city data rows.
|
||||
|
||||
Inserts two article templates (EN + DE) and 18 cities × 2 language rows
|
||||
into the database. Optionally runs the full generation pipeline to produce
|
||||
pre-built HTML files and articles rows.
|
||||
|
||||
Usage:
|
||||
# Seed templates + data rows only (then trigger generation in admin):
|
||||
uv run python -m padelnomics.scripts.seed_content
|
||||
|
||||
# Seed + generate articles immediately (all published, backdated 30 days):
|
||||
uv run python -m padelnomics.scripts.seed_content --generate
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
|
||||
|
||||
# =============================================================================
|
||||
# Input schema — shared between EN and DE templates.
|
||||
# Fields whose "name" matches a calculator DEFAULTS key are auto-passed to
|
||||
# the financial model at generation time; all others are display-only.
|
||||
# =============================================================================
|
||||
|
||||
INPUT_SCHEMA = [
|
||||
{"name": "city", "label": "City Name", "field_type": "text", "required": True},
|
||||
{"name": "city_slug", "label": "City Slug", "field_type": "text", "required": True},
|
||||
{"name": "country", "label": "Country Code (DE/US/GB)", "field_type": "text", "required": True},
|
||||
{"name": "country_name", "label": "Country Name", "field_type": "text", "required": True},
|
||||
{"name": "region", "label": "Region (for hub grouping)", "field_type": "text", "required": True},
|
||||
{"name": "language", "label": "Language (en/de)", "field_type": "text", "required": True},
|
||||
{"name": "population", "label": "Population (display)", "field_type": "text", "required": False},
|
||||
{"name": "padel_context", "label": "Padel Market Context (1-2 sentences)", "field_type": "text", "required": True},
|
||||
{"name": "currency_note", "label": "Currency Disclaimer (non-EUR markets)", "field_type": "text", "required": False},
|
||||
# --- Calc overrides (names must match DEFAULTS keys) ---
|
||||
{"name": "venue", "label": "Venue Type (indoor/outdoor)", "field_type": "text", "required": False},
|
||||
{"name": "own", "label": "Ownership (rent/buy)", "field_type": "text", "required": False},
|
||||
{"name": "dblCourts", "label": "Double Courts", "field_type": "number", "required": False},
|
||||
{"name": "sglCourts", "label": "Single Courts", "field_type": "number", "required": False},
|
||||
{"name": "ratePeak", "label": "Peak Rate (EUR/hr)", "field_type": "number", "required": False},
|
||||
{"name": "rateOffPeak", "label": "Off-Peak Rate (EUR/hr)", "field_type": "number", "required": False},
|
||||
{"name": "rentSqm", "label": "Commercial Rent (EUR/sqm/month)", "field_type": "number", "required": False},
|
||||
{"name": "electricity", "label": "Electricity (EUR/month)", "field_type": "number", "required": False},
|
||||
{"name": "heating", "label": "Heating (EUR/month)", "field_type": "number", "required": False},
|
||||
{"name": "staff", "label": "Staff Cost (EUR/month)", "field_type": "number", "required": False},
|
||||
{"name": "permitsCompliance", "label": "Permits & Compliance (EUR)", "field_type": "number", "required": False},
|
||||
{"name": "utilTarget", "label": "Utilisation Target (%)", "field_type": "number", "required": False},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Article body templates (Jinja2 Markdown).
|
||||
# Uses only standard Jinja2 built-in filters — no custom extensions.
|
||||
# [scenario:slug:section] markers are replaced with rendered HTML cards
|
||||
# at generation time by bake_scenario_cards().
|
||||
# =============================================================================
|
||||
|
||||
_EN_BODY = """\
|
||||
{{ padel_context }}
|
||||
|
||||
This analysis models a **{{ dblCourts }}-double + {{ sglCourts }}-single court indoor facility** \
|
||||
using local market data for {{ city }}, {{ country_name }}.
|
||||
{% if currency_note %}
|
||||
> **Note:** {{ currency_note }}
|
||||
{% endif %}
|
||||
## Investment Overview
|
||||
|
||||
[scenario:{{ scenario_slug }}]
|
||||
|
||||
## Capital Expenditure Breakdown
|
||||
|
||||
[scenario:{{ scenario_slug }}:capex]
|
||||
|
||||
Court equipment, installation, and fit-out are the primary cost drivers. \
|
||||
In {{ city }}, commercial property rents at approximately €{{ rentSqm }}/sqm per month. \
|
||||
Permits and regulatory compliance for a {{ country_name }} venue total €{{ permitsCompliance | int }}.
|
||||
|
||||
## Revenue Model & Operating Costs
|
||||
|
||||
[scenario:{{ scenario_slug }}:operating]
|
||||
|
||||
At a peak rate of €{{ ratePeak }}/hour and off-peak at €{{ rateOffPeak }}/hour, \
|
||||
with a {{ utilTarget }}% utilisation target, the model above shows the steady-state monthly \
|
||||
P&L once the initial ramp-up period is complete.
|
||||
|
||||
## 5-Year Cash Flow Projection
|
||||
|
||||
[scenario:{{ scenario_slug }}:cashflow]
|
||||
|
||||
The projection accounts for a 12-month ramp-up curve as the venue builds its customer base. \
|
||||
The cumulative cash flow column shows when the investment turns cash-positive.
|
||||
|
||||
## Returns & Exit Analysis
|
||||
|
||||
[scenario:{{ scenario_slug }}:returns]
|
||||
|
||||
Return metrics reflect a 5-year hold period with exit valued at a multiple of stabilised EBITDA — \
|
||||
a standard assumption in padel and leisure real estate transactions across {{ country_name }}.
|
||||
|
||||
## Key Takeaways for {{ city }}
|
||||
|
||||
- **Commercial rent:** €{{ rentSqm }}/sqm per month — {% if rentSqm | float >= 6 %}above{% elif rentSqm | float <= 3.5 %}below{% else %}near{% endif %} the European average for padel facilities
|
||||
- **Peak court rate:** €{{ ratePeak }}/hour reflects local demand and competitive positioning
|
||||
- **Utilisation target:** {{ utilTarget }}% — achievable for a well-located {{ city }} venue in its second year
|
||||
- **Permit costs:** €{{ permitsCompliance | int }} — representative of the {{ country_name }} regulatory environment
|
||||
|
||||
## Model Your Own {{ city }} Scenario
|
||||
|
||||
Every market is different. Use the free Padelnomics financial planner to adjust court count, \
|
||||
pricing, rent, financing terms, and see how the numbers change in real time.\
|
||||
"""
|
||||
|
||||
_DE_BODY = """\
|
||||
{{ padel_context }}
|
||||
|
||||
Diese Analyse modelliert eine **{{ dblCourts }} Doppel- + {{ sglCourts }} Einzelcourt-Innenhalle** \
|
||||
auf Basis lokaler Marktdaten für {{ city }}, {{ country_name }}.
|
||||
{% if currency_note %}
|
||||
> **Hinweis:** {{ currency_note }}
|
||||
{% endif %}
|
||||
## Investitionsübersicht
|
||||
|
||||
[scenario:{{ scenario_slug }}]
|
||||
|
||||
## Investitionskosten (CAPEX)
|
||||
|
||||
[scenario:{{ scenario_slug }}:capex]
|
||||
|
||||
Courtausstattung, Installation und Innenausbau sind die größten Kostentreiber. \
|
||||
In {{ city }} liegen Gewerbeimmobilien bei ca. €{{ rentSqm }}/m² pro Monat. \
|
||||
Genehmigungen und Compliance für eine {{ country_name }}er Anlage belaufen sich auf €{{ permitsCompliance | int }}.
|
||||
|
||||
## Umsatzmodell & Betriebskosten
|
||||
|
||||
[scenario:{{ scenario_slug }}:operating]
|
||||
|
||||
Bei einem Peak-Preis von €{{ ratePeak }}/Stunde und Off-Peak von €{{ rateOffPeak }}/Stunde \
|
||||
sowie einem Auslastungsziel von {{ utilTarget }}% zeigt das Modell die monatliche GuV \
|
||||
nach Abschluss der Anlaufphase.
|
||||
|
||||
## 5-Jahres-Cashflow-Projektion
|
||||
|
||||
[scenario:{{ scenario_slug }}:cashflow]
|
||||
|
||||
Die Projektion berücksichtigt eine 12-monatige Anlaufkurve, während die Anlage ihren Kundenstamm aufbaut. \
|
||||
Die kumulative Cashflow-Spalte zeigt, wann die Investition den Break-even erreicht.
|
||||
|
||||
## Rendite- & Exit-Analyse
|
||||
|
||||
[scenario:{{ scenario_slug }}:returns]
|
||||
|
||||
Die Renditekennzahlen basieren auf einem 5-Jahres-Haltezeitraum, wobei der Exit auf einem Vielfachen \
|
||||
des stabilisierten EBITDA bewertet wird — eine gängige Annahme bei Padel- und Freizeitimmobilien in {{ country_name }}.
|
||||
|
||||
## Fazit für {{ city }}
|
||||
|
||||
- **Gewerbemiete:** €{{ rentSqm }}/m²/Monat — {% if rentSqm | float >= 6 %}über{% elif rentSqm | float <= 3.5 %}unter{% else %}um{% endif %} dem europäischen Durchschnitt für Padelanlagen
|
||||
- **Peak-Courtpreis:** €{{ ratePeak }}/Stunde entspricht der lokalen Nachfrage und Marktpositionierung
|
||||
- **Auslastungsziel:** {{ utilTarget }}% — erreichbar für eine gut gelegene {{ city }}er Anlage im zweiten Betriebsjahr
|
||||
- **Genehmigungskosten:** €{{ permitsCompliance | int }} — repräsentativ für das {{ country_name }}e Regulierungsumfeld
|
||||
|
||||
## Ihr eigenes {{ city }}-Szenario modellieren
|
||||
|
||||
Jeder Markt ist anders. Nutzen Sie den kostenlosen Padelnomics Finanzplaner, um Courtanzahl, \
|
||||
Preise, Miete und Finanzierungskonditionen anzupassen und die Zahlen in Echtzeit zu sehen.\
|
||||
"""
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"name": "City Padel Cost Analysis (EN)",
|
||||
"slug": "city-padel-cost-en",
|
||||
"content_type": "calculator",
|
||||
"input_schema": json.dumps(INPUT_SCHEMA),
|
||||
"url_pattern": "/padel-cost/{{ city_slug }}",
|
||||
"title_pattern": "Padel Court Cost in {{ city }}, {{ country_name }} — Investment Analysis 2026",
|
||||
"meta_description_pattern": (
|
||||
"How much does it cost to open a padel center in {{ city }}? "
|
||||
"Full investment breakdown: CAPEX, revenue model, and ROI analysis "
|
||||
"for a {{ dblCourts }}+{{ sglCourts }} court indoor venue."
|
||||
),
|
||||
"body_template": _EN_BODY,
|
||||
},
|
||||
{
|
||||
"name": "City Padel Cost Analysis (DE)",
|
||||
"slug": "city-padel-cost-de",
|
||||
"content_type": "calculator",
|
||||
"input_schema": json.dumps(INPUT_SCHEMA),
|
||||
"url_pattern": "/padel-kosten/{{ city_slug }}",
|
||||
"title_pattern": "Padelhalle Kosten {{ city }}, {{ country_name }} — Investitionsanalyse 2026",
|
||||
"meta_description_pattern": (
|
||||
"Was kostet es, eine Padelhalle in {{ city }} zu eröffnen? "
|
||||
"Vollständige Investitionsanalyse: CAPEX, Umsatzmodell und Rendite "
|
||||
"für eine {{ dblCourts }}+{{ sglCourts }} Court Innenhalle."
|
||||
),
|
||||
"body_template": _DE_BODY,
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# City data.
|
||||
# Each entry generates two template_data rows — one per template (EN + DE).
|
||||
# Fields matching calculator DEFAULTS keys become calc overrides at generation.
|
||||
# =============================================================================
|
||||
|
||||
# Currency note used for all non-EUR markets
|
||||
_EUR_NOTE_EN = (
|
||||
"All projections are modelled in EUR for cross-market comparability. "
|
||||
"Use the planner to adjust inputs for local currency costs."
|
||||
)
|
||||
_EUR_NOTE_DE = (
|
||||
"Alle Projektionen sind in EUR für den Marktvergleich dargestellt. "
|
||||
"Nutzen Sie den Planer, um Eingaben auf lokale Währungskosten anzupassen."
|
||||
)
|
||||
|
||||
CITIES = [
|
||||
# ------------------------------------------------------------------ Germany
|
||||
{
|
||||
"city": "Berlin",
|
||||
"city_slug_en": "berlin",
|
||||
"city_slug_de": "berlin",
|
||||
"country": "DE",
|
||||
"country_name_en": "Germany",
|
||||
"country_name_de": "Deutschland",
|
||||
"region": "Europe",
|
||||
"population": "3.6M",
|
||||
"padel_context_en": (
|
||||
"Berlin is Germany's leading padel market, with growing demand driven by "
|
||||
"a young, sports-active population and a dense network of sports facilities. "
|
||||
"The city's large commercial real estate stock keeps rents moderate relative "
|
||||
"to Munich or Frankfurt."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Berlin ist Deutschlands führender Padelmarkt mit wachsender Nachfrage, "
|
||||
"getrieben von einer jungen, sportbegeisterten Bevölkerung und einem dichten "
|
||||
"Netz an Sportanlagen. Das große Gewerbeimmobilienangebot hält die Mieten "
|
||||
"moderat im Vergleich zu München oder Frankfurt."
|
||||
),
|
||||
"currency_note_en": "",
|
||||
"currency_note_de": "",
|
||||
"dblCourts": 4, "sglCourts": 2,
|
||||
"ratePeak": 50, "rateOffPeak": 35,
|
||||
"rentSqm": 5, "electricity": 650, "heating": 450,
|
||||
"staff": 2000, "permitsCompliance": 12000, "utilTarget": 40,
|
||||
},
|
||||
{
|
||||
"city": "Munich",
|
||||
"city_slug_en": "munich",
|
||||
"city_slug_de": "muenchen",
|
||||
"country": "DE",
|
||||
"country_name_en": "Germany",
|
||||
"country_name_de": "Deutschland",
|
||||
"region": "Europe",
|
||||
"population": "1.5M",
|
||||
"padel_context_en": (
|
||||
"Munich is Germany's highest-spending padel market. Its affluent, "
|
||||
"sports-oriented population supports premium court rates, and the city "
|
||||
"already hosts over a dozen padel facilities with strong year-round demand."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"München ist Deutschlands umsatzstärkster Padelmarkt. Die kaufkräftige, "
|
||||
"sportbegeisterte Bevölkerung unterstützt Premium-Courtpreise, und die "
|
||||
"Stadt beherbergt bereits mehr als ein Dutzend Padelanlagen mit starker "
|
||||
"ganzjähriger Nachfrage."
|
||||
),
|
||||
"currency_note_en": "",
|
||||
"currency_note_de": "",
|
||||
"dblCourts": 4, "sglCourts": 2,
|
||||
"ratePeak": 60, "rateOffPeak": 42,
|
||||
"rentSqm": 7, "electricity": 750, "heating": 500,
|
||||
"staff": 2500, "permitsCompliance": 15000, "utilTarget": 45,
|
||||
},
|
||||
{
|
||||
"city": "Hamburg",
|
||||
"city_slug_en": "hamburg",
|
||||
"city_slug_de": "hamburg",
|
||||
"country": "DE",
|
||||
"country_name_en": "Germany",
|
||||
"country_name_de": "Deutschland",
|
||||
"region": "Europe",
|
||||
"population": "1.9M",
|
||||
"padel_context_en": (
|
||||
"Hamburg has seen rapid padel growth, fuelled by its large expatriate "
|
||||
"community and proximity to the Scandinavian padel tradition. "
|
||||
"Commercial rents in suitable industrial-conversion zones remain attractive."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Hamburg verzeichnet ein rasantes Padelwachstum, angetrieben durch seine "
|
||||
"große Expat-Gemeinde und die Nähe zur skandinavischen Padeltradition. "
|
||||
"Gewerbeimmobilienmieten in geeigneten Industriekonversionszonen "
|
||||
"bleiben attraktiv."
|
||||
),
|
||||
"currency_note_en": "",
|
||||
"currency_note_de": "",
|
||||
"dblCourts": 4, "sglCourts": 2,
|
||||
"ratePeak": 55, "rateOffPeak": 38,
|
||||
"rentSqm": 6, "electricity": 700, "heating": 500,
|
||||
"staff": 2200, "permitsCompliance": 13000, "utilTarget": 42,
|
||||
},
|
||||
{
|
||||
"city": "Frankfurt",
|
||||
"city_slug_en": "frankfurt",
|
||||
"city_slug_de": "frankfurt",
|
||||
"country": "DE",
|
||||
"country_name_en": "Germany",
|
||||
"country_name_de": "Deutschland",
|
||||
"region": "Europe",
|
||||
"population": "760K",
|
||||
"padel_context_en": (
|
||||
"Frankfurt's financial sector workforce and high disposable incomes make it "
|
||||
"a natural fit for padel. The city's compact size means a smaller but highly "
|
||||
"engaged target market with premium spending power."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Frankfurts Finanzsektor-Belegschaft und hohes verfügbares Einkommen machen "
|
||||
"die Stadt zu einem natürlichen Padelstandort. Die kompakte Größe der Stadt "
|
||||
"bedeutet einen kleineren, aber hochengagierten Zielmarkt mit Premium-Kaufkraft."
|
||||
),
|
||||
"currency_note_en": "",
|
||||
"currency_note_de": "",
|
||||
"dblCourts": 4, "sglCourts": 1,
|
||||
"ratePeak": 58, "rateOffPeak": 40,
|
||||
"rentSqm": 7, "electricity": 700, "heating": 480,
|
||||
"staff": 2400, "permitsCompliance": 14000, "utilTarget": 43,
|
||||
},
|
||||
{
|
||||
"city": "Cologne",
|
||||
"city_slug_en": "cologne",
|
||||
"city_slug_de": "koeln",
|
||||
"country": "DE",
|
||||
"country_name_en": "Germany",
|
||||
"country_name_de": "Deutschland",
|
||||
"region": "Europe",
|
||||
"population": "1.1M",
|
||||
"padel_context_en": (
|
||||
"Cologne is an emerging padel market with several new clubs opening "
|
||||
"in the past two years. The city's large student population and active "
|
||||
"sports culture create a broad potential customer base."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Köln ist ein aufstrebender Padelmarkt, in dem in den letzten zwei Jahren "
|
||||
"mehrere neue Clubs eröffnet haben. Die große Studentenschaft und aktive "
|
||||
"Sportkultur der Stadt schaffen eine breite potenzielle Kundenbasis."
|
||||
),
|
||||
"currency_note_en": "",
|
||||
"currency_note_de": "",
|
||||
"dblCourts": 4, "sglCourts": 2,
|
||||
"ratePeak": 48, "rateOffPeak": 34,
|
||||
"rentSqm": 4.5, "electricity": 620, "heating": 420,
|
||||
"staff": 1800, "permitsCompliance": 11000, "utilTarget": 38,
|
||||
},
|
||||
{
|
||||
"city": "Düsseldorf",
|
||||
"city_slug_en": "dusseldorf",
|
||||
"city_slug_de": "duesseldorf",
|
||||
"country": "DE",
|
||||
"country_name_en": "Germany",
|
||||
"country_name_de": "Deutschland",
|
||||
"region": "Europe",
|
||||
"population": "640K",
|
||||
"padel_context_en": (
|
||||
"Düsseldorf's fashion and business community has embraced padel as a "
|
||||
"networking sport. Several corporate-backed clubs are already operating, "
|
||||
"indicating strong B2B membership potential."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Düsseldorfs Mode- und Geschäftswelt hat Padel als Networking-Sport angenommen. "
|
||||
"Mehrere unternehmensgestützte Clubs sind bereits in Betrieb, "
|
||||
"was auf ein starkes B2B-Mitgliedschaftspotenzial hinweist."
|
||||
),
|
||||
"currency_note_en": "",
|
||||
"currency_note_de": "",
|
||||
"dblCourts": 3, "sglCourts": 2,
|
||||
"ratePeak": 52, "rateOffPeak": 36,
|
||||
"rentSqm": 5.5, "electricity": 660, "heating": 440,
|
||||
"staff": 2100, "permitsCompliance": 12000, "utilTarget": 40,
|
||||
},
|
||||
{
|
||||
"city": "Stuttgart",
|
||||
"city_slug_en": "stuttgart",
|
||||
"city_slug_de": "stuttgart",
|
||||
"country": "DE",
|
||||
"country_name_en": "Germany",
|
||||
"country_name_de": "Deutschland",
|
||||
"region": "Europe",
|
||||
"population": "630K",
|
||||
"padel_context_en": (
|
||||
"Stuttgart's engineering and automotive workforce has strong disposable "
|
||||
"income and a culture of team sports. The city's hilly terrain limits "
|
||||
"outdoor options, making indoor padel particularly attractive."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Stuttgarts Ingenieur- und Automobilbelegschaft verfügt über hohes "
|
||||
"verfügbares Einkommen und eine Kultur des Teamsports. Die hügelige "
|
||||
"Topografie der Stadt schränkt Outdoor-Optionen ein und macht Indoor-Padel "
|
||||
"besonders attraktiv."
|
||||
),
|
||||
"currency_note_en": "",
|
||||
"currency_note_de": "",
|
||||
"dblCourts": 4, "sglCourts": 1,
|
||||
"ratePeak": 55, "rateOffPeak": 38,
|
||||
"rentSqm": 6, "electricity": 680, "heating": 460,
|
||||
"staff": 2300, "permitsCompliance": 13000, "utilTarget": 42,
|
||||
},
|
||||
{
|
||||
"city": "Leipzig",
|
||||
"city_slug_en": "leipzig",
|
||||
"city_slug_de": "leipzig",
|
||||
"country": "DE",
|
||||
"country_name_en": "Germany",
|
||||
"country_name_de": "Deutschland",
|
||||
"region": "Europe",
|
||||
"population": "600K",
|
||||
"padel_context_en": (
|
||||
"Leipzig offers the most cost-effective padel entry point in Germany. "
|
||||
"Low commercial rents and a growing young professional population make it "
|
||||
"attractive for first-mover operators willing to build the market."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Leipzig bietet den kosteneffizientesten Padeleinstieg in Deutschland. "
|
||||
"Niedrige Gewerbemieten und eine wachsende junge Berufsschicht machen es "
|
||||
"attraktiv für Erstanbieter, die bereit sind, den Markt aufzubauen."
|
||||
),
|
||||
"currency_note_en": "",
|
||||
"currency_note_de": "",
|
||||
"dblCourts": 3, "sglCourts": 2,
|
||||
"ratePeak": 42, "rateOffPeak": 30,
|
||||
"rentSqm": 3.5, "electricity": 550, "heating": 400,
|
||||
"staff": 1500, "permitsCompliance": 10000, "utilTarget": 35,
|
||||
},
|
||||
# ------------------------------------------------------------------ USA
|
||||
{
|
||||
"city": "Miami",
|
||||
"city_slug_en": "miami",
|
||||
"city_slug_de": "miami",
|
||||
"country": "US",
|
||||
"country_name_en": "USA",
|
||||
"country_name_de": "USA",
|
||||
"region": "North America",
|
||||
"population": "440K (metro 6.1M)",
|
||||
"padel_context_en": (
|
||||
"Miami leads the US padel boom, fuelled by its large Latin American community "
|
||||
"and year-round warm weather. The city has more than 30 operating courts "
|
||||
"and demand continues to outstrip supply."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Miami führt den US-Padelboom an, angetrieben durch seine große "
|
||||
"lateinamerikanische Gemeinschaft und das ganzjährig warme Wetter. "
|
||||
"Die Stadt hat mehr als 30 aktive Courts und die Nachfrage übersteigt "
|
||||
"weiterhin das Angebot."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 5, "sglCourts": 2,
|
||||
"ratePeak": 70, "rateOffPeak": 50,
|
||||
"rentSqm": 3.5, "electricity": 800, "heating": 100,
|
||||
"staff": 3500, "permitsCompliance": 20000, "utilTarget": 45,
|
||||
},
|
||||
{
|
||||
"city": "New York",
|
||||
"city_slug_en": "new-york",
|
||||
"city_slug_de": "new-york",
|
||||
"country": "US",
|
||||
"country_name_en": "USA",
|
||||
"country_name_de": "USA",
|
||||
"region": "North America",
|
||||
"population": "8.3M",
|
||||
"padel_context_en": (
|
||||
"New York City is the highest-cost padel market in North America, with premium "
|
||||
"rates justified by extreme demand. Early movers are achieving 80%+ utilisation, "
|
||||
"driving strong investor interest in new capacity."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"New York City ist der teuerste Padelmarkt Nordamerikas, mit Premium-Preisen "
|
||||
"gerechtfertigt durch extreme Nachfrage. Frühe Markteinsteiger erzielen über "
|
||||
"80% Auslastung und treiben starkes Investoreninteresse an neuen Kapazitäten."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 4, "sglCourts": 1,
|
||||
"ratePeak": 80, "rateOffPeak": 58,
|
||||
"rentSqm": 8, "electricity": 900, "heating": 600,
|
||||
"staff": 5000, "permitsCompliance": 30000, "utilTarget": 50,
|
||||
},
|
||||
{
|
||||
"city": "Los Angeles",
|
||||
"city_slug_en": "los-angeles",
|
||||
"city_slug_de": "los-angeles",
|
||||
"country": "US",
|
||||
"country_name_en": "USA",
|
||||
"country_name_de": "USA",
|
||||
"region": "North America",
|
||||
"population": "3.9M",
|
||||
"padel_context_en": (
|
||||
"Los Angeles combines a large Hispanic community, a wellness-oriented culture, "
|
||||
"and year-round outdoor lifestyle to create one of the fastest-growing padel "
|
||||
"markets in the country. Several celebrity-backed clubs have boosted visibility."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Los Angeles kombiniert eine große hispanische Gemeinschaft, eine "
|
||||
"wellnessorientierte Kultur und einen ganzjährigen Outdoor-Lebensstil zu einem "
|
||||
"der am schnellsten wachsenden Padelmärkte des Landes. "
|
||||
"Mehrere prominentengestützte Clubs haben die Bekanntheit gesteigert."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 5, "sglCourts": 2,
|
||||
"ratePeak": 75, "rateOffPeak": 55,
|
||||
"rentSqm": 5, "electricity": 750, "heating": 150,
|
||||
"staff": 4000, "permitsCompliance": 25000, "utilTarget": 48,
|
||||
},
|
||||
{
|
||||
"city": "Dallas",
|
||||
"city_slug_en": "dallas",
|
||||
"city_slug_de": "dallas",
|
||||
"country": "US",
|
||||
"country_name_en": "USA",
|
||||
"country_name_de": "USA",
|
||||
"region": "North America",
|
||||
"population": "1.3M",
|
||||
"padel_context_en": (
|
||||
"Dallas offers one of the most affordable commercial real estate environments "
|
||||
"for padel in the US, combined with a growing Hispanic population and strong "
|
||||
"sports culture. Several clubs are already operational in the metro area."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Dallas bietet eines der günstigsten Gewerbeimmobilienumfelder für Padel "
|
||||
"in den USA, kombiniert mit einer wachsenden hispanischen Bevölkerung und "
|
||||
"starker Sportkultur. Mehrere Clubs sind bereits in der Metropolregion aktiv."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 4, "sglCourts": 2,
|
||||
"ratePeak": 55, "rateOffPeak": 40,
|
||||
"rentSqm": 3, "electricity": 700, "heating": 200,
|
||||
"staff": 2800, "permitsCompliance": 15000, "utilTarget": 40,
|
||||
},
|
||||
{
|
||||
"city": "Austin",
|
||||
"city_slug_en": "austin",
|
||||
"city_slug_de": "austin",
|
||||
"country": "US",
|
||||
"country_name_en": "USA",
|
||||
"country_name_de": "USA",
|
||||
"region": "North America",
|
||||
"population": "960K",
|
||||
"padel_context_en": (
|
||||
"Austin's tech-affluent, fitness-obsessed demographic is an ideal fit "
|
||||
"for padel. The city's rapid population growth and lack of established "
|
||||
"padel venues creates a significant first-mover opportunity."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Austins technikaffine, fitnessbegeisterte Bevölkerung passt ideal zu Padel. "
|
||||
"Das schnelle Bevölkerungswachstum und die fehlenden etablierten Padelanlagen "
|
||||
"schaffen eine erhebliche First-Mover-Chance."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 4, "sglCourts": 2,
|
||||
"ratePeak": 55, "rateOffPeak": 38,
|
||||
"rentSqm": 3, "electricity": 700, "heating": 150,
|
||||
"staff": 2500, "permitsCompliance": 12000, "utilTarget": 42,
|
||||
},
|
||||
{
|
||||
"city": "Chicago",
|
||||
"city_slug_en": "chicago",
|
||||
"city_slug_de": "chicago",
|
||||
"country": "US",
|
||||
"country_name_en": "USA",
|
||||
"country_name_de": "USA",
|
||||
"region": "North America",
|
||||
"population": "2.7M",
|
||||
"padel_context_en": (
|
||||
"Chicago's large Hispanic community and established racquet sports culture "
|
||||
"make it a natural padel market. The harsh winters drive demand for "
|
||||
"indoor facilities, reducing the outdoor competition that affects "
|
||||
"warmer-climate markets."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Chicagos große hispanische Gemeinschaft und etablierte Schlägertsportkultur "
|
||||
"machen es zu einem natürlichen Padelmarkt. Die harten Winter treiben die "
|
||||
"Nachfrage nach Innenhallen und reduzieren den Outdoor-Wettbewerb, "
|
||||
"der wärmere Märkte beeinflusst."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 4, "sglCourts": 2,
|
||||
"ratePeak": 60, "rateOffPeak": 42,
|
||||
"rentSqm": 4, "electricity": 750, "heating": 550,
|
||||
"staff": 3200, "permitsCompliance": 18000, "utilTarget": 38,
|
||||
},
|
||||
# ------------------------------------------------------------------ UK
|
||||
{
|
||||
"city": "London",
|
||||
"city_slug_en": "london",
|
||||
"city_slug_de": "london",
|
||||
"country": "GB",
|
||||
"country_name_en": "UK",
|
||||
"country_name_de": "Großbritannien",
|
||||
"region": "Europe",
|
||||
"population": "8.8M",
|
||||
"padel_context_en": (
|
||||
"London is the UK's most established padel market, with premium clubs "
|
||||
"operating in affluent areas. High commercial rents are offset by "
|
||||
"the city's exceptional spending power and the sport's growing social cachet."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"London ist der etablierteste Padelmarkt Großbritanniens, mit Premium-Clubs "
|
||||
"in wohlhabenden Stadtteilen. Hohe Gewerbemieten werden durch die "
|
||||
"außergewöhnliche Kaufkraft der Stadt und den wachsenden sozialen Status "
|
||||
"des Sports ausgeglichen."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 4, "sglCourts": 2,
|
||||
"ratePeak": 65, "rateOffPeak": 45,
|
||||
"rentSqm": 7, "electricity": 800, "heating": 500,
|
||||
"staff": 3500, "permitsCompliance": 15000, "utilTarget": 45,
|
||||
},
|
||||
{
|
||||
"city": "Manchester",
|
||||
"city_slug_en": "manchester",
|
||||
"city_slug_de": "manchester",
|
||||
"country": "GB",
|
||||
"country_name_en": "UK",
|
||||
"country_name_de": "Großbritannien",
|
||||
"region": "Europe",
|
||||
"population": "550K (metro 2.8M)",
|
||||
"padel_context_en": (
|
||||
"Manchester's strong sports culture, large student population, and "
|
||||
"lower commercial rents versus London make it an attractive second city "
|
||||
"for padel investment, with several clubs already gaining strong membership."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Manchesters starke Sportkultur, große Studentenschaft und niedrigere "
|
||||
"Gewerbemieten gegenüber London machen es zu einer attraktiven zweiten "
|
||||
"Stadt für Padelinvestitionen, mit mehreren Clubs, die bereits starke "
|
||||
"Mitgliederzahlen aufbauen."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 4, "sglCourts": 2,
|
||||
"ratePeak": 50, "rateOffPeak": 35,
|
||||
"rentSqm": 4.5, "electricity": 650, "heating": 450,
|
||||
"staff": 2200, "permitsCompliance": 10000, "utilTarget": 40,
|
||||
},
|
||||
{
|
||||
"city": "Edinburgh",
|
||||
"city_slug_en": "edinburgh",
|
||||
"city_slug_de": "edinburgh",
|
||||
"country": "GB",
|
||||
"country_name_en": "UK",
|
||||
"country_name_de": "Großbritannien",
|
||||
"region": "Europe",
|
||||
"population": "540K",
|
||||
"padel_context_en": (
|
||||
"Edinburgh is an early-stage padel market with significant first-mover "
|
||||
"potential. Its affluent professional population, university community, "
|
||||
"and lack of existing padel supply create a compelling entry opportunity."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Edinburgh ist ein Frühphasen-Padelmarkt mit erheblichem First-Mover-Potenzial. "
|
||||
"Die wohlhabende Berufsschicht, Universitätsgemeinde und fehlende bestehende "
|
||||
"Padeliversorgung schaffen eine überzeugende Einstiegsmöglichkeit."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 3, "sglCourts": 2,
|
||||
"ratePeak": 48, "rateOffPeak": 33,
|
||||
"rentSqm": 4, "electricity": 620, "heating": 480,
|
||||
"staff": 2000, "permitsCompliance": 9000, "utilTarget": 38,
|
||||
},
|
||||
{
|
||||
"city": "Birmingham",
|
||||
"city_slug_en": "birmingham",
|
||||
"city_slug_de": "birmingham",
|
||||
"country": "GB",
|
||||
"country_name_en": "UK",
|
||||
"country_name_de": "Großbritannien",
|
||||
"region": "Europe",
|
||||
"population": "1.1M",
|
||||
"padel_context_en": (
|
||||
"Birmingham's large and diverse population, strong sports heritage, "
|
||||
"and relatively affordable commercial property make it one of the UK's "
|
||||
"most promising regional padel markets outside London."
|
||||
),
|
||||
"padel_context_de": (
|
||||
"Birminghams große und vielfältige Bevölkerung, starkes Sporterbe und "
|
||||
"relativ erschwingliche Gewerbeimmobilien machen es zu einem der "
|
||||
"vielversprechendsten regionalen Padelmärkte Großbritanniens außerhalb Londons."
|
||||
),
|
||||
"currency_note_en": _EUR_NOTE_EN,
|
||||
"currency_note_de": _EUR_NOTE_DE,
|
||||
"dblCourts": 4, "sglCourts": 1,
|
||||
"ratePeak": 48, "rateOffPeak": 34,
|
||||
"rentSqm": 4, "electricity": 630, "heating": 440,
|
||||
"staff": 2100, "permitsCompliance": 10000, "utilTarget": 38,
|
||||
},
|
||||
]
|
||||
|
||||
# Calc fields that get passed to the financial model (must match DEFAULTS keys)
|
||||
_CALC_FIELDS = {
|
||||
"venue", "own", "country",
|
||||
"dblCourts", "sglCourts",
|
||||
"ratePeak", "rateOffPeak",
|
||||
"rentSqm", "electricity", "heating", "staff",
|
||||
"permitsCompliance", "utilTarget",
|
||||
}
|
||||
|
||||
|
||||
def _build_row(city: dict, lang: str) -> dict:
|
||||
"""Build a template_data data_json dict for one city + language."""
|
||||
is_de = lang == "de"
|
||||
row = {
|
||||
"city": city["city"],
|
||||
"city_slug": city["city_slug_de"] if is_de else city["city_slug_en"],
|
||||
"country": city["country"],
|
||||
"country_name": city["country_name_de"] if is_de else city["country_name_en"],
|
||||
"region": city["region"],
|
||||
"language": lang,
|
||||
"population": city.get("population", ""),
|
||||
"padel_context": city["padel_context_de"] if is_de else city["padel_context_en"],
|
||||
"currency_note": city.get("currency_note_de" if is_de else "currency_note_en", ""),
|
||||
"venue": "indoor",
|
||||
"own": "rent",
|
||||
"dblCourts": city["dblCourts"],
|
||||
"sglCourts": city["sglCourts"],
|
||||
"ratePeak": city["ratePeak"],
|
||||
"rateOffPeak": city["rateOffPeak"],
|
||||
"rentSqm": city["rentSqm"],
|
||||
"electricity": city["electricity"],
|
||||
"heating": city["heating"],
|
||||
"staff": city["staff"],
|
||||
"permitsCompliance": city["permitsCompliance"],
|
||||
"utilTarget": city["utilTarget"],
|
||||
}
|
||||
return row
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Seed logic (synchronous sqlite3)
|
||||
# =============================================================================
|
||||
|
||||
def seed_templates(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
"""Insert article templates if they don't exist. Returns {slug: id} map."""
|
||||
template_ids: dict[str, int] = {}
|
||||
|
||||
for tmpl in TEMPLATES:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM article_templates WHERE slug = ?", (tmpl["slug"],)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
print(f" Template '{tmpl['slug']}' already exists (id={existing[0]}), skipping.")
|
||||
template_ids[tmpl["slug"]] = existing[0]
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO article_templates
|
||||
(name, slug, content_type, input_schema,
|
||||
url_pattern, title_pattern, meta_description_pattern, body_template)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
tmpl["name"],
|
||||
tmpl["slug"],
|
||||
tmpl["content_type"],
|
||||
tmpl["input_schema"],
|
||||
tmpl["url_pattern"],
|
||||
tmpl["title_pattern"],
|
||||
tmpl["meta_description_pattern"],
|
||||
tmpl["body_template"],
|
||||
),
|
||||
)
|
||||
template_ids[tmpl["slug"]] = cur.lastrowid
|
||||
print(f" Created template '{tmpl['slug']}' (id={cur.lastrowid})")
|
||||
|
||||
return template_ids
|
||||
|
||||
|
||||
def seed_data_rows(conn: sqlite3.Connection, template_ids: dict[str, int]) -> int:
|
||||
"""Insert template_data rows for all cities × languages. Returns count inserted."""
|
||||
now = __import__("datetime").datetime.utcnow().isoformat()
|
||||
inserted = 0
|
||||
|
||||
en_id = template_ids.get("city-padel-cost-en")
|
||||
de_id = template_ids.get("city-padel-cost-de")
|
||||
|
||||
for city in CITIES:
|
||||
for lang, tmpl_id in [("en", en_id), ("de", de_id)]:
|
||||
row_data = _build_row(city, lang)
|
||||
data_json = json.dumps(row_data)
|
||||
city_slug = row_data["city_slug"]
|
||||
|
||||
# Check for existing row by matching city_slug inside data_json
|
||||
existing = conn.execute(
|
||||
"""SELECT id FROM template_data
|
||||
WHERE template_id = ?
|
||||
AND json_extract(data_json, '$.city_slug') = ?""",
|
||||
(tmpl_id, city_slug),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
print(f" Data row '{city_slug}' ({lang}) already exists, skipping.")
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO template_data (template_id, data_json, created_at)
|
||||
VALUES (?, ?, ?)""",
|
||||
(tmpl_id, data_json, now),
|
||||
)
|
||||
inserted += 1
|
||||
print(f" Inserted data row '{city_slug}' ({lang})")
|
||||
|
||||
return inserted
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Async generation (bootstraps the DB layer without full Quart app context)
|
||||
# =============================================================================
|
||||
|
||||
async def generate_articles(template_ids: dict[str, int]) -> None:
|
||||
from padelnomics.admin.routes import _generate_from_template # noqa: PLC0415
|
||||
from padelnomics.core import close_db, fetch_one, init_db
|
||||
|
||||
print("\nInitialising database connection...")
|
||||
await init_db(DATABASE_PATH)
|
||||
|
||||
start_date = date.today() - timedelta(days=30) # backdate so all are immediately live
|
||||
|
||||
for slug, tmpl_id in template_ids.items():
|
||||
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (tmpl_id,))
|
||||
assert template is not None, f"Template '{slug}' not found in DB"
|
||||
|
||||
print(f"\nGenerating articles for template '{slug}'...")
|
||||
count = await _generate_from_template(template, start_date, articles_per_day=3)
|
||||
print(f" Generated {count} articles.")
|
||||
|
||||
await close_db()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Entry point
|
||||
# =============================================================================
|
||||
|
||||
def main() -> None:
|
||||
assert Path(DATABASE_PATH).exists(), (
|
||||
f"Database not found at {DATABASE_PATH!r}. "
|
||||
"Run migrations first: uv run python -m padelnomics.migrations"
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
print("Seeding article templates...")
|
||||
template_ids = seed_templates(conn)
|
||||
|
||||
print("\nSeeding city data rows...")
|
||||
inserted = seed_data_rows(conn, template_ids)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"\nDone. {inserted} data rows inserted.")
|
||||
print("Templates and data rows are visible in admin → Templates.")
|
||||
|
||||
if "--generate" in sys.argv:
|
||||
print("\nRunning article generation pipeline...")
|
||||
asyncio.run(generate_articles(template_ids))
|
||||
print("\nGeneration complete. Check admin → Articles.")
|
||||
else:
|
||||
print(
|
||||
"\nTo generate articles, either:\n"
|
||||
" 1. Run: uv run python -m padelnomics.scripts.seed_content --generate\n"
|
||||
" 2. Or visit admin → Templates → (template) → Generate"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user