Compare commits
45 Commits
v202603051
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e537bfd9d3 | ||
|
|
a27da79705 | ||
|
|
8d86669360 | ||
|
|
7d523250f7 | ||
|
|
fee0d6913b | ||
|
|
71e08a5fa6 | ||
|
|
27e86db6a1 | ||
|
|
90754b8d9f | ||
|
|
277c92e507 | ||
|
|
77ec3a289f | ||
|
|
f81d5f19da | ||
|
|
4d29ecf1d6 | ||
|
|
a3b4e1fab6 | ||
|
|
8b794d24a6 | ||
|
|
688f2dd1ee | ||
|
|
81b556b205 | ||
|
|
cda94c9ee4 | ||
|
|
4fbd91b59b | ||
|
|
159d1b5b9a | ||
|
|
fcd0c9b007 | ||
|
|
f841ae105a | ||
|
|
dec4f07fbb | ||
|
|
4e4ff61699 | ||
|
|
f907f2cd60 | ||
|
|
3ad2885c84 | ||
|
|
e2f54552b0 | ||
|
|
07ca1ce15b | ||
|
|
be9b10c13f | ||
|
|
82d6333517 | ||
|
|
ed48936dad | ||
|
|
e3bda5b816 | ||
|
|
831233cb29 | ||
|
|
c5327c4012 | ||
|
|
4426ab2cb6 | ||
|
|
93c9408f6b | ||
|
|
84128a3a64 | ||
|
|
e9b4faa05c | ||
|
|
a834bb481d | ||
|
|
9515ec8ae9 | ||
|
|
fb99d6e0db | ||
|
|
4ee80603ef | ||
|
|
2e42245ad5 | ||
|
|
2f47d1e589 | ||
|
|
ead12c4552 | ||
|
|
c54eb50004 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -6,7 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Unified `location_profiles` serving model** — merged `city_market_profile` and `location_opportunity_profile` into a single `serving.location_profiles` table at `(country_code, geoname_id)` grain. Both Marktreife-Score (Market Score) and Marktpotenzial-Score (Opportunity Score) are now computed per location. City data enriched via LEFT JOIN `dim_cities` on `geoname_id`. Downstream models (`planner_defaults`, `pseo_city_costs_de`, `pseo_city_pricing`) updated to query `location_profiles` directly. `city_padel_venue_count` (exact from dim_cities) distinguished from `padel_venue_count` (spatial 5km from dim_locations).
|
||||||
|
- **Both scores on all map tooltips** — country map shows avg Market Score + avg Opportunity Score; city map shows Market Score + Opportunity Score per city; opportunity map shows Opportunity Score + Market Score per location. All score labels use the trademarked "Padelnomics Market Score" / "Padelnomics Opportunity Score" names.
|
||||||
|
- **API endpoints** — `/api/markets/countries.json` adds `avg_opportunity_score`; `/api/markets/<country>/cities.json` adds `opportunity_score`; `/api/opportunity/<country>.json` adds `market_score`.
|
||||||
|
- **Marktpotenzial-Score v3: H3 catchment lens** — addressable market (25pts) and supply gap (30pts) now use a regional H3 catchment (~15-18km radius, res-4 cell + 6 neighbours, ~462km²) instead of local city population and 5km court count. Mid-size cities surrounded by dense Gemeinden (e.g. Oldenburg) now score correctly. New output columns: `catchment_population`, `catchment_padel_courts`, `catchment_venues_per_100k`. Requires one-time `INSTALL h3 FROM community` in DuckDB on each machine.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Custom 404/500 error pages** — styled error pages extending `base.html` with i18n support (EN/DE). The 404 page is context-aware: when the URL matches `/markets/{country}/{city}`, it shows a city-specific message with a link back to the country overview instead of a generic "page not found".
|
||||||
|
- **Map: city article indicators** — country overview map bubbles now differentiate cities with/without published articles. All cities retain score-based colors (green/amber/red); non-article cities are visually receded with lower opacity, dashed borders, desaturated color, and default cursor (no click). Tooltips show scores for all cities — article cities get "Click to explore →", non-article cities get "Coming soon". The `/api/markets/<country>/cities.json` endpoint includes a `has_article` boolean per city.
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- **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 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`.
|
- **Eurostat labour costs extractor** — `lc_lci_lev` used non-existent `currency` filter dimension; corrected to `unit: EUR`.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ 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 — plan detects new/changed models; run only executes existing plans.
|
# 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 sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply
|
uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply
|
||||||
|
|||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2142,7 +2142,7 @@ async def scenario_preview(scenario_id: int):
|
|||||||
async def scenario_pdf(scenario_id: int):
|
async def scenario_pdf(scenario_id: int):
|
||||||
"""Generate and immediately download a business plan PDF for a published scenario."""
|
"""Generate and immediately download a business plan PDF for a published scenario."""
|
||||||
from ..businessplan import get_plan_sections
|
from ..businessplan import get_plan_sections
|
||||||
from ..planner.calculator import validate_state
|
from ..planner.calculator import calc, validate_state
|
||||||
|
|
||||||
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
|
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
|
||||||
if not scenario:
|
if not scenario:
|
||||||
@@ -2153,7 +2153,7 @@ async def scenario_pdf(scenario_id: int):
|
|||||||
lang = "en"
|
lang = "en"
|
||||||
|
|
||||||
state = validate_state(json.loads(scenario["state_json"]))
|
state = validate_state(json.loads(scenario["state_json"]))
|
||||||
d = json.loads(scenario["calc_json"])
|
d = calc(state)
|
||||||
sections = get_plan_sections(state, d, lang)
|
sections = get_plan_sections(state, d, lang)
|
||||||
sections["scenario_name"] = scenario["title"]
|
sections["scenario_name"] = scenario["title"]
|
||||||
sections["location"] = scenario.get("location", "")
|
sections["location"] = scenario.get("location", "")
|
||||||
@@ -2274,6 +2274,53 @@ async def _sync_static_articles() -> None:
|
|||||||
template_slug, group_key, now_iso, now_iso),
|
template_slug, group_key, now_iso, now_iso),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build HTML so the article is immediately servable (cornerstones have no template)
|
||||||
|
if template_slug is None:
|
||||||
|
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards
|
||||||
|
|
||||||
|
body = raw[m.end():]
|
||||||
|
body_html = mistune.html(body)
|
||||||
|
body_html = await bake_scenario_cards(body_html, lang=language)
|
||||||
|
body_html = await bake_product_cards(body_html, lang=language)
|
||||||
|
build_dir = BUILD_DIR / language
|
||||||
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(build_dir / f"{slug}.html").write_text(body_html)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_article_where(
|
||||||
|
status: str = None,
|
||||||
|
template_slug: str = None,
|
||||||
|
language: str = None,
|
||||||
|
search: str = None,
|
||||||
|
) -> tuple[list[str], list]:
|
||||||
|
"""Build WHERE clauses and params for article queries.
|
||||||
|
|
||||||
|
template_slug='__manual__' filters for articles with template_slug IS NULL
|
||||||
|
(cornerstone / manually written articles, no pSEO template).
|
||||||
|
"""
|
||||||
|
wheres = ["1=1"]
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if status == "live":
|
||||||
|
wheres.append("status = 'published' AND published_at <= datetime('now')")
|
||||||
|
elif status == "scheduled":
|
||||||
|
wheres.append("status = 'published' AND published_at > datetime('now')")
|
||||||
|
elif status == "draft":
|
||||||
|
wheres.append("status = 'draft'")
|
||||||
|
if template_slug == "__manual__":
|
||||||
|
wheres.append("template_slug IS NULL")
|
||||||
|
elif template_slug:
|
||||||
|
wheres.append("template_slug = ?")
|
||||||
|
params.append(template_slug)
|
||||||
|
if language:
|
||||||
|
wheres.append("language = ?")
|
||||||
|
params.append(language)
|
||||||
|
if search:
|
||||||
|
wheres.append("title LIKE ?")
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
|
||||||
|
return wheres, params
|
||||||
|
|
||||||
|
|
||||||
async def _get_article_list(
|
async def _get_article_list(
|
||||||
status: str = None,
|
status: str = None,
|
||||||
@@ -2284,25 +2331,8 @@ async def _get_article_list(
|
|||||||
per_page: int = 50,
|
per_page: int = 50,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get articles with optional filters and pagination."""
|
"""Get articles with optional filters and pagination."""
|
||||||
wheres = ["1=1"]
|
wheres, params = _build_article_where(status=status, template_slug=template_slug,
|
||||||
params: list = []
|
language=language, search=search)
|
||||||
|
|
||||||
if status == "live":
|
|
||||||
wheres.append("status = 'published' AND published_at <= datetime('now')")
|
|
||||||
elif status == "scheduled":
|
|
||||||
wheres.append("status = 'published' AND published_at > datetime('now')")
|
|
||||||
elif status == "draft":
|
|
||||||
wheres.append("status = 'draft'")
|
|
||||||
if template_slug:
|
|
||||||
wheres.append("template_slug = ?")
|
|
||||||
params.append(template_slug)
|
|
||||||
if language:
|
|
||||||
wheres.append("language = ?")
|
|
||||||
params.append(language)
|
|
||||||
if search:
|
|
||||||
wheres.append("title LIKE ?")
|
|
||||||
params.append(f"%{search}%")
|
|
||||||
|
|
||||||
where = " AND ".join(wheres)
|
where = " AND ".join(wheres)
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
params.extend([per_page, offset])
|
params.extend([per_page, offset])
|
||||||
@@ -2332,22 +2362,8 @@ async def _get_article_list_grouped(
|
|||||||
Static cornerstones (group_key e.g. 'C2') group by cornerstone key regardless of url_path.
|
Static cornerstones (group_key e.g. 'C2') group by cornerstone key regardless of url_path.
|
||||||
Each returned item has a 'variants' list (one dict per language variant).
|
Each returned item has a 'variants' list (one dict per language variant).
|
||||||
"""
|
"""
|
||||||
wheres = ["1=1"]
|
wheres, params = _build_article_where(status=status, template_slug=template_slug,
|
||||||
params: list = []
|
search=search)
|
||||||
|
|
||||||
if status == "live":
|
|
||||||
wheres.append("status = 'published' AND published_at <= datetime('now')")
|
|
||||||
elif status == "scheduled":
|
|
||||||
wheres.append("status = 'published' AND published_at > datetime('now')")
|
|
||||||
elif status == "draft":
|
|
||||||
wheres.append("status = 'draft'")
|
|
||||||
if template_slug:
|
|
||||||
wheres.append("template_slug = ?")
|
|
||||||
params.append(template_slug)
|
|
||||||
if search:
|
|
||||||
wheres.append("title LIKE ?")
|
|
||||||
params.append(f"%{search}%")
|
|
||||||
|
|
||||||
where = " AND ".join(wheres)
|
where = " AND ".join(wheres)
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
@@ -2465,6 +2481,18 @@ async def articles():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/articles/stats")
|
||||||
|
@role_required("admin")
|
||||||
|
async def article_stats():
|
||||||
|
"""HTMX partial: article stats bar (polled while generating)."""
|
||||||
|
stats = await _get_article_stats()
|
||||||
|
return await render_template(
|
||||||
|
"admin/partials/article_stats.html",
|
||||||
|
stats=stats,
|
||||||
|
is_generating=await _is_generating(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/articles/results")
|
@bp.route("/articles/results")
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def article_results():
|
async def article_results():
|
||||||
@@ -2495,26 +2523,134 @@ async def article_results():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/articles/matching-count")
|
||||||
|
@role_required("admin")
|
||||||
|
async def articles_matching_count():
|
||||||
|
"""Return count of articles matching current filters (for bulk select-all banner)."""
|
||||||
|
status_filter = request.args.get("status", "")
|
||||||
|
template_filter = request.args.get("template", "")
|
||||||
|
language_filter = request.args.get("language", "")
|
||||||
|
search = request.args.get("search", "").strip()
|
||||||
|
|
||||||
|
wheres, params = _build_article_where(
|
||||||
|
status=status_filter or None,
|
||||||
|
template_slug=template_filter or None,
|
||||||
|
language=language_filter or None,
|
||||||
|
search=search or None,
|
||||||
|
)
|
||||||
|
where = " AND ".join(wheres)
|
||||||
|
row = await fetch_one(f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(params))
|
||||||
|
count = row["cnt"] if row else 0
|
||||||
|
return f"{count:,}"
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/articles/bulk", methods=["POST"])
|
@bp.route("/articles/bulk", methods=["POST"])
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def articles_bulk():
|
async def articles_bulk():
|
||||||
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete."""
|
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete.
|
||||||
|
|
||||||
|
Supports two modes:
|
||||||
|
- Explicit IDs: article_ids=1,2,3 (max 500)
|
||||||
|
- Apply to all matching: apply_to_all=true + filter params (rebuild capped at 2000, delete at 5000)
|
||||||
|
"""
|
||||||
form = await request.form
|
form = await request.form
|
||||||
ids_raw = form.get("article_ids", "").strip()
|
|
||||||
action = form.get("action", "").strip()
|
action = form.get("action", "").strip()
|
||||||
|
apply_to_all = form.get("apply_to_all", "").strip() == "true"
|
||||||
|
|
||||||
|
# Common filter params (used for action scope and re-render)
|
||||||
|
search = form.get("search", "").strip()
|
||||||
|
status_filter = form.get("status", "")
|
||||||
|
template_filter = form.get("template", "")
|
||||||
|
language_filter = form.get("language", "")
|
||||||
|
|
||||||
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
|
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
|
||||||
if action not in valid_actions or not ids_raw:
|
if action not in valid_actions:
|
||||||
return "", 400
|
return "", 400
|
||||||
|
|
||||||
|
now = utcnow_iso()
|
||||||
|
|
||||||
|
if apply_to_all:
|
||||||
|
wheres, where_params = _build_article_where(
|
||||||
|
status=status_filter or None,
|
||||||
|
template_slug=template_filter or None,
|
||||||
|
language=language_filter or None,
|
||||||
|
search=search or None,
|
||||||
|
)
|
||||||
|
where = " AND ".join(wheres)
|
||||||
|
|
||||||
|
if action == "rebuild":
|
||||||
|
count_row = await fetch_one(
|
||||||
|
f"SELECT COUNT(*) AS cnt FROM articles WHERE {where}", tuple(where_params)
|
||||||
|
)
|
||||||
|
count = count_row["cnt"] if count_row else 0
|
||||||
|
if count > 2000:
|
||||||
|
return (
|
||||||
|
f"<p class='text-red-600 p-4'>Too many articles ({count:,}) for bulk rebuild"
|
||||||
|
f" — max 2,000. Narrow your filters first.</p>",
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "publish":
|
||||||
|
await execute(
|
||||||
|
f"UPDATE articles SET status = 'published', updated_at = ? WHERE {where}",
|
||||||
|
(now, *where_params),
|
||||||
|
)
|
||||||
|
from ..sitemap import invalidate_sitemap_cache
|
||||||
|
invalidate_sitemap_cache()
|
||||||
|
|
||||||
|
elif action == "unpublish":
|
||||||
|
await execute(
|
||||||
|
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE {where}",
|
||||||
|
(now, *where_params),
|
||||||
|
)
|
||||||
|
from ..sitemap import invalidate_sitemap_cache
|
||||||
|
invalidate_sitemap_cache()
|
||||||
|
|
||||||
|
elif action == "toggle_noindex":
|
||||||
|
await execute(
|
||||||
|
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END,"
|
||||||
|
f" updated_at = ? WHERE {where}",
|
||||||
|
(now, *where_params),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "rebuild":
|
||||||
|
rows = await fetch_all(
|
||||||
|
f"SELECT id FROM articles WHERE {where} LIMIT 2000", tuple(where_params)
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
await _rebuild_article(r["id"])
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
from ..content.routes import BUILD_DIR
|
||||||
|
|
||||||
|
rows = await fetch_all(
|
||||||
|
f"SELECT id, slug, template_slug FROM articles WHERE {where} LIMIT 5000",
|
||||||
|
tuple(where_params),
|
||||||
|
)
|
||||||
|
for a in rows:
|
||||||
|
build_path = BUILD_DIR / f"{a['slug']}.html"
|
||||||
|
if build_path.exists():
|
||||||
|
build_path.unlink()
|
||||||
|
# Only remove source .md for generated articles; cornerstones have no template
|
||||||
|
if a["template_slug"] is not None:
|
||||||
|
md_path = Path("data/content/articles") / f"{a['slug']}.md"
|
||||||
|
if md_path.exists():
|
||||||
|
md_path.unlink()
|
||||||
|
await execute(f"DELETE FROM articles WHERE {where}", tuple(where_params))
|
||||||
|
from ..sitemap import invalidate_sitemap_cache
|
||||||
|
invalidate_sitemap_cache()
|
||||||
|
|
||||||
|
else:
|
||||||
|
ids_raw = form.get("article_ids", "").strip()
|
||||||
|
if not ids_raw:
|
||||||
|
return "", 400
|
||||||
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
|
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
|
||||||
assert len(article_ids) <= 500, "too many article IDs in bulk action"
|
assert len(article_ids) <= 500, "too many article IDs in bulk action"
|
||||||
if not article_ids:
|
if not article_ids:
|
||||||
return "", 400
|
return "", 400
|
||||||
|
|
||||||
placeholders = ",".join("?" for _ in article_ids)
|
placeholders = ",".join("?" for _ in article_ids)
|
||||||
now = utcnow_iso()
|
|
||||||
|
|
||||||
if action == "publish":
|
if action == "publish":
|
||||||
await execute(
|
await execute(
|
||||||
@@ -2545,18 +2681,19 @@ async def articles_bulk():
|
|||||||
elif action == "delete":
|
elif action == "delete":
|
||||||
from ..content.routes import BUILD_DIR
|
from ..content.routes import BUILD_DIR
|
||||||
|
|
||||||
articles = await fetch_all(
|
articles_rows = await fetch_all(
|
||||||
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})",
|
f"SELECT id, slug, template_slug FROM articles WHERE id IN ({placeholders})",
|
||||||
tuple(article_ids),
|
tuple(article_ids),
|
||||||
)
|
)
|
||||||
for a in articles:
|
for a in articles_rows:
|
||||||
build_path = BUILD_DIR / f"{a['slug']}.html"
|
build_path = BUILD_DIR / f"{a['slug']}.html"
|
||||||
if build_path.exists():
|
if build_path.exists():
|
||||||
build_path.unlink()
|
build_path.unlink()
|
||||||
|
# Only remove source .md for generated articles; cornerstones have no template
|
||||||
|
if a["template_slug"] is not None:
|
||||||
md_path = Path("data/content/articles") / f"{a['slug']}.md"
|
md_path = Path("data/content/articles") / f"{a['slug']}.md"
|
||||||
if md_path.exists():
|
if md_path.exists():
|
||||||
md_path.unlink()
|
md_path.unlink()
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
f"DELETE FROM articles WHERE id IN ({placeholders})",
|
f"DELETE FROM articles WHERE id IN ({placeholders})",
|
||||||
tuple(article_ids),
|
tuple(article_ids),
|
||||||
@@ -2565,11 +2702,6 @@ async def articles_bulk():
|
|||||||
invalidate_sitemap_cache()
|
invalidate_sitemap_cache()
|
||||||
|
|
||||||
# Re-render results partial with current filters
|
# Re-render results partial with current filters
|
||||||
search = form.get("search", "").strip()
|
|
||||||
status_filter = form.get("status", "")
|
|
||||||
template_filter = form.get("template", "")
|
|
||||||
language_filter = form.get("language", "")
|
|
||||||
|
|
||||||
grouped = not language_filter
|
grouped = not language_filter
|
||||||
if grouped:
|
if grouped:
|
||||||
article_list = await _get_article_list_grouped(
|
article_list = await _get_article_list_grouped(
|
||||||
@@ -2736,13 +2868,13 @@ async def article_edit(article_id: int):
|
|||||||
body = raw[m.end():].lstrip("\n") if m else raw
|
body = raw[m.end():].lstrip("\n") if m else raw
|
||||||
|
|
||||||
body_html = mistune.html(body) if body else ""
|
body_html = mistune.html(body) if body else ""
|
||||||
css_url = url_for("static", filename="css/output.css")
|
|
||||||
preview_doc = (
|
preview_doc = (
|
||||||
f"<!doctype html><html><head>"
|
await render_template(
|
||||||
f"<link rel='stylesheet' href='{css_url}'>"
|
"admin/partials/article_preview_doc.html", body_html=body_html
|
||||||
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
)
|
||||||
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
if body_html
|
||||||
) if body_html else ""
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
data = {**dict(article), "body": body}
|
data = {**dict(article), "body": body}
|
||||||
return await render_template(
|
return await render_template(
|
||||||
@@ -2764,13 +2896,13 @@ async def article_preview():
|
|||||||
m = _FRONTMATTER_RE.match(body)
|
m = _FRONTMATTER_RE.match(body)
|
||||||
body = body[m.end():].lstrip("\n") if m else body
|
body = body[m.end():].lstrip("\n") if m else body
|
||||||
body_html = mistune.html(body) if body else ""
|
body_html = mistune.html(body) if body else ""
|
||||||
css_url = url_for("static", filename="css/output.css")
|
|
||||||
preview_doc = (
|
preview_doc = (
|
||||||
f"<!doctype html><html><head>"
|
await render_template(
|
||||||
f"<link rel='stylesheet' href='{css_url}'>"
|
"admin/partials/article_preview_doc.html", body_html=body_html
|
||||||
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
|
)
|
||||||
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
|
if body_html
|
||||||
) if body_html else ""
|
else ""
|
||||||
|
)
|
||||||
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
|
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
<label class="text-xs font-semibold text-slate block mb-1">Template</label>
|
<label class="text-xs font-semibold text-slate block mb-1">Template</label>
|
||||||
<select name="template" class="form-input" style="min-width:140px">
|
<select name="template" class="form-input" style="min-width:140px">
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
|
<option value="__manual__" {% if current_template == '__manual__' %}selected{% endif %}>Manual</option>
|
||||||
{% for t in template_slugs %}
|
{% for t in template_slugs %}
|
||||||
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
|
<option value="{{ t }}" {% if t == current_template %}selected{% endif %}>{{ t }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -75,12 +76,13 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
|
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
|
||||||
<input type="hidden" name="action" id="article-bulk-action" value="">
|
<input type="hidden" name="action" id="article-bulk-action" value="">
|
||||||
<input type="hidden" name="search" value="{{ current_search }}">
|
<input type="hidden" name="apply_to_all" id="article-bulk-apply-to-all" value="false">
|
||||||
<input type="hidden" name="status" value="{{ current_status }}">
|
<input type="hidden" name="search" id="article-bulk-search" value="{{ current_search }}">
|
||||||
<input type="hidden" name="template" value="{{ current_template }}">
|
<input type="hidden" name="status" id="article-bulk-status" value="{{ current_status }}">
|
||||||
<input type="hidden" name="language" value="{{ current_language }}">
|
<input type="hidden" name="template" id="article-bulk-template" value="{{ current_template }}">
|
||||||
|
<input type="hidden" name="language" id="article-bulk-language" value="{{ current_language }}">
|
||||||
</form>
|
</form>
|
||||||
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
|
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;flex-wrap:wrap;background:#EFF6FF;border:1px solid #BFDBFE;">
|
||||||
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
|
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
|
||||||
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
|
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
|
||||||
<option value="">Action…</option>
|
<option value="">Action…</option>
|
||||||
@@ -92,6 +94,20 @@
|
|||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
|
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
|
||||||
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
|
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
|
||||||
|
<span id="article-select-all-banner" style="display:none;font-size:0.8125rem;color:#1E40AF;margin-left:0.5rem">
|
||||||
|
All <strong id="article-page-count"></strong> on this page selected.
|
||||||
|
<button type="button" onclick="enableApplyToAll()"
|
||||||
|
style="background:none;border:none;color:#1D4ED8;font-weight:600;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
|
||||||
|
Select all <span id="article-matching-count">…</span> matching instead?
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span id="article-apply-to-all-banner" style="display:none;font-size:0.8125rem;color:#991B1B;font-weight:600;margin-left:0.5rem">
|
||||||
|
All matching articles selected (<span id="article-matching-count-confirm"></span> total).
|
||||||
|
<button type="button" onclick="disableApplyToAll()"
|
||||||
|
style="background:none;border:none;color:#1D4ED8;font-weight:400;cursor:pointer;text-decoration:underline;padding:0;font-size:inherit">
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Results #}
|
{# Results #}
|
||||||
@@ -101,10 +117,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const articleSelectedIds = new Set();
|
const articleSelectedIds = new Set();
|
||||||
|
let articleApplyToAll = false;
|
||||||
|
let articleMatchingCount = 0;
|
||||||
|
|
||||||
function toggleArticleSelect(id, checked) {
|
function toggleArticleSelect(id, checked) {
|
||||||
if (checked) articleSelectedIds.add(id);
|
if (checked) articleSelectedIds.add(id);
|
||||||
else articleSelectedIds.delete(id);
|
else articleSelectedIds.delete(id);
|
||||||
|
disableApplyToAll();
|
||||||
updateArticleBulkBar();
|
updateArticleBulkBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,30 +133,91 @@ function toggleArticleGroupSelect(checkbox) {
|
|||||||
if (checkbox.checked) articleSelectedIds.add(id);
|
if (checkbox.checked) articleSelectedIds.add(id);
|
||||||
else articleSelectedIds.delete(id);
|
else articleSelectedIds.delete(id);
|
||||||
});
|
});
|
||||||
|
disableApplyToAll();
|
||||||
updateArticleBulkBar();
|
updateArticleBulkBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearArticleSelection() {
|
function clearArticleSelection() {
|
||||||
articleSelectedIds.clear();
|
articleSelectedIds.clear();
|
||||||
|
articleApplyToAll = false;
|
||||||
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
|
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
|
||||||
var selectAll = document.getElementById('article-select-all');
|
var selectAll = document.getElementById('article-select-all');
|
||||||
if (selectAll) selectAll.checked = false;
|
if (selectAll) selectAll.checked = false;
|
||||||
updateArticleBulkBar();
|
updateArticleBulkBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enableApplyToAll() {
|
||||||
|
articleApplyToAll = true;
|
||||||
|
document.getElementById('article-bulk-apply-to-all').value = 'true';
|
||||||
|
document.getElementById('article-select-all-banner').style.display = 'none';
|
||||||
|
document.getElementById('article-apply-to-all-banner').style.display = 'inline';
|
||||||
|
var confirmEl = document.getElementById('article-matching-count-confirm');
|
||||||
|
if (confirmEl) confirmEl.textContent = articleMatchingCount.toLocaleString();
|
||||||
|
document.getElementById('article-bulk-count').textContent = 'All matching selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableApplyToAll() {
|
||||||
|
articleApplyToAll = false;
|
||||||
|
document.getElementById('article-bulk-apply-to-all').value = 'false';
|
||||||
|
document.getElementById('article-select-all-banner').style.display = 'none';
|
||||||
|
document.getElementById('article-apply-to-all-banner').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function updateArticleBulkBar() {
|
function updateArticleBulkBar() {
|
||||||
var bar = document.getElementById('article-bulk-bar');
|
var bar = document.getElementById('article-bulk-bar');
|
||||||
var count = document.getElementById('article-bulk-count');
|
var countEl = document.getElementById('article-bulk-count');
|
||||||
var ids = document.getElementById('article-bulk-ids');
|
var ids = document.getElementById('article-bulk-ids');
|
||||||
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
|
|
||||||
count.textContent = articleSelectedIds.size + ' selected';
|
if (articleSelectedIds.size === 0 && !articleApplyToAll) {
|
||||||
|
bar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
|
||||||
|
if (!articleApplyToAll) {
|
||||||
|
countEl.textContent = articleSelectedIds.size + ' selected';
|
||||||
ids.value = Array.from(articleSelectedIds).join(',');
|
ids.value = Array.from(articleSelectedIds).join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if select-all is checked → show "select all matching" banner
|
||||||
|
var selectAll = document.getElementById('article-select-all');
|
||||||
|
var allOnPage = document.querySelectorAll('.article-checkbox');
|
||||||
|
var pageCount = 0;
|
||||||
|
allOnPage.forEach(function(cb) {
|
||||||
|
if (cb.dataset.ids) {
|
||||||
|
pageCount += (cb.dataset.ids || '').split(',').filter(Boolean).length;
|
||||||
|
} else {
|
||||||
|
pageCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var selectAllBanner = document.getElementById('article-select-all-banner');
|
||||||
|
if (!articleApplyToAll && selectAll && selectAll.checked && pageCount > 0) {
|
||||||
|
document.getElementById('article-page-count').textContent = pageCount;
|
||||||
|
selectAllBanner.style.display = 'inline';
|
||||||
|
// Fetch count of matching articles
|
||||||
|
var params = new URLSearchParams({
|
||||||
|
search: document.getElementById('article-bulk-search').value,
|
||||||
|
status: document.getElementById('article-bulk-status').value,
|
||||||
|
template: document.getElementById('article-bulk-template').value,
|
||||||
|
language: document.getElementById('article-bulk-language').value,
|
||||||
|
});
|
||||||
|
fetch('{{ url_for("admin.articles_matching_count") }}?' + params.toString())
|
||||||
|
.then(function(r) { return r.text(); })
|
||||||
|
.then(function(text) {
|
||||||
|
articleMatchingCount = parseInt(text.replace(/,/g, ''), 10) || 0;
|
||||||
|
var el = document.getElementById('article-matching-count');
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
});
|
||||||
|
} else if (!articleApplyToAll) {
|
||||||
|
selectAllBanner.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function submitArticleBulk() {
|
function submitArticleBulk() {
|
||||||
var action = document.getElementById('article-bulk-action-select').value;
|
var action = document.getElementById('article-bulk-action-select').value;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
if (articleSelectedIds.size === 0) return;
|
if (!articleApplyToAll && articleSelectedIds.size === 0) return;
|
||||||
|
|
||||||
function doSubmit() {
|
function doSubmit() {
|
||||||
document.getElementById('article-bulk-action').value = action;
|
document.getElementById('article-bulk-action').value = action;
|
||||||
@@ -150,7 +230,13 @@ function submitArticleBulk() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) {
|
var subject = articleApplyToAll
|
||||||
|
? 'Delete all ' + articleMatchingCount.toLocaleString() + ' matching articles? This cannot be undone.'
|
||||||
|
: 'Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.';
|
||||||
|
showConfirm(subject).then(function(ok) { if (ok) doSubmit(); });
|
||||||
|
} else if (articleApplyToAll) {
|
||||||
|
var verb = action.charAt(0).toUpperCase() + action.slice(1);
|
||||||
|
showConfirm(verb + ' all ' + articleMatchingCount.toLocaleString() + ' matching articles?').then(function(ok) {
|
||||||
if (ok) doSubmit();
|
if (ok) doSubmit();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -158,6 +244,30 @@ function submitArticleBulk() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync filter values into bulk form hidden inputs when filters change
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var filterForm = document.querySelector('form[hx-get*="article_results"]');
|
||||||
|
if (!filterForm) return;
|
||||||
|
filterForm.addEventListener('change', syncBulkFilters);
|
||||||
|
filterForm.addEventListener('input', syncBulkFilters);
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncBulkFilters() {
|
||||||
|
var filterForm = document.querySelector('form[hx-get*="article_results"]');
|
||||||
|
if (!filterForm) return;
|
||||||
|
var fd = new FormData(filterForm);
|
||||||
|
var searchEl = document.getElementById('article-bulk-search');
|
||||||
|
var statusEl = document.getElementById('article-bulk-status');
|
||||||
|
var templateEl = document.getElementById('article-bulk-template');
|
||||||
|
var languageEl = document.getElementById('article-bulk-language');
|
||||||
|
if (searchEl) searchEl.value = fd.get('search') || '';
|
||||||
|
if (statusEl) statusEl.value = fd.get('status') || '';
|
||||||
|
if (templateEl) templateEl.value = fd.get('template') || '';
|
||||||
|
if (languageEl) languageEl.value = fd.get('language') || '';
|
||||||
|
// Changing filters clears apply-to-all and resets selection
|
||||||
|
clearArticleSelection();
|
||||||
|
}
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
if (evt.detail.target.id === 'article-results') {
|
if (evt.detail.target.id === 'article-results') {
|
||||||
document.querySelectorAll('.article-checkbox').forEach(function(cb) {
|
document.querySelectorAll('.article-checkbox').forEach(function(cb) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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