From 923f250926b5a2d4be7162f1e906a9d53bbee7c8 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 11 May 2026 21:44:22 +0300 Subject: [PATCH] feat(site-finder): /parcels/{cad}/isochrones via OpenRouteService --- backend/app/api/v1/parcels.py | 123 +++++++++++++++++++++++++++++++++- backend/app/core/config.py | 5 ++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 8c8898ab..05bdb5a5 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -4,10 +4,11 @@ import math from typing import Annotated, Any import httpx -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings from app.core.db import get_db from app.schemas.parcel import ParcelDetail, ParcelSearchRequest, ParcelSearchResponse @@ -805,4 +806,124 @@ def analyze_parcel( "utilities": utilities, "geotech_risk": _geotech_risk(66, db, geom_wkt), "market_trend": market_trend, + "isochrones_available": bool(settings.openrouteservice_api_key), + } + + +_ORS_BASE = "https://api.openrouteservice.org/v2/isochrones" +_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"}) + + +@router.get("/{cad_num}/isochrones") +def get_isochrones( + cad_num: str, + db: Annotated[Session, Depends(get_db)], + mode: str = "foot-walking", + times_min: Annotated[list[int], Query()] = [10, 15], # noqa: B006 +) -> dict[str, Any]: + """Изохроны доступности от центроида участка через OpenRouteService. + + Modes: foot-walking | cycling-regular | driving-car. + times_min — список минут (1-60), например ?times_min=10×_min=15. + Возвращает GeoJSON FeatureCollection для рендера на карте. + """ + if not settings.openrouteservice_api_key: + raise HTTPException( + status_code=503, + detail=( + "OPENROUTESERVICE_API_KEY не задан в env. " + "Получи free key на https://openrouteservice.org/dev/#/signup " + "и пропиши в backend/.env.runtime" + ), + ) + + if mode not in _ORS_VALID_MODES: + raise HTTPException( + status_code=422, + detail=f"Недопустимый mode '{mode}'. Допустимые: {sorted(_ORS_VALID_MODES)}", + ) + + invalid_times = [t for t in times_min if not (1 <= t <= 60)] + if invalid_times: + raise HTTPException( + status_code=422, + detail=f"times_min значения вне диапазона 1-60: {invalid_times}", + ) + + # Получить координаты центроида из доступных геометрий участка + coord_row = ( + db.execute( + text(""" + SELECT ST_X(ST_Centroid(g.geom)) AS lon, + ST_Y(ST_Centroid(g.geom)) AS lat + FROM ( + SELECT geom FROM cad_quarters_geom WHERE cad_number = :c + UNION ALL + SELECT geom FROM cad_buildings WHERE cad_num = :c + UNION ALL + SELECT geom FROM cad_parcels_geom WHERE cad_num = :c + ) g + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + + if not coord_row: + raise HTTPException( + status_code=404, + detail=f"Геометрия для {cad_num} не найдена.", + ) + + lat = float(coord_row["lat"]) + lon = float(coord_row["lon"]) + + # OpenRouteService isochrones API — POST с JSON body + url = f"{_ORS_BASE}/{mode}" + body: dict[str, Any] = { + "locations": [[lon, lat]], + "range": [t * 60 for t in times_min], # ORS ожидает секунды + "range_type": "time", + "units": "m", + } + headers = { + "Authorization": settings.openrouteservice_api_key, + "Content-Type": "application/json", + "Accept": "application/geo+json", + } + + try: + with httpx.Client(timeout=10) as client: + resp = client.post(url, json=body, headers=headers) + resp.raise_for_status() + geojson = resp.json() + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 429: + raise HTTPException( + status_code=429, + detail="OpenRouteService daily limit (2000 req) exceeded.", + ) from exc + logger.error("ORS HTTP error for %s: %s — %s", cad_num, exc.response.status_code, exc) + raise HTTPException( + status_code=500, + detail=f"ORS error {exc.response.status_code}: {exc.response.text[:200]}", + ) from exc + except Exception as exc: + logger.error("ORS request failed for %s: %s", cad_num, exc) + raise HTTPException( + status_code=500, + detail=f"Isochrones fetch failed: {exc}", + ) from exc + + return { + "cad_num": cad_num, + "lat": lat, + "lon": lon, + "mode": mode, + "times_min": times_min, + "geojson": geojson, + "source": "openrouteservice.org", + "note": "Free tier 2000 req/day. Замена на self-hosted OSRM — в #27.", } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c6c89974..69f9b1ff 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -92,5 +92,10 @@ class Settings(BaseSettings): # OBJECTIVE_ANTON_SQLITE_PATH=C:/Users/user/source/repos/gendesign/sf_anton_snapshot.db objective_anton_sqlite_path: str = "/data/anton-sqlite/analysis.db" + # OpenRouteService API (https://openrouteservice.org/dev/#/signup). + # Free tier: 2000 запросов/день. Используется в /parcels/{cad}/isochrones. + # Если не задан — endpoint вернёт 503 с инструкцией по регистрации. + openrouteservice_api_key: str = "" + settings = Settings()