Compare commits

...

55 Commits

Author SHA1 Message Date
Deeman
e537bfd9d3 fix(admin): protect cornerstone .md files from bulk delete + fix PDF 500
All checks were successful
CI / test (push) Successful in 1m0s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:13:23 +01:00
Deeman
a27da79705 fix(admin): protect cornerstone .md files from bulk delete + fix PDF 500
- Bulk delete (both explicit-IDs and apply_to_all paths) now only unlinks
  source .md files for generated articles (template_slug IS NOT NULL).
  Manual cornerstone articles keep their .md source on disk.

- _sync_static_articles() now also renders markdown → HTML and writes to
  BUILD_DIR/<lang>/<slug>.html after upserting the DB row, so cornerstones
  are immediately servable after a sync without a separate rebuild step.

- scenario_pdf(): replace d = json.loads(scenario["calc_json"]) with
  d = calc(state) so all current calc fields (moic, dscr, cashOnCash, …)
  are present and the PDF route no longer 500s on stale stored JSON.

- Restored data/content/articles/ cornerstone .md files via git checkout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:09:19 +01:00
Deeman
8d86669360 merge: article bulk select-all matching + cornerstone filter
All checks were successful
CI / test (push) Successful in 59s
CI / tag (push) Successful in 3s
2026-03-06 23:48:26 +01:00
Deeman
7d523250f7 feat(admin): article bulk select-all matching + cornerstone filter
- Extract _build_article_where() helper, eliminating duplicated WHERE
  logic from _get_article_list() and _get_article_list_grouped()
- Add template_slug='__manual__' sentinel → filters template_slug IS NULL
  (cornerstone / hand-written articles without a pSEO template)
- Add GET /articles/matching-count endpoint returning count of articles
  matching current filter params (for the Gmail-style select-all banner)
- Extend POST /articles/bulk with apply_to_all=true mode: builds WHERE
  from filter params instead of explicit IDs; rebuild capped at 2,000,
  delete at 5,000
- Add "Manual" option to Template filter dropdown
- Add Gmail-style "select all matching" banner: appears when select-all
  checkbox is checked, fetches total count, lets user switch to
  apply_to_all mode with confirmation dialog
- Sync filter hidden inputs into bulk form on filter change; changing
  filters resets apply-to-all state and clears selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:47:10 +01:00
Deeman
fee0d6913b fix(pipeline): use sqlmesh plan --auto-apply instead of run
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
2026-03-06 22:34:58 +01:00
Deeman
71e08a5fa6 fix(pipeline): also update supervisor.py to use plan --auto-apply
Missed the Python supervisor module — same fix as supervisor.sh and
worker.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:33:59 +01:00
Deeman
27e86db6a1 fix(pipeline): use sqlmesh plan --auto-apply instead of sqlmesh run
`sqlmesh run` only re-evaluates intervals for already-planned models —
it does not detect new, modified, or deleted models. Switching to
`plan prod --auto-apply` ensures schema changes (like the new
location_profiles model) are picked up automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:33:17 +01:00
Deeman
90754b8d9f chore: move ci.py to ~/.claude/scripts (uv inline script, no project dep)
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 2s
Script now lives globally as a uv inline-dependency script.
Removes per-project scripts/ci.py and the msgspec dev dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:51:36 +01:00
Deeman
277c92e507 chore: add scripts/ci.py for Gitea CI pipeline status
Copies ci.py from beanflows (same script, shared across projects).
Adds msgspec dev dependency required by the script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:38:42 +01:00
Deeman
77ec3a289f feat(transform): H3 catchment index, res 5 k_ring(1) ~24km radius
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
Merges worktree-h3-catchment-index. dim_locations now computes h3_cell_res5
(res 5, ~8.5km edge). location_profiles and dim_locations updated;
old location_opportunity_profile.sql already removed on master.

Conflict: location_opportunity_profile.sql deleted on master, kept deletion
and applied h3_cell_res4→res5 rename to location_profiles instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:45:45 +01:00
Deeman
f81d5f19da fix(transform): tighten H3 catchment to res 5 (~24km radius)
Res 4 + k_ring(1) gave ~50-60km effective radius, causing Oldenburg to
absorb Bremen (40km away) and destroying score differentiation.

Res 5 + k_ring(1) gives ~24km — captures adjacent Gemeinden (Delmenhorst
at 15km) without bleeding into unrelated cities at 40km+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:34:56 +01:00
Deeman
4d29ecf1d6 merge: unified location_profiles serving model + both scores on map tooltips
All checks were successful
CI / test (push) Successful in 55s
CI / tag (push) Successful in 3s
# Conflicts:
#	CHANGELOG.md
#	transform/sqlmesh_padelnomics/models/serving/location_opportunity_profile.sql
2026-03-06 14:03:55 +01:00
Deeman
a3b4e1fab6 docs: update CHANGELOG, CLAUDE.md, and comments for location_profiles
Update transform CLAUDE.md source integration map and conformed
dimensions table. Update CHANGELOG with unified model + tooltip
changes. Fix stale comments in dim_cities.sql and serving README.

Subtask 5/5: documentation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:45:08 +01:00
Deeman
8b794d24a6 feat(maps): show both scores in all map tooltips
Country map: avg Market Score + avg Opportunity Score.
City map: Market Score + Opportunity Score per city.
Opportunity map: Opportunity Score + Market Score per location.

Subtask 4/5: tooltip updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:42:36 +01:00
Deeman
688f2dd1ee refactor(web): update all references to location_profiles
Update api.py (3 endpoints), public/routes.py, analytics.py docstring,
pipeline_routes.py DAG, pipeline_query.html placeholder, and
test_pipeline.py fixtures to use the new unified model.

Subtask 3/5: web app references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:41:42 +01:00
Deeman
81b556b205 refactor(serving): replace old models with location_profiles
Delete city_market_profile.sql and location_opportunity_profile.sql.
Update downstream models (planner_defaults, pseo_city_costs_de,
pseo_city_pricing) to read from location_profiles instead.

Subtask 2/5: delete old models + update downstream SQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:39:52 +01:00
Deeman
cda94c9ee4 feat(serving): add unified location_profiles model
Combines city_market_profile and location_opportunity_profile into a
single serving model at (country_code, geoname_id) grain. Both Market
Score and Opportunity Score computed per location. City data enriched
via LEFT JOIN dim_cities on geoname_id.

Subtask 1/5: create new model (old models not yet removed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:36:36 +01:00
Deeman
4fbd91b59b merge: automate h3 community extension install via sqlmesh config 2026-03-06 10:27:03 +01:00
Deeman
159d1b5b9a fix(transform): use community repository for h3 extension install
SQLMesh's extensions config supports dict form with 'repository' key,
which runs INSTALL h3 FROM community + LOAD h3 automatically at connect
time. No manual one-time install needed per machine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:26:56 +01:00
Deeman
fcd0c9b007 docs: update CHANGELOG with H3 catchment score v3 2026-03-06 10:20:15 +01:00
Deeman
f841ae105a merge: use full trademarked score names in map tooltips 2026-03-06 10:19:56 +01:00
Deeman
dec4f07fbb merge: H3 catchment index for Marktpotenzial-Score v3 2026-03-06 10:19:51 +01:00
Deeman
4e4ff61699 feat(transform): H3 catchment index for Marktpotenzial-Score v3
Add H3 res-4 regional catchment metrics (~15-18km radius, cell + 6
neighbours) to both the addressable market (25pts) and supply gap
(30pts) components of location_opportunity_profile.

Changes:
- config.yaml: add h3 to DuckDB extensions (requires one-time
  INSTALL h3 FROM community on each machine)
- dim_locations: add h3_cell_res4 column via h3_latlng_to_cell()
- location_opportunity_profile: add hex_stats + catchment CTEs;
  update score formula to use catchment_population and
  catchment_padel_courts; expose catchment_population,
  catchment_padel_courts, catchment_venues_per_100k as output cols

Motivation: local population underestimates functional market for
mid-size cities (e.g. Oldenburg ~170K misses surrounding Gemeinden).
H3 k_ring(1) captures the realistic driving-distance catchment
(~462km²) consistently across both score components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:19:43 +01:00
Deeman
f907f2cd60 fix(maps): use full trademarked score names in all map tooltips
"Score X/100" → "Padelnomics Market Score: X/100" on country map (markets
hub), city map (country overview). Opportunity map uses "Padelnomics
Opportunity Score: X/100". Consistent branding across all three map views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:19:08 +01:00
Deeman
3ad2885c84 merge: fix map bubble styling + improve hover UX 2026-03-06 10:11:26 +01:00
Deeman
e2f54552b0 fix(maps): restore score colors for non-article cities, improve hover UX
Non-article cities were fully gray (#9CA3AF), stripping informational value.
Now all cities show score-based colors (green/amber/red). Non-article cities
are differentiated via lower opacity, dashed border, desaturation, and
default cursor (no click handler). Tooltips show scores for all cities —
article cities get "Click to explore →", non-article cities get "Coming soon".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:09:58 +01:00
Deeman
07ca1ce15b merge: custom 404/500 error pages + smarter map city clicks 2026-03-06 10:01:50 +01:00
Deeman
be9b10c13f docs: update CHANGELOG with error pages and map improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:59:29 +01:00
Deeman
82d6333517 feat: differentiate cities with/without articles on country map
Cities without published articles appear in muted gray and are not
clickable. The cities.json API endpoint now queries SQLite for
published articles and adds a has_article boolean to each city row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:57:01 +01:00
Deeman
ed48936dad feat: add styled 404/500 error pages with i18n support
Custom error templates extending base.html with centered layout.
404 is context-aware: detects /markets/{country}/{city} paths and
shows city-specific message with link back to country overview.
Both pages support EN/DE translations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:55:13 +01:00
Deeman
e3bda5b816 merge: fix admin template preview UX issues (maps, article_stats route, dev debug mode) 2026-03-06 09:35:45 +01:00
Deeman
831233cb29 fix(admin): add missing article_stats route, 500 handler, dev debug mode
- Add /admin/articles/stats HTMX partial endpoint that was referenced
  by article_stats.html but never created (caused 500 during generation)
- Add @app.errorhandler(500) to log exceptions with traceback
- Switch dev_run.sh from Granian to Quart debug mode for browser
  tracebacks and auto-reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:34:22 +01:00
Deeman
c5327c4012 fix(maps): move VENUE_ICON creation after Leaflet loads
L.divIcon() was called at IIFE top level before the dynamic Leaflet
script loaded, throwing ReferenceError and preventing all maps from
rendering. Move icon creation into script.onload callback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 09:01:54 +01:00
Deeman
4426ab2cb6 fix(admin): render Leaflet maps in template preview 2026-03-05 22:58:34 +01:00
Deeman
93c9408f6b fix(admin): render Leaflet maps in template preview
The .card wrapper has overflow:hidden which clips Leaflet's
absolutely-positioned tile layers. Override to overflow:visible
on the rendered-article card. Add .catch() to map fetch calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:58:27 +01:00
Deeman
84128a3a64 merge: fix map scripts in template preview 2026-03-05 22:33:16 +01:00
Deeman
e9b4faa05c fix(admin): move map scripts inline in template preview
Put Leaflet init scripts inside admin_content block instead of relying
on the scripts block inheritance chain through base_admin → base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:33:16 +01:00
Deeman
a834bb481d merge: load Leaflet maps in admin template preview 2026-03-05 22:29:13 +01:00
Deeman
9515ec8ae9 fix(admin): load Leaflet maps in template preview page
The /admin/templates/<slug>/preview/<key> page renders article HTML
directly but never loaded Leaflet CSS/JS, so country-map and city-map
divs appeared empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:29:00 +01:00
Deeman
fb99d6e0db merge: fix sqlmesh worker command + article preview maps 2026-03-05 22:14:37 +01:00
Deeman
4ee80603ef fix(articles): load Leaflet maps in article editor preview
The admin article preview iframe was missing Leaflet CSS/JS and had
scripts blocked by the sandbox policy, so map shortcodes rendered as
empty divs.

- Extract inline map script to static/js/article-maps.js (shared
  between article_detail.html and admin preview)
- Replace f-string preview doc with a proper Jinja template that
  includes Leaflet assets
- Add allow-scripts to iframe sandbox on both initial load and HTMX
  preview updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:03:50 +01:00
Deeman
2e42245ad5 fix(worker): use sqlmesh run prod instead of plan prod --auto-apply
`plan --auto-apply` only detects SQL model changes and won't re-run
for new data. `run prod` evaluates missing cron intervals and picks
up newly extracted data — matching the fix already applied to the
supervisor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 21:49:51 +01:00
Deeman
2f47d1e589 fix(pipeline): make availability chain incremental + fix supervisor
Convert the availability chain (stg_playtomic_availability →
fct_availability_slot → fct_daily_availability) from FULL to
INCREMENTAL_BY_TIME_RANGE so sqlmesh run processes only new daily
intervals instead of re-reading all files.

Supervisor changes:
- run_transform(): plan prod --auto-apply → run prod (evaluates
  missing cron intervals, picks up new data)
- git_pull_and_sync(): add plan prod --auto-apply before re-exec
  so model code changes are applied on deploy
- supervisor.sh: same plan → run change

Staging model uses a date-scoped glob (@start_ds) to read only
the current interval's files. snapshot_date cast to DATE (was
VARCHAR) as required by time_column.

Clean up redundant TRY_CAST(snapshot_date AS DATE) in
venue_pricing_benchmarks since it's already DATE from foundation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 21:34:02 +01:00
Deeman
ead12c4552 fix(planner): prevent chart containers from overflowing on small screens
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 2s
2026-03-05 18:27:33 +01:00
Deeman
c54eb50004 fix(planner): prevent chart containers from overflowing on small screens
Grid children default to min-width:auto, letting the Chart.js canvas
push the container wider than its grid track. Adding min-width:0 and
overflow:hidden constrains charts to their column width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 18:27:27 +01:00
Deeman
5d7fcec17a chore: change GISCO extraction schedule from monthly to yearly
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
2026-03-05 17:50:19 +01:00
Deeman
f7faf7ab57 chore: change GISCO extraction schedule from monthly to yearly
NUTS2 boundaries rarely change; yearly (Jan 1) is sufficient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:50:14 +01:00
Deeman
add5f8ddfa fix(extract): correct lc_lci_lev lcstruct filter value
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
2026-03-05 17:39:37 +01:00
Deeman
15ca316682 fix(extract): correct lc_lci_lev lcstruct filter value
D1_D2_A_HW doesn't exist in the API; use D1_D4_MD5 (total labour cost
= compensation + taxes - subsidies).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:32:49 +01:00
Deeman
103ef73cf5 fix(pipeline): eurostat filter bugs + supervisor uses sqlmesh plan
All checks were successful
CI / test (push) Successful in 53s
CI / tag (push) Successful in 3s
2026-03-05 17:19:21 +01:00
Deeman
aa27f14f3c fix(pipeline): eurostat filter bugs + supervisor uses sqlmesh plan
- nrg_pc_203: add missing unit=KWH filter (API returns 2 units)
- lc_lci_lev: fix currency→unit filter dimension name
- supervisor: use `sqlmesh plan prod --auto-apply` instead of
  `sqlmesh run` so new/changed models are detected automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:19:12 +01:00
Deeman
8205744444 chore: remove accidentally committed .claire/ worktree directory
All checks were successful
CI / test (push) Successful in 56s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:10:48 +01:00
Deeman
1cbefe349c add env var 2026-03-05 17:08:52 +01:00
Deeman
003f19e071 fix(pipeline): handle DuckDB catalog naming in diagnostic script 2026-03-05 17:07:52 +01:00
Deeman
c3f15535b8 fix(pipeline): handle DuckDB catalog naming in diagnostic script
The lakehouse.duckdb file uses catalog "lakehouse" not "local", causing
SQLMesh logical views to break. Script now auto-detects the catalog via
USE and falls back to physical tables when views fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 17:06:44 +01:00
48 changed files with 1144 additions and 580 deletions

View File

@@ -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] 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] 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] 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] #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] 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] #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_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_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_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
sops_lastmodified=2026-03-01T20:26:09Z sops_lastmodified=2026-03-05T15:55:19Z
sops_mac=ENC[AES256_GCM,data:IxzU6VehA0iHgpIEqDSoMywKyKONI6jSr/6Amo+g3JI72awJtk6ft0ppfDWZjeHhL0ixfnvgqMNwai+1e0V/U8hSP8/FqYKEVpAO0UGJfBPKP3pbw+tx3WJQMF5dIh2/UVNrKvoACZq0IDJfXlVqalCnRMQEHGtKVTIT3fn8m6c=,iv:0w0ohOBsqTzuoQdtt6AI5ZdHEKw9+hI73tycBjDSS0o=,tag:Guw7LweA4m4Nw+3kSuZKWA==,type:str] 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_unencrypted_suffix=_unencrypted
sops_version=3.12.1 sops_version=3.12.1

View File

@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [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 ### 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. - **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`. - **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`.

View File

@@ -63,15 +63,15 @@ DATASETS: dict[str, dict] = {
"time_dim": "time", "time_dim": "time",
}, },
"nrg_pc_203": { "nrg_pc_203": {
# Gas prices for non-household consumers, EUR/GJ, excl. taxes # Gas prices for non-household consumers, EUR/kWh, excl. taxes
"filters": {"freq": "S", "nrg_cons": "GJ1000-9999", "currency": "EUR", "tax": "I_TAX"}, "filters": {"freq": "S", "nrg_cons": "GJ1000-9999", "unit": "KWH", "currency": "EUR", "tax": "I_TAX"},
"geo_dim": "geo", "geo_dim": "geo",
"time_dim": "time", "time_dim": "time",
}, },
"lc_lci_lev": { "lc_lci_lev": {
# Labour cost levels EUR/hour — NACE N (administrative/support services) # Labour cost levels EUR/hour — NACE N (administrative/support services)
# Stored in dim_countries for future staffed-scenario calculations. # D1_D4_MD5 = compensation of employees + taxes - subsidies (total labour cost)
"filters": {"lcstruct": "D1_D2_A_HW", "nace_r2": "N", "currency": "EUR"}, "filters": {"lcstruct": "D1_D4_MD5", "nace_r2": "N", "unit": "EUR"},
"geo_dim": "geo", "geo_dim": "geo",
"time_dim": "time", "time_dim": "time",
}, },

View File

@@ -33,10 +33,10 @@ do
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \ DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
uv run --package padelnomics_extract extract uv run --package padelnomics_extract extract
# Transform # Transform — plan detects new/modified/deleted models and applies changes.
LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \ LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \ 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). # Export serving tables to analytics.duckdb (atomic swap).
# The web app detects the inode change on next query — no restart needed. # The web app detects the inode change on next query — no restart needed.

View File

@@ -70,5 +70,5 @@ description = "UK local authority population estimates from ONS"
[gisco] [gisco]
module = "padelnomics_extract.gisco" module = "padelnomics_extract.gisco"
schedule = "monthly" schedule = "0 0 1 1 *"
description = "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO" description = "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"

View File

@@ -8,6 +8,10 @@ Or locally:
DUCKDB_PATH=data/lakehouse.duckdb uv run python scripts/check_pipeline.py DUCKDB_PATH=data/lakehouse.duckdb uv run python scripts/check_pipeline.py
Read-only — never writes to the database. 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 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(): def main():
if not os.path.exists(DUCKDB_PATH): if not os.path.exists(DUCKDB_PATH):
print(f"ERROR: {DUCKDB_PATH} not found") print(f"ERROR: {DUCKDB_PATH} not found")
@@ -36,6 +120,10 @@ def main():
print(f"Database: {DUCKDB_PATH}") print(f"Database: {DUCKDB_PATH}")
print(f"DuckDB version: {con.execute('SELECT version()').fetchone()[0]}") print(f"DuckDB version: {con.execute('SELECT version()').fetchone()[0]}")
catalog = _use_catalog(con)
if catalog:
print(f"Catalog: {catalog}")
print() print()
# ── Row counts at each layer ────────────────────────────────────────── # ── Row counts at each layer ──────────────────────────────────────────
@@ -44,28 +132,11 @@ def main():
print("=" * 60) print("=" * 60)
for schema, table in PIPELINE_TABLES: for schema, table in PIPELINE_TABLES:
# SQLMesh may use __<env> suffixed physical tables fqn, result = _query_table(con, schema, table)
# Try the logical name first, then scan for physical tables if isinstance(result, int):
candidates = [f"{schema}.{table}"] print(f" {fqn:55s} {result:>10,} rows")
try: else:
phys = con.execute( print(f" {fqn:55s} {result}")
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}")
# ── Date range in fct_daily_availability ────────────────────────────── # ── Date range in fct_daily_availability ──────────────────────────────
print() print()
@@ -74,7 +145,9 @@ def main():
print("=" * 60) print("=" * 60)
try: try:
row = con.execute(""" row = _query_sql(
con,
"""
SELECT SELECT
MIN(snapshot_date) AS min_date, MIN(snapshot_date) AS min_date,
MAX(snapshot_date) AS max_date, MAX(snapshot_date) AS max_date,
@@ -82,7 +155,9 @@ def main():
CURRENT_DATE AS today, CURRENT_DATE AS today,
CURRENT_DATE - INTERVAL '30 days' AS window_start CURRENT_DATE - INTERVAL '30 days' AS window_start
FROM foundation.fct_daily_availability FROM foundation.fct_daily_availability
""").fetchone() """,
[("foundation", "fct_daily_availability")],
).fetchone()
if row: if row:
min_date, max_date, days, today, window_start = row min_date, max_date, days, today, window_start = row
print(f" Min snapshot_date: {min_date}") print(f" Min snapshot_date: {min_date}")
@@ -104,7 +179,9 @@ def main():
print("=" * 60) print("=" * 60)
try: try:
row = con.execute(""" row = _query_sql(
con,
"""
WITH venue_stats AS ( WITH venue_stats AS (
SELECT SELECT
da.tenant_id, da.tenant_id,
@@ -124,7 +201,9 @@ def main():
MAX(days_observed) AS max_days, MAX(days_observed) AS max_days,
MIN(days_observed) AS min_days MIN(days_observed) AS min_days
FROM venue_stats FROM venue_stats
""").fetchone() """,
[("foundation", "fct_daily_availability")],
).fetchone()
if row: if row:
total, passing, failing, max_d, min_d = row total, passing, failing, max_d, min_d = row
print(f" Venues in 30-day window: {total}") print(f" Venues in 30-day window: {total}")
@@ -145,7 +224,9 @@ def main():
print("=" * 60) print("=" * 60)
try: try:
rows = con.execute(""" rows = _query_sql(
con,
"""
SELECT SELECT
CASE CASE
WHEN occupancy_rate IS NULL THEN 'NULL' WHEN occupancy_rate IS NULL THEN 'NULL'
@@ -160,7 +241,9 @@ def main():
FROM foundation.fct_daily_availability FROM foundation.fct_daily_availability
GROUP BY 1 GROUP BY 1
ORDER BY 1 ORDER BY 1
""").fetchall() """,
[("foundation", "fct_daily_availability")],
).fetchall()
for bucket, cnt in rows: for bucket, cnt in rows:
print(f" {bucket:25s} {cnt:>10,}") print(f" {bucket:25s} {cnt:>10,}")
except Exception as e: except Exception as e:
@@ -173,14 +256,21 @@ def main():
print("=" * 60) print("=" * 60)
try: try:
row = con.execute(""" row = _query_sql(
con,
"""
SELECT SELECT
COUNT(DISTINCT a.tenant_id) AS slot_tenants, COUNT(DISTINCT a.tenant_id) AS slot_tenants,
COUNT(DISTINCT c.tenant_id) AS capacity_tenants, COUNT(DISTINCT c.tenant_id) AS capacity_tenants,
COUNT(DISTINCT a.tenant_id) - COUNT(DISTINCT c.tenant_id) AS missing_capacity COUNT(DISTINCT a.tenant_id) - COUNT(DISTINCT c.tenant_id) AS missing_capacity
FROM foundation.fct_availability_slot a FROM foundation.fct_availability_slot a
LEFT JOIN foundation.dim_venue_capacity c ON a.tenant_id = c.tenant_id 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: if row:
slot_t, cap_t, missing = row slot_t, cap_t, missing = row
print(f" Tenants in fct_availability_slot: {slot_t}") print(f" Tenants in fct_availability_slot: {slot_t}")

View File

@@ -247,7 +247,7 @@ def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> tu
def run_transform() -> None: 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") logger.info("Running SQLMesh transform")
ok, err = run_shell( ok, err = run_shell(
"uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply", "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(f"git checkout --detach {latest}")
run_shell("sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env") run_shell("sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env")
run_shell("uv sync --all-packages") 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; # 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. # systemd sees it as the same PID and does not restart the unit.
logger.info("Deploy complete — re-execing to load new code") logger.info("Deploy complete — re-execing to load new code")

View File

@@ -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_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_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_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_opportunity_profile` — all GeoNames locations (pop ≥1K), incl. zero-court locations | | `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` | | `foundation.dim_venue_capacity` | `tenant_id` | `foundation.fct_daily_availability` |
## Source integration map ## Source integration map
``` ```
stg_playtomic_venues ─┐ stg_playtomic_venues ─┐
stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──────────────→ city_market_profile stg_playtomic_resources─┤→ dim_venues ─┬→ dim_cities ──
stg_padel_courts ─┘ └→ dim_venue_capacity (Marktreife-Score) stg_padel_courts ─┘ └→ dim_venue_capacity
stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_availability stg_playtomic_availability ──→ fct_availability_slot ──→ fct_daily_availability
venue_pricing_benchmarks venue_pricing_benchmarks
stg_population ──→ dim_cities ─────────────────────────────┘ stg_population ──→ dim_cities ─────────────────────────────┘
stg_income ──→ dim_cities stg_income ──→ dim_cities
stg_population_geonames ─┐ stg_population_geonames ─┐ location_profiles
stg_padel_courts ─┤→ dim_locations ──→ location_opportunity_profile stg_padel_courts ─┤→ dim_locations ────────→ (both scores:
stg_tennis_courts ─┤ (Marktpotenzial-Score) stg_tennis_courts ─┤ Marktreife + Marktpotenzial)
stg_income ─┘ stg_income ─┘
``` ```

View File

@@ -6,6 +6,8 @@ gateways:
local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}" local: "{{ env_var('DUCKDB_PATH', 'data/lakehouse.duckdb') }}"
extensions: extensions:
- spatial - spatial
- name: h3
repository: community
default_gateway: duckdb default_gateway: duckdb

View File

@@ -2,7 +2,7 @@
-- Built from venue locations (dim_venues) as the primary source — padelnomics -- Built from venue locations (dim_venues) as the primary source — padelnomics
-- tracks cities where padel venues actually exist, not an administrative city list. -- 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: -- Integrates four sources:
-- dim_venues → city list, venue count, coordinates (Playtomic + OSM) -- dim_venues → city list, venue count, coordinates (Playtomic + OSM)
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps -- foundation.dim_countries → country_name_en, country_slug, median_income_pps
@@ -128,7 +128,7 @@ SELECT
vc.padel_venue_count, vc.padel_venue_count,
c.median_income_pps, c.median_income_pps,
c.income_year, 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.) -- 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 COALESCE(gn.geoname_id, gs.spatial_geoname_id) AS geoname_id
FROM venue_cities vc FROM venue_cities vc

View File

@@ -215,6 +215,7 @@ SELECT
l.location_slug, l.location_slug,
l.lat, l.lat,
l.lon, l.lon,
h3_latlng_to_cell(l.lat, l.lon, 5) AS h3_cell_res5,
l.admin1_code, l.admin1_code,
l.admin2_code, l.admin2_code,
l.population, l.population,

View File

@@ -14,7 +14,10 @@
MODEL ( MODEL (
name foundation.fct_availability_slot, name foundation.fct_availability_slot,
kind FULL, kind INCREMENTAL_BY_TIME_RANGE (
time_column snapshot_date
),
start '2026-03-01',
cron '@daily', cron '@daily',
grain (snapshot_date, tenant_id, resource_id, slot_start_time) grain (snapshot_date, tenant_id, resource_id, slot_start_time)
); );
@@ -37,7 +40,8 @@ WITH deduped AS (
captured_at_utc DESC captured_at_utc DESC
) AS rn ) AS rn
FROM staging.stg_playtomic_availability 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 AND price_amount > 0
) )
SELECT SELECT

View File

@@ -12,7 +12,10 @@
MODEL ( MODEL (
name foundation.fct_daily_availability, name foundation.fct_daily_availability,
kind FULL, kind INCREMENTAL_BY_TIME_RANGE (
time_column snapshot_date
),
start '2026-03-01',
cron '@daily', cron '@daily',
grain (snapshot_date, tenant_id) grain (snapshot_date, tenant_id)
); );
@@ -37,6 +40,7 @@ WITH slot_agg AS (
MAX(a.price_currency) AS price_currency, MAX(a.price_currency) AS price_currency,
MAX(a.captured_at_utc) AS captured_at_utc MAX(a.captured_at_utc) AS captured_at_utc
FROM foundation.fct_availability_slot a FROM foundation.fct_availability_slot a
WHERE a.snapshot_date BETWEEN @start_ds AND @end_ds
GROUP BY a.snapshot_date, a.tenant_id GROUP BY a.snapshot_date, a.tenant_id
) )
SELECT SELECT

View File

@@ -3,4 +3,4 @@
Analytics-ready views consumed by the web app and programmatic SEO. Analytics-ready views consumed by the web app and programmatic SEO.
Query these from `analytics.py` via DuckDB read-only connection. 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`)

View File

@@ -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 (0100):
-- 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

View File

@@ -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 (0100):
-- 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

View File

@@ -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, 0100):
-- "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, 0100):
-- "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

View File

@@ -76,11 +76,12 @@ city_profiles AS (
city_slug, city_slug,
country_code, country_code,
city_name, city_name,
padel_venue_count, city_padel_venue_count AS padel_venue_count,
population, population,
market_score, market_score,
venues_per_100k city_venues_per_100k AS venues_per_100k
FROM serving.city_market_profile FROM serving.location_profiles
WHERE city_slug IS NOT NULL
) )
SELECT SELECT
cp.city_slug, cp.city_slug,

View File

@@ -31,10 +31,10 @@ SELECT
c.lon, c.lon,
-- Market metrics -- Market metrics
c.population, c.population,
c.padel_venue_count, c.city_padel_venue_count AS padel_venue_count,
c.venues_per_100k, c.city_venues_per_100k AS venues_per_100k,
c.market_score, c.market_score,
lop.opportunity_score, c.opportunity_score,
c.data_confidence, c.data_confidence,
-- Pricing (from Playtomic, NULL when no coverage) -- Pricing (from Playtomic, NULL when no coverage)
c.median_hourly_rate, c.median_hourly_rate,
@@ -85,15 +85,13 @@ SELECT
cc.working_capital AS "workingCapital", cc.working_capital AS "workingCapital",
cc.permits_compliance AS "permitsCompliance", cc.permits_compliance AS "permitsCompliance",
CURRENT_DATE AS refreshed_date CURRENT_DATE AS refreshed_date
FROM serving.city_market_profile c FROM serving.location_profiles c
LEFT JOIN serving.planner_defaults p LEFT JOIN serving.planner_defaults p
ON c.country_code = p.country_code ON c.country_code = p.country_code
AND c.city_slug = p.city_slug 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 LEFT JOIN foundation.dim_countries cc
ON c.country_code = cc.country_code ON c.country_code = cc.country_code
-- Only cities with actual padel presence and at least some rate data -- 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) AND (p.rate_peak IS NOT NULL OR c.median_peak_rate IS NOT NULL)

View File

@@ -1,6 +1,6 @@
-- pSEO article data: per-city padel court pricing. -- pSEO article data: per-city padel court pricing.
-- One row per city — consumed by the city-pricing.md.jinja template. -- 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). -- (population, venue count, country metadata).
-- --
-- Stricter filter than pseo_city_costs_de: requires >= 2 venues with real -- Stricter filter than pseo_city_costs_de: requires >= 2 venues with real
@@ -16,7 +16,7 @@ MODEL (
SELECT SELECT
-- Composite natural key: country_slug + city_slug ensures uniqueness across countries -- Composite natural key: country_slug + city_slug ensures uniqueness across countries
c.country_slug || '-' || c.city_slug AS city_key, 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_slug,
c.city_name, c.city_name,
c.country_code, c.country_code,
@@ -24,8 +24,8 @@ SELECT
c.country_slug, c.country_slug,
-- Market context -- Market context
c.population, c.population,
c.padel_venue_count, c.city_padel_venue_count AS padel_venue_count,
c.venues_per_100k, c.city_venues_per_100k AS venues_per_100k,
c.market_score, c.market_score,
-- Pricing benchmarks (from Playtomic availability data) -- Pricing benchmarks (from Playtomic availability data)
vpb.median_hourly_rate, vpb.median_hourly_rate,
@@ -38,9 +38,10 @@ SELECT
vpb.price_currency, vpb.price_currency,
CURRENT_DATE AS refreshed_date CURRENT_DATE AS refreshed_date
FROM serving.venue_pricing_benchmarks vpb FROM serving.venue_pricing_benchmarks vpb
-- Join city_market_profile to get the canonical city_slug and country metadata -- Join location_profiles to get canonical city metadata
INNER JOIN serving.city_market_profile c INNER JOIN serving.location_profiles c
ON vpb.country_code = c.country_code ON vpb.country_code = c.country_code
AND vpb.city_slug = c.city_slug AND vpb.city_slug = c.city_slug
AND c.city_slug IS NOT NULL
-- Only cities with enough venues for meaningful pricing statistics -- Only cities with enough venues for meaningful pricing statistics
WHERE vpb.venue_count >= 2 WHERE vpb.venue_count >= 2

View File

@@ -27,7 +27,7 @@ WITH venue_stats AS (
MAX(da.active_court_count) AS court_count, MAX(da.active_court_count) AS court_count,
COUNT(DISTINCT da.snapshot_date) AS days_observed COUNT(DISTINCT da.snapshot_date) AS days_observed
FROM foundation.fct_daily_availability da 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 IS NOT NULL
AND da.occupancy_rate BETWEEN 0 AND 1.5 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 GROUP BY da.tenant_id, da.country_code, da.city, da.city_slug, da.price_currency

View File

@@ -13,44 +13,28 @@
MODEL ( MODEL (
name staging.stg_playtomic_availability, name staging.stg_playtomic_availability,
kind FULL, kind INCREMENTAL_BY_TIME_RANGE (
time_column snapshot_date
),
start '2026-03-01',
cron '@daily', cron '@daily',
grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc) grain (snapshot_date, tenant_id, resource_id, slot_start_time, snapshot_type, captured_at_utc)
); );
WITH WITH
morning_jsonl AS ( all_jsonl AS (
SELECT SELECT
date AS snapshot_date, CAST(date AS DATE) AS snapshot_date,
captured_at_utc, captured_at_utc,
'morning' AS snapshot_type, CASE
NULL::INTEGER AS recheck_hour, WHEN filename LIKE '%_recheck_%' THEN 'recheck'
tenant_id, ELSE 'morning'
slots AS slots_json END AS snapshot_type,
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,
TRY_CAST(recheck_hour AS INTEGER) AS recheck_hour, TRY_CAST(recheck_hour AS INTEGER) AS recheck_hour,
tenant_id, tenant_id,
slots AS slots_json slots AS slots_json
FROM read_json( FROM read_json(
@LANDING_DIR || '/playtomic/*/*/availability_*_recheck_*.jsonl.gz', @LANDING_DIR || '/playtomic/*/*/availability_' || @start_ds || '*.jsonl.gz',
format = 'newline_delimited', format = 'newline_delimited',
columns = { columns = {
date: 'VARCHAR', date: 'VARCHAR',
@@ -63,11 +47,6 @@ recheck_jsonl AS (
) )
WHERE tenant_id IS NOT NULL WHERE tenant_id IS NOT NULL
), ),
all_venues AS (
SELECT * FROM morning_jsonl
UNION ALL
SELECT * FROM recheck_jsonl
),
raw_resources AS ( raw_resources AS (
SELECT SELECT
av.snapshot_date, av.snapshot_date,
@@ -76,7 +55,7 @@ raw_resources AS (
av.recheck_hour, av.recheck_hour,
av.tenant_id, av.tenant_id,
resource_json resource_json
FROM all_venues av, FROM all_jsonl av,
LATERAL UNNEST( LATERAL UNNEST(
from_json(av.slots_json, '["JSON"]') from_json(av.slots_json, '["JSON"]')
) AS t(resource_json) ) AS t(resource_json)

5
uv.lock generated
View File

@@ -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" } 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 = [ 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/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/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" }, { 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" },

View File

@@ -165,7 +165,7 @@ echo ""
echo "Press Ctrl-C to stop all processes." echo "Press Ctrl-C to stop all processes."
echo "" 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_WORKER" "worker" uv run python -u -m padelnomics.worker
run_with_label "$COLOR_CSS" "css " make css-watch run_with_label "$COLOR_CSS" "css " make css-watch

View File

@@ -111,13 +111,12 @@ _DAG: dict[str, list[str]] = {
"fct_daily_availability": ["fct_availability_slot", "dim_venue_capacity"], "fct_daily_availability": ["fct_availability_slot", "dim_venue_capacity"],
# Serving # Serving
"venue_pricing_benchmarks": ["fct_daily_availability"], "venue_pricing_benchmarks": ["fct_daily_availability"],
"city_market_profile": ["dim_cities", "venue_pricing_benchmarks"], "location_profiles": ["dim_locations", "dim_cities", "venue_pricing_benchmarks"],
"planner_defaults": ["venue_pricing_benchmarks", "city_market_profile"], "planner_defaults": ["venue_pricing_benchmarks", "location_profiles"],
"location_opportunity_profile": ["dim_locations"],
"pseo_city_costs_de": [ "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"], "pseo_country_overview": ["pseo_city_costs_de"],
} }

View File

@@ -2142,7 +2142,7 @@ async def scenario_preview(scenario_id: int):
async def scenario_pdf(scenario_id: int): async def scenario_pdf(scenario_id: int):
"""Generate and immediately download a business plan PDF for a published scenario.""" """Generate and immediately download a business plan PDF for a published scenario."""
from ..businessplan import get_plan_sections 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,)) scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
if not scenario: if not scenario:
@@ -2153,7 +2153,7 @@ async def scenario_pdf(scenario_id: int):
lang = "en" lang = "en"
state = validate_state(json.loads(scenario["state_json"])) 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 = get_plan_sections(state, d, lang)
sections["scenario_name"] = scenario["title"] sections["scenario_name"] = scenario["title"]
sections["location"] = scenario.get("location", "") sections["location"] = scenario.get("location", "")
@@ -2274,6 +2274,53 @@ async def _sync_static_articles() -> None:
template_slug, group_key, now_iso, now_iso), 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( async def _get_article_list(
status: str = None, status: str = None,
@@ -2284,25 +2331,8 @@ async def _get_article_list(
per_page: int = 50, per_page: int = 50,
) -> list[dict]: ) -> list[dict]:
"""Get articles with optional filters and pagination.""" """Get articles with optional filters and pagination."""
wheres = ["1=1"] wheres, params = _build_article_where(status=status, template_slug=template_slug,
params: list = [] language=language, search=search)
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}%")
where = " AND ".join(wheres) where = " AND ".join(wheres)
offset = (page - 1) * per_page offset = (page - 1) * per_page
params.extend([per_page, offset]) 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. 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). Each returned item has a 'variants' list (one dict per language variant).
""" """
wheres = ["1=1"] wheres, params = _build_article_where(status=status, template_slug=template_slug,
params: list = [] search=search)
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}%")
where = " AND ".join(wheres) where = " AND ".join(wheres)
offset = (page - 1) * per_page 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") @bp.route("/articles/results")
@role_required("admin") @role_required("admin")
async def article_results(): 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"]) @bp.route("/articles/bulk", methods=["POST"])
@role_required("admin") @role_required("admin")
@csrf_protect @csrf_protect
async def articles_bulk(): 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 form = await request.form
ids_raw = form.get("article_ids", "").strip()
action = form.get("action", "").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") 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 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()] 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" assert len(article_ids) <= 500, "too many article IDs in bulk action"
if not article_ids: if not article_ids:
return "", 400 return "", 400
placeholders = ",".join("?" for _ in article_ids) placeholders = ",".join("?" for _ in article_ids)
now = utcnow_iso()
if action == "publish": if action == "publish":
await execute( await execute(
@@ -2545,18 +2681,19 @@ async def articles_bulk():
elif action == "delete": elif action == "delete":
from ..content.routes import BUILD_DIR from ..content.routes import BUILD_DIR
articles = await fetch_all( articles_rows = await fetch_all(
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})", f"SELECT id, slug, template_slug FROM articles WHERE id IN ({placeholders})",
tuple(article_ids), tuple(article_ids),
) )
for a in articles: for a in articles_rows:
build_path = BUILD_DIR / f"{a['slug']}.html" build_path = BUILD_DIR / f"{a['slug']}.html"
if build_path.exists(): if build_path.exists():
build_path.unlink() 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" md_path = Path("data/content/articles") / f"{a['slug']}.md"
if md_path.exists(): if md_path.exists():
md_path.unlink() md_path.unlink()
await execute( await execute(
f"DELETE FROM articles WHERE id IN ({placeholders})", f"DELETE FROM articles WHERE id IN ({placeholders})",
tuple(article_ids), tuple(article_ids),
@@ -2565,11 +2702,6 @@ async def articles_bulk():
invalidate_sitemap_cache() invalidate_sitemap_cache()
# Re-render results partial with current filters # 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 grouped = not language_filter
if grouped: if grouped:
article_list = await _get_article_list_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 = raw[m.end():].lstrip("\n") if m else raw
body_html = mistune.html(body) if body else "" body_html = mistune.html(body) if body else ""
css_url = url_for("static", filename="css/output.css")
preview_doc = ( preview_doc = (
f"<!doctype html><html><head>" await render_template(
f"<link rel='stylesheet' href='{css_url}'>" "admin/partials/article_preview_doc.html", body_html=body_html
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
) if body_html else "" else ""
)
data = {**dict(article), "body": body} data = {**dict(article), "body": body}
return await render_template( return await render_template(
@@ -2764,13 +2896,13 @@ async def article_preview():
m = _FRONTMATTER_RE.match(body) m = _FRONTMATTER_RE.match(body)
body = body[m.end():].lstrip("\n") if m else body body = body[m.end():].lstrip("\n") if m else body
body_html = mistune.html(body) if body else "" body_html = mistune.html(body) if body else ""
css_url = url_for("static", filename="css/output.css")
preview_doc = ( preview_doc = (
f"<!doctype html><html><head>" await render_template(
f"<link rel='stylesheet' href='{css_url}'>" "admin/partials/article_preview_doc.html", body_html=body_html
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
) if body_html else "" else ""
)
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc) return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)

View File

@@ -384,7 +384,7 @@
<iframe <iframe
srcdoc="{{ preview_doc | e }}" srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;" style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin" sandbox="allow-same-origin allow-scripts"
title="Article preview" title="Article preview"
></iframe> ></iframe>
{% else %} {% else %}

View File

@@ -48,6 +48,7 @@
<label class="text-xs font-semibold text-slate block mb-1">Template</label> <label class="text-xs font-semibold text-slate block mb-1">Template</label>
<select name="template" class="form-input" style="min-width:140px"> <select name="template" class="form-input" style="min-width:140px">
<option value="">All</option> <option value="">All</option>
<option value="__manual__" {% if current_template == '__manual__' %}selected{% endif %}>Manual</option>
{% for t in template_slugs %} {% for t in template_slugs %}
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option> <option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
{% endfor %} {% endfor %}
@@ -75,12 +76,13 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="article_ids" id="article-bulk-ids" value=""> <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="action" id="article-bulk-action" value="">
<input type="hidden" name="search" value="{{ current_search }}"> <input type="hidden" name="apply_to_all" id="article-bulk-apply-to-all" value="false">
<input type="hidden" name="status" value="{{ current_status }}"> <input type="hidden" name="search" id="article-bulk-search" value="{{ current_search }}">
<input type="hidden" name="template" value="{{ current_template }}"> <input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}">
<input type="hidden" name="language" value="{{ current_language }}"> <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> </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> <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"> <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> <option value="">Action…</option>
@@ -92,6 +94,20 @@
</select> </select>
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button> <button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</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> </div>
{# Results #} {# Results #}
@@ -101,10 +117,13 @@
<script> <script>
const articleSelectedIds = new Set(); const articleSelectedIds = new Set();
let articleApplyToAll = false;
let articleMatchingCount = 0;
function toggleArticleSelect(id, checked) { function toggleArticleSelect(id, checked) {
if (checked) articleSelectedIds.add(id); if (checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id); else articleSelectedIds.delete(id);
disableApplyToAll();
updateArticleBulkBar(); updateArticleBulkBar();
} }
@@ -114,30 +133,91 @@ function toggleArticleGroupSelect(checkbox) {
if (checkbox.checked) articleSelectedIds.add(id); if (checkbox.checked) articleSelectedIds.add(id);
else articleSelectedIds.delete(id); else articleSelectedIds.delete(id);
}); });
disableApplyToAll();
updateArticleBulkBar(); updateArticleBulkBar();
} }
function clearArticleSelection() { function clearArticleSelection() {
articleSelectedIds.clear(); articleSelectedIds.clear();
articleApplyToAll = false;
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; }); document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
var selectAll = document.getElementById('article-select-all'); var selectAll = document.getElementById('article-select-all');
if (selectAll) selectAll.checked = false; if (selectAll) selectAll.checked = false;
updateArticleBulkBar(); 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() { function updateArticleBulkBar() {
var bar = document.getElementById('article-bulk-bar'); 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'); 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(','); 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() { function submitArticleBulk() {
var action = document.getElementById('article-bulk-action-select').value; var action = document.getElementById('article-bulk-action-select').value;
if (!action) return; if (!action) return;
if (articleSelectedIds.size === 0) return; if (!articleApplyToAll && articleSelectedIds.size === 0) return;
function doSubmit() { function doSubmit() {
document.getElementById('article-bulk-action').value = action; document.getElementById('article-bulk-action').value = action;
@@ -150,7 +230,13 @@ function submitArticleBulk() {
} }
if (action === 'delete') { 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(); if (ok) doSubmit();
}); });
} else { } 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) { document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'article-results') { if (evt.detail.target.id === 'article-results') {
document.querySelectorAll('.article-checkbox').forEach(function(cb) { document.querySelectorAll('.article-checkbox').forEach(function(cb) {

View File

@@ -4,7 +4,7 @@
<iframe <iframe
srcdoc="{{ preview_doc | e }}" srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;" style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin" sandbox="allow-same-origin allow-scripts"
title="Article preview" title="Article preview"
></iframe> ></iframe>
{% else %} {% else %}

View File

@@ -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>

View File

@@ -171,7 +171,7 @@
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
placeholder="-- SELECT * FROM serving.city_market_profile&#10;-- WHERE country_code = 'DE'&#10;-- ORDER BY marktreife_score DESC&#10;-- LIMIT 20" placeholder="-- SELECT * FROM serving.location_profiles&#10;-- WHERE country_code = 'DE' AND city_slug IS NOT NULL&#10;-- ORDER BY market_score DESC&#10;-- LIMIT 20"
></textarea> ></textarea>
<div class="query-controls"> <div class="query-controls">

View File

@@ -3,6 +3,10 @@
{% block title %}Preview - {{ preview.title }} - Admin{% endblock %} {% 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 %} {% block admin_content %}
<a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">&larr; Back to template</a> <a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">&larr; Back to template</a>
@@ -21,11 +25,14 @@
</div> </div>
</div> </div>
{# Rendered article #} {# Rendered article — overflow:visible needed so Leaflet tile layers render #}
<div class="card"> <div class="card" style="overflow: visible;">
<h2 class="text-lg mb-4">Rendered HTML</h2> <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 }} {{ preview.html | safe }}
</div> </div>
</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 %} {% endblock %}

View File

@@ -13,7 +13,7 @@ Usage:
rows = await fetch_analytics("SELECT * FROM serving.planner_defaults WHERE city_slug = ?", ["berlin"]) 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 asyncio
import logging import logging

View File

@@ -8,7 +8,7 @@ daily when the pipeline runs).
from quart import Blueprint, abort, jsonify from quart import Blueprint, abort, jsonify
from .analytics import fetch_analytics from .analytics import fetch_analytics
from .core import is_flag_enabled from .core import fetch_all, is_flag_enabled
bp = Blueprint("api", __name__) bp = Blueprint("api", __name__)
@@ -32,12 +32,14 @@ async def countries():
rows = await fetch_analytics(""" rows = await fetch_analytics("""
SELECT country_code, country_name_en, country_slug, SELECT country_code, country_name_en, country_slug,
COUNT(*) AS city_count, 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(market_score), 1) AS avg_market_score,
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
AVG(lat) AS lat, AVG(lon) AS lon 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 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 ORDER BY total_venues DESC
""") """)
return jsonify(rows), 200, _CACHE_HEADERS return jsonify(rows), 200, _CACHE_HEADERS
@@ -51,14 +53,29 @@ async def country_cities(country_slug: str):
rows = await fetch_analytics( rows = await fetch_analytics(
""" """
SELECT city_name, city_slug, lat, lon, SELECT city_name, city_slug, lat, lon,
padel_venue_count, market_score, population city_padel_venue_count AS padel_venue_count,
FROM serving.city_market_profile market_score, opportunity_score, population
WHERE country_slug = ? FROM serving.location_profiles
ORDER BY padel_venue_count DESC WHERE country_slug = ? AND city_slug IS NOT NULL
ORDER BY city_padel_venue_count DESC
LIMIT 200 LIMIT 200
""", """,
[country_slug], [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 return jsonify(rows), 200, _CACHE_HEADERS
@@ -88,9 +105,10 @@ async def opportunity(country_slug: str):
rows = await fetch_analytics( rows = await fetch_analytics(
""" """
SELECT location_name, location_slug, lat, lon, 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 padel_venue_count, population
FROM serving.location_opportunity_profile FROM serving.location_profiles
WHERE country_slug = ? AND opportunity_score > 0 WHERE country_slug = ? AND opportunity_score > 0
ORDER BY opportunity_score DESC ORDER BY opportunity_score DESC
LIMIT 500 LIMIT 500

View File

@@ -5,7 +5,7 @@ import json
import time import time
from pathlib import Path 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 .analytics import close_analytics_db, open_analytics_db
from .core import ( from .core import (
@@ -270,6 +270,40 @@ def create_app() -> Quart:
from .sitemap import sitemap_response from .sitemap import sitemap_response
return await sitemap_response(config.BASE_URL) 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 # Health check
@app.route("/health") @app.route("/health")
async def health(): async def health():

View File

@@ -60,106 +60,6 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
(function() { <script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
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 = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <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>
{% endblock %} {% endblock %}

View File

@@ -102,9 +102,11 @@
if (!c.lat || !c.lon) return; if (!c.lat || !c.lon) return;
var size = 12 + 44 * Math.sqrt(c.total_venues / maxV); var size = 12 + 44 * Math.sqrt(c.total_venues / maxV);
var color = scoreColor(c.avg_market_score); 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>' var tip = '<strong>' + c.country_name_en + '</strong><br>'
+ c.total_venues + ' venues · ' + c.city_count + ' cities<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) }) L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; }) .on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; })

View File

@@ -1825,5 +1825,16 @@
"affiliate_pros_label": "Vorteile", "affiliate_pros_label": "Vorteile",
"affiliate_cons_label": "Nachteile", "affiliate_cons_label": "Nachteile",
"affiliate_at_retailer": "bei {retailer}", "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"
} }

View File

@@ -1828,5 +1828,16 @@
"affiliate_pros_label": "Pros", "affiliate_pros_label": "Pros",
"affiliate_cons_label": "Cons", "affiliate_cons_label": "Cons",
"affiliate_at_retailer": "at {retailer}", "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"
} }

View File

@@ -80,7 +80,8 @@ async def opportunity_map():
abort(404) abort(404)
countries = await fetch_analytics(""" countries = await fetch_analytics("""
SELECT DISTINCT country_slug, country_name_en 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 ORDER BY country_name_en
""") """)
return await render_template("opportunity_map.html", countries=countries) return await render_template("opportunity_map.html", countries=countries)

View File

@@ -104,8 +104,10 @@
var dist = loc.nearest_padel_court_km != null var dist = loc.nearest_padel_court_km != null
? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court' ? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court'
: 'No nearby courts'; : 'No nearby courts';
var mktColor = loc.market_score >= 60 ? '#16A34A' : (loc.market_score >= 30 ? '#D97706' : '#DC2626');
var tip = '<strong>' + loc.location_name + '</strong><br>' 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); + dist + ' · Pop. ' + fmtPop(loc.population);
L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) }) L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })

View File

@@ -892,6 +892,18 @@
transform: scale(1.1); 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 */ /* Small fixed venue dot */
.pn-venue { .pn-venue {
width: 10px; width: 10px;

View File

@@ -455,6 +455,8 @@
border-radius: 18px; border-radius: 18px;
padding: 1rem; padding: 1rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.04); box-shadow: 0 1px 4px rgba(0,0,0,0.04);
min-width: 0;
overflow: hidden;
} }
.chart-container__label { .chart-container__label {
font-size: 11px; font-size: 11px;

View 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 = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <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);
})();

View 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 %}

View 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 %}

View File

@@ -737,7 +737,7 @@ async def handle_run_extraction(payload: dict) -> None:
@task("run_transform") @task("run_transform")
async def handle_run_transform(payload: dict) -> None: 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`. Shells out to `uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply`.
2-hour absolute timeout — same as extraction. 2-hour absolute timeout — same as extraction.

View File

@@ -64,7 +64,7 @@ def serving_meta_dir():
meta = { meta = {
"exported_at_utc": "2026-02-25T08:30:00+00:00", "exported_at_utc": "2026-02-25T08:30:00+00:00",
"tables": { "tables": {
"city_market_profile": {"row_count": 612}, "location_profiles": {"row_count": 612},
"planner_defaults": {"row_count": 612}, "planner_defaults": {"row_count": 612},
"pseo_city_costs_de": {"row_count": 487}, "pseo_city_costs_de": {"row_count": 487},
}, },
@@ -78,16 +78,16 @@ def serving_meta_dir():
# ── Schema + query mocks ────────────────────────────────────────────────────── # ── Schema + query mocks ──────────────────────────────────────────────────────
_MOCK_SCHEMA_ROWS = [ _MOCK_SCHEMA_ROWS = [
{"table_name": "city_market_profile", "column_name": "city_slug", "data_type": "VARCHAR", "ordinal_position": 1}, {"table_name": "location_profiles", "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": "location_profiles", "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": "market_score", "data_type": "DOUBLE", "ordinal_position": 3},
{"table_name": "planner_defaults", "column_name": "city_slug", "data_type": "VARCHAR", "ordinal_position": 1}, {"table_name": "planner_defaults", "column_name": "city_slug", "data_type": "VARCHAR", "ordinal_position": 1},
] ]
_MOCK_TABLE_EXISTS = [{"1": 1}] _MOCK_TABLE_EXISTS = [{"1": 1}]
_MOCK_SAMPLE_ROWS = [ _MOCK_SAMPLE_ROWS = [
{"city_slug": "berlin", "country_code": "DE", "marktreife_score": 82.5}, {"city_slug": "berlin", "country_code": "DE", "market_score": 82.5},
{"city_slug": "munich", "country_code": "DE", "marktreife_score": 77.0}, {"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]] return [r for r in _MOCK_SCHEMA_ROWS if r["table_name"] == params[0]]
if "information_schema.columns" in sql: if "information_schema.columns" in sql:
return _MOCK_SCHEMA_ROWS return _MOCK_SCHEMA_ROWS
if "city_market_profile" in sql: if "location_profiles" in sql:
return _MOCK_SAMPLE_ROWS return _MOCK_SAMPLE_ROWS
return [] return []
return _mock 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") resp = await admin_client.get("/admin/pipeline/overview")
assert resp.status_code == 200 assert resp.status_code == 200
data = await resp.get_data(as_text=True) 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 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") resp = await admin_client.get("/admin/pipeline/catalog")
assert resp.status_code == 200 assert resp.status_code == 200
data = await resp.get_data(as_text=True) 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 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): async def test_pipeline_table_detail(admin_client):
"""Table detail returns columns and sample rows.""" """Table detail returns columns and sample rows."""
with patch("padelnomics.analytics.fetch_analytics", side_effect=_make_fetch_analytics_mock()): 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 assert resp.status_code == 200
data = await resp.get_data(as_text=True) data = await resp.get_data(as_text=True)
assert "city_slug" in data 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) data = await resp.get_data(as_text=True)
assert "query-editor" in data assert "query-editor" in data
assert "schema-panel" in data assert "schema-panel" in data
assert "city_market_profile" in data assert "location_profiles" in data
@pytest.mark.asyncio @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): with patch("padelnomics.analytics.execute_user_query", new_callable=AsyncMock, return_value=mock_result):
resp = await admin_client.post( resp = await admin_client.post(
"/admin/pipeline/query/execute", "/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 assert resp.status_code == 200
data = await resp.get_data(as_text=True) 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: with patch("padelnomics.analytics.execute_user_query", new_callable=AsyncMock) as mock_q:
resp = await admin_client.post( resp = await admin_client.post(
"/admin/pipeline/query/execute", "/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 assert resp.status_code == 200
data = await resp.get_data(as_text=True) 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")): with patch.object(pipeline_mod, "_SERVING_DUCKDB_PATH", str(Path(serving_meta_dir) / "analytics.duckdb")):
meta = pipeline_mod._load_serving_meta() meta = pipeline_mod._load_serving_meta()
assert meta is not None assert meta is not None
assert "city_market_profile" in meta["tables"] assert "location_profiles" in meta["tables"]
assert meta["tables"]["city_market_profile"]["row_count"] == 612 assert meta["tables"]["location_profiles"]["row_count"] == 612
def test_load_serving_meta_missing(): def test_load_serving_meta_missing():