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:
parent
400b1c04c1
commit
49141c005b
1 changed files with 434 additions and 0 deletions
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue