feat: backend FastAPI Smart App City — auth JWT, IoT, GIS, notifications, reporting
This commit is contained in:
443
smart-app-city/backend/app/routes/gis.py
Normal file
443
smart-app-city/backend/app/routes/gis.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""GIS routes — layers, features, geocoding search, and reverse geocoding.
|
||||
|
||||
All endpoints require JWT authentication (Bearer token).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.jwt import get_current_user
|
||||
from app.database import get_session
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
router = APIRouter(prefix="/gis", tags=["GIS"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GIS Layer model (in-memory store for now, can be replaced by SQLModel)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sample GIS layers for Martinique digital twin
|
||||
SAMPLE_LAYERS: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "layer-zones",
|
||||
"name": "Zones",
|
||||
"description": "Administrative and operational zones of Martinique",
|
||||
"type": "vector",
|
||||
"feature_count": 0,
|
||||
},
|
||||
{
|
||||
"id": "layer-sensors",
|
||||
"name": "Capteurs IoT",
|
||||
"description": "All IoT sensor locations",
|
||||
"type": "vector",
|
||||
"feature_count": 0,
|
||||
},
|
||||
{
|
||||
"id": "layer-road-network",
|
||||
"name": "Réseau routier",
|
||||
"description": "Martinique road network",
|
||||
"type": "vector",
|
||||
"feature_count": 0,
|
||||
},
|
||||
{
|
||||
"id": "layer-buildings",
|
||||
"name": "Bâtiments",
|
||||
"description": "Building footprints",
|
||||
"type": "vector",
|
||||
"feature_count": 0,
|
||||
},
|
||||
{
|
||||
"id": "layer-environment",
|
||||
"name": "Environnement",
|
||||
"description": "Environmental data layers (air quality, temperature, humidity)",
|
||||
"type": "raster",
|
||||
"feature_count": 0,
|
||||
},
|
||||
]
|
||||
|
||||
# Sample GeoJSON features per layer
|
||||
SAMPLE_FEATURES: dict[str, dict[str, Any]] = {
|
||||
"layer-zones": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": "zone-fort-france",
|
||||
"properties": {
|
||||
"name": "Fort-de-France",
|
||||
"description": "Capital city zone",
|
||||
"color": "#E53935",
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-61.083, 14.595],
|
||||
[-61.055, 14.595],
|
||||
[-61.055, 14.635],
|
||||
[-61.083, 14.635],
|
||||
[-61.083, 14.595],
|
||||
]
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": "zone-le-lamentin",
|
||||
"properties": {
|
||||
"name": "Le Lamentin",
|
||||
"description": "Industrial and commercial zone",
|
||||
"color": "#1E88E5",
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-61.015, 14.598],
|
||||
[-60.985, 14.598],
|
||||
[-60.985, 14.625],
|
||||
[-61.015, 14.625],
|
||||
[-61.015, 14.598],
|
||||
]
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"layer-sensors": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": "sensor-temp-01",
|
||||
"properties": {
|
||||
"name": "Température Centre-Ville",
|
||||
"type": "temperature",
|
||||
"status": "active",
|
||||
"last_value": 28.5,
|
||||
"unit": "°C",
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [-61.07, 14.61],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": "sensor-aq-01",
|
||||
"properties": {
|
||||
"name": "Qualité de l'air Schoelcher",
|
||||
"type": "air_quality",
|
||||
"status": "active",
|
||||
"last_value": 42.0,
|
||||
"unit": "AQI",
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [-61.095, 14.615],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"layer-road-network": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": "road-n1",
|
||||
"properties": {
|
||||
"name": "Route Nationale 1",
|
||||
"type": "primary",
|
||||
"surface": "asphalt",
|
||||
},
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [
|
||||
[-61.055, 14.61],
|
||||
[-61.02, 14.605],
|
||||
[-60.99, 14.61],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"layer-buildings": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": "building-mairie",
|
||||
"properties": {
|
||||
"name": "Hôtel de Ville",
|
||||
"type": "public",
|
||||
"address": "1 Rue de l'Hôtel de Ville, Fort-de-France",
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-61.0715, 14.6055],
|
||||
[-61.0705, 14.6055],
|
||||
[-61.0705, 14.6065],
|
||||
[-61.0715, 14.6065],
|
||||
[-61.0715, 14.6055],
|
||||
]
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"layer-environment": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
},
|
||||
}
|
||||
|
||||
# Sample geocoding results for Martinique addresses
|
||||
SAMPLE_ADDRESSES: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "addr-1",
|
||||
"label": "Fort-de-France, Martinique",
|
||||
"street": "",
|
||||
"city": "Fort-de-France",
|
||||
"postal_code": "97200",
|
||||
"region": "Martinique",
|
||||
"country": "France",
|
||||
"latitude": 14.6161,
|
||||
"longitude": -61.0588,
|
||||
},
|
||||
{
|
||||
"id": "addr-2",
|
||||
"label": "Le Lamentin, Martinique",
|
||||
"street": "",
|
||||
"city": "Le Lamentin",
|
||||
"postal_code": "97232",
|
||||
"region": "Martinique",
|
||||
"country": "France",
|
||||
"latitude": 14.6167,
|
||||
"longitude": -61.0000,
|
||||
},
|
||||
{
|
||||
"id": "addr-3",
|
||||
"label": "Schoelcher, Martinique",
|
||||
"street": "",
|
||||
"city": "Schoelcher",
|
||||
"postal_code": "97233",
|
||||
"region": "Martinique",
|
||||
"country": "France",
|
||||
"latitude": 14.6167,
|
||||
"longitude": -61.0833,
|
||||
},
|
||||
{
|
||||
"id": "addr-4",
|
||||
"label": "Le Robert, Martinique",
|
||||
"street": "",
|
||||
"city": "Le Robert",
|
||||
"postal_code": "97222",
|
||||
"region": "Martinique",
|
||||
"country": "France",
|
||||
"latitude": 14.6833,
|
||||
"longitude": -60.9500,
|
||||
},
|
||||
{
|
||||
"id": "addr-5",
|
||||
"label": "Ducos, Martinique",
|
||||
"street": "",
|
||||
"city": "Ducos",
|
||||
"postal_code": "97224",
|
||||
"region": "Martinique",
|
||||
"country": "France",
|
||||
"latitude": 14.5833,
|
||||
"longitude": -60.9833,
|
||||
},
|
||||
{
|
||||
"id": "addr-6",
|
||||
"label": "1 Rue de l'Hôtel de Ville, Fort-de-France",
|
||||
"street": "1 Rue de l'Hôtel de Ville",
|
||||
"city": "Fort-de-France",
|
||||
"postal_code": "97200",
|
||||
"region": "Martinique",
|
||||
"country": "France",
|
||||
"latitude": 14.6060,
|
||||
"longitude": -61.0710,
|
||||
},
|
||||
{
|
||||
"id": "addr-7",
|
||||
"label": "Pointe du Bout, Trois-Îlets, Martinique",
|
||||
"street": "Pointe du Bout",
|
||||
"city": "Trois-Îlets",
|
||||
"postal_code": "97229",
|
||||
"region": "Martinique",
|
||||
"country": "France",
|
||||
"latitude": 14.5333,
|
||||
"longitude": -61.0333,
|
||||
},
|
||||
{
|
||||
"id": "addr-8",
|
||||
"label": "Saint-Pierre, Martinique",
|
||||
"street": "",
|
||||
"city": "Saint-Pierre",
|
||||
"postal_code": "97250",
|
||||
"region": "Martinique",
|
||||
"country": "France",
|
||||
"latitude": 14.7500,
|
||||
"longitude": -61.1833,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# HELPERS
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _find_layer(layer_id: str) -> Optional[dict[str, Any]]:
|
||||
"""Return the layer dict matching *layer_id*, or None."""
|
||||
for layer in SAMPLE_LAYERS:
|
||||
if layer["id"] == layer_id:
|
||||
return layer
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ENDPOINTS
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/layers",
|
||||
summary="List all available GIS layers",
|
||||
response_model=dict,
|
||||
)
|
||||
async def list_layers(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Return the list of all available GIS layers."""
|
||||
return {
|
||||
"layers": SAMPLE_LAYERS,
|
||||
"total": len(SAMPLE_LAYERS),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/layers/{layer_id}/features",
|
||||
summary="Get GeoJSON features for a specific layer",
|
||||
response_model=dict,
|
||||
)
|
||||
async def get_layer_features(
|
||||
layer_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Return the GeoJSON FeatureCollection for the given *layer_id*.
|
||||
|
||||
Raises 404 if the layer does not exist.
|
||||
"""
|
||||
layer = _find_layer(layer_id)
|
||||
if layer is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Layer '{layer_id}' not found",
|
||||
)
|
||||
|
||||
features = SAMPLE_FEATURES.get(layer_id, {"type": "FeatureCollection", "features": []})
|
||||
return {
|
||||
"layer": layer,
|
||||
"geojson": features,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search",
|
||||
summary="Search for an address (forward geocoding)",
|
||||
response_model=dict,
|
||||
)
|
||||
async def search_address(
|
||||
q: str = Query(..., min_length=2, max_length=200, description="Search query (address, city, place name)"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Maximum number of results"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Search for an address or place name in Martinique.
|
||||
|
||||
Returns a list of matching addresses with their coordinates.
|
||||
The search is case-insensitive and matches against city, street,
|
||||
postal code, and region fields.
|
||||
"""
|
||||
query_lower = q.lower().strip()
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for addr in SAMPLE_ADDRESSES:
|
||||
searchable = " ".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
addr.get("street", ""),
|
||||
addr.get("city", ""),
|
||||
addr.get("postal_code", ""),
|
||||
addr.get("region", ""),
|
||||
addr.get("label", ""),
|
||||
],
|
||||
)
|
||||
).lower()
|
||||
if query_lower in searchable:
|
||||
results.append(addr)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return {
|
||||
"query": q,
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/reverse",
|
||||
summary="Reverse geocoding — find address from coordinates",
|
||||
response_model=dict,
|
||||
)
|
||||
async def reverse_geocode(
|
||||
lat: float = Query(..., ge=-90.0, le=90.0, description="Latitude"),
|
||||
lng: float = Query(..., ge=-180.0, le=180.0, description="Longitude"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Find the nearest known address for the given coordinates.
|
||||
|
||||
Uses a simplified Euclidean distance calculation over the
|
||||
Martinique sample addresses. In production this would call an
|
||||
external geocoding service (Nominatim, Photon, etc.).
|
||||
"""
|
||||
# Find nearest sample address by approximate distance
|
||||
best: Optional[dict[str, Any]] = None
|
||||
best_dist = float("inf")
|
||||
|
||||
deg_to_km = 111.0 # approximate km per degree at equator
|
||||
|
||||
for addr in SAMPLE_ADDRESSES:
|
||||
dlat = (addr["latitude"] - lat) * deg_to_km
|
||||
dlng = (addr["longitude"] - lng) * deg_to_km
|
||||
dist = (dlat**2 + dlng**2) ** 0.5
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best = addr
|
||||
|
||||
if best is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No known address near the given coordinates",
|
||||
)
|
||||
|
||||
return {
|
||||
"query": {"latitude": lat, "longitude": lng},
|
||||
"result": best,
|
||||
"distance_km": round(best_dist, 3),
|
||||
}
|
||||
Reference in New Issue
Block a user