Compare commits
55 Commits
v202603051
...
v202603071
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e537bfd9d3 | ||
|
|
a27da79705 | ||
|
|
8d86669360 | ||
|
|
7d523250f7 | ||
|
|
fee0d6913b | ||
|
|
71e08a5fa6 | ||
|
|
27e86db6a1 | ||
|
|
90754b8d9f | ||
|
|
277c92e507 | ||
|
|
77ec3a289f | ||
|
|
f81d5f19da | ||
|
|
4d29ecf1d6 | ||
|
|
a3b4e1fab6 | ||
|
|
8b794d24a6 | ||
|
|
688f2dd1ee | ||
|
|
81b556b205 | ||
|
|
cda94c9ee4 | ||
|
|
4fbd91b59b | ||
|
|
159d1b5b9a | ||
|
|
fcd0c9b007 | ||
|
|
f841ae105a | ||
|
|
dec4f07fbb | ||
|
|
4e4ff61699 | ||
|
|
f907f2cd60 | ||
|
|
3ad2885c84 | ||
|
|
e2f54552b0 | ||
|
|
07ca1ce15b | ||
|
|
be9b10c13f | ||
|
|
82d6333517 | ||
|
|
ed48936dad | ||
|
|
e3bda5b816 | ||
|
|
831233cb29 | ||
|
|
c5327c4012 | ||
|
|
4426ab2cb6 | ||
|
|
93c9408f6b | ||
|
|
84128a3a64 | ||
|
|
e9b4faa05c | ||
|
|
a834bb481d | ||
|
|
9515ec8ae9 | ||
|
|
fb99d6e0db | ||
|
|
4ee80603ef | ||
|
|
2e42245ad5 | ||
|
|
2f47d1e589 | ||
|
|
ead12c4552 | ||
|
|
c54eb50004 | ||
|
|
5d7fcec17a | ||
|
|
f7faf7ab57 | ||
|
|
add5f8ddfa | ||
|
|
15ca316682 | ||
|
|
103ef73cf5 | ||
|
|
aa27f14f3c | ||
|
|
8205744444 | ||
|
|
1cbefe349c | ||
|
|
003f19e071 | ||
|
|
c3f15535b8 |
@@ -3,6 +3,7 @@ APP_NAME=ENC[AES256_GCM,data:ldJf4P0iD9ziMVg=,iv:hiVl2whhd02yZCafzBfbxX5/EU/suvz
|
||||
SECRET_KEY=ENC[AES256_GCM,data:hmlXm7NKVVFmeea4DnlrH/oSnsoaMAkUz42oWwFXOXL1XwAh3iemIKHUQOV2G4SPlmjfmEVQD64xbxaJW0OcPQ/8KqhrRYDsy0F/u0h7nmNQdwJrcvzcmbvjgcwU5IITPIr23d/W5PeSJzxhB93uaJ0+zFN2CyHfeewrJKafPfw=,iv:e+ZSLUO+dlt+ET8r/0/pf74UtGIBMkaVoJMWlJn1W5U=,tag:LdDCCrHcJnKLkKL/cY/R/Q==,type:str]
|
||||
BASE_URL=ENC[AES256_GCM,data:50k/RqlZ1EHqGM4UkSmTaCsuJgyU4w==,iv:f8zKr2jkts4RsawA97hzICHwj9Quzgp+Dw8AhQ7GSWA=,tag:9KhNvwmoOtDyuIql7okeew==,type:str]
|
||||
DEBUG=ENC[AES256_GCM,data:O0/uRF4=,iv:cZ+vyUuXjQOYYRf4l8lWS3JIWqL/w3pnlCTDPAZpB1E=,tag:OmJE9oJpzYzth0xwaMqADQ==,type:str]
|
||||
LANDING_DIR=ENC[AES256_GCM,data:rn8u+tGob0vU7kSAtxmrpYQlneesvyO10A==,iv:PuGtdcQBdRbnybulzd6L7JVQClcK3/QjMeYFXZSxGW0=,tag:K2PJPMCWXdqTlQpwP9+DOQ==,type:str]
|
||||
#ENC[AES256_GCM,data:xmJc6WTb3yumHzvLeA==,iv:9jKuYaDgm4zR/DTswIMwsajV0s5UTe+AOX4Sue0GPCs=,tag:b/7H9js1HmFYjuQE4zJz8w==,type:comment]
|
||||
ADMIN_EMAILS=ENC[AES256_GCM,data:R/2YTk8KDEpNQ71RN8Fm6miLZvXNJQ==,iv:kzmiaBK7KvnSjR5gx6lp7zEMzs5xRul6LBhmLf48bCU=,tag:csVZ0W1TxBAoJacQurW9VQ==,type:str]
|
||||
#ENC[AES256_GCM,data:S7Pdg9tcom3N,iv:OjmYk3pqbZHKPS1Y06w1y8BE7CU0y6Vx2wnio9tEhus=,tag:YAOGbrHQ+UOcdSQFWdiCDA==,type:comment]
|
||||
@@ -63,7 +64,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
|
||||
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
||||
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
||||
sops_lastmodified=2026-03-01T20:26:09Z
|
||||
sops_mac=ENC[AES256_GCM,data:IxzU6VehA0iHgpIEqDSoMywKyKONI6jSr/6Amo+g3JI72awJtk6ft0ppfDWZjeHhL0ixfnvgqMNwai+1e0V/U8hSP8/FqYKEVpAO0UGJfBPKP3pbw+tx3WJQMF5dIh2/UVNrKvoACZq0IDJfXlVqalCnRMQEHGtKVTIT3fn8m6c=,iv:0w0ohOBsqTzuoQdtt6AI5ZdHEKw9+hI73tycBjDSS0o=,tag:Guw7LweA4m4Nw+3kSuZKWA==,type:str]
|
||||
sops_lastmodified=2026-03-05T15:55:19Z
|
||||
sops_mac=ENC[AES256_GCM,data:orLypjurBTYmk3um0bDQV3wFxj1pjCsjOf2D+AZyoIYY88MeY8BjK8mg8BWhmJYlGWqHH1FCpoJS+2SECv2Bvgejqvx/C/HSysA8et5CArM/p/MBbcupLAKOD8bTXorKMRDYPkWpK/snkPToxIZZd7dNj/zSU+OhRp5qLGCHkvM=,iv:eBn93z4DSk8UPHgP/Jf/Kz+3KwoKIQ9Et72pbLFcLP8=,tag:79kzPIKp0rtHGhH1CkXqwg==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.12.1
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Unified `location_profiles` serving model** — merged `city_market_profile` and `location_opportunity_profile` into a single `serving.location_profiles` table at `(country_code, geoname_id)` grain. Both Marktreife-Score (Market Score) and Marktpotenzial-Score (Opportunity Score) are now computed per location. City data enriched via LEFT JOIN `dim_cities` on `geoname_id`. Downstream models (`planner_defaults`, `pseo_city_costs_de`, `pseo_city_pricing`) updated to query `location_profiles` directly. `city_padel_venue_count` (exact from dim_cities) distinguished from `padel_venue_count` (spatial 5km from dim_locations).
|
||||
- **Both scores on all map tooltips** — country map shows avg Market Score + avg Opportunity Score; city map shows Market Score + Opportunity Score per city; opportunity map shows Opportunity Score + Market Score per location. All score labels use the trademarked "Padelnomics Market Score" / "Padelnomics Opportunity Score" names.
|
||||
- **API endpoints** — `/api/markets/countries.json` adds `avg_opportunity_score`; `/api/markets/<country>/cities.json` adds `opportunity_score`; `/api/opportunity/<country>.json` adds `market_score`.
|
||||
- **Marktpotenzial-Score v3: H3 catchment lens** — addressable market (25pts) and supply gap (30pts) now use a regional H3 catchment (~15-18km radius, res-4 cell + 6 neighbours, ~462km²) instead of local city population and 5km court count. Mid-size cities surrounded by dense Gemeinden (e.g. Oldenburg) now score correctly. New output columns: `catchment_population`, `catchment_padel_courts`, `catchment_venues_per_100k`. Requires one-time `INSTALL h3 FROM community` in DuckDB on each machine.
|
||||
|
||||
### Added
|
||||
- **Custom 404/500 error pages** — styled error pages extending `base.html` with i18n support (EN/DE). The 404 page is context-aware: when the URL matches `/markets/{country}/{city}`, it shows a city-specific message with a link back to the country overview instead of a generic "page not found".
|
||||
- **Map: city article indicators** — country overview map bubbles now differentiate cities with/without published articles. All cities retain score-based colors (green/amber/red); non-article cities are visually receded with lower opacity, dashed borders, desaturated color, and default cursor (no click). Tooltips show scores for all cities — article cities get "Click to explore →", non-article cities get "Coming soon". The `/api/markets/<country>/cities.json` endpoint includes a `has_article` boolean per city.
|
||||
|
||||
### Fixed
|
||||
- **Admin template preview maps** — Leaflet maps rendered blank because `article-maps.js` called `L.divIcon()` at the IIFE top level before Leaflet was dynamically loaded, crashing the script. Moved `VENUE_ICON` creation into the `script.onload` callback so it runs after Leaflet is available. Previous commit's `.card` `overflow: visible` fix remains (clips tile layers otherwise).
|
||||
- **Admin articles page 500** — `/admin/articles` crashed with `BuildError` when an article generation task was running because `article_stats.html` partial referenced `url_for('admin.article_stats')` but the route didn't exist. Added the missing HTMX partial endpoint.
|
||||
- **Silent 500 errors in dev** — `dev_run.sh` used Granian which swallowed Quart's debug error pages, showing generic "Internal Server Error" with no traceback. Switched to `uv run python -m padelnomics.app` for proper debug mode with browser tracebacks. Added `@app.errorhandler(500)` to log exceptions even when running under Granian in production.
|
||||
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — handle DuckDB catalog naming quirk where `lakehouse.duckdb` uses catalog `lakehouse` instead of `local`, causing SQLMesh logical views to break. Script now auto-detects the catalog via `USE`, and falls back to querying physical tables (`sqlmesh__<schema>.<table>__<hash>`) when views fail.
|
||||
- **Eurostat gas prices extractor** — `nrg_pc_203` filter missing `unit` dimension (API returns both KWH and GJ_GCV); now filters to `KWH`.
|
||||
- **Eurostat labour costs extractor** — `lc_lci_lev` used non-existent `currency` filter dimension; corrected to `unit: EUR`.
|
||||
- **Supervisor transform step** — changed `sqlmesh run` to `sqlmesh plan prod --auto-apply` so new/modified models are detected and applied automatically.
|
||||
|
||||
### Added
|
||||
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — read-only script that reports row counts at every layer of the pricing pipeline (staging → foundation → serving), date range analysis, HAVING filter impact, and join coverage. Run on prod to diagnose empty serving tables.
|
||||
- **Extraction card descriptions** — each workflow card on the admin pipeline page now shows a one-line description explaining what the data source is (e.g. "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"). Descriptions defined in `workflows.toml`.
|
||||
|
||||
@@ -63,15 +63,15 @@ DATASETS: dict[str, dict] = {
|
||||
"time_dim": "time",
|
||||
},
|
||||
"nrg_pc_203": {
|
||||
# Gas prices for non-household consumers, EUR/GJ, excl. taxes
|
||||
"filters": {"freq": "S", "nrg_cons": "GJ1000-9999", "currency": "EUR", "tax": "I_TAX"},
|
||||
# Gas prices for non-household consumers, EUR/kWh, excl. taxes
|
||||
"filters": {"freq": "S", "nrg_cons": "GJ1000-9999", "unit": "KWH", "currency": "EUR", "tax": "I_TAX"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
"lc_lci_lev": {
|
||||
# Labour cost levels EUR/hour — NACE N (administrative/support services)
|
||||
# Stored in dim_countries for future staffed-scenario calculations.
|
||||
"filters": {"lcstruct": "D1_D2_A_HW", "nace_r2": "N", "currency": "EUR"},
|
||||
# D1_D4_MD5 = compensation of employees + taxes - subsidies (total labour cost)
|
||||
"filters": {"lcstruct": "D1_D4_MD5", "nace_r2": "N", "unit": "EUR"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
|
||||
@@ -33,10 +33,10 @@ do
|
||||
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
|
||||
uv run --package padelnomics_extract extract
|
||||
|
||||
# Transform
|
||||
# Transform — plan detects new/modified/deleted models and applies changes.
|
||||
LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \
|
||||
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
|
||||
uv run --package sqlmesh_padelnomics sqlmesh run --select-model "serving.*"
|
||||
uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply
|
||||
|
||||
# Export serving tables to analytics.duckdb (atomic swap).
|
||||
# The web app detects the inode change on next query — no restart needed.
|
||||
|
||||
@@ -70,5 +70,5 @@ description = "UK local authority population estimates from ONS"
|
||||
|
||||
[gisco]
|
||||
module = "padelnomics_extract.gisco"
|
||||
schedule = "monthly"
|
||||
schedule = "0 0 1 1 *"
|
||||
description = "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"
|
||||
|
||||
@@ -8,6 +8,10 @@ Or locally:
|
||||
DUCKDB_PATH=data/lakehouse.duckdb uv run python scripts/check_pipeline.py
|
||||
|
||||
Read-only — never writes to the database.
|
||||
|
||||
Handles the DuckDB catalog naming quirk: when the file is named lakehouse.duckdb,
|
||||
the catalog is "lakehouse" not "local". SQLMesh views may reference the wrong catalog,
|
||||
so we fall back to querying physical tables (sqlmesh__<schema>.<table>__<hash>).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -27,6 +31,86 @@ PIPELINE_TABLES = [
|
||||
]
|
||||
|
||||
|
||||
def _use_catalog(con):
|
||||
"""Detect and USE the database catalog so schema-qualified queries work."""
|
||||
catalogs = [
|
||||
row[0]
|
||||
for row in con.execute(
|
||||
"SELECT catalog_name FROM information_schema.schemata"
|
||||
).fetchall()
|
||||
]
|
||||
# Pick the non-system catalog (not 'system', 'temp', 'memory')
|
||||
user_catalogs = [c for c in set(catalogs) if c not in ("system", "temp", "memory")]
|
||||
if user_catalogs:
|
||||
catalog = user_catalogs[0]
|
||||
con.execute(f"USE {catalog}")
|
||||
return catalog
|
||||
return None
|
||||
|
||||
|
||||
def _find_physical_table(con, schema, table):
|
||||
"""Find the SQLMesh physical table name for a logical table.
|
||||
|
||||
SQLMesh stores physical tables as:
|
||||
sqlmesh__<schema>.<schema>__<table>__<hash>
|
||||
"""
|
||||
sqlmesh_schema = f"sqlmesh__{schema}"
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT table_schema, table_name "
|
||||
"FROM information_schema.tables "
|
||||
f"WHERE table_schema = '{sqlmesh_schema}' "
|
||||
f"AND table_name LIKE '{schema}__{table}%' "
|
||||
"ORDER BY table_name "
|
||||
"LIMIT 1"
|
||||
).fetchall()
|
||||
if rows:
|
||||
return f"{rows[0][0]}.{rows[0][1]}"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _query_table(con, schema, table):
|
||||
"""Try logical view first, fall back to physical table. Returns (fqn, count) or (fqn, error_str)."""
|
||||
logical = f"{schema}.{table}"
|
||||
try:
|
||||
(count,) = con.execute(f"SELECT COUNT(*) FROM {logical}").fetchone()
|
||||
return logical, count
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
physical = _find_physical_table(con, schema, table)
|
||||
if physical:
|
||||
try:
|
||||
(count,) = con.execute(f"SELECT COUNT(*) FROM {physical}").fetchone()
|
||||
return f"{physical} (physical)", count
|
||||
except Exception as e:
|
||||
return f"{physical} (physical)", f"ERROR: {e}"
|
||||
|
||||
return logical, "ERROR: view broken, no physical table found"
|
||||
|
||||
|
||||
def _query_sql(con, sql, schema_tables):
|
||||
"""Execute SQL, falling back to rewritten SQL using physical table names if views fail.
|
||||
|
||||
schema_tables: list of (schema, table) tuples used in the SQL, in order of appearance.
|
||||
The SQL must use {schema}.{table} format for these references.
|
||||
"""
|
||||
try:
|
||||
return con.execute(sql)
|
||||
except Exception:
|
||||
# Rewrite SQL to use physical table names
|
||||
rewritten = sql
|
||||
for schema, table in schema_tables:
|
||||
physical = _find_physical_table(con, schema, table)
|
||||
if physical:
|
||||
rewritten = rewritten.replace(f"{schema}.{table}", physical)
|
||||
else:
|
||||
raise
|
||||
return con.execute(rewritten)
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(DUCKDB_PATH):
|
||||
print(f"ERROR: {DUCKDB_PATH} not found")
|
||||
@@ -36,6 +120,10 @@ def main():
|
||||
|
||||
print(f"Database: {DUCKDB_PATH}")
|
||||
print(f"DuckDB version: {con.execute('SELECT version()').fetchone()[0]}")
|
||||
|
||||
catalog = _use_catalog(con)
|
||||
if catalog:
|
||||
print(f"Catalog: {catalog}")
|
||||
print()
|
||||
|
||||
# ── Row counts at each layer ──────────────────────────────────────────
|
||||
@@ -44,28 +132,11 @@ def main():
|
||||
print("=" * 60)
|
||||
|
||||
for schema, table in PIPELINE_TABLES:
|
||||
# SQLMesh may use __<env> suffixed physical tables
|
||||
# Try the logical name first, then scan for physical tables
|
||||
candidates = [f"{schema}.{table}"]
|
||||
try:
|
||||
phys = con.execute(
|
||||
f"SELECT table_schema || '.' || table_name "
|
||||
f"FROM information_schema.tables "
|
||||
f"WHERE table_name LIKE '{table}%' "
|
||||
f"ORDER BY table_name"
|
||||
).fetchall()
|
||||
for (name,) in phys:
|
||||
if name not in candidates:
|
||||
candidates.append(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for fqn in candidates:
|
||||
try:
|
||||
(count,) = con.execute(f"SELECT COUNT(*) FROM {fqn}").fetchone()
|
||||
print(f" {fqn:50s} {count:>10,} rows")
|
||||
except Exception as e:
|
||||
print(f" {fqn:50s} ERROR: {e}")
|
||||
fqn, result = _query_table(con, schema, table)
|
||||
if isinstance(result, int):
|
||||
print(f" {fqn:55s} {result:>10,} rows")
|
||||
else:
|
||||
print(f" {fqn:55s} {result}")
|
||||
|
||||
# ── Date range in fct_daily_availability ──────────────────────────────
|
||||
print()
|
||||
@@ -74,7 +145,9 @@ def main():
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
row = con.execute("""
|
||||
row = _query_sql(
|
||||
con,
|
||||
"""
|
||||
SELECT
|
||||
MIN(snapshot_date) AS min_date,
|
||||
MAX(snapshot_date) AS max_date,
|
||||
@@ -82,7 +155,9 @@ def main():
|
||||
CURRENT_DATE AS today,
|
||||
CURRENT_DATE - INTERVAL '30 days' AS window_start
|
||||
FROM foundation.fct_daily_availability
|
||||
""").fetchone()
|
||||
""",
|
||||
[("foundation", "fct_daily_availability")],
|
||||
).fetchone()
|
||||
if row:
|
||||
min_date, max_date, days, today, window_start = row
|
||||
print(f" Min snapshot_date: {min_date}")
|
||||
@@ -104,7 +179,9 @@ def main():
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
row = con.execute("""
|
||||
row = _query_sql(
|
||||
con,
|
||||
"""
|
||||
WITH venue_stats AS (
|
||||
SELECT
|
||||
da.tenant_id,
|
||||
@@ -124,7 +201,9 @@ def main():
|
||||
MAX(days_observed) AS max_days,
|
||||
MIN(days_observed) AS min_days
|
||||
FROM venue_stats
|
||||
""").fetchone()
|
||||
""",
|
||||
[("foundation", "fct_daily_availability")],
|
||||
).fetchone()
|
||||
if row:
|
||||
total, passing, failing, max_d, min_d = row
|
||||
print(f" Venues in 30-day window: {total}")
|
||||
@@ -145,7 +224,9 @@ def main():
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
rows = con.execute("""
|
||||
rows = _query_sql(
|
||||
con,
|
||||
"""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN occupancy_rate IS NULL THEN 'NULL'
|
||||
@@ -160,7 +241,9 @@ def main():
|
||||
FROM foundation.fct_daily_availability
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""").fetchall()
|
||||
""",
|
||||
[("foundation", "fct_daily_availability")],
|
||||
).fetchall()
|
||||
for bucket, cnt in rows:
|
||||
print(f" {bucket:25s} {cnt:>10,}")
|
||||
except Exception as e:
|
||||
@@ -173,14 +256,21 @@ def main():
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
row = con.execute("""
|
||||
row = _query_sql(
|
||||
con,
|
||||
"""
|
||||
SELECT
|
||||
COUNT(DISTINCT a.tenant_id) AS slot_tenants,
|
||||
COUNT(DISTINCT c.tenant_id) AS capacity_tenants,
|
||||
COUNT(DISTINCT a.tenant_id) - COUNT(DISTINCT c.tenant_id) AS missing_capacity
|
||||
FROM foundation.fct_availability_slot a
|
||||
LEFT JOIN foundation.dim_venue_capacity c ON a.tenant_id = c.tenant_id
|
||||
""").fetchone()
|
||||
""",
|
||||
[
|
||||
("foundation", "fct_availability_slot"),
|
||||
("foundation", "dim_venue_capacity"),
|
||||
],
|
||||
).fetchone()
|
||||
if row:
|
||||
slot_t, cap_t, missing = row
|
||||
print(f" Tenants in fct_availability_slot: {slot_t}")
|
||||
|
||||
@@ -247,7 +247,7 @@ def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> tu
|
||||
|
||||
|
||||
def run_transform() -> None:
|
||||
"""Run SQLMesh — it evaluates model staleness internally."""
|
||||
"""Run SQLMesh — detects new/modified/deleted models and applies changes."""
|
||||
logger.info("Running SQLMesh transform")
|
||||
ok, err = run_shell(
|
||||
"uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply",
|
||||
@@ -358,6 +358,8 @@ def git_pull_and_sync() -> None:
|
||||
run_shell(f"git checkout --detach {latest}")
|
||||
run_shell("sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env")
|
||||
run_shell("uv sync --all-packages")
|
||||
# Apply any model changes (FULL→INCREMENTAL, new models, etc.) before re-exec
|
||||
run_shell("uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply")
|
||||
# Re-exec so the new code is loaded. os.execv replaces this process in-place;
|
||||
# systemd sees it as the same PID and does not restart the unit.
|
||||
logger.info("Deploy complete — re-execing to load new code")
|
||||
|
||||
@@ -56,27 +56,27 @@ Grain must match reality — use `QUALIFY ROW_NUMBER()` to enforce it.
|
||||
|-----------|-------|---------|
|
||||
| `foundation.dim_countries` | `country_code` | `dim_cities`, `dim_locations`, `pseo_city_costs_de`, `planner_defaults` — single source for country names, income, PLI/cost overrides |
|
||||
| `foundation.dim_venues` | `venue_id` | `dim_cities`, `dim_venue_capacity`, `fct_daily_availability` (via capacity join) |
|
||||
| `foundation.dim_cities` | `(country_code, city_slug)` | `serving.city_market_profile` → all pSEO serving models |
|
||||
| `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_opportunity_profile` — all GeoNames locations (pop ≥1K), incl. zero-court locations |
|
||||
| `foundation.dim_cities` | `(country_code, city_slug)` | `serving.location_profiles` (city_slug + city_padel_venue_count) → all pSEO serving models |
|
||||
| `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_profiles` — all GeoNames locations (pop ≥1K), incl. zero-court locations |
|
||||
| `foundation.dim_venue_capacity` | `tenant_id` | `foundation.fct_daily_availability` |
|
||||
|
||||
## Source integration map
|
||||
|
||||
```
|
||||
stg_playtomic_venues ─┐
|
||||
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──────────────→ city_market_profile
|
||||
stg_padel_courts ─┘ └→ dim_venue_capacity (Marktreife-Score)
|
||||
↓
|
||||
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──┐
|
||||
stg_padel_courts ─┘ └→ dim_venue_capacity
|
||||
│
|
||||
stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_availability
|
||||
↓
|
||||
venue_pricing_benchmarks
|
||||
↓
|
||||
stg_population ──→ dim_cities ─────────────────────────────┘
|
||||
stg_income ──→ dim_cities
|
||||
|
||||
stg_population_geonames ─┐
|
||||
stg_padel_courts ─┤→ dim_locations ──→ location_opportunity_profile
|
||||
stg_tennis_courts ─┤ (Marktpotenzial-Score)
|
||||
stg_income ──→ dim_cities │
|
||||
↓
|
||||
stg_population_geonames ─┐ location_profiles
|
||||
stg_padel_courts ─┤→ dim_locations ────────→ (both scores:
|
||||
stg_tennis_courts ─┤ Marktreife + Marktpotenzial)
|
||||
stg_income ─┘
|
||||
```
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ gateways:
|
||||
local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}"
|
||||
extensions:
|
||||
- spatial
|
||||
- name: h3
|
||||
repository: community
|
||||
|
||||
default_gateway: duckdb
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- Built from venue locations (dim_venues) as the primary source — padelnomics
|
||||
-- tracks cities where padel venues actually exist, not an administrative city list.
|
||||
--
|
||||
-- Conformed dimension: used by city_market_profile and all pSEO serving models.
|
||||
-- Conformed dimension: used by location_profiles and all pSEO serving models.
|
||||
-- Integrates four sources:
|
||||
-- dim_venues → city list, venue count, coordinates (Playtomic + OSM)
|
||||
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps
|
||||
@@ -128,7 +128,7 @@ SELECT
|
||||
vc.padel_venue_count,
|
||||
c.median_income_pps,
|
||||
c.income_year,
|
||||
-- GeoNames ID: FK to dim_locations / location_opportunity_profile.
|
||||
-- GeoNames ID: FK to dim_locations / location_profiles.
|
||||
-- String match preferred; spatial fallback used when name doesn't match (Milano→Milan, etc.)
|
||||
COALESCE(gn.geoname_id, gs.spatial_geoname_id) AS geoname_id
|
||||
FROM venue_cities vc
|
||||
|
||||
@@ -215,6 +215,7 @@ SELECT
|
||||
l.location_slug,
|
||||
l.lat,
|
||||
l.lon,
|
||||
h3_latlng_to_cell(l.lat, l.lon, 5) AS h3_cell_res5,
|
||||
l.admin1_code,
|
||||
l.admin2_code,
|
||||
l.population,
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
MODEL (
|
||||
name foundation.fct_availability_slot,
|
||||
kind FULL,
|
||||
kind INCREMENTAL_BY_TIME_RANGE (
|
||||
time_column snapshot_date
|
||||
),
|
||||
start '2026-03-01',
|
||||
cron '@daily',
|
||||
grain (snapshot_date, tenant_id, resource_id, slot_start_time)
|
||||
);
|
||||
@@ -37,7 +40,8 @@ WITH deduped AS (
|
||||
captured_at_utc DESC
|
||||
) AS rn
|
||||
FROM staging.stg_playtomic_availability
|
||||
WHERE price_amount IS NOT NULL
|
||||
WHERE snapshot_date BETWEEN @start_ds AND @end_ds
|
||||
AND price_amount IS NOT NULL
|
||||
AND price_amount > 0
|
||||
)
|
||||
SELECT
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
|
||||
MODEL (
|
||||
name foundation.fct_daily_availability,
|
||||
kind FULL,
|
||||
kind INCREMENTAL_BY_TIME_RANGE (
|
||||
time_column snapshot_date
|
||||
),
|
||||
start '2026-03-01',
|
||||
cron '@daily',
|
||||
grain (snapshot_date, tenant_id)
|
||||
);
|
||||
@@ -37,6 +40,7 @@ WITH slot_agg AS (
|
||||
MAX(a.price_currency) AS price_currency,
|
||||
MAX(a.captured_at_utc) AS captured_at_utc
|
||||
FROM foundation.fct_availability_slot a
|
||||
WHERE a.snapshot_date BETWEEN @start_ds AND @end_ds
|
||||
GROUP BY a.snapshot_date, a.tenant_id
|
||||
)
|
||||
SELECT
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
Analytics-ready views consumed by the web app and programmatic SEO.
|
||||
Query these from `analytics.py` via DuckDB read-only connection.
|
||||
|
||||
Naming convention: `serving.<purpose>` (e.g. `serving.city_market_profile`)
|
||||
Naming convention: `serving.<purpose>` (e.g. `serving.location_profiles`)
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
-- One Big Table: per-city padel market intelligence.
|
||||
-- Consumed by: SEO article generation, planner city-select pre-fill, API endpoints.
|
||||
--
|
||||
-- Padelnomics Marktreife-Score v3 (0–100):
|
||||
-- Answers "How mature/established is this padel market?"
|
||||
-- Only computed for cities with ≥1 padel venue (padel_venue_count > 0).
|
||||
-- For white-space opportunity scoring, see serving.location_opportunity_profile.
|
||||
--
|
||||
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
|
||||
-- (min(1, count/5) kills small-town inflation)
|
||||
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
|
||||
-- 15 pts addressable market — log-scaled population, ceiling 1M (context only)
|
||||
-- 10 pts economic context — income PPS normalised to 200 ceiling
|
||||
-- 10 pts data quality — completeness discount
|
||||
-- No saturation discount: high density = maturity, not a penalty
|
||||
|
||||
MODEL (
|
||||
name serving.city_market_profile,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, city_slug)
|
||||
);
|
||||
|
||||
WITH base AS (
|
||||
SELECT
|
||||
c.country_code,
|
||||
c.country_name_en,
|
||||
c.country_slug,
|
||||
c.city_name,
|
||||
c.city_slug,
|
||||
c.lat,
|
||||
c.lon,
|
||||
c.population,
|
||||
c.population_year,
|
||||
c.padel_venue_count,
|
||||
c.median_income_pps,
|
||||
c.income_year,
|
||||
c.geoname_id,
|
||||
-- Venue density: padel venues per 100K residents
|
||||
CASE WHEN c.population > 0
|
||||
THEN ROUND(c.padel_venue_count::DOUBLE / c.population * 100000, 2)
|
||||
ELSE NULL
|
||||
END AS venues_per_100k,
|
||||
-- Data confidence: 1.0 if both population and venues are present
|
||||
CASE
|
||||
WHEN c.population > 0 AND c.padel_venue_count > 0 THEN 1.0
|
||||
WHEN c.population > 0 OR c.padel_venue_count > 0 THEN 0.5
|
||||
ELSE 0.0
|
||||
END AS data_confidence,
|
||||
-- Pricing / occupancy from Playtomic (NULL when no availability data)
|
||||
vpb.median_hourly_rate,
|
||||
vpb.median_peak_rate,
|
||||
vpb.median_offpeak_rate,
|
||||
vpb.median_occupancy_rate,
|
||||
vpb.median_daily_revenue_per_venue,
|
||||
vpb.price_currency
|
||||
FROM foundation.dim_cities c
|
||||
LEFT JOIN serving.venue_pricing_benchmarks vpb
|
||||
ON c.country_code = vpb.country_code
|
||||
AND c.city_slug = vpb.city_slug
|
||||
WHERE c.padel_venue_count > 0
|
||||
),
|
||||
scored AS (
|
||||
SELECT *,
|
||||
ROUND(
|
||||
-- Supply development (40 pts): THE maturity signal.
|
||||
-- Log-scaled density: LN(density+1)/LN(21) → 20/100k ≈ full marks.
|
||||
-- Count gate: min(1, count/5) — 1 venue=20%, 5+ venues=100%.
|
||||
-- Kills small-town inflation (1 court / 5k pop = 20/100k) without hard cutoffs.
|
||||
40.0 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
|
||||
* LEAST(1.0, padel_venue_count / 5.0)
|
||||
-- Demand evidence (25 pts): occupancy when Playtomic data available.
|
||||
-- Fallback: 40% of density score (avoids double-counting with supply component).
|
||||
+ 25.0 * CASE
|
||||
WHEN median_occupancy_rate IS NOT NULL
|
||||
THEN LEAST(1.0, median_occupancy_rate / 0.65)
|
||||
ELSE 0.4 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
|
||||
* LEAST(1.0, padel_venue_count / 5.0)
|
||||
END
|
||||
-- Addressable market (15 pts): population as context, not maturity signal.
|
||||
-- LN(1) = 0 so zero-pop cities score 0 here.
|
||||
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
|
||||
-- Economic context (10 pts): country-level income PPS.
|
||||
-- Flat per country — kept as context modifier, not primary signal.
|
||||
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
|
||||
-- Data quality (10 pts): completeness discount.
|
||||
+ 10.0 * data_confidence
|
||||
, 1)
|
||||
AS market_score
|
||||
FROM base
|
||||
)
|
||||
SELECT
|
||||
s.country_code,
|
||||
s.country_name_en,
|
||||
s.country_slug,
|
||||
s.city_name,
|
||||
s.city_slug,
|
||||
s.lat,
|
||||
s.lon,
|
||||
s.population,
|
||||
s.population_year,
|
||||
s.padel_venue_count,
|
||||
s.venues_per_100k,
|
||||
s.data_confidence,
|
||||
s.market_score,
|
||||
s.median_income_pps,
|
||||
s.income_year,
|
||||
s.median_hourly_rate,
|
||||
s.median_peak_rate,
|
||||
s.median_offpeak_rate,
|
||||
s.median_occupancy_rate,
|
||||
s.median_daily_revenue_per_venue,
|
||||
s.price_currency,
|
||||
s.geoname_id,
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM scored s
|
||||
ORDER BY s.market_score DESC
|
||||
@@ -1,86 +0,0 @@
|
||||
-- Per-location padel investment opportunity intelligence.
|
||||
-- Consumed by: Gemeinde-level pSEO pages, opportunity map, "top markets" lists.
|
||||
--
|
||||
-- Padelnomics Marktpotenzial-Score v2 (0–100):
|
||||
-- Answers "Where should I build a padel court?"
|
||||
-- Covers ALL GeoNames locations (pop ≥ 1K) — NOT filtered to existing padel markets.
|
||||
-- Zero-court locations score highest on supply gap component (white space = opportunity).
|
||||
--
|
||||
-- 25 pts addressable market — log-scaled population, ceiling 500K
|
||||
-- (opportunity peaks in mid-size cities; megacities already served)
|
||||
-- 20 pts economic power — country income PPS, normalised to 35,000
|
||||
-- EU PPS values range 18k-37k; /35k gives real spread.
|
||||
-- DE ≈ 13.2pts, ES ≈ 10.7pts, SE ≈ 14.3pts.
|
||||
-- Previously /200 caused all countries to saturate at 20/20.
|
||||
-- 30 pts supply gap — INVERTED venue density; 0 courts/100K = full marks.
|
||||
-- Ceiling raised to 8/100K (was 4) for a gentler gradient
|
||||
-- and to account for ~87% data undercount vs FIP totals.
|
||||
-- Linear: GREATEST(0, 1 - density/8)
|
||||
-- 15 pts catchment gap — distance to nearest padel court.
|
||||
-- DuckDB LEAST ignores NULLs: LEAST(1.0, NULL/30) = 1.0,
|
||||
-- so NULL nearest_km = full marks (no court in bounding box
|
||||
-- = high opportunity). COALESCE fallback is dead code.
|
||||
-- 10 pts sports culture — tennis courts within 25km (≥10 = full marks).
|
||||
-- NOTE: dim_locations tennis data is empty (all 0 rows).
|
||||
-- Component contributes 0 pts everywhere until data lands.
|
||||
|
||||
MODEL (
|
||||
name serving.location_opportunity_profile,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, geoname_id)
|
||||
);
|
||||
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
l.country_code,
|
||||
l.country_name_en,
|
||||
l.country_slug,
|
||||
l.location_name,
|
||||
l.location_slug,
|
||||
l.lat,
|
||||
l.lon,
|
||||
l.admin1_code,
|
||||
l.admin2_code,
|
||||
l.population,
|
||||
l.population_year,
|
||||
l.median_income_pps,
|
||||
l.income_year,
|
||||
l.padel_venue_count,
|
||||
l.padel_venues_per_100k,
|
||||
l.nearest_padel_court_km,
|
||||
l.tennis_courts_within_25km,
|
||||
ROUND(
|
||||
-- Addressable market (25 pts): log-scaled to 500K ceiling.
|
||||
-- Lower ceiling than Marktreife (1M) — opportunity peaks in mid-size cities
|
||||
-- that can support a court but aren't already saturated by large-city operators.
|
||||
25.0 * LEAST(1.0, LN(GREATEST(l.population, 1)) / LN(500000))
|
||||
|
||||
-- Economic power (20 pts): country-level income PPS normalised to 35,000.
|
||||
-- Drives willingness-to-pay for court fees (€20-35/hr target range).
|
||||
-- EU PPS values range 18k-37k; ceiling 35k gives meaningful spread.
|
||||
-- v1 used /200 which caused LEAST(1.0, 115) = 1.0 for ALL countries (flat, no differentiation).
|
||||
-- v2: /35000 → DE 0.66×20=13.2pts, ES 0.53×20=10.7pts, SE 0.71×20=14.3pts.
|
||||
-- Default 15000 for missing data = reasonable developing-market assumption (~0.43).
|
||||
+ 20.0 * LEAST(1.0, COALESCE(l.median_income_pps, 15000) / 35000.0)
|
||||
|
||||
-- Supply gap (30 pts): INVERTED venue density.
|
||||
-- 0 courts/100K = full 30 pts (white space); ≥8/100K = 0 pts (served market).
|
||||
-- Ceiling raised from 4→8/100K for a gentler gradient and to account for data
|
||||
-- undercount (~87% of real courts not in our data).
|
||||
-- This is the key signal that separates Marktpotenzial from Marktreife.
|
||||
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(l.padel_venues_per_100k, 0) / 8.0)
|
||||
|
||||
-- Catchment gap (15 pts): distance to nearest existing padel court.
|
||||
-- >30km = full 15 pts (underserved catchment area).
|
||||
-- NULL = no courts found anywhere (rare edge case) → neutral 0.5.
|
||||
+ 15.0 * COALESCE(LEAST(1.0, l.nearest_padel_court_km / 30.0), 0.5)
|
||||
|
||||
-- Sports culture proxy (10 pts): tennis courts within 25km.
|
||||
-- ≥10 courts = full 10 pts (proven racket sport market = faster padel adoption).
|
||||
-- 0 courts = 0 pts. Many new padel courts open inside existing tennis clubs.
|
||||
+ 10.0 * LEAST(1.0, l.tennis_courts_within_25km / 10.0)
|
||||
, 1) AS opportunity_score,
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM foundation.dim_locations l
|
||||
ORDER BY opportunity_score DESC
|
||||
@@ -0,0 +1,243 @@
|
||||
-- Unified location profile: both scores at (country_code, geoname_id) grain.
|
||||
-- Base: dim_locations (ALL GeoNames locations, pop ≥ 1K, ~140K rows).
|
||||
-- Enriched with dim_cities (city_slug, city_name, exact venue count) and
|
||||
-- venue_pricing_benchmarks (Playtomic pricing/occupancy).
|
||||
--
|
||||
-- Two scores per location:
|
||||
--
|
||||
-- Padelnomics Market Score (Marktreife-Score v3, 0–100):
|
||||
-- "How mature/established is this padel market?"
|
||||
-- Only meaningful for locations matched to a dim_cities row (city_slug IS NOT NULL)
|
||||
-- with padel venues. 0 for all other locations.
|
||||
--
|
||||
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
|
||||
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
|
||||
-- 15 pts addressable market — log-scaled population, ceiling 1M
|
||||
-- 10 pts economic context — income PPS normalised to 200 ceiling
|
||||
-- 10 pts data quality — completeness discount
|
||||
--
|
||||
-- Padelnomics Opportunity Score (Marktpotenzial-Score v3, 0–100):
|
||||
-- "Where should I build a padel court?"
|
||||
-- Computed for ALL locations — zero-court locations score highest on supply gap.
|
||||
-- H3 catchment methodology: addressable market and supply gap use a regional
|
||||
-- H3 catchment (res-5 cell + 6 neighbours, ~24km radius).
|
||||
--
|
||||
-- 25 pts addressable market — log-scaled catchment population, ceiling 500K
|
||||
-- 20 pts economic power — income PPS, normalised to 35,000
|
||||
-- 30 pts supply gap — inverted catchment venue density; 0 courts = full marks
|
||||
-- 15 pts catchment gap — distance to nearest padel court
|
||||
-- 10 pts sports culture — tennis courts within 25km
|
||||
--
|
||||
-- Consumers query directly with WHERE filters:
|
||||
-- cities API: WHERE country_slug = ? AND city_slug IS NOT NULL
|
||||
-- opportunity API: WHERE country_slug = ? AND opportunity_score > 0
|
||||
-- planner_defaults: WHERE city_slug IS NOT NULL
|
||||
-- pseo_*: WHERE city_slug IS NOT NULL AND city_padel_venue_count > 0
|
||||
|
||||
MODEL (
|
||||
name serving.location_profiles,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, geoname_id)
|
||||
);
|
||||
|
||||
WITH
|
||||
-- All locations from dim_locations (superset)
|
||||
base AS (
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
l.country_code,
|
||||
l.country_name_en,
|
||||
l.country_slug,
|
||||
l.location_name,
|
||||
l.location_slug,
|
||||
l.lat,
|
||||
l.lon,
|
||||
l.admin1_code,
|
||||
l.admin2_code,
|
||||
l.population,
|
||||
l.population_year,
|
||||
l.median_income_pps,
|
||||
l.income_year,
|
||||
l.padel_venue_count,
|
||||
l.padel_venues_per_100k,
|
||||
l.nearest_padel_court_km,
|
||||
l.tennis_courts_within_25km,
|
||||
l.h3_cell_res5
|
||||
FROM foundation.dim_locations l
|
||||
),
|
||||
-- Aggregate population and court counts per H3 cell (res 5, ~8.5km edge).
|
||||
-- Grouping by cell first (~50-80K distinct cells vs 140K locations) keeps the
|
||||
-- subsequent lateral join small.
|
||||
hex_stats AS (
|
||||
SELECT
|
||||
h3_cell_res5,
|
||||
SUM(population) AS hex_population,
|
||||
SUM(padel_venue_count) AS hex_padel_courts
|
||||
FROM foundation.dim_locations
|
||||
GROUP BY h3_cell_res5
|
||||
),
|
||||
-- For each location, sum hex_stats across the cell + 6 neighbours (k_ring=1).
|
||||
-- Effective catchment: ~24km radius — realistic driving distance.
|
||||
catchment AS (
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
SUM(hs.hex_population) AS catchment_population,
|
||||
SUM(hs.hex_padel_courts) AS catchment_padel_courts
|
||||
FROM base l,
|
||||
LATERAL (SELECT UNNEST(h3_grid_disk(l.h3_cell_res5, 1)) AS cell) ring
|
||||
JOIN hex_stats hs ON hs.h3_cell_res5 = ring.cell
|
||||
GROUP BY l.geoname_id
|
||||
),
|
||||
-- Match dim_cities via (country_code, geoname_id) to get city_slug + exact venue count.
|
||||
-- QUALIFY handles rare multi-city-per-geoname collisions (keep highest venue count).
|
||||
city_match AS (
|
||||
SELECT
|
||||
c.country_code,
|
||||
c.geoname_id,
|
||||
c.city_slug,
|
||||
c.city_name,
|
||||
c.padel_venue_count AS city_padel_venue_count
|
||||
FROM foundation.dim_cities c
|
||||
WHERE c.geoname_id IS NOT NULL
|
||||
QUALIFY ROW_NUMBER() OVER (
|
||||
PARTITION BY c.country_code, c.geoname_id
|
||||
ORDER BY c.padel_venue_count DESC
|
||||
) = 1
|
||||
),
|
||||
-- Pricing / occupancy from Playtomic (via city_slug) + H3 catchment
|
||||
with_pricing AS (
|
||||
SELECT
|
||||
b.*,
|
||||
cm.city_slug,
|
||||
cm.city_name,
|
||||
cm.city_padel_venue_count,
|
||||
vpb.median_hourly_rate,
|
||||
vpb.median_peak_rate,
|
||||
vpb.median_offpeak_rate,
|
||||
vpb.median_occupancy_rate,
|
||||
vpb.median_daily_revenue_per_venue,
|
||||
vpb.price_currency,
|
||||
COALESCE(ct.catchment_population, b.population)::BIGINT AS catchment_population,
|
||||
COALESCE(ct.catchment_padel_courts, b.padel_venue_count)::INTEGER AS catchment_padel_courts
|
||||
FROM base b
|
||||
LEFT JOIN city_match cm
|
||||
ON b.country_code = cm.country_code
|
||||
AND b.geoname_id = cm.geoname_id
|
||||
LEFT JOIN serving.venue_pricing_benchmarks vpb
|
||||
ON cm.country_code = vpb.country_code
|
||||
AND cm.city_slug = vpb.city_slug
|
||||
LEFT JOIN catchment ct
|
||||
ON b.geoname_id = ct.geoname_id
|
||||
),
|
||||
-- Both scores computed from the enriched base
|
||||
scored AS (
|
||||
SELECT *,
|
||||
-- City-level venue density (from dim_cities exact count, not dim_locations spatial 5km)
|
||||
CASE WHEN population > 0
|
||||
THEN ROUND(COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000, 2)
|
||||
ELSE NULL
|
||||
END AS city_venues_per_100k,
|
||||
-- Data confidence (for market_score)
|
||||
CASE
|
||||
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
|
||||
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
|
||||
ELSE 0.0
|
||||
END AS data_confidence,
|
||||
-- ── Market Score (Marktreife-Score v3) ──────────────────────────────────
|
||||
-- 0 when no city match or no venues (city_padel_venue_count NULL or 0)
|
||||
CASE WHEN COALESCE(city_padel_venue_count, 0) > 0 THEN
|
||||
ROUND(
|
||||
-- Supply development (40 pts)
|
||||
40.0 * LEAST(1.0, LN(
|
||||
COALESCE(
|
||||
CASE WHEN population > 0
|
||||
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
||||
ELSE 0 END
|
||||
, 0) + 1) / LN(21))
|
||||
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
|
||||
-- Demand evidence (25 pts)
|
||||
+ 25.0 * CASE
|
||||
WHEN median_occupancy_rate IS NOT NULL
|
||||
THEN LEAST(1.0, median_occupancy_rate / 0.65)
|
||||
ELSE 0.4 * LEAST(1.0, LN(
|
||||
COALESCE(
|
||||
CASE WHEN population > 0
|
||||
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
||||
ELSE 0 END
|
||||
, 0) + 1) / LN(21))
|
||||
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
|
||||
END
|
||||
-- Addressable market (15 pts)
|
||||
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
|
||||
-- Economic context (10 pts)
|
||||
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
|
||||
-- Data quality (10 pts)
|
||||
+ 10.0 * CASE
|
||||
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
|
||||
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
|
||||
ELSE 0.0
|
||||
END
|
||||
, 1)
|
||||
ELSE 0
|
||||
END AS market_score,
|
||||
-- ── Opportunity Score (Marktpotenzial-Score v3, H3 catchment) ──────────
|
||||
ROUND(
|
||||
-- Addressable market (25 pts): log-scaled catchment population, ceiling 500K
|
||||
25.0 * LEAST(1.0, LN(GREATEST(catchment_population, 1)) / LN(500000))
|
||||
-- Economic power (20 pts): income PPS normalised to 35,000
|
||||
+ 20.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
|
||||
-- Supply gap (30 pts): inverted catchment venue density
|
||||
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(
|
||||
CASE WHEN catchment_population > 0
|
||||
THEN catchment_padel_courts::DOUBLE / catchment_population * 100000
|
||||
ELSE 0.0
|
||||
END, 0.0) / 8.0)
|
||||
-- Catchment gap (15 pts): distance to nearest court
|
||||
+ 15.0 * COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.5)
|
||||
-- Sports culture (10 pts): tennis courts within 25km
|
||||
+ 10.0 * LEAST(1.0, tennis_courts_within_25km / 10.0)
|
||||
, 1) AS opportunity_score
|
||||
FROM with_pricing
|
||||
)
|
||||
SELECT
|
||||
s.geoname_id,
|
||||
s.country_code,
|
||||
s.country_name_en,
|
||||
s.country_slug,
|
||||
s.location_name,
|
||||
s.location_slug,
|
||||
s.city_slug,
|
||||
s.city_name,
|
||||
s.lat,
|
||||
s.lon,
|
||||
s.admin1_code,
|
||||
s.admin2_code,
|
||||
s.population,
|
||||
s.population_year,
|
||||
s.median_income_pps,
|
||||
s.income_year,
|
||||
s.padel_venue_count,
|
||||
s.padel_venues_per_100k,
|
||||
s.nearest_padel_court_km,
|
||||
s.tennis_courts_within_25km,
|
||||
s.city_padel_venue_count,
|
||||
s.city_venues_per_100k,
|
||||
s.data_confidence,
|
||||
s.catchment_population,
|
||||
s.catchment_padel_courts,
|
||||
CASE WHEN s.catchment_population > 0
|
||||
THEN ROUND(s.catchment_padel_courts::DOUBLE / s.catchment_population * 100000, 2)
|
||||
ELSE NULL
|
||||
END AS catchment_venues_per_100k,
|
||||
s.market_score,
|
||||
s.opportunity_score,
|
||||
s.median_hourly_rate,
|
||||
s.median_peak_rate,
|
||||
s.median_offpeak_rate,
|
||||
s.median_occupancy_rate,
|
||||
s.median_daily_revenue_per_venue,
|
||||
s.price_currency,
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM scored s
|
||||
ORDER BY s.market_score DESC, s.opportunity_score DESC
|
||||
@@ -76,11 +76,12 @@ city_profiles AS (
|
||||
city_slug,
|
||||
country_code,
|
||||
city_name,
|
||||
padel_venue_count,
|
||||
city_padel_venue_count AS padel_venue_count,
|
||||
population,
|
||||
market_score,
|
||||
venues_per_100k
|
||||
FROM serving.city_market_profile
|
||||
city_venues_per_100k AS venues_per_100k
|
||||
FROM serving.location_profiles
|
||||
WHERE city_slug IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
cp.city_slug,
|
||||
|
||||
@@ -31,10 +31,10 @@ SELECT
|
||||
c.lon,
|
||||
-- Market metrics
|
||||
c.population,
|
||||
c.padel_venue_count,
|
||||
c.venues_per_100k,
|
||||
c.city_padel_venue_count AS padel_venue_count,
|
||||
c.city_venues_per_100k AS venues_per_100k,
|
||||
c.market_score,
|
||||
lop.opportunity_score,
|
||||
c.opportunity_score,
|
||||
c.data_confidence,
|
||||
-- Pricing (from Playtomic, NULL when no coverage)
|
||||
c.median_hourly_rate,
|
||||
@@ -85,15 +85,13 @@ SELECT
|
||||
cc.working_capital AS "workingCapital",
|
||||
cc.permits_compliance AS "permitsCompliance",
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM serving.city_market_profile c
|
||||
FROM serving.location_profiles c
|
||||
LEFT JOIN serving.planner_defaults p
|
||||
ON c.country_code = p.country_code
|
||||
AND c.city_slug = p.city_slug
|
||||
LEFT JOIN serving.location_opportunity_profile lop
|
||||
ON c.country_code = lop.country_code
|
||||
AND c.geoname_id = lop.geoname_id
|
||||
LEFT JOIN foundation.dim_countries cc
|
||||
ON c.country_code = cc.country_code
|
||||
-- Only cities with actual padel presence and at least some rate data
|
||||
WHERE c.padel_venue_count > 0
|
||||
WHERE c.city_slug IS NOT NULL
|
||||
AND c.city_padel_venue_count > 0
|
||||
AND (p.rate_peak IS NOT NULL OR c.median_peak_rate IS NOT NULL)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- pSEO article data: per-city padel court pricing.
|
||||
-- One row per city — consumed by the city-pricing.md.jinja template.
|
||||
-- Joins venue_pricing_benchmarks (real Playtomic data) with city_market_profile
|
||||
-- Joins venue_pricing_benchmarks (real Playtomic data) with location_profiles
|
||||
-- (population, venue count, country metadata).
|
||||
--
|
||||
-- Stricter filter than pseo_city_costs_de: requires >= 2 venues with real
|
||||
@@ -16,7 +16,7 @@ MODEL (
|
||||
SELECT
|
||||
-- Composite natural key: country_slug + city_slug ensures uniqueness across countries
|
||||
c.country_slug || '-' || c.city_slug AS city_key,
|
||||
-- City identity (from city_market_profile, which has the canonical city_slug)
|
||||
-- City identity (from location_profiles, which has the canonical city_slug)
|
||||
c.city_slug,
|
||||
c.city_name,
|
||||
c.country_code,
|
||||
@@ -24,8 +24,8 @@ SELECT
|
||||
c.country_slug,
|
||||
-- Market context
|
||||
c.population,
|
||||
c.padel_venue_count,
|
||||
c.venues_per_100k,
|
||||
c.city_padel_venue_count AS padel_venue_count,
|
||||
c.city_venues_per_100k AS venues_per_100k,
|
||||
c.market_score,
|
||||
-- Pricing benchmarks (from Playtomic availability data)
|
||||
vpb.median_hourly_rate,
|
||||
@@ -38,9 +38,10 @@ SELECT
|
||||
vpb.price_currency,
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM serving.venue_pricing_benchmarks vpb
|
||||
-- Join city_market_profile to get the canonical city_slug and country metadata
|
||||
INNER JOIN serving.city_market_profile c
|
||||
-- Join location_profiles to get canonical city metadata
|
||||
INNER JOIN serving.location_profiles c
|
||||
ON vpb.country_code = c.country_code
|
||||
AND vpb.city_slug = c.city_slug
|
||||
AND c.city_slug IS NOT NULL
|
||||
-- Only cities with enough venues for meaningful pricing statistics
|
||||
WHERE vpb.venue_count >= 2
|
||||
|
||||
@@ -27,7 +27,7 @@ WITH venue_stats AS (
|
||||
MAX(da.active_court_count) AS court_count,
|
||||
COUNT(DISTINCT da.snapshot_date) AS days_observed
|
||||
FROM foundation.fct_daily_availability da
|
||||
WHERE TRY_CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
|
||||
WHERE da.snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
|
||||
AND da.occupancy_rate IS NOT NULL
|
||||
AND da.occupancy_rate BETWEEN 0 AND 1.5
|
||||
GROUP BY da.tenant_id, da.country_code, da.city, da.city_slug, da.price_currency
|
||||
|
||||
@@ -13,44 +13,28 @@
|
||||
|
||||
MODEL (
|
||||
name staging.stg_playtomic_availability,
|
||||
kind FULL,
|
||||
kind INCREMENTAL_BY_TIME_RANGE (
|
||||
time_column snapshot_date
|
||||
),
|
||||
start '2026-03-01',
|
||||
cron '@daily',
|
||||
grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc)
|
||||
);
|
||||
|
||||
WITH
|
||||
morning_jsonl AS (
|
||||
all_jsonl AS (
|
||||
SELECT
|
||||
date AS snapshot_date,
|
||||
CAST(date AS DATE) AS snapshot_date,
|
||||
captured_at_utc,
|
||||
'morning' AS snapshot_type,
|
||||
NULL::INTEGER AS recheck_hour,
|
||||
tenant_id,
|
||||
slots AS slots_json
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
columns = {
|
||||
date: 'VARCHAR',
|
||||
captured_at_utc: 'VARCHAR',
|
||||
tenant_id: 'VARCHAR',
|
||||
slots: 'JSON'
|
||||
},
|
||||
filename = true
|
||||
)
|
||||
WHERE filename NOT LIKE '%_recheck_%'
|
||||
AND tenant_id IS NOT NULL
|
||||
),
|
||||
recheck_jsonl AS (
|
||||
SELECT
|
||||
date AS snapshot_date,
|
||||
captured_at_utc,
|
||||
'recheck' AS snapshot_type,
|
||||
CASE
|
||||
WHEN filename LIKE '%_recheck_%' THEN 'recheck'
|
||||
ELSE 'morning'
|
||||
END AS snapshot_type,
|
||||
TRY_CAST(recheck_hour AS INTEGER) AS recheck_hour,
|
||||
tenant_id,
|
||||
slots AS slots_json
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_*_recheck_*.jsonl.gz',
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_' || @start_ds || '*.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
columns = {
|
||||
date: 'VARCHAR',
|
||||
@@ -63,11 +47,6 @@ recheck_jsonl AS (
|
||||
)
|
||||
WHERE tenant_id IS NOT NULL
|
||||
),
|
||||
all_venues AS (
|
||||
SELECT * FROM morning_jsonl
|
||||
UNION ALL
|
||||
SELECT * FROM recheck_jsonl
|
||||
),
|
||||
raw_resources AS (
|
||||
SELECT
|
||||
av.snapshot_date,
|
||||
@@ -76,7 +55,7 @@ raw_resources AS (
|
||||
av.recheck_hour,
|
||||
av.tenant_id,
|
||||
resource_json
|
||||
FROM all_venues av,
|
||||
FROM all_jsonl av,
|
||||
LATERAL UNNEST(
|
||||
from_json(av.slots_json, '["JSON"]')
|
||||
) AS t(resource_json)
|
||||
|
||||
5
uv.lock
generated
5
uv.lock
generated
@@ -150,6 +150,11 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/87/ba6298c3d7f8d66ce80d7a487f2a487ebae74a79c6049c7c2990178ce529/brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68", size = 433038, upload-time = "2026-03-05T17:57:37.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/49/16c7a77d1cae0519953ef0389a11a9c2e2e62e87d04f8e7afbae40124255/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af", size = 1541124, upload-time = "2026-03-05T17:57:39.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/17/fab2c36ea820e2288f8c1bf562de1b6cd9f30e28d66f1ce2929a4baff6de/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9", size = 1541983, upload-time = "2026-03-05T17:57:41.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c9/849a669b3b3bb8ac96005cdef04df4db658c33443a7fc704a6d4a2f07a56/brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2", size = 349046, upload-time = "2026-03-05T17:57:42.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/25/09c0fd21cfc451fa38ad538f4d18d8be566746531f7f27143f63f8c45a9f/brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0", size = 385653, upload-time = "2026-03-05T17:57:44.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },
|
||||
|
||||
@@ -165,7 +165,7 @@ echo ""
|
||||
echo "Press Ctrl-C to stop all processes."
|
||||
echo ""
|
||||
|
||||
run_with_label "$COLOR_APP" "app " uv run granian --interface asgi --host 127.0.0.1 --port 5000 --reload --reload-paths web/src padelnomics.app:app
|
||||
run_with_label "$COLOR_APP" "app " uv run python -m padelnomics.app
|
||||
run_with_label "$COLOR_WORKER" "worker" uv run python -u -m padelnomics.worker
|
||||
run_with_label "$COLOR_CSS" "css " make css-watch
|
||||
|
||||
|
||||
@@ -111,13 +111,12 @@ _DAG: dict[str, list[str]] = {
|
||||
"fct_daily_availability": ["fct_availability_slot", "dim_venue_capacity"],
|
||||
# Serving
|
||||
"venue_pricing_benchmarks": ["fct_daily_availability"],
|
||||
"city_market_profile": ["dim_cities", "venue_pricing_benchmarks"],
|
||||
"planner_defaults": ["venue_pricing_benchmarks", "city_market_profile"],
|
||||
"location_opportunity_profile": ["dim_locations"],
|
||||
"location_profiles": ["dim_locations", "dim_cities", "venue_pricing_benchmarks"],
|
||||
"planner_defaults": ["venue_pricing_benchmarks", "location_profiles"],
|
||||
"pseo_city_costs_de": [
|
||||
"city_market_profile", "planner_defaults", "location_opportunity_profile",
|
||||
"location_profiles", "planner_defaults",
|
||||
],
|
||||
"pseo_city_pricing": ["venue_pricing_benchmarks", "city_market_profile"],
|
||||
"pseo_city_pricing": ["venue_pricing_benchmarks", "location_profiles"],
|
||||
"pseo_country_overview": ["pseo_city_costs_de"],
|
||||
}
|
||||
|
||||
|
||||
@@ -2142,7 +2142,7 @@ async def scenario_preview(scenario_id: int):
|
||||
async def scenario_pdf(scenario_id: int):
|
||||
"""Generate and immediately download a business plan PDF for a published scenario."""
|
||||
from ..businessplan import get_plan_sections
|
||||
from ..planner.calculator import validate_state
|
||||
from ..planner.calculator import calc, validate_state
|
||||
|
||||
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
|
||||
if not scenario:
|
||||
@@ -2153,7 +2153,7 @@ async def scenario_pdf(scenario_id: int):
|
||||
lang = "en"
|
||||
|
||||
state = validate_state(json.loads(scenario["state_json"]))
|
||||
d = json.loads(scenario["calc_json"])
|
||||
d = calc(state)
|
||||
sections = get_plan_sections(state, d, lang)
|
||||
sections["scenario_name"] = scenario["title"]
|
||||
sections["location"] = scenario.get("location", "")
|
||||
@@ -2274,6 +2274,53 @@ async def _sync_static_articles() -> None:
|
||||
template_slug, group_key, now_iso, now_iso),
|
||||
)
|
||||
|
||||
# Build HTML so the article is immediately servable (cornerstones have no template)
|
||||
if template_slug is None:
|
||||
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards
|
||||
|
||||
body = raw[m.end():]
|
||||
body_html = mistune.html(body)
|
||||
body_html = await bake_scenario_cards(body_html, lang=language)
|
||||
body_html = await bake_product_cards(body_html, lang=language)
|
||||
build_dir = BUILD_DIR / language
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / f"{slug}.html").write_text(body_html)
|
||||
|
||||
|
||||
def _build_article_where(
|
||||
status: str = None,
|
||||
template_slug: str = None,
|
||||
language: str = None,
|
||||
search: str = None,
|
||||
) -> tuple[list[str], list]:
|
||||
"""Build WHERE clauses and params for article queries.
|
||||
|
||||
template_slug='__manual__' filters for articles with template_slug IS NULL
|
||||
(cornerstone / manually written articles, no pSEO template).
|
||||
"""
|
||||
wheres = ["1=1"]
|
||||
params: list = []
|
||||
|
||||
if status == "live":
|
||||
wheres.append("status = 'published' AND published_at <= datetime('now')")
|
||||
elif status == "scheduled":
|
||||
wheres.append("status = 'published' AND published_at > datetime('now')")
|
||||
elif status == "draft":
|
||||
wheres.append("status = 'draft'")
|
||||
if template_slug == "__manual__":
|
||||
wheres.append("template_slug IS NULL")
|
||||
elif template_slug:
|
||||
wheres.append("template_slug = ?")
|
||||
params.append(template_slug)
|
||||
if language:
|
||||
wheres.append("language = ?")
|
||||
params.append(language)
|
||||
if search:
|
||||
wheres.append("title LIKE ?")
|
||||
params.append(f"%{search}%")
|
||||
|
||||
return wheres, params
|
||||
|
||||
|
||||
async def _get_article_list(
|
||||
status: str = None,
|
||||
@@ -2284,25 +2331,8 @@ async def _get_article_list(
|
||||
per_page: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Get articles with optional filters and pagination."""
|
||||
wheres = ["1=1"]
|
||||
params: list = []
|
||||
|
||||
if status == "live":
|
||||
wheres.append("status = 'published' AND published_at <= datetime('now')")
|
||||
elif status == "scheduled":
|
||||
wheres.append("status = 'published' AND published_at > datetime('now')")
|
||||
elif status == "draft":
|
||||
wheres.append("status = 'draft'")
|
||||
if template_slug:
|
||||
wheres.append("template_slug = ?")
|
||||
params.append(template_slug)
|
||||
if language:
|
||||
wheres.append("language = ?")
|
||||
params.append(language)
|
||||
if search:
|
||||
wheres.append("title LIKE ?")
|
||||
params.append(f"%{search}%")
|
||||
|
||||
wheres, params = _build_article_where(status=status, template_slug=template_slug,
|
||||
language=language, search=search)
|
||||
where = " AND ".join(wheres)
|
||||
offset = (page - 1) * per_page
|
||||
params.extend([per_page, offset])
|
||||
@@ -2332,22 +2362,8 @@ async def _get_article_list_grouped(
|
||||
Static cornerstones (group_key e.g. 'C2') group by cornerstone key regardless of url_path.
|
||||
Each returned item has a 'variants' list (one dict per language variant).
|
||||
"""
|
||||
wheres = ["1=1"]
|
||||
params: list = []
|
||||
|
||||
if status == "live":
|
||||
wheres.append("status = 'published' AND published_at <= datetime('now')")
|
||||
elif status == "scheduled":
|
||||
wheres.append("status = 'published' AND published_at > datetime('now')")
|
||||
elif status == "draft":
|
||||
wheres.append("status = 'draft'")
|
||||
if template_slug:
|
||||
wheres.append("template_slug = ?")
|
||||
params.append(template_slug)
|
||||
if search:
|
||||
wheres.append("title LIKE ?")
|
||||
params.append(f"%{search}%")
|
||||
|
||||
wheres, params = _build_article_where(status=status, template_slug=template_slug,
|
||||
search=search)
|
||||
where = " AND ".join(wheres)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
@@ -2465,6 +2481,18 @@ async def articles():
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/articles/stats")
|
||||
@role_required("admin")
|
||||
async def article_stats():
|
||||
"""HTMX partial: article stats bar (polled while generating)."""
|
||||
stats = await _get_article_stats()
|
||||
return await render_template(
|
||||
"admin/partials/article_stats.html",
|
||||
stats=stats,
|
||||
is_generating=await _is_generating(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/articles/results")
|
||||
@role_required("admin")
|
||||
async def article_results():
|
||||
@@ -2495,26 +2523,134 @@ async def article_results():
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/articles/matching-count")
|
||||
@role_required("admin")
|
||||
async def articles_matching_count():
|
||||
"""Return count of articles matching current filters (for bulk select-all banner)."""
|
||||
status_filter = request.args.get("status", "")
|
||||
template_filter = request.args.get("template", "")
|
||||
language_filter = request.args.get("language", "")
|
||||
search = request.args.get("search", "").strip()
|
||||
|
||||
wheres, params = _build_article_where(
|
||||
status=status_filter or None,
|
||||
template_slug=template_filter or None,
|
||||
language=language_filter or None,
|
||||
search=search or None,
|
||||
)
|
||||
where = " AND ".join(wheres)
|
||||
row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params))
|
||||
count = row["cnt"] if row else 0
|
||||
return f"{count:,}"
|
||||
|
||||
|
||||
@bp.route("/articles/bulk", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def articles_bulk():
|
||||
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete."""
|
||||
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete.
|
||||
|
||||
Supports two modes:
|
||||
- Explicit IDs: article_ids=1,2,3 (max 500)
|
||||
- Apply to all matching: apply_to_all=true + filter params (rebuild capped at 2000, delete at 5000)
|
||||
"""
|
||||
form = await request.form
|
||||
ids_raw = form.get("article_ids", "").strip()
|
||||
action = form.get("action", "").strip()
|
||||
apply_to_all = form.get("apply_to_all", "").strip() == "true"
|
||||
|
||||
# Common filter params (used for action scope and re-render)
|
||||
search = form.get("search", "").strip()
|
||||
status_filter = form.get("status", "")
|
||||
template_filter = form.get("template", "")
|
||||
language_filter = form.get("language", "")
|
||||
|
||||
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
|
||||
if action not in valid_actions or not ids_raw:
|
||||
if action not in valid_actions:
|
||||
return "", 400
|
||||
|
||||
now = utcnow_iso()
|
||||
|
||||
if apply_to_all:
|
||||
wheres, where_params = _build_article_where(
|
||||
status=status_filter or None,
|
||||
template_slug=template_filter or None,
|
||||
language=language_filter or None,
|
||||
search=search or None,
|
||||
)
|
||||
where = " AND ".join(wheres)
|
||||
|
||||
if action == "rebuild":
|
||||
count_row = await fetch_one(
|
||||
f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(where_params)
|
||||
)
|
||||
count = count_row["cnt"] if count_row else 0
|
||||
if count > 2000:
|
||||
return (
|
||||
f"<p class='text-red-600 p-4'>Too many articles ({count:,}) for bulk rebuild"
|
||||
f" — max 2,000. Narrow your filters first.</p>",
|
||||
400,
|
||||
)
|
||||
|
||||
if action == "publish":
|
||||
await execute(
|
||||
f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}",
|
||||
(now, *where_params),
|
||||
)
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
invalidate_sitemap_cache()
|
||||
|
||||
elif action == "unpublish":
|
||||
await execute(
|
||||
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}",
|
||||
(now, *where_params),
|
||||
)
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
invalidate_sitemap_cache()
|
||||
|
||||
elif action == "toggle_noindex":
|
||||
await execute(
|
||||
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END,"
|
||||
f" updated_at = ? WHERE {where}",
|
||||
(now, *where_params),
|
||||
)
|
||||
|
||||
elif action == "rebuild":
|
||||
rows = await fetch_all(
|
||||
f"SELECT id FROM articles WHERE {where} LIMIT 2000", tuple(where_params)
|
||||
)
|
||||
for r in rows:
|
||||
await _rebuild_article(r["id"])
|
||||
|
||||
elif action == "delete":
|
||||
from ..content.routes import BUILD_DIR
|
||||
|
||||
rows = await fetch_all(
|
||||
f"SELECT id, slug, template_slug FROM articles WHERE {where} LIMIT 5000",
|
||||
tuple(where_params),
|
||||
)
|
||||
for a in rows:
|
||||
build_path = BUILD_DIR / f"{a['slug']}.html"
|
||||
if build_path.exists():
|
||||
build_path.unlink()
|
||||
# Only remove source .md for generated articles; cornerstones have no template
|
||||
if a["template_slug"] is not None:
|
||||
md_path = Path("data/content/articles") / f"{a['slug']}.md"
|
||||
if md_path.exists():
|
||||
md_path.unlink()
|
||||
await execute(f"DELETE FROM articles WHERE {where}", tuple(where_params))
|
||||
from ..sitemap import invalidate_sitemap_cache
|
||||
invalidate_sitemap_cache()
|
||||
|
||||
else:
|
||||
ids_raw = form.get("article_ids", "").strip()
|
||||
if not ids_raw:
|
||||
return "", 400
|
||||
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
|
||||
assert len(article_ids) <= 500, "too many article IDs in bulk action"
|
||||
if not article_ids:
|
||||
return "", 400
|
||||
|
||||
placeholders = ",".join("?" for _ in article_ids)
|
||||
now = utcnow_iso()
|
||||
|
||||
if action == "publish":
|
||||
await execute(
|
||||
@@ -2545,18 +2681,19 @@ async def articles_bulk():
|
||||
elif action == "delete":
|
||||
from ..content.routes import BUILD_DIR
|
||||
|
||||
articles = await fetch_all(
|
||||
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})",
|
||||
articles_rows = await fetch_all(
|
||||
f"SELECT id, slug, template_slug FROM articles WHERE id IN ({placeholders})",
|
||||
tuple(article_ids),
|
||||
)
|
||||
for a in articles:
|
||||
for a in articles_rows:
|
||||
build_path = BUILD_DIR / f"{a['slug']}.html"
|
||||
if build_path.exists():
|
||||
build_path.unlink()
|
||||
# Only remove source .md for generated articles; cornerstones have no template
|
||||
if a["template_slug"] is not None:
|
||||
md_path = Path("data/content/articles") / f"{a['slug']}.md"
|
||||
if md_path.exists():
|
||||
md_path.unlink()
|
||||
|
||||
await execute(
|
||||
f"DELETE FROM articles WHERE id IN ({placeholders})",
|
||||
tuple(article_ids),
|
||||
@@ -2565,11 +2702,6 @@ async def articles_bulk():
|
||||
invalidate_sitemap_cache()
|
||||
|
||||
# Re-render results partial with current filters
|
||||
search = form.get("search", "").strip()
|
||||
status_filter = form.get("status", "")
|
||||
template_filter = form.get("template", "")
|
||||
language_filter = form.get("language", "")
|
||||
|
||||
grouped = not language_filter
|
||||
if grouped:
|
||||
article_list = await _get_article_list_grouped(
|
||||
@@ -2736,13 +2868,13 @@ async def article_edit(article_id: int):
|
||||
body = raw[m.end():].lstrip("\n") if m else raw
|
||||
|
||||
body_html = mistune.html(body) if body else ""
|
||||
css_url = url_for("static", filename="css/output.css")
|
||||
preview_doc = (
|
||||
f"<!doctype html><html><head>"
|
||||
f"<link rel='stylesheet' href='{css_url}'>"
|
||||
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
||||
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
||||
) if body_html else ""
|
||||
await render_template(
|
||||
"admin/partials/article_preview_doc.html", body_html=body_html
|
||||
)
|
||||
if body_html
|
||||
else ""
|
||||
)
|
||||
|
||||
data = {**dict(article), "body": body}
|
||||
return await render_template(
|
||||
@@ -2764,13 +2896,13 @@ async def article_preview():
|
||||
m = _FRONTMATTER_RE.match(body)
|
||||
body = body[m.end():].lstrip("\n") if m else body
|
||||
body_html = mistune.html(body) if body else ""
|
||||
css_url = url_for("static", filename="css/output.css")
|
||||
preview_doc = (
|
||||
f"<!doctype html><html><head>"
|
||||
f"<link rel='stylesheet' href='{css_url}'>"
|
||||
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
||||
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
||||
) if body_html else ""
|
||||
await render_template(
|
||||
"admin/partials/article_preview_doc.html", body_html=body_html
|
||||
)
|
||||
if body_html
|
||||
else ""
|
||||
)
|
||||
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
|
||||
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@
|
||||
<iframe
|
||||
srcdoc="{{ preview_doc | e }}"
|
||||
style="flex:1;width:100%;border:none;display:block;"
|
||||
sandbox="allow-same-origin"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
title="Article preview"
|
||||
></iframe>
|
||||
{% else %}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Template</label>
|
||||
<select name="template" class="form-input" style="min-width:140px">
|
||||
<option value="">All</option>
|
||||
<option value="__manual__" {% if current_template == '__manual__' %}selected{% endif %}>Manual</option>
|
||||
{% for t in template_slugs %}
|
||||
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
@@ -75,12 +76,13 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
|
||||
<input type="hidden" name="action" id="article-bulk-action" value="">
|
||||
<input type="hidden" name="search" value="{{ current_search }}">
|
||||
<input type="hidden" name="status" value="{{ current_status }}">
|
||||
<input type="hidden" name="template" value="{{ current_template }}">
|
||||
<input type="hidden" name="language" value="{{ current_language }}">
|
||||
<input type="hidden" name="apply_to_all" id="article-bulk-apply-to-all" value="false">
|
||||
<input type="hidden" name="search" id="article-bulk-search" value="{{ current_search }}">
|
||||
<input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}">
|
||||
<input type="hidden" name="template" id="article-bulk-template" value="{{ current_template }}">
|
||||
<input type="hidden" name="language" id="article-bulk-language" value="{{ current_language }}">
|
||||
</form>
|
||||
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
|
||||
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;background:#EFF6FF;border:1px solid #BFDBFE;">
|
||||
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
|
||||
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
|
||||
<option value="">Action…</option>
|
||||
@@ -92,6 +94,20 @@
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
|
||||
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
|
||||
<span id="article-select-all-banner" style="display:none;font-size:0.8125rem;color:#1E40AF;margin-left:0.5rem">
|
||||
All <strong id="article-page-count"></strong> on this page selected.
|
||||
<button type="button" onclick="enableApplyToAll()"
|
||||
style="background:none;border:none;color:#1D4ED8;font-weight:600;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
|
||||
Select all <span id="article-matching-count">…</span> matching instead?
|
||||
</button>
|
||||
</span>
|
||||
<span id="article-apply-to-all-banner" style="display:none;font-size:0.8125rem;color:#991B1B;font-weight:600;margin-left:0.5rem">
|
||||
All matching articles selected (<span id="article-matching-count-confirm"></span> total).
|
||||
<button type="button" onclick="disableApplyToAll()"
|
||||
style="background:none;border:none;color:#1D4ED8;font-weight:400;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
|
||||
Undo
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Results #}
|
||||
@@ -101,10 +117,13 @@
|
||||
|
||||
<script>
|
||||
const articleSelectedIds = new Set();
|
||||
let articleApplyToAll = false;
|
||||
let articleMatchingCount = 0;
|
||||
|
||||
function toggleArticleSelect(id, checked) {
|
||||
if (checked) articleSelectedIds.add(id);
|
||||
else articleSelectedIds.delete(id);
|
||||
disableApplyToAll();
|
||||
updateArticleBulkBar();
|
||||
}
|
||||
|
||||
@@ -114,30 +133,91 @@ function toggleArticleGroupSelect(checkbox) {
|
||||
if (checkbox.checked) articleSelectedIds.add(id);
|
||||
else articleSelectedIds.delete(id);
|
||||
});
|
||||
disableApplyToAll();
|
||||
updateArticleBulkBar();
|
||||
}
|
||||
|
||||
function clearArticleSelection() {
|
||||
articleSelectedIds.clear();
|
||||
articleApplyToAll = false;
|
||||
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
|
||||
var selectAll = document.getElementById('article-select-all');
|
||||
if (selectAll) selectAll.checked = false;
|
||||
updateArticleBulkBar();
|
||||
}
|
||||
|
||||
function enableApplyToAll() {
|
||||
articleApplyToAll = true;
|
||||
document.getElementById('article-bulk-apply-to-all').value = 'true';
|
||||
document.getElementById('article-select-all-banner').style.display = 'none';
|
||||
document.getElementById('article-apply-to-all-banner').style.display = 'inline';
|
||||
var confirmEl = document.getElementById('article-matching-count-confirm');
|
||||
if (confirmEl) confirmEl.textContent = articleMatchingCount.toLocaleString();
|
||||
document.getElementById('article-bulk-count').textContent = 'All matching selected';
|
||||
}
|
||||
|
||||
function disableApplyToAll() {
|
||||
articleApplyToAll = false;
|
||||
document.getElementById('article-bulk-apply-to-all').value = 'false';
|
||||
document.getElementById('article-select-all-banner').style.display = 'none';
|
||||
document.getElementById('article-apply-to-all-banner').style.display = 'none';
|
||||
}
|
||||
|
||||
function updateArticleBulkBar() {
|
||||
var bar = document.getElementById('article-bulk-bar');
|
||||
var count = document.getElementById('article-bulk-count');
|
||||
var countEl = document.getElementById('article-bulk-count');
|
||||
var ids = document.getElementById('article-bulk-ids');
|
||||
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
|
||||
count.textContent = articleSelectedIds.size + ' selected';
|
||||
|
||||
if (articleSelectedIds.size === 0 && !articleApplyToAll) {
|
||||
bar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'flex';
|
||||
|
||||
if (!articleApplyToAll) {
|
||||
countEl.textContent = articleSelectedIds.size + ' selected';
|
||||
ids.value = Array.from(articleSelectedIds).join(',');
|
||||
}
|
||||
|
||||
// Check if select-all is checked → show "select all matching" banner
|
||||
var selectAll = document.getElementById('article-select-all');
|
||||
var allOnPage = document.querySelectorAll('.article-checkbox');
|
||||
var pageCount = 0;
|
||||
allOnPage.forEach(function(cb) {
|
||||
if (cb.dataset.ids) {
|
||||
pageCount += (cb.dataset.ids || '').split(',').filter(Boolean).length;
|
||||
} else {
|
||||
pageCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
var selectAllBanner = document.getElementById('article-select-all-banner');
|
||||
if (!articleApplyToAll && selectAll && selectAll.checked && pageCount > 0) {
|
||||
document.getElementById('article-page-count').textContent = pageCount;
|
||||
selectAllBanner.style.display = 'inline';
|
||||
// Fetch count of matching articles
|
||||
var params = new URLSearchParams({
|
||||
search: document.getElementById('article-bulk-search').value,
|
||||
status: document.getElementById('article-bulk-status').value,
|
||||
template: document.getElementById('article-bulk-template').value,
|
||||
language: document.getElementById('article-bulk-language').value,
|
||||
});
|
||||
fetch('{{ url_for("admin.articles_matching_count") }}?' + params.toString())
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(text) {
|
||||
articleMatchingCount = parseInt(text.replace(/,/g, ''), 10) || 0;
|
||||
var el = document.getElementById('article-matching-count');
|
||||
if (el) el.textContent = text;
|
||||
});
|
||||
} else if (!articleApplyToAll) {
|
||||
selectAllBanner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function submitArticleBulk() {
|
||||
var action = document.getElementById('article-bulk-action-select').value;
|
||||
if (!action) return;
|
||||
if (articleSelectedIds.size === 0) return;
|
||||
if (!articleApplyToAll && articleSelectedIds.size === 0) return;
|
||||
|
||||
function doSubmit() {
|
||||
document.getElementById('article-bulk-action').value = action;
|
||||
@@ -150,7 +230,13 @@ function submitArticleBulk() {
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) {
|
||||
var subject = articleApplyToAll
|
||||
? 'Delete all ' + articleMatchingCount.toLocaleString() + ' matching articles? This cannot be undone.'
|
||||
: 'Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.';
|
||||
showConfirm(subject).then(function(ok) { if (ok) doSubmit(); });
|
||||
} else if (articleApplyToAll) {
|
||||
var verb = action.charAt(0).toUpperCase() + action.slice(1);
|
||||
showConfirm(verb + ' all ' + articleMatchingCount.toLocaleString() + ' matching articles?').then(function(ok) {
|
||||
if (ok) doSubmit();
|
||||
});
|
||||
} else {
|
||||
@@ -158,6 +244,30 @@ function submitArticleBulk() {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync filter values into bulk form hidden inputs when filters change
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var filterForm = document.querySelector('form[hx-get*="article_results"]');
|
||||
if (!filterForm) return;
|
||||
filterForm.addEventListener('change', syncBulkFilters);
|
||||
filterForm.addEventListener('input', syncBulkFilters);
|
||||
});
|
||||
|
||||
function syncBulkFilters() {
|
||||
var filterForm = document.querySelector('form[hx-get*="article_results"]');
|
||||
if (!filterForm) return;
|
||||
var fd = new FormData(filterForm);
|
||||
var searchEl = document.getElementById('article-bulk-search');
|
||||
var statusEl = document.getElementById('article-bulk-status');
|
||||
var templateEl = document.getElementById('article-bulk-template');
|
||||
var languageEl = document.getElementById('article-bulk-language');
|
||||
if (searchEl) searchEl.value = fd.get('search') || '';
|
||||
if (statusEl) statusEl.value = fd.get('status') || '';
|
||||
if (templateEl) templateEl.value = fd.get('template') || '';
|
||||
if (languageEl) languageEl.value = fd.get('language') || '';
|
||||
// Changing filters clears apply-to-all and resets selection
|
||||
clearArticleSelection();
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.detail.target.id === 'article-results') {
|
||||
document.querySelectorAll('.article-checkbox').forEach(function(cb) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<iframe
|
||||
srcdoc="{{ preview_doc | e }}"
|
||||
style="flex:1;width:100%;border:none;display:block;"
|
||||
sandbox="allow-same-origin"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
title="Article preview"
|
||||
></iframe>
|
||||
{% else %}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{# Standalone HTML document used as iframe srcdoc for the article editor preview.
|
||||
Includes Leaflet so map shortcodes render correctly. #}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
|
||||
<style>html,body{margin:0;padding:0}body{padding:2rem 2.5rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="article-body">{{ body_html | safe }}</div>
|
||||
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -171,7 +171,7 @@
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
placeholder="-- SELECT * FROM serving.city_market_profile -- WHERE country_code = 'DE' -- ORDER BY marktreife_score DESC -- LIMIT 20"
|
||||
placeholder="-- SELECT * FROM serving.location_profiles -- WHERE country_code = 'DE' AND city_slug IS NOT NULL -- ORDER BY market_score DESC -- LIMIT 20"
|
||||
></textarea>
|
||||
|
||||
<div class="query-controls">
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
{% block title %}Preview - {{ preview.title }} - Admin{% endblock %}
|
||||
|
||||
{% block head %}{{ super() }}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">← Back to template</a>
|
||||
|
||||
@@ -21,11 +25,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Rendered article #}
|
||||
<div class="card">
|
||||
{# Rendered article — overflow:visible needed so Leaflet tile layers render #}
|
||||
<div class="card" style="overflow: visible;">
|
||||
<h2 class="text-lg mb-4">Rendered HTML</h2>
|
||||
<div class="prose" style="max-width: none;">
|
||||
<div class="article-body" style="max-width: none;">
|
||||
{{ preview.html | safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,7 +13,7 @@ Usage:
|
||||
|
||||
rows = await fetch_analytics("SELECT * FROM serving.planner_defaults WHERE city_slug = ?", ["berlin"])
|
||||
|
||||
cols, rows, error, elapsed_ms = await execute_user_query("SELECT city_slug FROM serving.city_market_profile LIMIT 5")
|
||||
cols, rows, error, elapsed_ms = await execute_user_query("SELECT city_slug FROM serving.location_profiles LIMIT 5")
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
@@ -8,7 +8,7 @@ daily when the pipeline runs).
|
||||
from quart import Blueprint, abort, jsonify
|
||||
|
||||
from .analytics import fetch_analytics
|
||||
from .core import is_flag_enabled
|
||||
from .core import fetch_all, is_flag_enabled
|
||||
|
||||
bp = Blueprint("api", __name__)
|
||||
|
||||
@@ -32,12 +32,14 @@ async def countries():
|
||||
rows = await fetch_analytics("""
|
||||
SELECT country_code, country_name_en, country_slug,
|
||||
COUNT(*) AS city_count,
|
||||
SUM(padel_venue_count) AS total_venues,
|
||||
SUM(city_padel_venue_count) AS total_venues,
|
||||
ROUND(AVG(market_score), 1) AS avg_market_score,
|
||||
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
|
||||
AVG(lat) AS lat, AVG(lon) AS lon
|
||||
FROM serving.city_market_profile
|
||||
FROM serving.location_profiles
|
||||
WHERE city_slug IS NOT NULL
|
||||
GROUP BY country_code, country_name_en, country_slug
|
||||
HAVING SUM(padel_venue_count) > 0
|
||||
HAVING SUM(city_padel_venue_count) > 0
|
||||
ORDER BY total_venues DESC
|
||||
""")
|
||||
return jsonify(rows), 200, _CACHE_HEADERS
|
||||
@@ -51,14 +53,29 @@ async def country_cities(country_slug: str):
|
||||
rows = await fetch_analytics(
|
||||
"""
|
||||
SELECT city_name, city_slug, lat, lon,
|
||||
padel_venue_count, market_score, population
|
||||
FROM serving.city_market_profile
|
||||
WHERE country_slug = ?
|
||||
ORDER BY padel_venue_count DESC
|
||||
city_padel_venue_count AS padel_venue_count,
|
||||
market_score, opportunity_score, population
|
||||
FROM serving.location_profiles
|
||||
WHERE country_slug = ? AND city_slug IS NOT NULL
|
||||
ORDER BY city_padel_venue_count DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
[country_slug],
|
||||
)
|
||||
# Check which cities have published articles (any language).
|
||||
article_rows = await fetch_all(
|
||||
"""SELECT url_path FROM articles
|
||||
WHERE url_path LIKE ? AND status = 'published'
|
||||
AND published_at <= datetime('now')""",
|
||||
(f"/markets/{country_slug}/%",),
|
||||
)
|
||||
article_slugs = set()
|
||||
for a in article_rows:
|
||||
parts = a["url_path"].rstrip("/").split("/")
|
||||
if len(parts) >= 4:
|
||||
article_slugs.add(parts[3])
|
||||
for row in rows:
|
||||
row["has_article"] = row["city_slug"] in article_slugs
|
||||
return jsonify(rows), 200, _CACHE_HEADERS
|
||||
|
||||
|
||||
@@ -88,9 +105,10 @@ async def opportunity(country_slug: str):
|
||||
rows = await fetch_analytics(
|
||||
"""
|
||||
SELECT location_name, location_slug, lat, lon,
|
||||
opportunity_score, nearest_padel_court_km,
|
||||
opportunity_score, market_score,
|
||||
nearest_padel_court_km,
|
||||
padel_venue_count, population
|
||||
FROM serving.location_opportunity_profile
|
||||
FROM serving.location_profiles
|
||||
WHERE country_slug = ? AND opportunity_score > 0
|
||||
ORDER BY opportunity_score DESC
|
||||
LIMIT 500
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Quart, Response, abort, g, redirect, request, session, url_for
|
||||
from quart import Quart, Response, abort, g, redirect, render_template, request, session, url_for
|
||||
|
||||
from .analytics import close_analytics_db, open_analytics_db
|
||||
from .core import (
|
||||
@@ -270,6 +270,40 @@ def create_app() -> Quart:
|
||||
from .sitemap import sitemap_response
|
||||
return await sitemap_response(config.BASE_URL)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Error pages
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _error_lang() -> str:
|
||||
"""Best-effort language from URL path prefix (no g.lang in error handlers)."""
|
||||
path = request.path
|
||||
if path.startswith("/de/"):
|
||||
return "de"
|
||||
return "en"
|
||||
|
||||
@app.errorhandler(404)
|
||||
async def handle_404(error):
|
||||
import re
|
||||
lang = _error_lang()
|
||||
t = get_translations(lang)
|
||||
country_slug = None
|
||||
country_name = None
|
||||
m = re.match(r"^/(?:en|de)/markets/([^/]+)/[^/]+/?$", request.path)
|
||||
if m:
|
||||
country_slug = m.group(1)
|
||||
country_name = country_slug.replace("-", " ").title()
|
||||
return await render_template(
|
||||
"404.html", lang=lang, t=t, country_slug=country_slug,
|
||||
country_name=country_name or "",
|
||||
), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
async def handle_500(error):
|
||||
app.logger.exception("Unhandled 500 error: %s", error)
|
||||
lang = _error_lang()
|
||||
t = get_translations(lang)
|
||||
return await render_template("500.html", lang=lang, t=t), 500
|
||||
|
||||
# Health check
|
||||
@app.route("/health")
|
||||
async def health():
|
||||
|
||||
@@ -60,106 +60,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function() {
|
||||
var countryMapEl = document.getElementById('country-map');
|
||||
var cityMapEl = document.getElementById('city-map');
|
||||
if (!countryMapEl && !cityMapEl) return;
|
||||
|
||||
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
var TILES_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
||||
|
||||
function scoreColor(score) {
|
||||
if (score >= 60) return '#16A34A';
|
||||
if (score >= 30) return '#D97706';
|
||||
return '#DC2626';
|
||||
}
|
||||
|
||||
function makeIcon(size, color) {
|
||||
var s = Math.round(size);
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.82;"></div>',
|
||||
iconSize: [s, s],
|
||||
iconAnchor: [s / 2, s / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function initCountryMap(el) {
|
||||
var slug = el.dataset.countrySlug;
|
||||
var map = L.map(el, {scrollWheelZoom: false});
|
||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||
var lang = document.documentElement.lang || 'en';
|
||||
fetch('/api/markets/' + slug + '/cities.json')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.length) return;
|
||||
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
|
||||
var bounds = [];
|
||||
data.forEach(function(c) {
|
||||
if (!c.lat || !c.lon) return;
|
||||
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
|
||||
var color = scoreColor(c.market_score);
|
||||
var pop = c.population >= 1000000
|
||||
? (c.population / 1000000).toFixed(1) + 'M'
|
||||
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
|
||||
var tip = '<strong>' + c.city_name + '</strong><br>'
|
||||
+ (c.padel_venue_count || 0) + ' venues'
|
||||
+ (pop ? ' · ' + pop : '') + '<br>'
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Score ' + Math.round(c.market_score) + '/100</span>';
|
||||
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||
.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; })
|
||||
.addTo(map);
|
||||
bounds.push([c.lat, c.lon]);
|
||||
});
|
||||
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
||||
});
|
||||
}
|
||||
|
||||
var VENUE_ICON = L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="pn-venue"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5],
|
||||
});
|
||||
|
||||
function initCityMap(el) {
|
||||
var countrySlug = el.dataset.countrySlug;
|
||||
var citySlug = el.dataset.citySlug;
|
||||
var lat = parseFloat(el.dataset.lat);
|
||||
var lon = parseFloat(el.dataset.lon);
|
||||
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
|
||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
data.forEach(function(v) {
|
||||
if (!v.lat || !v.lon) return;
|
||||
var indoor = v.indoor_court_count || 0;
|
||||
var outdoor = v.outdoor_court_count || 0;
|
||||
var total = v.court_count || (indoor + outdoor);
|
||||
var courtLine = total
|
||||
? total + ' court' + (total > 1 ? 's' : '')
|
||||
+ (indoor || outdoor
|
||||
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
|
||||
: '')
|
||||
: '';
|
||||
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
|
||||
L.marker([v.lat, v.lon], { icon: VENUE_ICON })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
|
||||
.addTo(map);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var script = document.createElement('script');
|
||||
script.src = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
|
||||
script.onload = function() {
|
||||
if (countryMapEl) initCountryMap(countryMapEl);
|
||||
if (cityMapEl) initCityMap(cityMapEl);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -102,9 +102,11 @@
|
||||
if (!c.lat || !c.lon) return;
|
||||
var size = 12 + 44 * Math.sqrt(c.total_venues / maxV);
|
||||
var color = scoreColor(c.avg_market_score);
|
||||
var oppColor = c.avg_opportunity_score >= 60 ? '#16A34A' : (c.avg_opportunity_score >= 30 ? '#D97706' : '#3B82F6');
|
||||
var tip = '<strong>' + c.country_name_en + '</strong><br>'
|
||||
+ c.total_venues + ' venues · ' + c.city_count + ' cities<br>'
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Score ' + c.avg_market_score + '/100</span>';
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + c.avg_market_score + '/100</span><br>'
|
||||
+ '<span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + (c.avg_opportunity_score || 0) + '/100</span>';
|
||||
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||
.on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; })
|
||||
|
||||
@@ -1825,5 +1825,16 @@
|
||||
"affiliate_pros_label": "Vorteile",
|
||||
"affiliate_cons_label": "Nachteile",
|
||||
"affiliate_at_retailer": "bei {retailer}",
|
||||
"affiliate_our_picks": "Unsere Empfehlungen"
|
||||
"affiliate_our_picks": "Unsere Empfehlungen",
|
||||
|
||||
"error_404_title": "Seite nicht gefunden",
|
||||
"error_404_heading": "Diese Seite gibt es nicht",
|
||||
"error_404_message": "Die gesuchte Seite wurde verschoben oder existiert noch nicht.",
|
||||
"error_404_city_message": "Die Marktanalyse für diese Stadt ist noch nicht verfügbar.",
|
||||
"error_404_back_home": "Zur Startseite",
|
||||
"error_404_back_country": "Zurück zur {country}-Übersicht",
|
||||
"error_500_title": "Etwas ist schiefgelaufen",
|
||||
"error_500_heading": "Etwas ist schiefgelaufen",
|
||||
"error_500_message": "Wir arbeiten an einer Lösung. Bitte versuche es gleich noch einmal.",
|
||||
"error_500_back_home": "Zur Startseite"
|
||||
}
|
||||
@@ -1828,5 +1828,16 @@
|
||||
"affiliate_pros_label": "Pros",
|
||||
"affiliate_cons_label": "Cons",
|
||||
"affiliate_at_retailer": "at {retailer}",
|
||||
"affiliate_our_picks": "Our picks"
|
||||
"affiliate_our_picks": "Our picks",
|
||||
|
||||
"error_404_title": "Page Not Found",
|
||||
"error_404_heading": "This page doesn't exist",
|
||||
"error_404_message": "The page you're looking for may have been moved or doesn't exist yet.",
|
||||
"error_404_city_message": "The market analysis for this city isn't available yet.",
|
||||
"error_404_back_home": "Back to Home",
|
||||
"error_404_back_country": "Back to {country} overview",
|
||||
"error_500_title": "Something Went Wrong",
|
||||
"error_500_heading": "Something went wrong",
|
||||
"error_500_message": "We're working on fixing this. Please try again in a moment.",
|
||||
"error_500_back_home": "Back to Home"
|
||||
}
|
||||
@@ -80,7 +80,8 @@ async def opportunity_map():
|
||||
abort(404)
|
||||
countries = await fetch_analytics("""
|
||||
SELECT DISTINCT country_slug, country_name_en
|
||||
FROM serving.city_market_profile
|
||||
FROM serving.location_profiles
|
||||
WHERE city_slug IS NOT NULL
|
||||
ORDER BY country_name_en
|
||||
""")
|
||||
return await render_template("opportunity_map.html", countries=countries)
|
||||
|
||||
@@ -104,8 +104,10 @@
|
||||
var dist = loc.nearest_padel_court_km != null
|
||||
? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court'
|
||||
: 'No nearby courts';
|
||||
var mktColor = loc.market_score >= 60 ? '#16A34A' : (loc.market_score >= 30 ? '#D97706' : '#DC2626');
|
||||
var tip = '<strong>' + loc.location_name + '</strong><br>'
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Score ' + loc.opportunity_score + '/100</span><br>'
|
||||
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Opportunity Score: ' + loc.opportunity_score + '/100</span><br>'
|
||||
+ '<span style="color:' + mktColor + ';font-weight:600;">Padelnomics Market Score: ' + (loc.market_score || 0) + '/100</span><br>'
|
||||
+ dist + ' · Pop. ' + fmtPop(loc.population);
|
||||
L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||
|
||||
@@ -892,6 +892,18 @@
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Non-article city markers: faded + dashed border, no click affordance */
|
||||
.pn-marker--muted {
|
||||
opacity: 0.45;
|
||||
border: 2px dashed rgba(255,255,255,0.6);
|
||||
cursor: default;
|
||||
filter: saturate(0.7);
|
||||
}
|
||||
.pn-marker--muted:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
|
||||
}
|
||||
|
||||
/* Small fixed venue dot */
|
||||
.pn-venue {
|
||||
width: 10px;
|
||||
|
||||
@@ -455,6 +455,8 @@
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart-container__label {
|
||||
font-size: 11px;
|
||||
|
||||
122
web/src/padelnomics/static/js/article-maps.js
Normal file
122
web/src/padelnomics/static/js/article-maps.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Leaflet map initialisation for article pages (country + city maps).
|
||||
*
|
||||
* Looks for #country-map and #city-map elements. If neither exists, does nothing.
|
||||
* Expects data-* attributes on the map elements and a global LEAFLET_JS_URL
|
||||
* variable pointing to the Leaflet JS bundle.
|
||||
*/
|
||||
(function() {
|
||||
var countryMapEl = document.getElementById('country-map');
|
||||
var cityMapEl = document.getElementById('city-map');
|
||||
if (!countryMapEl && !cityMapEl) return;
|
||||
|
||||
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
var TILES_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
||||
|
||||
function scoreColor(score) {
|
||||
if (score >= 60) return '#16A34A';
|
||||
if (score >= 30) return '#D97706';
|
||||
return '#DC2626';
|
||||
}
|
||||
|
||||
function makeIcon(size, color, muted) {
|
||||
var s = Math.round(size);
|
||||
var cls = 'pn-marker' + (muted ? ' pn-marker--muted' : '');
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="' + cls + '" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';"></div>',
|
||||
iconSize: [s, s],
|
||||
iconAnchor: [s / 2, s / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function initCountryMap(el) {
|
||||
var slug = el.dataset.countrySlug;
|
||||
var map = L.map(el, {scrollWheelZoom: false});
|
||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||
var lang = document.documentElement.lang || 'en';
|
||||
fetch('/api/markets/' + slug + '/cities.json')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.length) return;
|
||||
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
|
||||
var bounds = [];
|
||||
data.forEach(function(c) {
|
||||
if (!c.lat || !c.lon) return;
|
||||
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
|
||||
var hasArticle = c.has_article !== false;
|
||||
var color = scoreColor(c.market_score);
|
||||
var pop = c.population >= 1000000
|
||||
? (c.population / 1000000).toFixed(1) + 'M'
|
||||
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
|
||||
var oppColor = c.opportunity_score >= 60 ? '#16A34A' : (c.opportunity_score >= 30 ? '#D97706' : '#3B82F6');
|
||||
var tip = '<strong>' + c.city_name + '</strong><br>'
|
||||
+ (c.padel_venue_count || 0) + ' venues'
|
||||
+ (pop ? ' · ' + pop : '')
|
||||
+ '<br><span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + Math.round(c.market_score) + '/100</span>'
|
||||
+ '<br><span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + Math.round(c.opportunity_score || 0) + '/100</span>';
|
||||
if (hasArticle) {
|
||||
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Click to explore →</span>';
|
||||
} else {
|
||||
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Coming soon</span>';
|
||||
}
|
||||
var marker = L.marker([c.lat, c.lon], { icon: makeIcon(size, color, !hasArticle) })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||
.addTo(map);
|
||||
if (hasArticle) {
|
||||
marker.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; });
|
||||
}
|
||||
bounds.push([c.lat, c.lon]);
|
||||
});
|
||||
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
||||
})
|
||||
.catch(function(err) { console.error('Country map fetch failed:', err); });
|
||||
}
|
||||
|
||||
function initCityMap(el, venueIcon) {
|
||||
var countrySlug = el.dataset.countrySlug;
|
||||
var citySlug = el.dataset.citySlug;
|
||||
var lat = parseFloat(el.dataset.lat);
|
||||
var lon = parseFloat(el.dataset.lon);
|
||||
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
|
||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
data.forEach(function(v) {
|
||||
if (!v.lat || !v.lon) return;
|
||||
var indoor = v.indoor_court_count || 0;
|
||||
var outdoor = v.outdoor_court_count || 0;
|
||||
var total = v.court_count || (indoor + outdoor);
|
||||
var courtLine = total
|
||||
? total + ' court' + (total > 1 ? 's' : '')
|
||||
+ (indoor || outdoor
|
||||
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
|
||||
: '')
|
||||
: '';
|
||||
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
|
||||
L.marker([v.lat, v.lon], { icon: venueIcon })
|
||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
|
||||
.addTo(map);
|
||||
});
|
||||
})
|
||||
.catch(function(err) { console.error('City map fetch failed:', err); });
|
||||
}
|
||||
|
||||
/* Dynamically load Leaflet JS then init maps */
|
||||
var script = document.createElement('script');
|
||||
script.src = window.LEAFLET_JS_URL || '/static/vendor/leaflet/leaflet.min.js';
|
||||
script.onload = function() {
|
||||
if (countryMapEl) initCountryMap(countryMapEl);
|
||||
if (cityMapEl) {
|
||||
var venueIcon = L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="pn-venue"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5],
|
||||
});
|
||||
initCityMap(cityMapEl, venueIcon);
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
23
web/src/padelnomics/templates/404.html
Normal file
23
web/src/padelnomics/templates/404.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.error_404_title }} — {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-page py-12">
|
||||
<div style="max-width:28rem;margin:0 auto;text-align:center;">
|
||||
<p style="font-size:6rem;font-weight:800;line-height:1;color:var(--slate);opacity:0.3;margin:0;">404</p>
|
||||
<h1 class="text-navy" style="font-size:1.5rem;font-weight:700;margin:1rem 0 0.5rem;">{{ t.error_404_heading }}</h1>
|
||||
{% if country_slug %}
|
||||
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_404_city_message }}</p>
|
||||
<a href="/{{ lang }}/markets/{{ country_slug }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||
{{ t.error_404_back_country.replace('{country}', country_name) }}
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_404_message }}</p>
|
||||
<a href="/{{ lang }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||
{{ t.error_404_back_home }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
web/src/padelnomics/templates/500.html
Normal file
16
web/src/padelnomics/templates/500.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.error_500_title }} — {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-page py-12">
|
||||
<div style="max-width:28rem;margin:0 auto;text-align:center;">
|
||||
<p style="font-size:6rem;font-weight:800;line-height:1;color:var(--slate);opacity:0.3;margin:0;">500</p>
|
||||
<h1 class="text-navy" style="font-size:1.5rem;font-weight:700;margin:1rem 0 0.5rem;">{{ t.error_500_heading }}</h1>
|
||||
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_500_message }}</p>
|
||||
<a href="/{{ lang }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||
{{ t.error_500_back_home }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -737,7 +737,7 @@ async def handle_run_extraction(payload: dict) -> None:
|
||||
|
||||
@task("run_transform")
|
||||
async def handle_run_transform(payload: dict) -> None:
|
||||
"""Run SQLMesh transform (prod plan --auto-apply) in the background.
|
||||
"""Run SQLMesh transform (prod plan + apply) in the background.
|
||||
|
||||
Shells out to `uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply`.
|
||||
2-hour absolute timeout — same as extraction.
|
||||
|
||||
@@ -64,7 +64,7 @@ def serving_meta_dir():
|
||||
meta = {
|
||||
"exported_at_utc": "2026-02-25T08:30:00+00:00",
|
||||
"tables": {
|
||||
"city_market_profile": {"row_count": 612},
|
||||
"location_profiles": {"row_count": 612},
|
||||
"planner_defaults": {"row_count": 612},
|
||||
"pseo_city_costs_de": {"row_count": 487},
|
||||
},
|
||||
@@ -78,16 +78,16 @@ def serving_meta_dir():
|
||||
# ── Schema + query mocks ──────────────────────────────────────────────────────
|
||||
|
||||
_MOCK_SCHEMA_ROWS = [
|
||||
{"table_name": "city_market_profile", "column_name": "city_slug", "data_type": "VARCHAR", "ordinal_position": 1},
|
||||
{"table_name": "city_market_profile", "column_name": "country_code", "data_type": "VARCHAR", "ordinal_position": 2},
|
||||
{"table_name": "city_market_profile", "column_name": "marktreife_score", "data_type": "DOUBLE", "ordinal_position": 3},
|
||||
{"table_name": "location_profiles", "column_name": "city_slug", "data_type": "VARCHAR", "ordinal_position": 1},
|
||||
{"table_name": "location_profiles", "column_name": "country_code", "data_type": "VARCHAR", "ordinal_position": 2},
|
||||
{"table_name": "location_profiles", "column_name": "market_score", "data_type": "DOUBLE", "ordinal_position": 3},
|
||||
{"table_name": "planner_defaults", "column_name": "city_slug", "data_type": "VARCHAR", "ordinal_position": 1},
|
||||
]
|
||||
|
||||
_MOCK_TABLE_EXISTS = [{"1": 1}]
|
||||
_MOCK_SAMPLE_ROWS = [
|
||||
{"city_slug": "berlin", "country_code": "DE", "marktreife_score": 82.5},
|
||||
{"city_slug": "munich", "country_code": "DE", "marktreife_score": 77.0},
|
||||
{"city_slug": "berlin", "country_code": "DE", "market_score": 82.5},
|
||||
{"city_slug": "munich", "country_code": "DE", "market_score": 77.0},
|
||||
]
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ def _make_fetch_analytics_mock(schema=True):
|
||||
return [r for r in _MOCK_SCHEMA_ROWS if r["table_name"] == params[0]]
|
||||
if "information_schema.columns" in sql:
|
||||
return _MOCK_SCHEMA_ROWS
|
||||
if "city_market_profile" in sql:
|
||||
if "location_profiles" in sql:
|
||||
return _MOCK_SAMPLE_ROWS
|
||||
return []
|
||||
return _mock
|
||||
@@ -162,7 +162,7 @@ async def test_pipeline_overview(admin_client, state_db_dir, serving_meta_dir):
|
||||
resp = await admin_client.get("/admin/pipeline/overview")
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
assert "city_market_profile" in data
|
||||
assert "location_profiles" in data
|
||||
assert "612" in data # row count from serving meta
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ async def test_pipeline_catalog(admin_client, serving_meta_dir):
|
||||
resp = await admin_client.get("/admin/pipeline/catalog")
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
assert "city_market_profile" in data
|
||||
assert "location_profiles" in data
|
||||
assert "612" in data # row count from serving meta
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ async def test_pipeline_catalog(admin_client, serving_meta_dir):
|
||||
async def test_pipeline_table_detail(admin_client):
|
||||
"""Table detail returns columns and sample rows."""
|
||||
with patch("padelnomics.analytics.fetch_analytics", side_effect=_make_fetch_analytics_mock()):
|
||||
resp = await admin_client.get("/admin/pipeline/catalog/city_market_profile")
|
||||
resp = await admin_client.get("/admin/pipeline/catalog/location_profiles")
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
assert "city_slug" in data
|
||||
@@ -362,7 +362,7 @@ async def test_pipeline_query_editor_loads(admin_client):
|
||||
data = await resp.get_data(as_text=True)
|
||||
assert "query-editor" in data
|
||||
assert "schema-panel" in data
|
||||
assert "city_market_profile" in data
|
||||
assert "location_profiles" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -380,7 +380,7 @@ async def test_pipeline_query_execute_valid(admin_client):
|
||||
with patch("padelnomics.analytics.execute_user_query", new_callable=AsyncMock, return_value=mock_result):
|
||||
resp = await admin_client.post(
|
||||
"/admin/pipeline/query/execute",
|
||||
form={"csrf_token": "test", "sql": "SELECT city_slug, country_code FROM serving.city_market_profile"},
|
||||
form={"csrf_token": "test", "sql": "SELECT city_slug, country_code FROM serving.location_profiles"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
@@ -397,7 +397,7 @@ async def test_pipeline_query_execute_blocked_keyword(admin_client):
|
||||
with patch("padelnomics.analytics.execute_user_query", new_callable=AsyncMock) as mock_q:
|
||||
resp = await admin_client.post(
|
||||
"/admin/pipeline/query/execute",
|
||||
form={"csrf_token": "test", "sql": "DROP TABLE serving.city_market_profile"},
|
||||
form={"csrf_token": "test", "sql": "DROP TABLE serving.location_profiles"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = await resp.get_data(as_text=True)
|
||||
@@ -532,8 +532,8 @@ def test_load_serving_meta(serving_meta_dir):
|
||||
with patch.object(pipeline_mod, "_SERVING_DUCKDB_PATH", str(Path(serving_meta_dir) / "analytics.duckdb")):
|
||||
meta = pipeline_mod._load_serving_meta()
|
||||
assert meta is not None
|
||||
assert "city_market_profile" in meta["tables"]
|
||||
assert meta["tables"]["city_market_profile"]["row_count"] == 612
|
||||
assert "location_profiles" in meta["tables"]
|
||||
assert meta["tables"]["location_profiles"]["row_count"] == 612
|
||||
|
||||
|
||||
def test_load_serving_meta_missing():
|
||||
|
||||
Reference in New Issue
Block a user