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_scrape,
|
||||||
admin_weight_profiles,
|
admin_weight_profiles,
|
||||||
analytics,
|
analytics,
|
||||||
|
cadastral,
|
||||||
concepts,
|
concepts,
|
||||||
custom_pois,
|
custom_pois,
|
||||||
landing,
|
landing,
|
||||||
|
|
@ -78,6 +79,7 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
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(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
|
||||||
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
|
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
|
||||||
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,11 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
default: {}
|
||||||
|
shared:
|
||||||
|
aliases:
|
||||||
|
- gendesign-backend
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest}
|
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 для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
|
||||||
redis_url: str = "redis://localhost:6379/0"
|
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()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -569,14 +569,32 @@ def _format_reverse_address(addr: dict) -> str | None:
|
||||||
return ", ".join(parts)
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
async def _cadastral_reverse(lat: float, lon: float) -> str | None:
|
||||||
async def reverse_geocode(lat: float, lon: float) -> str | None:
|
"""Reverse via main backend cad_buildings — fast, no external API.
|
||||||
"""Обратный геокодинг: координаты → адрес (Nominatim /reverse).
|
|
||||||
|
|
||||||
Клик по дому на карте ЕКБ → адрес подставляется в форму оценки.
|
Returns None on any error (network, 404, timeout, missing config).
|
||||||
Возвращаем уличный адрес (улица, дом, город), а не `display_name` —
|
|
||||||
тот начинается с названия POI/организации и не геокодируется обратно.
|
|
||||||
"""
|
"""
|
||||||
|
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 = {
|
headers = {
|
||||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
|
|
@ -603,3 +621,23 @@ async def reverse_geocode(lat: float, lon: float) -> str | None:
|
||||||
if street:
|
if street:
|
||||||
return street
|
return street
|
||||||
return data.get("display_name")
|
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:-}"
|
ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
|
||||||
# GlitchTip DSN — мониторинг ошибок (#396). Пусто = выключено.
|
# GlitchTip DSN — мониторинг ошибок (#396). Пусто = выключено.
|
||||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
||||||
|
MAIN_BACKEND_URL: "${MAIN_BACKEND_URL:-http://gendesign-backend:8000}"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue