feat(tradein): cadastral reverse geocode via main backend cad_buildings #492
8 changed files with 308 additions and 6 deletions
101
backend/app/api/v1/cadastral.py
Normal file
101
backend/app/api/v1/cadastral.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Cadastral lookup endpoints — reverse geocode via cad_buildings.
|
||||
|
||||
Used by trade-in backend (and potentially other internal services) to resolve
|
||||
координаты → адрес жилого дома без зависимости от внешних геокодеров.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CadastralReverseResult(BaseModel):
|
||||
address: str
|
||||
lat: float
|
||||
lon: float
|
||||
distance_m: float
|
||||
cad_num: str | None = None
|
||||
|
||||
|
||||
@router.get("/reverse", response_model=CadastralReverseResult)
|
||||
async def reverse(
|
||||
lat: Annotated[float, Query(ge=-90, le=90)],
|
||||
lon: Annotated[float, Query(ge=-180, le=180)],
|
||||
radius_m: Annotated[int, Query(ge=10, le=2000)] = 200,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
) -> CadastralReverseResult:
|
||||
"""Координаты → ближайшее здание из cad_buildings.
|
||||
|
||||
Filters out гаражи/СНТ/садовод/товарищества — для trade-in нужны жилые дома.
|
||||
|
||||
Returns 404 если в радиусе нет подходящего здания.
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
readable_address,
|
||||
cad_num,
|
||||
ST_Y(ST_Centroid(geom)) AS lat,
|
||||
ST_X(ST_Centroid(geom)) AS lon,
|
||||
ST_Distance(
|
||||
geom::geography,
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)
|
||||
),
|
||||
4326
|
||||
)::geography
|
||||
) AS distance_m
|
||||
FROM cad_buildings
|
||||
WHERE ST_DWithin(
|
||||
geom::geography,
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)
|
||||
),
|
||||
4326
|
||||
)::geography,
|
||||
CAST(:radius AS double precision)
|
||||
)
|
||||
AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)'
|
||||
ORDER BY ST_Distance(
|
||||
geom::geography,
|
||||
ST_SetSRID(
|
||||
ST_MakePoint(
|
||||
CAST(:lon AS double precision),
|
||||
CAST(:lat AS double precision)
|
||||
),
|
||||
4326
|
||||
)::geography
|
||||
)
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"lat": lat, "lon": lon, "radius": float(radius_m)},
|
||||
).first()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="no cadastral building found within radius")
|
||||
|
||||
return CadastralReverseResult(
|
||||
address=row.readable_address,
|
||||
lat=float(row.lat),
|
||||
lon=float(row.lon),
|
||||
distance_m=round(float(row.distance_m), 1),
|
||||
cad_num=row.cad_num,
|
||||
)
|
||||
|
|
@ -21,6 +21,7 @@ from app.api.v1 import (
|
|||
admin_scrape,
|
||||
admin_weight_profiles,
|
||||
analytics,
|
||||
cadastral,
|
||||
concepts,
|
||||
custom_pois,
|
||||
landing,
|
||||
|
|
@ -78,6 +79,7 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(cadastral.router, prefix="/api/v1/cadastral", tags=["cadastral"])
|
||||
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
|
||||
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
|
||||
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ services:
|
|||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
networks:
|
||||
default: {}
|
||||
shared:
|
||||
aliases:
|
||||
- gendesign-backend
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest}
|
||||
|
|
|
|||
|
|
@ -32,5 +32,10 @@ class Settings(BaseSettings):
|
|||
# Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Main GenDesign backend URL — для cadastral reverse geocode (cad_buildings).
|
||||
# В prod: http://gendesign-backend:8000 (network alias в docker-compose.prod.yml).
|
||||
# Пусто = cadastral lookup пропускается, используется только Nominatim.
|
||||
main_backend_url: str | None = None
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -569,14 +569,32 @@ def _format_reverse_address(addr: dict) -> str | None:
|
|||
return ", ".join(parts)
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
async def reverse_geocode(lat: float, lon: float) -> str | None:
|
||||
"""Обратный геокодинг: координаты → адрес (Nominatim /reverse).
|
||||
async def _cadastral_reverse(lat: float, lon: float) -> str | None:
|
||||
"""Reverse via main backend cad_buildings — fast, no external API.
|
||||
|
||||
Клик по дому на карте ЕКБ → адрес подставляется в форму оценки.
|
||||
Возвращаем уличный адрес (улица, дом, город), а не `display_name` —
|
||||
тот начинается с названия POI/организации и не геокодируется обратно.
|
||||
Returns None on any error (network, 404, timeout, missing config).
|
||||
"""
|
||||
if not settings.main_backend_url:
|
||||
return None
|
||||
url = settings.main_backend_url.rstrip("/") + "/api/v1/cadastral/reverse"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
response = await client.get(url, params={"lat": str(lat), "lon": str(lon)})
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
addr = data.get("address")
|
||||
if isinstance(addr, str) and addr.strip():
|
||||
return addr
|
||||
except Exception:
|
||||
logger.exception("cadastral reverse failed for (%.5f, %.5f)", lat, lon)
|
||||
return None
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
async def _nominatim_reverse(lat: float, lon: float) -> str | None:
|
||||
"""Nominatim /reverse — extracted for fallback usage. @retry handles transient errors."""
|
||||
headers = {
|
||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||
"Accept": "application/json",
|
||||
|
|
@ -603,3 +621,23 @@ async def reverse_geocode(lat: float, lon: float) -> str | None:
|
|||
if street:
|
||||
return street
|
||||
return data.get("display_name")
|
||||
|
||||
|
||||
async def reverse_geocode(lat: float, lon: float) -> str | None:
|
||||
"""Cadastral (cad_buildings via main backend) → Nominatim fallback.
|
||||
|
||||
Возвращает None если ни один источник не дал адрес. Endpoint
|
||||
api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError
|
||||
наверх — раньше Nominatim 403 → RetryError → FastAPI 500.
|
||||
"""
|
||||
# 1. Cadastral primary (fast, no external API, returns жилой дом not POI)
|
||||
cadastral = await _cadastral_reverse(lat, lon)
|
||||
if cadastral:
|
||||
return cadastral
|
||||
|
||||
# 2. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
|
||||
try:
|
||||
return await _nominatim_reverse(lat, lon)
|
||||
except Exception:
|
||||
logger.exception("nominatim reverse failed for (%.5f, %.5f)", lat, lon)
|
||||
return None
|
||||
|
|
|
|||
0
tradein-mvp/backend/tests/services/__init__.py
Normal file
0
tradein-mvp/backend/tests/services/__init__.py
Normal file
150
tradein-mvp/backend/tests/services/test_cadastral_reverse.py
Normal file
150
tradein-mvp/backend/tests/services/test_cadastral_reverse.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Unit tests for cadastral reverse geocode and modified reverse_geocode."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# DATABASE_URL required by config before any app import.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
os.environ.setdefault("MAIN_BACKEND_URL", "http://gendesign-backend:8000")
|
||||
|
||||
# WeasyPrint stub — not installed in CI without GTK.
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.geocoder import _cadastral_reverse, reverse_geocode
|
||||
|
||||
|
||||
# ── _cadastral_reverse ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_cadastral_reverse_returns_address_on_200() -> None:
|
||||
"""Main backend returns 200 → address string returned."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"address": "ул. Малышева, 30, Екатеринбург",
|
||||
"lat": 56.838,
|
||||
"lon": 60.605,
|
||||
"distance_m": 12.5,
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("app.services.geocoder.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await _cadastral_reverse(56.838, 60.605)
|
||||
|
||||
assert result == "ул. Малышева, 30, Екатеринбург"
|
||||
|
||||
|
||||
async def test_cadastral_reverse_returns_none_on_404() -> None:
|
||||
"""Main backend returns 404 (no building in radius) → None, no exception."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("app.services.geocoder.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await _cadastral_reverse(56.838, 60.605)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_cadastral_reverse_returns_none_on_timeout() -> None:
|
||||
"""Network timeout → None returned, no exception bubbles out."""
|
||||
import httpx
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("app.services.geocoder.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await _cadastral_reverse(56.838, 60.605)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_cadastral_reverse_returns_none_when_not_configured() -> None:
|
||||
"""main_backend_url is None → immediately returns None without HTTP call."""
|
||||
with patch("app.services.geocoder.settings") as mock_settings:
|
||||
mock_settings.main_backend_url = None
|
||||
|
||||
with patch("app.services.geocoder.httpx.AsyncClient") as mock_client_cls:
|
||||
result = await _cadastral_reverse(56.838, 60.605)
|
||||
mock_client_cls.assert_not_called()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── reverse_geocode ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_reverse_geocode_returns_cadastral_when_available() -> None:
|
||||
"""Cadastral returns address → Nominatim is never called."""
|
||||
with (
|
||||
patch(
|
||||
"app.services.geocoder._cadastral_reverse",
|
||||
new_callable=AsyncMock,
|
||||
return_value="ул. Малышева, 30, Екатеринбург",
|
||||
) as mock_cadastral,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
result = await reverse_geocode(56.838, 60.605)
|
||||
|
||||
assert result == "ул. Малышева, 30, Екатеринбург"
|
||||
mock_cadastral.assert_called_once_with(56.838, 60.605)
|
||||
mock_nominatim.assert_not_called()
|
||||
|
||||
|
||||
async def test_reverse_geocode_falls_back_to_nominatim_when_cadastral_none() -> None:
|
||||
"""Cadastral returns None → Nominatim is called and its result returned."""
|
||||
with (
|
||||
patch(
|
||||
"app.services.geocoder._cadastral_reverse",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
return_value="Трамвайный переулок, 2, Екатеринбург",
|
||||
) as mock_nominatim,
|
||||
):
|
||||
result = await reverse_geocode(56.838, 60.605)
|
||||
|
||||
assert result == "Трамвайный переулок, 2, Екатеринбург"
|
||||
mock_nominatim.assert_called_once_with(56.838, 60.605)
|
||||
|
||||
|
||||
async def test_reverse_geocode_returns_none_when_both_fail() -> None:
|
||||
"""Both cadastral and Nominatim fail → returns None, no exception raised."""
|
||||
with (
|
||||
patch(
|
||||
"app.services.geocoder._cadastral_reverse",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_reverse",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"),
|
||||
),
|
||||
):
|
||||
result = await reverse_geocode(56.838, 60.605)
|
||||
|
||||
# Must return None, not raise
|
||||
assert result is None
|
||||
|
|
@ -45,6 +45,7 @@ services:
|
|||
ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
|
||||
# GlitchTip DSN — мониторинг ошибок (#396). Пусто = выключено.
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
||||
MAIN_BACKEND_URL: "${MAIN_BACKEND_URL:-http://gendesign-backend:8000}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue