From 49141c005b4d066f62e1aafbfbf3bf2f483a4bdc Mon Sep 17 00:00:00 2001 From: kempersc Date: Sun, 7 Dec 2025 18:56:11 +0100 Subject: [PATCH] feat(api): add PostGIS boundary REST endpoints Add new endpoints for querying administrative boundaries: - GET /boundaries/countries - List all countries with boundary data - GET /boundaries/countries/{code}/admin1 - List provinces/states - GET /boundaries/countries/{code}/admin2 - List municipalities - GET /boundaries/lookup?lat=&lon= - Point-in-polygon lookup - GET /boundaries/admin2/{id}/geojson - Get single boundary GeoJSON - GET /boundaries/countries/{code}/admin2/geojson - Get all admin2 as GeoJSON - GET /boundaries/stats - Get boundary statistics Uses proper joins through country_id/admin1_id foreign keys. --- backend/postgres/main.py | 434 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) diff --git a/backend/postgres/main.py b/backend/postgres/main.py index 35e6ec6773..c647c8aab6 100644 --- a/backend/postgres/main.py +++ b/backend/postgres/main.py @@ -851,6 +851,440 @@ async def get_class_hierarchy( return [build_tree(root) for root in sorted(roots)] +# ============================================================================ +# Boundary/GIS Endpoints +# ============================================================================ +# PostGIS-backed endpoints for administrative boundaries and service areas + +class BoundaryInfo(BaseModel): + """Administrative boundary metadata""" + id: int + code: str + name: str + name_local: Optional[str] = None + admin_level: int + country_code: str + parent_code: Optional[str] = None + area_km2: Optional[float] = None + source: str + + +class PointLookupResult(BaseModel): + """Result of point-in-polygon lookup""" + country: Optional[BoundaryInfo] = None + admin1: Optional[BoundaryInfo] = None + admin2: Optional[BoundaryInfo] = None + geonames_settlement: Optional[Dict[str, Any]] = None + + +class GeoJSONFeature(BaseModel): + """GeoJSON Feature""" + type: str = "Feature" + properties: Dict[str, Any] + geometry: Optional[Dict[str, Any]] = None + + +class GeoJSONFeatureCollection(BaseModel): + """GeoJSON FeatureCollection""" + type: str = "FeatureCollection" + features: List[GeoJSONFeature] + + +@app.get("/boundaries/countries") +async def list_countries() -> List[Dict[str, Any]]: + """List all countries with boundary data""" + pool = await get_pool() + + async with pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT + bc.id, + bc.iso_a2 as code, + bc.country_name as name, + bc.country_name_local as name_local, + bc.area_km2, + bds.source_code as source, + ST_AsGeoJSON(bc.centroid)::json as centroid + FROM boundary_countries bc + LEFT JOIN boundary_data_sources bds ON bc.source_id = bds.id + WHERE bc.valid_to IS NULL + ORDER BY bc.country_name + """) + + return [ + { + "id": r['id'], + "code": r['code'].strip(), + "name": r['name'], + "name_local": r['name_local'], + "area_km2": float(r['area_km2']) if r['area_km2'] else None, + "source": r['source'], + "centroid": r['centroid'], + } + for r in rows + ] + + +@app.get("/boundaries/countries/{country_code}/admin1") +async def list_admin1(country_code: str) -> List[Dict[str, Any]]: + """List admin level 1 divisions (provinces/states) for a country""" + pool = await get_pool() + + async with pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT + ba.id, + ba.admin1_code as code, + ba.admin1_name as name, + ba.admin1_name_local as name_local, + ba.iso_3166_2 as iso_code, + ba.area_km2, + bc.iso_a2 as country_code, + bds.source_code as source, + ST_AsGeoJSON(ba.centroid)::json as centroid + FROM boundary_admin1 ba + JOIN boundary_countries bc ON ba.country_id = bc.id + LEFT JOIN boundary_data_sources bds ON ba.source_id = bds.id + WHERE bc.iso_a2 = $1 AND ba.valid_to IS NULL + ORDER BY ba.admin1_name + """, country_code.upper()) + + return [ + { + "id": r['id'], + "code": r['code'], + "name": r['name'], + "name_local": r['name_local'], + "iso_code": r['iso_code'], + "country_code": r['country_code'].strip() if r['country_code'] else None, + "area_km2": float(r['area_km2']) if r['area_km2'] else None, + "source": r['source'], + "centroid": r['centroid'], + } + for r in rows + ] + + +@app.get("/boundaries/countries/{country_code}/admin2") +async def list_admin2( + country_code: str, + admin1_code: Optional[str] = Query(None, description="Filter by admin1 code") +) -> List[Dict[str, Any]]: + """List admin level 2 divisions (municipalities/counties) for a country""" + pool = await get_pool() + + async with pool.acquire() as conn: + if admin1_code: + rows = await conn.fetch(""" + SELECT + ba2.id, + ba2.admin2_code as code, + ba2.admin2_name as name, + ba2.admin2_name_local as name_local, + ba1.admin1_code, + ba1.admin1_name, + bc.iso_a2 as country_code, + ba2.area_km2, + bds.source_code as source, + ST_AsGeoJSON(ba2.centroid)::json as centroid + FROM boundary_admin2 ba2 + JOIN boundary_admin1 ba1 ON ba2.admin1_id = ba1.id + JOIN boundary_countries bc ON ba1.country_id = bc.id + LEFT JOIN boundary_data_sources bds ON ba2.source_id = bds.id + WHERE bc.iso_a2 = $1 + AND ba1.admin1_code = $2 + AND ba2.valid_to IS NULL + ORDER BY ba2.admin2_name + """, country_code.upper(), admin1_code) + else: + rows = await conn.fetch(""" + SELECT + ba2.id, + ba2.admin2_code as code, + ba2.admin2_name as name, + ba2.admin2_name_local as name_local, + ba1.admin1_code, + ba1.admin1_name, + bc.iso_a2 as country_code, + ba2.area_km2, + bds.source_code as source, + ST_AsGeoJSON(ba2.centroid)::json as centroid + FROM boundary_admin2 ba2 + JOIN boundary_admin1 ba1 ON ba2.admin1_id = ba1.id + JOIN boundary_countries bc ON ba1.country_id = bc.id + LEFT JOIN boundary_data_sources bds ON ba2.source_id = bds.id + WHERE bc.iso_a2 = $1 AND ba2.valid_to IS NULL + ORDER BY ba2.admin2_name + """, country_code.upper()) + + return [ + { + "id": r['id'], + "code": r['code'], + "name": r['name'], + "name_local": r['name_local'], + "admin1_code": r['admin1_code'], + "admin1_name": r['admin1_name'], + "country_code": r['country_code'].strip() if r['country_code'] else None, + "area_km2": float(r['area_km2']) if r['area_km2'] else None, + "source": r['source'], + "centroid": r['centroid'], + } + for r in rows + ] + + +@app.get("/boundaries/lookup") +async def lookup_point( + lat: float = Query(..., description="Latitude (WGS84)"), + lon: float = Query(..., description="Longitude (WGS84)"), + country_code: Optional[str] = Query(None, description="ISO country code to filter results") +) -> PointLookupResult: + """Find administrative boundaries containing a point""" + pool = await get_pool() + + async with pool.acquire() as conn: + # Use the find_admin_for_point function + # Returns rows with: admin_level, admin_code, admin_name, iso_code, geonames_id + if country_code: + results = await conn.fetch(""" + SELECT * FROM find_admin_for_point($1, $2, $3) + """, lon, lat, country_code.upper()) + else: + results = await conn.fetch(""" + SELECT * FROM find_admin_for_point($1, $2) + """, lon, lat) + + # Parse results by admin level + admin1_info = None + admin2_info = None + + for row in results: + level = row['admin_level'] + if level == 1 and admin1_info is None: + admin1_info = BoundaryInfo( + id=0, + code=row['admin_code'], + name=row['admin_name'], + admin_level=1, + country_code=country_code or "XX", + source="PostGIS" + ) + elif level == 2 and admin2_info is None: + admin2_info = BoundaryInfo( + id=0, + code=row['admin_code'], + name=row['admin_name'], + admin_level=2, + country_code=country_code or "XX", + source="PostGIS" + ) + + # Also try to determine country from the point + country_info = None + country_row = await conn.fetchrow(""" + SELECT bc.id, bc.iso_a2, bc.country_name + FROM boundary_countries bc + WHERE ST_Contains(bc.geom, ST_SetSRID(ST_Point($1, $2), 4326)) + AND bc.valid_to IS NULL + LIMIT 1 + """, lon, lat) + + if country_row: + country_info = BoundaryInfo( + id=country_row['id'], + code=country_row['iso_a2'].strip(), + name=country_row['country_name'], + admin_level=0, + country_code=country_row['iso_a2'].strip(), + source="PostGIS" + ) + # Update admin boundaries with proper country code + if admin1_info: + admin1_info.country_code = country_row['iso_a2'].strip() + if admin2_info: + admin2_info.country_code = country_row['iso_a2'].strip() + + return PointLookupResult( + country=country_info, + admin1=admin1_info, + admin2=admin2_info, + ) + + +@app.get("/boundaries/admin2/{admin2_id}/geojson") +async def get_admin2_geojson(admin2_id: int) -> GeoJSONFeature: + """Get GeoJSON geometry for a specific admin2 boundary""" + pool = await get_pool() + + async with pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT + ba2.id, + ba2.admin2_code as code, + ba2.admin2_name as name, + ba2.admin2_name_local as name_local, + bc.iso_a2 as country_code, + ba1.admin1_code, + ba2.area_km2, + bds.source_code as source, + ST_AsGeoJSON(ba2.geom, 6)::json as geometry + FROM boundary_admin2 ba2 + JOIN boundary_admin1 ba1 ON ba2.admin1_id = ba1.id + JOIN boundary_countries bc ON ba1.country_id = bc.id + LEFT JOIN boundary_data_sources bds ON ba2.source_id = bds.id + WHERE ba2.id = $1 + """, admin2_id) + + if not row: + raise HTTPException(status_code=404, detail=f"Admin2 boundary {admin2_id} not found") + + return GeoJSONFeature( + properties={ + "id": row['id'], + "code": row['code'], + "name": row['name'], + "name_local": row['name_local'], + "country_code": row['country_code'].strip() if row['country_code'] else None, + "admin1_code": row['admin1_code'], + "area_km2": float(row['area_km2']) if row['area_km2'] else None, + "source": row['source'], + }, + geometry=row['geometry'] + ) + + +@app.get("/boundaries/countries/{country_code}/admin2/geojson") +async def get_country_admin2_geojson( + country_code: str, + admin1_code: Optional[str] = Query(None, description="Filter by admin1 code"), + simplify: float = Query(0.001, description="Geometry simplification tolerance (degrees)") +) -> GeoJSONFeatureCollection: + """Get GeoJSON FeatureCollection of all admin2 boundaries for a country""" + pool = await get_pool() + + async with pool.acquire() as conn: + if admin1_code: + rows = await conn.fetch(""" + SELECT + ba2.id, + ba2.admin2_code as code, + ba2.admin2_name as name, + ba2.admin2_name_local as name_local, + ba1.admin1_code, + ba1.admin1_name, + ba2.area_km2, + bds.source_code as source, + ST_AsGeoJSON(ST_Simplify(ba2.geom, $3), 6)::json as geometry + FROM boundary_admin2 ba2 + JOIN boundary_admin1 ba1 ON ba2.admin1_id = ba1.id + JOIN boundary_countries bc ON ba1.country_id = bc.id + LEFT JOIN boundary_data_sources bds ON ba2.source_id = bds.id + WHERE bc.iso_a2 = $1 + AND ba1.admin1_code = $2 + AND ba2.valid_to IS NULL + ORDER BY ba2.admin2_name + """, country_code.upper(), admin1_code, simplify) + else: + rows = await conn.fetch(""" + SELECT + ba2.id, + ba2.admin2_code as code, + ba2.admin2_name as name, + ba2.admin2_name_local as name_local, + ba1.admin1_code, + ba1.admin1_name, + ba2.area_km2, + bds.source_code as source, + ST_AsGeoJSON(ST_Simplify(ba2.geom, $2), 6)::json as geometry + FROM boundary_admin2 ba2 + JOIN boundary_admin1 ba1 ON ba2.admin1_id = ba1.id + JOIN boundary_countries bc ON ba1.country_id = bc.id + LEFT JOIN boundary_data_sources bds ON ba2.source_id = bds.id + WHERE bc.iso_a2 = $1 AND ba2.valid_to IS NULL + ORDER BY ba2.admin2_name + """, country_code.upper(), simplify) + + features = [ + GeoJSONFeature( + properties={ + "id": r['id'], + "code": r['code'], + "name": r['name'], + "name_local": r['name_local'], + "admin1_code": r['admin1_code'], + "area_km2": float(r['area_km2']) if r['area_km2'] else None, + "source": r['source'], + }, + geometry=r['geometry'] + ) + for r in rows + ] + + return GeoJSONFeatureCollection(features=features) + + +@app.get("/boundaries/stats") +async def get_boundary_stats() -> Dict[str, Any]: + """Get statistics about loaded boundary data""" + pool = await get_pool() + + async with pool.acquire() as conn: + stats = {} + + # Countries + country_count = await conn.fetchval(""" + SELECT COUNT(*) FROM boundary_countries WHERE valid_to IS NULL + """) + stats['countries'] = country_count + + # Admin1 by country + admin1_stats = await conn.fetch(""" + SELECT bc.iso_a2 as country_code, COUNT(*) as count + FROM boundary_admin1 ba1 + JOIN boundary_countries bc ON ba1.country_id = bc.id + WHERE ba1.valid_to IS NULL + GROUP BY bc.iso_a2 + ORDER BY bc.iso_a2 + """) + stats['admin1_by_country'] = { + r['country_code'].strip(): r['count'] for r in admin1_stats + } + stats['admin1_total'] = sum(r['count'] for r in admin1_stats) + + # Admin2 by country + admin2_stats = await conn.fetch(""" + SELECT bc.iso_a2 as country_code, COUNT(*) as count + FROM boundary_admin2 ba2 + JOIN boundary_admin1 ba1 ON ba2.admin1_id = ba1.id + JOIN boundary_countries bc ON ba1.country_id = bc.id + WHERE ba2.valid_to IS NULL + GROUP BY bc.iso_a2 + ORDER BY bc.iso_a2 + """) + stats['admin2_by_country'] = { + r['country_code'].strip(): r['count'] for r in admin2_stats + } + stats['admin2_total'] = sum(r['count'] for r in admin2_stats) + + # Data sources + sources = await conn.fetch(""" + SELECT source_code, source_name, coverage_scope + FROM boundary_data_sources + ORDER BY source_code + """) + stats['data_sources'] = [ + { + "code": s['source_code'], + "name": s['source_name'], + "scope": s['coverage_scope'], + } + for s in sources + ] + + return stats + + # ============================================================================ # Main Entry Point # ============================================================================