Files
padelnomics/web/src/padelnomics/api.py
Deeman 28e44384ef
All checks were successful
CI / test (push) Successful in 1m0s
CI / tag (push) Successful in 3s
merge: opportunity map HTMX data islands + remove dead API endpoint
# Conflicts:
#	transform/sqlmesh_padelnomics/models/serving/location_opportunity_profile.sql
#	web/src/padelnomics/api.py
#	web/src/padelnomics/public/templates/opportunity_map.html
2026-03-07 21:05:52 +01:00

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