diff --git a/backend/app/api/v1/cadastral.py b/backend/app/api/v1/cadastral.py new file mode 100644 index 00000000..a1b17b0f --- /dev/null +++ b/backend/app/api/v1/cadastral.py @@ -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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 07071a70..bd7afc5b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8c77d93c..c121a217 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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} diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 167a9370..f3d7bf6f 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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() diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index d5f0e146..bc8c7982 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -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 diff --git a/tradein-mvp/backend/tests/services/__init__.py b/tradein-mvp/backend/tests/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/tests/services/test_cadastral_reverse.py b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py new file mode 100644 index 00000000..d5585432 --- /dev/null +++ b/tradein-mvp/backend/tests/services/test_cadastral_reverse.py @@ -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 diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 83400ba1..f1b55f33 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -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