feat(tradein): cadastral reverse geocode via main backend cad_buildings

Fixes 500 on /trade-in/api/v1/geocode/reverse when Nominatim bans prod IP
after geocode_missing batch. Cadastral lookup via main backend new
endpoint /api/v1/cadastral/reverse is primary (37k buildings in ЕКБ,
<10ms, no external API). Nominatim is fallback wrapped in try/except
so HTTPStatusError no longer bubbles to FastAPI 500.

Architecture:
- main backend: new GET /api/v1/cadastral/reverse — cad_buildings nearest
  within radius, filters out гаражи/СНТ. Added to gendesign_shared network
  with alias gendesign-backend for cross-stack DNS.
- tradein backend: _cadastral_reverse() calls main backend, returns None
  on any error. reverse_geocode prefers cadastral → falls back to
  Nominatim → returns None (endpoint becomes 404 instead of 500).

Env: MAIN_BACKEND_URL=http://gendesign-backend:8000 in tradein prod.
Tests: 7 new cases covering cadastral/nominatim/fallback/error paths.
This commit is contained in:
lekss361 2026-05-24 10:22:06 +03:00
parent 7874a5c97b
commit bd6b8a2e76
8 changed files with 308 additions and 6 deletions

View 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,
)

View file

@ -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"])

View file

@ -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}

View file

@ -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()

View file

@ -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

View 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

View file

@ -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