Compare commits
54 Commits
v202603051
...
v202603062
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fee0d6913b | ||
|
|
71e08a5fa6 | ||
|
|
27e86db6a1 | ||
|
|
90754b8d9f | ||
|
|
277c92e507 | ||
|
|
77ec3a289f | ||
|
|
f81d5f19da | ||
|
|
4d29ecf1d6 | ||
|
|
a3b4e1fab6 | ||
|
|
8b794d24a6 | ||
|
|
688f2dd1ee | ||
|
|
81b556b205 | ||
|
|
cda94c9ee4 | ||
|
|
4fbd91b59b | ||
|
|
159d1b5b9a | ||
|
|
fcd0c9b007 | ||
|
|
f841ae105a | ||
|
|
dec4f07fbb | ||
|
|
4e4ff61699 | ||
|
|
f907f2cd60 | ||
|
|
3ad2885c84 | ||
|
|
e2f54552b0 | ||
|
|
07ca1ce15b | ||
|
|
be9b10c13f | ||
|
|
82d6333517 | ||
|
|
ed48936dad | ||
|
|
e3bda5b816 | ||
|
|
831233cb29 | ||
|
|
c5327c4012 | ||
|
|
4426ab2cb6 | ||
|
|
93c9408f6b | ||
|
|
84128a3a64 | ||
|
|
e9b4faa05c | ||
|
|
a834bb481d | ||
|
|
9515ec8ae9 | ||
|
|
fb99d6e0db | ||
|
|
4ee80603ef | ||
|
|
2e42245ad5 | ||
|
|
2f47d1e589 | ||
|
|
ead12c4552 | ||
|
|
c54eb50004 | ||
|
|
5d7fcec17a | ||
|
|
f7faf7ab57 | ||
|
|
add5f8ddfa | ||
|
|
15ca316682 | ||
|
|
103ef73cf5 | ||
|
|
aa27f14f3c | ||
|
|
8205744444 | ||
|
|
1cbefe349c | ||
|
|
003f19e071 | ||
|
|
c3f15535b8 | ||
|
|
fcb8ec4227 | ||
|
|
6b7fa45bce | ||
|
|
0d8687859d |
@@ -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
|
||||||
|
|||||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -6,7 +6,30 @@ 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
|
### Added
|
||||||
|
- **Custom 404/500 error pages** — styled error pages extending `base.html` with i18n support (EN/DE). The 404 page is context-aware: when the URL matches `/markets/{country}/{city}`, it shows a city-specific message with a link back to the country overview instead of a generic "page not found".
|
||||||
|
- **Map: city article indicators** — country overview map bubbles now differentiate cities with/without published articles. All cities retain score-based colors (green/amber/red); non-article cities are visually receded with lower opacity, dashed borders, desaturated color, and default cursor (no click). Tooltips show scores for all cities — article cities get "Click to explore →", non-article cities get "Coming soon". The `/api/markets/<country>/cities.json` endpoint includes a `has_article` boolean per city.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Admin template preview maps** — Leaflet maps rendered blank because `article-maps.js` called `L.divIcon()` at the IIFE top level before Leaflet was dynamically loaded, crashing the script. Moved `VENUE_ICON` creation into the `script.onload` callback so it runs after Leaflet is available. Previous commit's `.card` `overflow: visible` fix remains (clips tile layers otherwise).
|
||||||
|
- **Admin articles page 500** — `/admin/articles` crashed with `BuildError` when an article generation task was running because `article_stats.html` partial referenced `url_for('admin.article_stats')` but the route didn't exist. Added the missing HTMX partial endpoint.
|
||||||
|
- **Silent 500 errors in dev** — `dev_run.sh` used Granian which swallowed Quart's debug error pages, showing generic "Internal Server Error" with no traceback. Switched to `uv run python -m padelnomics.app` for proper debug mode with browser tracebacks. Added `@app.errorhandler(500)` to log exceptions even when running under Granian in production.
|
||||||
|
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — handle DuckDB catalog naming quirk where `lakehouse.duckdb` uses catalog `lakehouse` instead of `local`, causing SQLMesh logical views to break. Script now auto-detects the catalog via `USE`, and falls back to querying physical tables (`sqlmesh__<schema>.<table>__<hash>`) when views fail.
|
||||||
|
- **Eurostat gas prices extractor** — `nrg_pc_203` filter missing `unit` dimension (API returns both KWH and GJ_GCV); now filters to `KWH`.
|
||||||
|
- **Eurostat labour costs extractor** — `lc_lci_lev` used non-existent `currency` filter dimension; corrected to `unit: EUR`.
|
||||||
|
- **Supervisor transform step** — changed `sqlmesh run` to `sqlmesh plan prod --auto-apply` so new/modified models are detected and applied automatically.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — read-only script that reports row counts at every layer of the pricing pipeline (staging → foundation → serving), date range analysis, HAVING filter impact, and join coverage. Run on prod to diagnose empty serving tables.
|
||||||
|
- **Extraction card descriptions** — each workflow card on the admin pipeline page now shows a one-line description explaining what the data source is (e.g. "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"). Descriptions defined in `workflows.toml`.
|
||||||
|
- **Running state indicator** — extraction cards show a spinner + "Running" label with a blue-tinted border when an extraction is actively running, replacing the plain Run button. Cards also display the start time with "running..." text.
|
||||||
|
|
||||||
- **Interactive Leaflet maps** — geographic visualization across 4 key placements using self-hosted Leaflet 1.9.4 (GDPR-safe, no CDN):
|
- **Interactive Leaflet maps** — geographic visualization across 4 key placements using self-hosted Leaflet 1.9.4 (GDPR-safe, no CDN):
|
||||||
- **Markets hub** (`/markets`): country bubble map with circles sized by total venues, colored by avg market score (green ≥ 60, amber 30-60, red < 30). Click navigates to country overview.
|
- **Markets hub** (`/markets`): country bubble map with circles sized by total venues, colored by avg market score (green ≥ 60, amber 30-60, red < 30). Click navigates to country overview.
|
||||||
- **Country overview articles**: city bubble map loads after article render, auto-fits bounds, click navigates to city page. Bubbles colored by market score.
|
- **Country overview articles**: city bubble map loads after article render, auto-fits bounds, click navigates to city page. Bubbles colored by market score.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ WORKDIR /app
|
|||||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||||
COPY --from=build --chown=appuser:appuser /app .
|
COPY --from=build --chown=appuser:appuser /app .
|
||||||
COPY --from=css-build /app/web/src/padelnomics/static/css/output.css ./web/src/padelnomics/static/css/output.css
|
COPY --from=css-build /app/web/src/padelnomics/static/css/output.css ./web/src/padelnomics/static/css/output.css
|
||||||
|
COPY --chown=appuser:appuser infra/supervisor/workflows.toml ./infra/supervisor/workflows.toml
|
||||||
USER appuser
|
USER appuser
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV DATABASE_PATH=/app/data/app.db
|
ENV DATABASE_PATH=/app/data/app.db
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -8,54 +8,67 @@
|
|||||||
# entry — optional: function name if not "main" (default: "main")
|
# entry — optional: function name if not "main" (default: "main")
|
||||||
# depends_on — optional: list of workflow names that must run first
|
# depends_on — optional: list of workflow names that must run first
|
||||||
# proxy_mode — optional: "round-robin" (default) or "sticky"
|
# proxy_mode — optional: "round-robin" (default) or "sticky"
|
||||||
|
# description — optional: human-readable one-liner shown in the admin UI
|
||||||
|
|
||||||
[overpass]
|
[overpass]
|
||||||
module = "padelnomics_extract.overpass"
|
module = "padelnomics_extract.overpass"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
description = "Padel court locations from OpenStreetMap via Overpass API"
|
||||||
|
|
||||||
[overpass_tennis]
|
[overpass_tennis]
|
||||||
module = "padelnomics_extract.overpass_tennis"
|
module = "padelnomics_extract.overpass_tennis"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
description = "Tennis court locations from OpenStreetMap via Overpass API"
|
||||||
|
|
||||||
[eurostat]
|
[eurostat]
|
||||||
module = "padelnomics_extract.eurostat"
|
module = "padelnomics_extract.eurostat"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
description = "City population data from Eurostat Urban Audit"
|
||||||
|
|
||||||
[geonames]
|
[geonames]
|
||||||
module = "padelnomics_extract.geonames"
|
module = "padelnomics_extract.geonames"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
description = "Global city/town gazetteer from GeoNames (pop >= 1K)"
|
||||||
|
|
||||||
[playtomic_tenants]
|
[playtomic_tenants]
|
||||||
module = "padelnomics_extract.playtomic_tenants"
|
module = "padelnomics_extract.playtomic_tenants"
|
||||||
schedule = "daily"
|
schedule = "daily"
|
||||||
|
description = "Padel venue directory from Playtomic (names, locations, courts)"
|
||||||
|
|
||||||
[playtomic_availability]
|
[playtomic_availability]
|
||||||
module = "padelnomics_extract.playtomic_availability"
|
module = "padelnomics_extract.playtomic_availability"
|
||||||
schedule = "daily"
|
schedule = "daily"
|
||||||
depends_on = ["playtomic_tenants"]
|
depends_on = ["playtomic_tenants"]
|
||||||
|
description = "Morning availability snapshots — slot-level pricing per venue"
|
||||||
|
|
||||||
[playtomic_recheck]
|
[playtomic_recheck]
|
||||||
module = "padelnomics_extract.playtomic_availability"
|
module = "padelnomics_extract.playtomic_availability"
|
||||||
entry = "main_recheck"
|
entry = "main_recheck"
|
||||||
schedule = "0,30 6-23 * * *"
|
schedule = "0,30 6-23 * * *"
|
||||||
depends_on = ["playtomic_availability"]
|
depends_on = ["playtomic_availability"]
|
||||||
|
description = "Intraday availability rechecks for occupancy tracking"
|
||||||
|
|
||||||
[census_usa]
|
[census_usa]
|
||||||
module = "padelnomics_extract.census_usa"
|
module = "padelnomics_extract.census_usa"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
description = "US city/place population from Census Bureau ACS"
|
||||||
|
|
||||||
[census_usa_income]
|
[census_usa_income]
|
||||||
module = "padelnomics_extract.census_usa_income"
|
module = "padelnomics_extract.census_usa_income"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
description = "US county median household income from Census Bureau ACS"
|
||||||
|
|
||||||
[eurostat_city_labels]
|
[eurostat_city_labels]
|
||||||
module = "padelnomics_extract.eurostat_city_labels"
|
module = "padelnomics_extract.eurostat_city_labels"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
description = "City code-to-name mapping for Eurostat Urban Audit cities"
|
||||||
|
|
||||||
[ons_uk]
|
[ons_uk]
|
||||||
module = "padelnomics_extract.ons_uk"
|
module = "padelnomics_extract.ons_uk"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
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"
|
||||||
|
|||||||
290
scripts/check_pipeline.py
Normal file
290
scripts/check_pipeline.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
Diagnostic script: check row counts at every layer of the pricing pipeline.
|
||||||
|
|
||||||
|
Run on prod via SSH:
|
||||||
|
DUCKDB_PATH=/opt/padelnomics/data/lakehouse.duckdb uv run python scripts/check_pipeline.py
|
||||||
|
|
||||||
|
Or locally:
|
||||||
|
DUCKDB_PATH=data/lakehouse.duckdb uv run python scripts/check_pipeline.py
|
||||||
|
|
||||||
|
Read-only — never writes to the database.
|
||||||
|
|
||||||
|
Handles the DuckDB catalog naming quirk: when the file is named lakehouse.duckdb,
|
||||||
|
the catalog is "lakehouse" not "local". SQLMesh views may reference the wrong catalog,
|
||||||
|
so we fall back to querying physical tables (sqlmesh__<schema>.<table>__<hash>).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
|
||||||
|
DUCKDB_PATH = os.environ.get("DUCKDB_PATH", "data/lakehouse.duckdb")
|
||||||
|
|
||||||
|
PIPELINE_TABLES = [
|
||||||
|
("staging", "stg_playtomic_availability"),
|
||||||
|
("foundation", "fct_availability_slot"),
|
||||||
|
("foundation", "dim_venue_capacity"),
|
||||||
|
("foundation", "fct_daily_availability"),
|
||||||
|
("serving", "venue_pricing_benchmarks"),
|
||||||
|
("serving", "pseo_city_pricing"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _use_catalog(con):
|
||||||
|
"""Detect and USE the database catalog so schema-qualified queries work."""
|
||||||
|
catalogs = [
|
||||||
|
row[0]
|
||||||
|
for row in con.execute(
|
||||||
|
"SELECT catalog_name FROM information_schema.schemata"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
# Pick the non-system catalog (not 'system', 'temp', 'memory')
|
||||||
|
user_catalogs = [c for c in set(catalogs) if c not in ("system", "temp", "memory")]
|
||||||
|
if user_catalogs:
|
||||||
|
catalog = user_catalogs[0]
|
||||||
|
con.execute(f"USE {catalog}")
|
||||||
|
return catalog
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_physical_table(con, schema, table):
|
||||||
|
"""Find the SQLMesh physical table name for a logical table.
|
||||||
|
|
||||||
|
SQLMesh stores physical tables as:
|
||||||
|
sqlmesh__<schema>.<schema>__<table>__<hash>
|
||||||
|
"""
|
||||||
|
sqlmesh_schema = f"sqlmesh__{schema}"
|
||||||
|
try:
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT table_schema, table_name "
|
||||||
|
"FROM information_schema.tables "
|
||||||
|
f"WHERE table_schema = '{sqlmesh_schema}' "
|
||||||
|
f"AND table_name LIKE '{schema}__{table}%' "
|
||||||
|
"ORDER BY table_name "
|
||||||
|
"LIMIT 1"
|
||||||
|
).fetchall()
|
||||||
|
if rows:
|
||||||
|
return f"{rows[0][0]}.{rows[0][1]}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _query_table(con, schema, table):
|
||||||
|
"""Try logical view first, fall back to physical table. Returns (fqn, count) or (fqn, error_str)."""
|
||||||
|
logical = f"{schema}.{table}"
|
||||||
|
try:
|
||||||
|
(count,) = con.execute(f"SELECT COUNT(*) FROM {logical}").fetchone()
|
||||||
|
return logical, count
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
physical = _find_physical_table(con, schema, table)
|
||||||
|
if physical:
|
||||||
|
try:
|
||||||
|
(count,) = con.execute(f"SELECT COUNT(*) FROM {physical}").fetchone()
|
||||||
|
return f"{physical} (physical)", count
|
||||||
|
except Exception as e:
|
||||||
|
return f"{physical} (physical)", f"ERROR: {e}"
|
||||||
|
|
||||||
|
return logical, "ERROR: view broken, no physical table found"
|
||||||
|
|
||||||
|
|
||||||
|
def _query_sql(con, sql, schema_tables):
|
||||||
|
"""Execute SQL, falling back to rewritten SQL using physical table names if views fail.
|
||||||
|
|
||||||
|
schema_tables: list of (schema, table) tuples used in the SQL, in order of appearance.
|
||||||
|
The SQL must use {schema}.{table} format for these references.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return con.execute(sql)
|
||||||
|
except Exception:
|
||||||
|
# Rewrite SQL to use physical table names
|
||||||
|
rewritten = sql
|
||||||
|
for schema, table in schema_tables:
|
||||||
|
physical = _find_physical_table(con, schema, table)
|
||||||
|
if physical:
|
||||||
|
rewritten = rewritten.replace(f"{schema}.{table}", physical)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
return con.execute(rewritten)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(DUCKDB_PATH):
|
||||||
|
print(f"ERROR: {DUCKDB_PATH} not found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
con = duckdb.connect(DUCKDB_PATH, read_only=True)
|
||||||
|
|
||||||
|
print(f"Database: {DUCKDB_PATH}")
|
||||||
|
print(f"DuckDB version: {con.execute('SELECT version()').fetchone()[0]}")
|
||||||
|
|
||||||
|
catalog = _use_catalog(con)
|
||||||
|
if catalog:
|
||||||
|
print(f"Catalog: {catalog}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Row counts at each layer ──────────────────────────────────────────
|
||||||
|
print("=" * 60)
|
||||||
|
print("PIPELINE ROW COUNTS")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for schema, table in PIPELINE_TABLES:
|
||||||
|
fqn, result = _query_table(con, schema, table)
|
||||||
|
if isinstance(result, int):
|
||||||
|
print(f" {fqn:55s} {result:>10,} rows")
|
||||||
|
else:
|
||||||
|
print(f" {fqn:55s} {result}")
|
||||||
|
|
||||||
|
# ── Date range in fct_daily_availability ──────────────────────────────
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("DATE RANGE: fct_daily_availability")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = _query_sql(
|
||||||
|
con,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
MIN(snapshot_date) AS min_date,
|
||||||
|
MAX(snapshot_date) AS max_date,
|
||||||
|
COUNT(DISTINCT snapshot_date) AS distinct_days,
|
||||||
|
CURRENT_DATE AS today,
|
||||||
|
CURRENT_DATE - INTERVAL '30 days' AS window_start
|
||||||
|
FROM foundation.fct_daily_availability
|
||||||
|
""",
|
||||||
|
[("foundation", "fct_daily_availability")],
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
min_date, max_date, days, today, window_start = row
|
||||||
|
print(f" Min snapshot_date: {min_date}")
|
||||||
|
print(f" Max snapshot_date: {max_date}")
|
||||||
|
print(f" Distinct days: {days}")
|
||||||
|
print(f" Today: {today}")
|
||||||
|
print(f" 30-day window start: {window_start}")
|
||||||
|
if max_date and str(max_date) < str(window_start):
|
||||||
|
print()
|
||||||
|
print(" *** ALL DATA IS OUTSIDE THE 30-DAY WINDOW ***")
|
||||||
|
print(" This is why venue_pricing_benchmarks is empty.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
|
||||||
|
# ── HAVING filter impact in venue_pricing_benchmarks ──────────────────
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("HAVING FILTER IMPACT (venue_pricing_benchmarks)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = _query_sql(
|
||||||
|
con,
|
||||||
|
"""
|
||||||
|
WITH venue_stats AS (
|
||||||
|
SELECT
|
||||||
|
da.tenant_id,
|
||||||
|
da.country_code,
|
||||||
|
da.city,
|
||||||
|
COUNT(DISTINCT da.snapshot_date) AS days_observed
|
||||||
|
FROM foundation.fct_daily_availability da
|
||||||
|
WHERE TRY_CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
|
AND da.occupancy_rate IS NOT NULL
|
||||||
|
AND da.occupancy_rate BETWEEN 0 AND 1.5
|
||||||
|
GROUP BY da.tenant_id, da.country_code, da.city
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_venues,
|
||||||
|
COUNT(*) FILTER (WHERE days_observed >= 3) AS venues_passing_having,
|
||||||
|
COUNT(*) FILTER (WHERE days_observed < 3) AS venues_failing_having,
|
||||||
|
MAX(days_observed) AS max_days,
|
||||||
|
MIN(days_observed) AS min_days
|
||||||
|
FROM venue_stats
|
||||||
|
""",
|
||||||
|
[("foundation", "fct_daily_availability")],
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
total, passing, failing, max_d, min_d = row
|
||||||
|
print(f" Venues in 30-day window: {total}")
|
||||||
|
print(f" Venues with >= 3 days (PASSING): {passing}")
|
||||||
|
print(f" Venues with < 3 days (FILTERED): {failing}")
|
||||||
|
print(f" Max days observed: {max_d}")
|
||||||
|
print(f" Min days observed: {min_d}")
|
||||||
|
if total == 0:
|
||||||
|
print()
|
||||||
|
print(" *** NO VENUES IN 30-DAY WINDOW — check fct_daily_availability dates ***")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
|
||||||
|
# ── Occupancy rate distribution ───────────────────────────────────────
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("OCCUPANCY RATE DISTRIBUTION (fct_daily_availability)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = _query_sql(
|
||||||
|
con,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN occupancy_rate IS NULL THEN 'NULL'
|
||||||
|
WHEN occupancy_rate < 0 THEN '< 0 (invalid)'
|
||||||
|
WHEN occupancy_rate > 1.5 THEN '> 1.5 (filtered)'
|
||||||
|
WHEN occupancy_rate <= 0.25 THEN '0 – 0.25'
|
||||||
|
WHEN occupancy_rate <= 0.50 THEN '0.25 – 0.50'
|
||||||
|
WHEN occupancy_rate <= 0.75 THEN '0.50 – 0.75'
|
||||||
|
ELSE '0.75 – 1.0+'
|
||||||
|
END AS bucket,
|
||||||
|
COUNT(*) AS cnt
|
||||||
|
FROM foundation.fct_daily_availability
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1
|
||||||
|
""",
|
||||||
|
[("foundation", "fct_daily_availability")],
|
||||||
|
).fetchall()
|
||||||
|
for bucket, cnt in rows:
|
||||||
|
print(f" {bucket:25s} {cnt:>10,}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
|
||||||
|
# ── dim_venue_capacity join coverage ──────────────────────────────────
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("JOIN COVERAGE: fct_availability_slot → dim_venue_capacity")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = _query_sql(
|
||||||
|
con,
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT a.tenant_id) AS slot_tenants,
|
||||||
|
COUNT(DISTINCT c.tenant_id) AS capacity_tenants,
|
||||||
|
COUNT(DISTINCT a.tenant_id) - COUNT(DISTINCT c.tenant_id) AS missing_capacity
|
||||||
|
FROM foundation.fct_availability_slot a
|
||||||
|
LEFT JOIN foundation.dim_venue_capacity c ON a.tenant_id = c.tenant_id
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
("foundation", "fct_availability_slot"),
|
||||||
|
("foundation", "dim_venue_capacity"),
|
||||||
|
],
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
slot_t, cap_t, missing = row
|
||||||
|
print(f" Tenants in fct_availability_slot: {slot_t}")
|
||||||
|
print(f" Tenants with capacity match: {cap_t}")
|
||||||
|
print(f" Tenants missing capacity: {missing}")
|
||||||
|
if missing and missing > 0:
|
||||||
|
print(f" *** {missing} tenants dropped by INNER JOIN to dim_venue_capacity ***")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
|
||||||
|
con.close()
|
||||||
|
print()
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 ─┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
-- One Big Table: per-city padel market intelligence.
|
|
||||||
-- Consumed by: SEO article generation, planner city-select pre-fill, API endpoints.
|
|
||||||
--
|
|
||||||
-- Padelnomics Marktreife-Score v3 (0–100):
|
|
||||||
-- Answers "How mature/established is this padel market?"
|
|
||||||
-- Only computed for cities with ≥1 padel venue (padel_venue_count > 0).
|
|
||||||
-- For white-space opportunity scoring, see serving.location_opportunity_profile.
|
|
||||||
--
|
|
||||||
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
|
|
||||||
-- (min(1, count/5) kills small-town inflation)
|
|
||||||
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
|
|
||||||
-- 15 pts addressable market — log-scaled population, ceiling 1M (context only)
|
|
||||||
-- 10 pts economic context — income PPS normalised to 200 ceiling
|
|
||||||
-- 10 pts data quality — completeness discount
|
|
||||||
-- No saturation discount: high density = maturity, not a penalty
|
|
||||||
|
|
||||||
MODEL (
|
|
||||||
name serving.city_market_profile,
|
|
||||||
kind FULL,
|
|
||||||
cron '@daily',
|
|
||||||
grain (country_code, city_slug)
|
|
||||||
);
|
|
||||||
|
|
||||||
WITH base AS (
|
|
||||||
SELECT
|
|
||||||
c.country_code,
|
|
||||||
c.country_name_en,
|
|
||||||
c.country_slug,
|
|
||||||
c.city_name,
|
|
||||||
c.city_slug,
|
|
||||||
c.lat,
|
|
||||||
c.lon,
|
|
||||||
c.population,
|
|
||||||
c.population_year,
|
|
||||||
c.padel_venue_count,
|
|
||||||
c.median_income_pps,
|
|
||||||
c.income_year,
|
|
||||||
c.geoname_id,
|
|
||||||
-- Venue density: padel venues per 100K residents
|
|
||||||
CASE WHEN c.population > 0
|
|
||||||
THEN ROUND(c.padel_venue_count::DOUBLE / c.population * 100000, 2)
|
|
||||||
ELSE NULL
|
|
||||||
END AS venues_per_100k,
|
|
||||||
-- Data confidence: 1.0 if both population and venues are present
|
|
||||||
CASE
|
|
||||||
WHEN c.population > 0 AND c.padel_venue_count > 0 THEN 1.0
|
|
||||||
WHEN c.population > 0 OR c.padel_venue_count > 0 THEN 0.5
|
|
||||||
ELSE 0.0
|
|
||||||
END AS data_confidence,
|
|
||||||
-- Pricing / occupancy from Playtomic (NULL when no availability data)
|
|
||||||
vpb.median_hourly_rate,
|
|
||||||
vpb.median_peak_rate,
|
|
||||||
vpb.median_offpeak_rate,
|
|
||||||
vpb.median_occupancy_rate,
|
|
||||||
vpb.median_daily_revenue_per_venue,
|
|
||||||
vpb.price_currency
|
|
||||||
FROM foundation.dim_cities c
|
|
||||||
LEFT JOIN serving.venue_pricing_benchmarks vpb
|
|
||||||
ON c.country_code = vpb.country_code
|
|
||||||
AND c.city_slug = vpb.city_slug
|
|
||||||
WHERE c.padel_venue_count > 0
|
|
||||||
),
|
|
||||||
scored AS (
|
|
||||||
SELECT *,
|
|
||||||
ROUND(
|
|
||||||
-- Supply development (40 pts): THE maturity signal.
|
|
||||||
-- Log-scaled density: LN(density+1)/LN(21) → 20/100k ≈ full marks.
|
|
||||||
-- Count gate: min(1, count/5) — 1 venue=20%, 5+ venues=100%.
|
|
||||||
-- Kills small-town inflation (1 court / 5k pop = 20/100k) without hard cutoffs.
|
|
||||||
40.0 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
|
|
||||||
* LEAST(1.0, padel_venue_count / 5.0)
|
|
||||||
-- Demand evidence (25 pts): occupancy when Playtomic data available.
|
|
||||||
-- Fallback: 40% of density score (avoids double-counting with supply component).
|
|
||||||
+ 25.0 * CASE
|
|
||||||
WHEN median_occupancy_rate IS NOT NULL
|
|
||||||
THEN LEAST(1.0, median_occupancy_rate / 0.65)
|
|
||||||
ELSE 0.4 * LEAST(1.0, LN(COALESCE(venues_per_100k, 0) + 1) / LN(21))
|
|
||||||
* LEAST(1.0, padel_venue_count / 5.0)
|
|
||||||
END
|
|
||||||
-- Addressable market (15 pts): population as context, not maturity signal.
|
|
||||||
-- LN(1) = 0 so zero-pop cities score 0 here.
|
|
||||||
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
|
|
||||||
-- Economic context (10 pts): country-level income PPS.
|
|
||||||
-- Flat per country — kept as context modifier, not primary signal.
|
|
||||||
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
|
|
||||||
-- Data quality (10 pts): completeness discount.
|
|
||||||
+ 10.0 * data_confidence
|
|
||||||
, 1)
|
|
||||||
AS market_score
|
|
||||||
FROM base
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
s.country_code,
|
|
||||||
s.country_name_en,
|
|
||||||
s.country_slug,
|
|
||||||
s.city_name,
|
|
||||||
s.city_slug,
|
|
||||||
s.lat,
|
|
||||||
s.lon,
|
|
||||||
s.population,
|
|
||||||
s.population_year,
|
|
||||||
s.padel_venue_count,
|
|
||||||
s.venues_per_100k,
|
|
||||||
s.data_confidence,
|
|
||||||
s.market_score,
|
|
||||||
s.median_income_pps,
|
|
||||||
s.income_year,
|
|
||||||
s.median_hourly_rate,
|
|
||||||
s.median_peak_rate,
|
|
||||||
s.median_offpeak_rate,
|
|
||||||
s.median_occupancy_rate,
|
|
||||||
s.median_daily_revenue_per_venue,
|
|
||||||
s.price_currency,
|
|
||||||
s.geoname_id,
|
|
||||||
CURRENT_DATE AS refreshed_date
|
|
||||||
FROM scored s
|
|
||||||
ORDER BY s.market_score DESC
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
-- Per-location padel investment opportunity intelligence.
|
|
||||||
-- Consumed by: Gemeinde-level pSEO pages, opportunity map, "top markets" lists.
|
|
||||||
--
|
|
||||||
-- Padelnomics Marktpotenzial-Score v2 (0–100):
|
|
||||||
-- Answers "Where should I build a padel court?"
|
|
||||||
-- Covers ALL GeoNames locations (pop ≥ 1K) — NOT filtered to existing padel markets.
|
|
||||||
-- Zero-court locations score highest on supply gap component (white space = opportunity).
|
|
||||||
--
|
|
||||||
-- 25 pts addressable market — log-scaled population, ceiling 500K
|
|
||||||
-- (opportunity peaks in mid-size cities; megacities already served)
|
|
||||||
-- 20 pts economic power — country income PPS, normalised to 35,000
|
|
||||||
-- EU PPS values range 18k-37k; /35k gives real spread.
|
|
||||||
-- DE ≈ 13.2pts, ES ≈ 10.7pts, SE ≈ 14.3pts.
|
|
||||||
-- Previously /200 caused all countries to saturate at 20/20.
|
|
||||||
-- 30 pts supply gap — INVERTED venue density; 0 courts/100K = full marks.
|
|
||||||
-- Ceiling raised to 8/100K (was 4) for a gentler gradient
|
|
||||||
-- and to account for ~87% data undercount vs FIP totals.
|
|
||||||
-- Linear: GREATEST(0, 1 - density/8)
|
|
||||||
-- 15 pts catchment gap — distance to nearest padel court.
|
|
||||||
-- DuckDB LEAST ignores NULLs: LEAST(1.0, NULL/30) = 1.0,
|
|
||||||
-- so NULL nearest_km = full marks (no court in bounding box
|
|
||||||
-- = high opportunity). COALESCE fallback is dead code.
|
|
||||||
-- 10 pts sports culture — tennis courts within 25km (≥10 = full marks).
|
|
||||||
-- NOTE: dim_locations tennis data is empty (all 0 rows).
|
|
||||||
-- Component contributes 0 pts everywhere until data lands.
|
|
||||||
|
|
||||||
MODEL (
|
|
||||||
name serving.location_opportunity_profile,
|
|
||||||
kind FULL,
|
|
||||||
cron '@daily',
|
|
||||||
grain (country_code, geoname_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
l.geoname_id,
|
|
||||||
l.country_code,
|
|
||||||
l.country_name_en,
|
|
||||||
l.country_slug,
|
|
||||||
l.location_name,
|
|
||||||
l.location_slug,
|
|
||||||
l.lat,
|
|
||||||
l.lon,
|
|
||||||
l.admin1_code,
|
|
||||||
l.admin2_code,
|
|
||||||
l.population,
|
|
||||||
l.population_year,
|
|
||||||
l.median_income_pps,
|
|
||||||
l.income_year,
|
|
||||||
l.padel_venue_count,
|
|
||||||
l.padel_venues_per_100k,
|
|
||||||
l.nearest_padel_court_km,
|
|
||||||
l.tennis_courts_within_25km,
|
|
||||||
ROUND(
|
|
||||||
-- Addressable market (25 pts): log-scaled to 500K ceiling.
|
|
||||||
-- Lower ceiling than Marktreife (1M) — opportunity peaks in mid-size cities
|
|
||||||
-- that can support a court but aren't already saturated by large-city operators.
|
|
||||||
25.0 * LEAST(1.0, LN(GREATEST(l.population, 1)) / LN(500000))
|
|
||||||
|
|
||||||
-- Economic power (20 pts): country-level income PPS normalised to 35,000.
|
|
||||||
-- Drives willingness-to-pay for court fees (€20-35/hr target range).
|
|
||||||
-- EU PPS values range 18k-37k; ceiling 35k gives meaningful spread.
|
|
||||||
-- v1 used /200 which caused LEAST(1.0, 115) = 1.0 for ALL countries (flat, no differentiation).
|
|
||||||
-- v2: /35000 → DE 0.66×20=13.2pts, ES 0.53×20=10.7pts, SE 0.71×20=14.3pts.
|
|
||||||
-- Default 15000 for missing data = reasonable developing-market assumption (~0.43).
|
|
||||||
+ 20.0 * LEAST(1.0, COALESCE(l.median_income_pps, 15000) / 35000.0)
|
|
||||||
|
|
||||||
-- Supply gap (30 pts): INVERTED venue density.
|
|
||||||
-- 0 courts/100K = full 30 pts (white space); ≥8/100K = 0 pts (served market).
|
|
||||||
-- Ceiling raised from 4→8/100K for a gentler gradient and to account for data
|
|
||||||
-- undercount (~87% of real courts not in our data).
|
|
||||||
-- This is the key signal that separates Marktpotenzial from Marktreife.
|
|
||||||
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(l.padel_venues_per_100k, 0) / 8.0)
|
|
||||||
|
|
||||||
-- Catchment gap (15 pts): distance to nearest existing padel court.
|
|
||||||
-- >30km = full 15 pts (underserved catchment area).
|
|
||||||
-- NULL = no courts found anywhere (rare edge case) → neutral 0.5.
|
|
||||||
+ 15.0 * COALESCE(LEAST(1.0, l.nearest_padel_court_km / 30.0), 0.5)
|
|
||||||
|
|
||||||
-- Sports culture proxy (10 pts): tennis courts within 25km.
|
|
||||||
-- ≥10 courts = full 10 pts (proven racket sport market = faster padel adoption).
|
|
||||||
-- 0 courts = 0 pts. Many new padel courts open inside existing tennis clubs.
|
|
||||||
+ 10.0 * LEAST(1.0, l.tennis_courts_within_25km / 10.0)
|
|
||||||
, 1) AS opportunity_score,
|
|
||||||
CURRENT_DATE AS refreshed_date
|
|
||||||
FROM foundation.dim_locations l
|
|
||||||
ORDER BY opportunity_score DESC
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
-- Unified location profile: both scores at (country_code, geoname_id) grain.
|
||||||
|
-- Base: dim_locations (ALL GeoNames locations, pop ≥ 1K, ~140K rows).
|
||||||
|
-- Enriched with dim_cities (city_slug, city_name, exact venue count) and
|
||||||
|
-- venue_pricing_benchmarks (Playtomic pricing/occupancy).
|
||||||
|
--
|
||||||
|
-- Two scores per location:
|
||||||
|
--
|
||||||
|
-- Padelnomics Market Score (Marktreife-Score v3, 0–100):
|
||||||
|
-- "How mature/established is this padel market?"
|
||||||
|
-- Only meaningful for locations matched to a dim_cities row (city_slug IS NOT NULL)
|
||||||
|
-- with padel venues. 0 for all other locations.
|
||||||
|
--
|
||||||
|
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
|
||||||
|
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
|
||||||
|
-- 15 pts addressable market — log-scaled population, ceiling 1M
|
||||||
|
-- 10 pts economic context — income PPS normalised to 200 ceiling
|
||||||
|
-- 10 pts data quality — completeness discount
|
||||||
|
--
|
||||||
|
-- Padelnomics Opportunity Score (Marktpotenzial-Score v3, 0–100):
|
||||||
|
-- "Where should I build a padel court?"
|
||||||
|
-- Computed for ALL locations — zero-court locations score highest on supply gap.
|
||||||
|
-- H3 catchment methodology: addressable market and supply gap use a regional
|
||||||
|
-- H3 catchment (res-5 cell + 6 neighbours, ~24km radius).
|
||||||
|
--
|
||||||
|
-- 25 pts addressable market — log-scaled catchment population, ceiling 500K
|
||||||
|
-- 20 pts economic power — income PPS, normalised to 35,000
|
||||||
|
-- 30 pts supply gap — inverted catchment venue density; 0 courts = full marks
|
||||||
|
-- 15 pts catchment gap — distance to nearest padel court
|
||||||
|
-- 10 pts sports culture — tennis courts within 25km
|
||||||
|
--
|
||||||
|
-- Consumers query directly with WHERE filters:
|
||||||
|
-- cities API: WHERE country_slug = ? AND city_slug IS NOT NULL
|
||||||
|
-- opportunity API: WHERE country_slug = ? AND opportunity_score > 0
|
||||||
|
-- planner_defaults: WHERE city_slug IS NOT NULL
|
||||||
|
-- pseo_*: WHERE city_slug IS NOT NULL AND city_padel_venue_count > 0
|
||||||
|
|
||||||
|
MODEL (
|
||||||
|
name serving.location_profiles,
|
||||||
|
kind FULL,
|
||||||
|
cron '@daily',
|
||||||
|
grain (country_code, geoname_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
WITH
|
||||||
|
-- All locations from dim_locations (superset)
|
||||||
|
base AS (
|
||||||
|
SELECT
|
||||||
|
l.geoname_id,
|
||||||
|
l.country_code,
|
||||||
|
l.country_name_en,
|
||||||
|
l.country_slug,
|
||||||
|
l.location_name,
|
||||||
|
l.location_slug,
|
||||||
|
l.lat,
|
||||||
|
l.lon,
|
||||||
|
l.admin1_code,
|
||||||
|
l.admin2_code,
|
||||||
|
l.population,
|
||||||
|
l.population_year,
|
||||||
|
l.median_income_pps,
|
||||||
|
l.income_year,
|
||||||
|
l.padel_venue_count,
|
||||||
|
l.padel_venues_per_100k,
|
||||||
|
l.nearest_padel_court_km,
|
||||||
|
l.tennis_courts_within_25km,
|
||||||
|
l.h3_cell_res5
|
||||||
|
FROM foundation.dim_locations l
|
||||||
|
),
|
||||||
|
-- Aggregate population and court counts per H3 cell (res 5, ~8.5km edge).
|
||||||
|
-- Grouping by cell first (~50-80K distinct cells vs 140K locations) keeps the
|
||||||
|
-- subsequent lateral join small.
|
||||||
|
hex_stats AS (
|
||||||
|
SELECT
|
||||||
|
h3_cell_res5,
|
||||||
|
SUM(population) AS hex_population,
|
||||||
|
SUM(padel_venue_count) AS hex_padel_courts
|
||||||
|
FROM foundation.dim_locations
|
||||||
|
GROUP BY h3_cell_res5
|
||||||
|
),
|
||||||
|
-- For each location, sum hex_stats across the cell + 6 neighbours (k_ring=1).
|
||||||
|
-- Effective catchment: ~24km radius — realistic driving distance.
|
||||||
|
catchment AS (
|
||||||
|
SELECT
|
||||||
|
l.geoname_id,
|
||||||
|
SUM(hs.hex_population) AS catchment_population,
|
||||||
|
SUM(hs.hex_padel_courts) AS catchment_padel_courts
|
||||||
|
FROM base l,
|
||||||
|
LATERAL (SELECT UNNEST(h3_grid_disk(l.h3_cell_res5, 1)) AS cell) ring
|
||||||
|
JOIN hex_stats hs ON hs.h3_cell_res5 = ring.cell
|
||||||
|
GROUP BY l.geoname_id
|
||||||
|
),
|
||||||
|
-- Match dim_cities via (country_code, geoname_id) to get city_slug + exact venue count.
|
||||||
|
-- QUALIFY handles rare multi-city-per-geoname collisions (keep highest venue count).
|
||||||
|
city_match AS (
|
||||||
|
SELECT
|
||||||
|
c.country_code,
|
||||||
|
c.geoname_id,
|
||||||
|
c.city_slug,
|
||||||
|
c.city_name,
|
||||||
|
c.padel_venue_count AS city_padel_venue_count
|
||||||
|
FROM foundation.dim_cities c
|
||||||
|
WHERE c.geoname_id IS NOT NULL
|
||||||
|
QUALIFY ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY c.country_code, c.geoname_id
|
||||||
|
ORDER BY c.padel_venue_count DESC
|
||||||
|
) = 1
|
||||||
|
),
|
||||||
|
-- Pricing / occupancy from Playtomic (via city_slug) + H3 catchment
|
||||||
|
with_pricing AS (
|
||||||
|
SELECT
|
||||||
|
b.*,
|
||||||
|
cm.city_slug,
|
||||||
|
cm.city_name,
|
||||||
|
cm.city_padel_venue_count,
|
||||||
|
vpb.median_hourly_rate,
|
||||||
|
vpb.median_peak_rate,
|
||||||
|
vpb.median_offpeak_rate,
|
||||||
|
vpb.median_occupancy_rate,
|
||||||
|
vpb.median_daily_revenue_per_venue,
|
||||||
|
vpb.price_currency,
|
||||||
|
COALESCE(ct.catchment_population, b.population)::BIGINT AS catchment_population,
|
||||||
|
COALESCE(ct.catchment_padel_courts, b.padel_venue_count)::INTEGER AS catchment_padel_courts
|
||||||
|
FROM base b
|
||||||
|
LEFT JOIN city_match cm
|
||||||
|
ON b.country_code = cm.country_code
|
||||||
|
AND b.geoname_id = cm.geoname_id
|
||||||
|
LEFT JOIN serving.venue_pricing_benchmarks vpb
|
||||||
|
ON cm.country_code = vpb.country_code
|
||||||
|
AND cm.city_slug = vpb.city_slug
|
||||||
|
LEFT JOIN catchment ct
|
||||||
|
ON b.geoname_id = ct.geoname_id
|
||||||
|
),
|
||||||
|
-- Both scores computed from the enriched base
|
||||||
|
scored AS (
|
||||||
|
SELECT *,
|
||||||
|
-- City-level venue density (from dim_cities exact count, not dim_locations spatial 5km)
|
||||||
|
CASE WHEN population > 0
|
||||||
|
THEN ROUND(COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000, 2)
|
||||||
|
ELSE NULL
|
||||||
|
END AS city_venues_per_100k,
|
||||||
|
-- Data confidence (for market_score)
|
||||||
|
CASE
|
||||||
|
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
|
||||||
|
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
|
||||||
|
ELSE 0.0
|
||||||
|
END AS data_confidence,
|
||||||
|
-- ── Market Score (Marktreife-Score v3) ──────────────────────────────────
|
||||||
|
-- 0 when no city match or no venues (city_padel_venue_count NULL or 0)
|
||||||
|
CASE WHEN COALESCE(city_padel_venue_count, 0) > 0 THEN
|
||||||
|
ROUND(
|
||||||
|
-- Supply development (40 pts)
|
||||||
|
40.0 * LEAST(1.0, LN(
|
||||||
|
COALESCE(
|
||||||
|
CASE WHEN population > 0
|
||||||
|
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
||||||
|
ELSE 0 END
|
||||||
|
, 0) + 1) / LN(21))
|
||||||
|
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
|
||||||
|
-- Demand evidence (25 pts)
|
||||||
|
+ 25.0 * CASE
|
||||||
|
WHEN median_occupancy_rate IS NOT NULL
|
||||||
|
THEN LEAST(1.0, median_occupancy_rate / 0.65)
|
||||||
|
ELSE 0.4 * LEAST(1.0, LN(
|
||||||
|
COALESCE(
|
||||||
|
CASE WHEN population > 0
|
||||||
|
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
||||||
|
ELSE 0 END
|
||||||
|
, 0) + 1) / LN(21))
|
||||||
|
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
|
||||||
|
END
|
||||||
|
-- Addressable market (15 pts)
|
||||||
|
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
|
||||||
|
-- Economic context (10 pts)
|
||||||
|
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
|
||||||
|
-- Data quality (10 pts)
|
||||||
|
+ 10.0 * CASE
|
||||||
|
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
|
||||||
|
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
|
||||||
|
ELSE 0.0
|
||||||
|
END
|
||||||
|
, 1)
|
||||||
|
ELSE 0
|
||||||
|
END AS market_score,
|
||||||
|
-- ── Opportunity Score (Marktpotenzial-Score v3, H3 catchment) ──────────
|
||||||
|
ROUND(
|
||||||
|
-- Addressable market (25 pts): log-scaled catchment population, ceiling 500K
|
||||||
|
25.0 * LEAST(1.0, LN(GREATEST(catchment_population, 1)) / LN(500000))
|
||||||
|
-- Economic power (20 pts): income PPS normalised to 35,000
|
||||||
|
+ 20.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
|
||||||
|
-- Supply gap (30 pts): inverted catchment venue density
|
||||||
|
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(
|
||||||
|
CASE WHEN catchment_population > 0
|
||||||
|
THEN catchment_padel_courts::DOUBLE / catchment_population * 100000
|
||||||
|
ELSE 0.0
|
||||||
|
END, 0.0) / 8.0)
|
||||||
|
-- Catchment gap (15 pts): distance to nearest court
|
||||||
|
+ 15.0 * COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.5)
|
||||||
|
-- Sports culture (10 pts): tennis courts within 25km
|
||||||
|
+ 10.0 * LEAST(1.0, tennis_courts_within_25km / 10.0)
|
||||||
|
, 1) AS opportunity_score
|
||||||
|
FROM with_pricing
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.geoname_id,
|
||||||
|
s.country_code,
|
||||||
|
s.country_name_en,
|
||||||
|
s.country_slug,
|
||||||
|
s.location_name,
|
||||||
|
s.location_slug,
|
||||||
|
s.city_slug,
|
||||||
|
s.city_name,
|
||||||
|
s.lat,
|
||||||
|
s.lon,
|
||||||
|
s.admin1_code,
|
||||||
|
s.admin2_code,
|
||||||
|
s.population,
|
||||||
|
s.population_year,
|
||||||
|
s.median_income_pps,
|
||||||
|
s.income_year,
|
||||||
|
s.padel_venue_count,
|
||||||
|
s.padel_venues_per_100k,
|
||||||
|
s.nearest_padel_court_km,
|
||||||
|
s.tennis_courts_within_25km,
|
||||||
|
s.city_padel_venue_count,
|
||||||
|
s.city_venues_per_100k,
|
||||||
|
s.data_confidence,
|
||||||
|
s.catchment_population,
|
||||||
|
s.catchment_padel_courts,
|
||||||
|
CASE WHEN s.catchment_population > 0
|
||||||
|
THEN ROUND(s.catchment_padel_courts::DOUBLE / s.catchment_population * 100000, 2)
|
||||||
|
ELSE NULL
|
||||||
|
END AS catchment_venues_per_100k,
|
||||||
|
s.market_score,
|
||||||
|
s.opportunity_score,
|
||||||
|
s.median_hourly_rate,
|
||||||
|
s.median_peak_rate,
|
||||||
|
s.median_offpeak_rate,
|
||||||
|
s.median_occupancy_rate,
|
||||||
|
s.median_daily_revenue_per_venue,
|
||||||
|
s.price_currency,
|
||||||
|
CURRENT_DATE AS refreshed_date
|
||||||
|
FROM scored s
|
||||||
|
ORDER BY s.market_score DESC, s.opportunity_score DESC
|
||||||
@@ -76,11 +76,12 @@ city_profiles AS (
|
|||||||
city_slug,
|
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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
5
uv.lock
generated
@@ -150,6 +150,11 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
|
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" },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +539,7 @@ def _load_workflows() -> list[dict]:
|
|||||||
"schedule": schedule,
|
"schedule": schedule,
|
||||||
"schedule_label": schedule_label,
|
"schedule_label": schedule_label,
|
||||||
"depends_on": config.get("depends_on", []),
|
"depends_on": config.get("depends_on", []),
|
||||||
|
"description": config.get("description", ""),
|
||||||
})
|
})
|
||||||
return workflows
|
return workflows
|
||||||
|
|
||||||
|
|||||||
@@ -2465,6 +2465,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():
|
||||||
@@ -2736,13 +2748,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 +2776,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -16,8 +16,9 @@
|
|||||||
{% set wf = row.workflow %}
|
{% set wf = row.workflow %}
|
||||||
{% set run = row.run %}
|
{% set run = row.run %}
|
||||||
{% set stale = row.stale %}
|
{% set stale = row.stale %}
|
||||||
<div style="border:1px solid #E2E8F0;border-radius:10px;padding:0.875rem;background:#FAFAFA">
|
{% set is_running = run and run.status == 'running' and not stale %}
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div style="border:1px solid {% if is_running %}#93C5FD{% else %}#E2E8F0{% endif %};border-radius:10px;padding:0.875rem;background:{% if is_running %}#EFF6FF{% else %}#FAFAFA{% endif %}">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
{% if not run %}
|
{% if not run %}
|
||||||
<span class="status-dot pending"></span>
|
<span class="status-dot pending"></span>
|
||||||
{% elif stale %}
|
{% elif stale %}
|
||||||
@@ -33,6 +34,15 @@
|
|||||||
{% if stale %}
|
{% if stale %}
|
||||||
<span class="badge-warning" style="font-size:10px;padding:1px 6px;margin-left:auto">stale</span>
|
<span class="badge-warning" style="font-size:10px;padding:1px 6px;margin-left:auto">stale</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if is_running %}
|
||||||
|
<span class="btn btn-sm ml-auto"
|
||||||
|
style="padding:2px 8px;font-size:11px;opacity:0.6;cursor:default;pointer-events:none">
|
||||||
|
<svg class="spinner-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Running
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-sm ml-auto"
|
class="btn btn-sm ml-auto"
|
||||||
style="padding:2px 8px;font-size:11px"
|
style="padding:2px 8px;font-size:11px"
|
||||||
@@ -41,9 +51,17 @@
|
|||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-vals='{"extractor": "{{ wf.name }}", "csrf_token": "{{ csrf_token() }}"}'
|
hx-vals='{"extractor": "{{ wf.name }}", "csrf_token": "{{ csrf_token() }}"}'
|
||||||
hx-confirm="Run {{ wf.name }} extractor?">Run</button>
|
hx-confirm="Run {{ wf.name }} extractor?">Run</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if wf.description %}
|
||||||
|
<p class="text-xs text-slate" style="margin-top:2px;margin-bottom:4px">{{ wf.description }}</p>
|
||||||
|
{% endif %}
|
||||||
<p class="text-xs text-slate">{{ wf.schedule_label }}</p>
|
<p class="text-xs text-slate">{{ wf.schedule_label }}</p>
|
||||||
{% if run %}
|
{% if is_running %}
|
||||||
|
<p class="text-xs mt-1" style="color:#2563EB">
|
||||||
|
Started {{ run.started_at[:16].replace('T', ' ') if run.started_at else '—' }} — running...
|
||||||
|
</p>
|
||||||
|
{% elif run %}
|
||||||
<p class="text-xs mono text-slate-dark mt-1">{{ run.started_at[:16].replace('T', ' ') if run.started_at else '—' }}</p>
|
<p class="text-xs mono text-slate-dark mt-1">{{ run.started_at[:16].replace('T', ' ') if run.started_at else '—' }}</p>
|
||||||
{% if run.status == 'failed' and run.error_message %}
|
{% if run.status == 'failed' and run.error_message %}
|
||||||
<p class="text-xs text-danger mt-1" style="font-family:monospace;word-break:break-all">
|
<p class="text-xs text-danger mt-1" style="font-family:monospace;word-break:break-all">
|
||||||
|
|||||||
@@ -171,7 +171,7 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
placeholder="-- SELECT * FROM serving.city_market_profile -- WHERE country_code = 'DE' -- ORDER BY marktreife_score DESC -- LIMIT 20"
|
placeholder="-- SELECT * FROM serving.location_profiles -- WHERE country_code = 'DE' AND city_slug IS NOT NULL -- ORDER BY market_score DESC -- LIMIT 20"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<div class="query-controls">
|
<div class="query-controls">
|
||||||
|
|||||||
@@ -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">← Back to template</a>
|
<a href="{{ url_for('admin.template_detail', slug=config.slug) }}" class="text-sm text-slate">← Back to template</a>
|
||||||
|
|
||||||
@@ -21,11 +25,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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 = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
|
||||||
|
|
||||||
function scoreColor(score) {
|
|
||||||
if (score >= 60) return '#16A34A';
|
|
||||||
if (score >= 30) return '#D97706';
|
|
||||||
return '#DC2626';
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeIcon(size, color) {
|
|
||||||
var s = Math.round(size);
|
|
||||||
return L.divIcon({
|
|
||||||
className: '',
|
|
||||||
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.82;"></div>',
|
|
||||||
iconSize: [s, s],
|
|
||||||
iconAnchor: [s / 2, s / 2],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initCountryMap(el) {
|
|
||||||
var slug = el.dataset.countrySlug;
|
|
||||||
var map = L.map(el, {scrollWheelZoom: false});
|
|
||||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
|
||||||
var lang = document.documentElement.lang || 'en';
|
|
||||||
fetch('/api/markets/' + slug + '/cities.json')
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
if (!data.length) return;
|
|
||||||
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
|
|
||||||
var bounds = [];
|
|
||||||
data.forEach(function(c) {
|
|
||||||
if (!c.lat || !c.lon) return;
|
|
||||||
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
|
|
||||||
var color = scoreColor(c.market_score);
|
|
||||||
var pop = c.population >= 1000000
|
|
||||||
? (c.population / 1000000).toFixed(1) + 'M'
|
|
||||||
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
|
|
||||||
var tip = '<strong>' + c.city_name + '</strong><br>'
|
|
||||||
+ (c.padel_venue_count || 0) + ' venues'
|
|
||||||
+ (pop ? ' · ' + pop : '') + '<br>'
|
|
||||||
+ '<span style="color:' + color + ';font-weight:600;">Score ' + Math.round(c.market_score) + '/100</span>';
|
|
||||||
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
|
|
||||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
|
||||||
.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; })
|
|
||||||
.addTo(map);
|
|
||||||
bounds.push([c.lat, c.lon]);
|
|
||||||
});
|
|
||||||
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var VENUE_ICON = L.divIcon({
|
|
||||||
className: '',
|
|
||||||
html: '<div class="pn-venue"></div>',
|
|
||||||
iconSize: [10, 10],
|
|
||||||
iconAnchor: [5, 5],
|
|
||||||
});
|
|
||||||
|
|
||||||
function initCityMap(el) {
|
|
||||||
var countrySlug = el.dataset.countrySlug;
|
|
||||||
var citySlug = el.dataset.citySlug;
|
|
||||||
var lat = parseFloat(el.dataset.lat);
|
|
||||||
var lon = parseFloat(el.dataset.lon);
|
|
||||||
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
|
|
||||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
|
||||||
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
data.forEach(function(v) {
|
|
||||||
if (!v.lat || !v.lon) return;
|
|
||||||
var indoor = v.indoor_court_count || 0;
|
|
||||||
var outdoor = v.outdoor_court_count || 0;
|
|
||||||
var total = v.court_count || (indoor + outdoor);
|
|
||||||
var courtLine = total
|
|
||||||
? total + ' court' + (total > 1 ? 's' : '')
|
|
||||||
+ (indoor || outdoor
|
|
||||||
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
|
|
||||||
: '')
|
|
||||||
: '';
|
|
||||||
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
|
|
||||||
L.marker([v.lat, v.lon], { icon: VENUE_ICON })
|
|
||||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
|
|
||||||
.addTo(map);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var script = document.createElement('script');
|
|
||||||
script.src = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
|
|
||||||
script.onload = function() {
|
|
||||||
if (countryMapEl) initCountryMap(countryMapEl);
|
|
||||||
if (cityMapEl) initCityMap(cityMapEl);
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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; })
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)] })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
122
web/src/padelnomics/static/js/article-maps.js
Normal file
122
web/src/padelnomics/static/js/article-maps.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Leaflet map initialisation for article pages (country + city maps).
|
||||||
|
*
|
||||||
|
* Looks for #country-map and #city-map elements. If neither exists, does nothing.
|
||||||
|
* Expects data-* attributes on the map elements and a global LEAFLET_JS_URL
|
||||||
|
* variable pointing to the Leaflet JS bundle.
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
var countryMapEl = document.getElementById('country-map');
|
||||||
|
var cityMapEl = document.getElementById('city-map');
|
||||||
|
if (!countryMapEl && !cityMapEl) return;
|
||||||
|
|
||||||
|
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||||
|
var TILES_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
||||||
|
|
||||||
|
function scoreColor(score) {
|
||||||
|
if (score >= 60) return '#16A34A';
|
||||||
|
if (score >= 30) return '#D97706';
|
||||||
|
return '#DC2626';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIcon(size, color, muted) {
|
||||||
|
var s = Math.round(size);
|
||||||
|
var cls = 'pn-marker' + (muted ? ' pn-marker--muted' : '');
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: '<div class="' + cls + '" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';"></div>',
|
||||||
|
iconSize: [s, s],
|
||||||
|
iconAnchor: [s / 2, s / 2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCountryMap(el) {
|
||||||
|
var slug = el.dataset.countrySlug;
|
||||||
|
var map = L.map(el, {scrollWheelZoom: false});
|
||||||
|
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||||
|
var lang = document.documentElement.lang || 'en';
|
||||||
|
fetch('/api/markets/' + slug + '/cities.json')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.length) return;
|
||||||
|
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
|
||||||
|
var bounds = [];
|
||||||
|
data.forEach(function(c) {
|
||||||
|
if (!c.lat || !c.lon) return;
|
||||||
|
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
|
||||||
|
var hasArticle = c.has_article !== false;
|
||||||
|
var color = scoreColor(c.market_score);
|
||||||
|
var pop = c.population >= 1000000
|
||||||
|
? (c.population / 1000000).toFixed(1) + 'M'
|
||||||
|
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
|
||||||
|
var oppColor = c.opportunity_score >= 60 ? '#16A34A' : (c.opportunity_score >= 30 ? '#D97706' : '#3B82F6');
|
||||||
|
var tip = '<strong>' + c.city_name + '</strong><br>'
|
||||||
|
+ (c.padel_venue_count || 0) + ' venues'
|
||||||
|
+ (pop ? ' · ' + pop : '')
|
||||||
|
+ '<br><span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + Math.round(c.market_score) + '/100</span>'
|
||||||
|
+ '<br><span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + Math.round(c.opportunity_score || 0) + '/100</span>';
|
||||||
|
if (hasArticle) {
|
||||||
|
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Click to explore →</span>';
|
||||||
|
} else {
|
||||||
|
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Coming soon</span>';
|
||||||
|
}
|
||||||
|
var marker = L.marker([c.lat, c.lon], { icon: makeIcon(size, color, !hasArticle) })
|
||||||
|
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||||
|
.addTo(map);
|
||||||
|
if (hasArticle) {
|
||||||
|
marker.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; });
|
||||||
|
}
|
||||||
|
bounds.push([c.lat, c.lon]);
|
||||||
|
});
|
||||||
|
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
||||||
|
})
|
||||||
|
.catch(function(err) { console.error('Country map fetch failed:', err); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCityMap(el, venueIcon) {
|
||||||
|
var countrySlug = el.dataset.countrySlug;
|
||||||
|
var citySlug = el.dataset.citySlug;
|
||||||
|
var lat = parseFloat(el.dataset.lat);
|
||||||
|
var lon = parseFloat(el.dataset.lon);
|
||||||
|
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
|
||||||
|
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||||
|
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
data.forEach(function(v) {
|
||||||
|
if (!v.lat || !v.lon) return;
|
||||||
|
var indoor = v.indoor_court_count || 0;
|
||||||
|
var outdoor = v.outdoor_court_count || 0;
|
||||||
|
var total = v.court_count || (indoor + outdoor);
|
||||||
|
var courtLine = total
|
||||||
|
? total + ' court' + (total > 1 ? 's' : '')
|
||||||
|
+ (indoor || outdoor
|
||||||
|
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
|
||||||
|
: '')
|
||||||
|
: '';
|
||||||
|
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
|
||||||
|
L.marker([v.lat, v.lon], { icon: venueIcon })
|
||||||
|
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
|
||||||
|
.addTo(map);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(err) { console.error('City map fetch failed:', err); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dynamically load Leaflet JS then init maps */
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.src = window.LEAFLET_JS_URL || '/static/vendor/leaflet/leaflet.min.js';
|
||||||
|
script.onload = function() {
|
||||||
|
if (countryMapEl) initCountryMap(countryMapEl);
|
||||||
|
if (cityMapEl) {
|
||||||
|
var venueIcon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: '<div class="pn-venue"></div>',
|
||||||
|
iconSize: [10, 10],
|
||||||
|
iconAnchor: [5, 5],
|
||||||
|
});
|
||||||
|
initCityMap(cityMapEl, venueIcon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
})();
|
||||||
23
web/src/padelnomics/templates/404.html
Normal file
23
web/src/padelnomics/templates/404.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.error_404_title }} — {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-page py-12">
|
||||||
|
<div style="max-width:28rem;margin:0 auto;text-align:center;">
|
||||||
|
<p style="font-size:6rem;font-weight:800;line-height:1;color:var(--slate);opacity:0.3;margin:0;">404</p>
|
||||||
|
<h1 class="text-navy" style="font-size:1.5rem;font-weight:700;margin:1rem 0 0.5rem;">{{ t.error_404_heading }}</h1>
|
||||||
|
{% if country_slug %}
|
||||||
|
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_404_city_message }}</p>
|
||||||
|
<a href="/{{ lang }}/markets/{{ country_slug }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||||
|
{{ t.error_404_back_country.replace('{country}', country_name) }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_404_message }}</p>
|
||||||
|
<a href="/{{ lang }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||||
|
{{ t.error_404_back_home }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
16
web/src/padelnomics/templates/500.html
Normal file
16
web/src/padelnomics/templates/500.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.error_500_title }} — {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-page py-12">
|
||||||
|
<div style="max-width:28rem;margin:0 auto;text-align:center;">
|
||||||
|
<p style="font-size:6rem;font-weight:800;line-height:1;color:var(--slate);opacity:0.3;margin:0;">500</p>
|
||||||
|
<h1 class="text-navy" style="font-size:1.5rem;font-weight:700;margin:1rem 0 0.5rem;">{{ t.error_500_heading }}</h1>
|
||||||
|
<p class="text-slate" style="font-size:1rem;line-height:1.6;">{{ t.error_500_message }}</p>
|
||||||
|
<a href="/{{ lang }}" class="btn" style="margin-top:1.5rem;display:inline-block;">
|
||||||
|
{{ t.error_500_back_home }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -737,7 +737,7 @@ async def handle_run_extraction(payload: dict) -> None:
|
|||||||
|
|
||||||
@task("run_transform")
|
@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.
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user