# Conflicts: # transform/sqlmesh_padelnomics/models/serving/location_opportunity_profile.sql # web/src/padelnomics/api.py # web/src/padelnomics/public/templates/opportunity_map.html
101 lines
3.5 KiB
Python
101 lines
3.5 KiB
Python
"""
|
|
JSON API endpoints for interactive maps.
|
|
|
|
Serves pre-aggregated geographic data from analytics.duckdb for Leaflet maps.
|
|
All responses are JSON with 1-hour public cache headers (data changes at most
|
|
daily when the pipeline runs).
|
|
"""
|
|
from quart import Blueprint, abort, jsonify
|
|
|
|
from .analytics import fetch_analytics
|
|
from .auth.routes import login_required
|
|
from .core import fetch_all, is_flag_enabled
|
|
|
|
bp = Blueprint("api", __name__)
|
|
|
|
_CACHE_HEADERS = {"Cache-Control": "public, max-age=3600"}
|
|
|
|
|
|
async def _require_maps_flag() -> None:
|
|
"""Abort with 404 if the maps feature flag is explicitly disabled.
|
|
|
|
Defaults to enabled (True) so that dev environments without the flag row
|
|
in the DB still work. An admin can disable by setting the flag to False.
|
|
"""
|
|
if not await is_flag_enabled("maps", default=True):
|
|
abort(404)
|
|
|
|
|
|
@bp.route("/markets/countries.json")
|
|
@login_required
|
|
async def countries():
|
|
"""Country-level aggregates for the markets hub map."""
|
|
await _require_maps_flag()
|
|
rows = await fetch_analytics("""
|
|
SELECT country_code, country_name_en, country_slug,
|
|
COUNT(*) AS city_count,
|
|
SUM(city_padel_venue_count) AS total_venues,
|
|
ROUND(AVG(market_score), 1) AS avg_market_score,
|
|
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
|
|
AVG(lat) AS lat, AVG(lon) AS lon
|
|
FROM serving.location_profiles
|
|
WHERE city_slug IS NOT NULL
|
|
GROUP BY country_code, country_name_en, country_slug
|
|
HAVING SUM(city_padel_venue_count) > 0
|
|
ORDER BY total_venues DESC
|
|
""")
|
|
return jsonify(rows), 200, _CACHE_HEADERS
|
|
|
|
|
|
@bp.route("/markets/<country_slug>/cities.json")
|
|
async def country_cities(country_slug: str):
|
|
"""City-level data for a country overview bubble map."""
|
|
await _require_maps_flag()
|
|
assert country_slug, "country_slug required"
|
|
rows = await fetch_analytics(
|
|
"""
|
|
SELECT city_name, city_slug, lat, lon,
|
|
city_padel_venue_count AS padel_venue_count,
|
|
market_score, opportunity_score, population
|
|
FROM serving.location_profiles
|
|
WHERE country_slug = ? AND city_slug IS NOT NULL
|
|
ORDER BY city_padel_venue_count DESC
|
|
LIMIT 200
|
|
""",
|
|
[country_slug],
|
|
)
|
|
# Check which cities have published articles (any language).
|
|
article_rows = await fetch_all(
|
|
"""SELECT url_path FROM articles
|
|
WHERE url_path LIKE ? AND status = 'published'
|
|
AND published_at <= datetime('now')""",
|
|
(f"/markets/{country_slug}/%",),
|
|
)
|
|
article_slugs = set()
|
|
for a in article_rows:
|
|
parts = a["url_path"].rstrip("/").split("/")
|
|
if len(parts) >= 4:
|
|
article_slugs.add(parts[3])
|
|
for row in rows:
|
|
row["has_article"] = row["city_slug"] in article_slugs
|
|
return jsonify(rows), 200, _CACHE_HEADERS
|
|
|
|
|
|
@bp.route("/markets/<country_slug>/<city_slug>/venues.json")
|
|
async def city_venues(country_slug: str, city_slug: str):
|
|
"""Venue-level dots for the city detail map."""
|
|
await _require_maps_flag()
|
|
assert country_slug and city_slug, "country_slug and city_slug required"
|
|
rows = await fetch_analytics(
|
|
"""
|
|
SELECT name, lat, lon, court_count,
|
|
indoor_court_count, outdoor_court_count
|
|
FROM serving.city_venue_locations
|
|
WHERE country_slug = ? AND city_slug = ?
|
|
LIMIT 500
|
|
""",
|
|
[country_slug, city_slug],
|
|
)
|
|
return jsonify(rows), 200, _CACHE_HEADERS
|
|
|