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.
This commit is contained in:
kempersc 2025-12-07 18:56:11 +01:00
parent 400b1c04c1
commit 49141c005b

View file

@ -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
# ============================================================================