Replaces tradein.cad_buildings manual snapshot (36k rows, frozen 2026-05-22) with live postgres_fdw foreign table reading gendesign.v_tradein_cad_buildings directly. Also reverts PR #492 HTTP-based cadastral.py — superseded by FDW. Architecture: - gendesign DB: new role tradein_fdw_reader + flat view v_tradein_cad_buildings (EKB-only slice of cad_buildings with lat/lon flattened from geom) - networks: gendesign-postgres added to gendesign_shared (alias); tradein-postgres added to gendesign_shared for FDW connect - tradein DB: postgres_fdw extension + FOREIGN TABLE gendesign_cad_buildings - tradein backend: startup hook creates/refreshes USER MAPPING with password from env GENDESIGN_FDW_PASSWORD (password rotation handled via restart) - geocoder.py: cadastral primary for forward + reverse + suggest; Yandex/Nominatim fallback. reverse_geocode wraps Nominatim in try/except — no more HTTPStatusError → 500. - house_metadata.py and trade_in.py admin stats switched to foreign table - DROP TABLE cad_buildings in tradein (legacy snapshot removed entirely) - import-cadastre.sh deleted (no manual sync needed) Fixes: - /trade-in/api/v1/geocode/reverse 500 (Nominatim ban → no fallback) - estimate confidence_explanation=address_not_geocoded for addresses in our cadastre (e.g. Хохрякова 81) that Nominatim doesn't return Deploy ordering: main migration 100_tradein_fdw_role.sql adds role+view first (strict deploy.yml). Tradein next deploy applies 060_postgres_fdw_extension.sql and 061_drop_legacy_cad_buildings.sql (idempotent, errors ignored). Tradein backend startup creates USER MAPPING when env var present. Verify post-deploy with: docker exec tradein-postgres psql -U tradein -d tradein -c \ "SELECT count(*) FROM gendesign_cad_buildings" # expect ~36k EKB buildings.
87 lines
2.9 KiB
Python
87 lines
2.9 KiB
Python
"""Geocode endpoints — debug + frontend Suggest proxy."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from pydantic import BaseModel
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
@router.get("/lookup", response_model=GeocodeResult)
|
||
async def lookup(
|
||
address: Annotated[str, Query(min_length=3, max_length=500)],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> GeocodeResult:
|
||
"""Геокодинг адреса → lat/lon.
|
||
|
||
Примеры:
|
||
/api/v1/geocode/lookup?address=ул.+Малышева+30+Екатеринбург
|
||
/api/v1/geocode/lookup?address=Куйбышева+50+Екатеринбург
|
||
"""
|
||
result = await geocode(address, db)
|
||
if result is None:
|
||
raise HTTPException(status_code=404, detail=f"Address not found: {address}")
|
||
return result
|
||
|
||
|
||
class SuggestItem(BaseModel):
|
||
label: str
|
||
full_address: str
|
||
lat: float
|
||
lon: float
|
||
kind: str
|
||
|
||
|
||
class SuggestResponse(BaseModel):
|
||
items: list[SuggestItem]
|
||
|
||
|
||
@router.get("/suggest", response_model=SuggestResponse)
|
||
async def suggest_addresses(
|
||
q: Annotated[str, Query(min_length=2, max_length=200, description="Запрос для автокомплита")],
|
||
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||
) -> SuggestResponse:
|
||
"""Автокомплит адресов в пределах ЕКБ.
|
||
|
||
Используется в EstimateForm для подсказок пока пользователь печатает.
|
||
Bounded viewbox = ЕКБ (lon 60.40-60.85, lat 56.65-56.95).
|
||
|
||
Пример:
|
||
/api/v1/geocode/suggest?q=Малышева
|
||
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
|
||
"""
|
||
items = await suggest(q, db=db, limit=limit)
|
||
return SuggestResponse(items=[
|
||
SuggestItem(
|
||
label=s.label,
|
||
full_address=s.full_address,
|
||
lat=s.lat, lon=s.lon, kind=s.kind,
|
||
) for s in items
|
||
])
|
||
|
||
|
||
@router.get("/reverse")
|
||
async def reverse(
|
||
lat: Annotated[float, Query(ge=-90, le=90)],
|
||
lon: Annotated[float, Query(ge=-180, le=180)],
|
||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||
) -> dict[str, object]:
|
||
"""Обратный геокодинг — координаты с карты → адрес (map-picker).
|
||
|
||
Пример: /api/v1/geocode/reverse?lat=56.8389&lon=60.6057
|
||
"""
|
||
address = await reverse_geocode(lat, lon, db=db)
|
||
if address is None:
|
||
raise HTTPException(status_code=404, detail="address not found for coordinates")
|
||
return {"address": address, "lat": lat, "lon": lon}
|