feat(sf-b1): GET /parcels/by-bbox — map entry endpoint + parcel_user_status table

Добавляет bbox-поиск участков для карты Site Finder (issue #307):
- GET /api/v1/parcels/by-bbox с query params min_lat/min_lon/max_lat/max_lon/limit/user_id
- Overlay статуса из parcel_user_status (user_id опционален)
- Pydantic schemas: ParcelMapMarker, ParcelBboxResponse в app/schemas/parcel.py
- SQL-миграция 119_parcel_user_status.sql (CREATE TABLE IF NOT EXISTS + индексы)
- 4 unit-теста: valid bbox / invalid bbox (400) / status overlay / no-user-id null status
This commit is contained in:
lekss361 2026-05-18 00:17:40 +03:00
parent b013fd886c
commit 57676d5299
4 changed files with 293 additions and 0 deletions

View file

@ -22,7 +22,9 @@ from app.schemas.parcel import (
CompetitorsResponse,
ConnectionPointsResponse,
OpportunityParcel,
ParcelBboxResponse,
ParcelDetail,
ParcelMapMarker,
ParcelMeta,
ParcelSearchRequest,
ParcelSearchResponse,
@ -1007,6 +1009,96 @@ _INLINE_FETCH_WAIT_S = 15
_INLINE_FETCH_POLL_INTERVAL_S = 2
@router.get("/by-bbox", response_model=ParcelBboxResponse)
async def get_parcels_by_bbox(
min_lat: Annotated[float, Query(ge=-90, le=90, description="Южная граница bbox")],
min_lon: Annotated[float, Query(ge=-180, le=180, description="Западная граница bbox")],
max_lat: Annotated[float, Query(ge=-90, le=90, description="Северная граница bbox")],
max_lon: Annotated[float, Query(ge=-180, le=180, description="Восточная граница bbox")],
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
user_id: Annotated[str | None, Query(description="user_id для overlay статуса")] = None,
db: Annotated[Session, Depends(get_db)] = ...,
) -> ParcelBboxResponse:
"""GET /parcels/by-bbox — вернуть участки внутри bounding box для карты.
Использует GIST-индекс на cad_parcels.geom (ST_Intersects).
Если передан user_id добавляет статус из parcel_user_status (overlay).
last_analysis_date placeholder до реализации B2 auth (#307).
"""
if min_lat >= max_lat or min_lon >= max_lon:
raise HTTPException(status_code=400, detail="Некорректный bbox: min >= max")
# Площадь bbox в км² (приблизительно через формулу сферической трапеции)
lat_mid = (min_lat + max_lat) / 2
lat_km = (max_lat - min_lat) * 111.32
lon_km = (max_lon - min_lon) * 111.32 * math.cos(math.radians(lat_mid))
bbox_area_km2 = round(lat_km * lon_km, 4)
sql = text("""
WITH bbox AS (
SELECT ST_MakeEnvelope(
CAST(:min_lon AS float),
CAST(:min_lat AS float),
CAST(:max_lon AS float),
CAST(:max_lat AS float),
4326
) AS env
)
SELECT
p.cad_num,
ST_Y(ST_Centroid(p.geom)) AS centroid_lat,
ST_X(ST_Centroid(p.geom)) AS centroid_lon,
p.area_m2,
p.land_category,
pus.status AS user_status
FROM cad_parcels p
CROSS JOIN bbox
LEFT JOIN parcel_user_status pus
ON pus.cad_num = p.cad_num
AND pus.user_id = CAST(:user_id AS text)
WHERE p.geom IS NOT NULL
AND ST_Intersects(p.geom, bbox.env)
ORDER BY p.area_m2 DESC NULLS LAST
LIMIT CAST(:limit AS int)
""")
rows = (
db.execute(
sql,
{
"min_lat": min_lat,
"min_lon": min_lon,
"max_lat": max_lat,
"max_lon": max_lon,
"user_id": user_id,
"limit": limit,
},
)
.mappings()
.all()
)
markers: list[ParcelMapMarker] = [
ParcelMapMarker(
cad_num=row["cad_num"],
centroid_lat=float(row["centroid_lat"]) if row["centroid_lat"] is not None else 0.0,
centroid_lon=float(row["centroid_lon"]) if row["centroid_lon"] is not None else 0.0,
area_m2=float(row["area_m2"]) if row["area_m2"] is not None else None,
land_category=row["land_category"],
status=row["user_status"] if user_id else None,
last_analysis_date=None, # placeholder, real after B2 auth (#307)
)
for row in rows
]
return ParcelBboxResponse(
parcels=markers,
count=len(markers),
limit=limit,
bbox_area_km2=bbox_area_km2,
)
@router.post("/search", response_model=ParcelSearchResponse)
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
"""Search parcels by filters + scoring.

View file

@ -356,3 +356,31 @@ class AnalyzeRequest(BaseModel):
"Validated против ALLOWED_CATEGORIES + MIN_WEIGHT/MAX_WEIGHT."
),
)
# ── SF-B1: by-bbox map entry schemas (#307) ──────────────────────────────────
class ParcelMapMarker(BaseModel):
"""Один маркер участка на карте — облегчённый ответ для bbox-запроса.
status берётся из parcel_user_status если передан user_id, иначе null.
last_analysis_date placeholder до реализации B2 auth.
"""
cad_num: str
centroid_lat: float
centroid_lon: float
area_m2: float | None
land_category: str | None
status: str | None # 'in_work' | 'favorite' | 'dismissed' | null
last_analysis_date: str | None # placeholder, real after B2 auth
class ParcelBboxResponse(BaseModel):
"""Ответ GET /parcels/by-bbox."""
parcels: list[ParcelMapMarker]
count: int
limit: int
bbox_area_km2: float

View file

@ -0,0 +1,152 @@
"""Тесты GET /parcels/by-bbox (SF-B1).
Проверяют:
1. Валидный bbox 200 + корректная структура ответа.
2. Некорректный bbox (min_lat >= max_lat) 400.
3. user_id передан status заполняется из mock DB; без user_id status=null.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _make_db_row(
cad_num: str = "66:41:0204016:10",
centroid_lat: float = 56.83,
centroid_lon: float = 60.64,
area_m2: float = 1200.0,
land_category: str | None = "land_residential",
user_status: str | None = None,
) -> dict[str, Any]:
return {
"cad_num": cad_num,
"centroid_lat": centroid_lat,
"centroid_lon": centroid_lon,
"area_m2": area_m2,
"land_category": land_category,
"user_status": user_status,
}
def _build_mock_db(rows: list[dict[str, Any]]) -> MagicMock:
"""Сконструировать mock Session, возвращающий rows при execute().mappings().all()."""
mock_db = MagicMock()
mappings_mock = MagicMock()
mappings_mock.all.return_value = rows
execute_result = MagicMock()
execute_result.mappings.return_value = mappings_mock
mock_db.execute.return_value = execute_result
return mock_db
@pytest.fixture()
def client() -> TestClient:
return TestClient(app)
# ── Test 1: валидный bbox возвращает корректную структуру ──────────────────
def test_by_bbox_valid_returns_structure(client: TestClient) -> None:
rows = [_make_db_row()]
mock_db = _build_mock_db(rows)
with patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])):
resp = client.get(
"/api/v1/parcels/by-bbox",
params={
"min_lat": 56.80,
"min_lon": 60.60,
"max_lat": 56.90,
"max_lon": 60.70,
"limit": 200,
},
)
assert resp.status_code == 200
body = resp.json()
assert "parcels" in body
assert "count" in body
assert "limit" in body
assert "bbox_area_km2" in body
assert body["count"] == 1
assert body["limit"] == 200
parcel = body["parcels"][0]
assert parcel["cad_num"] == "66:41:0204016:10"
assert parcel["centroid_lat"] == pytest.approx(56.83, abs=0.01)
assert parcel["centroid_lon"] == pytest.approx(60.64, abs=0.01)
assert parcel["area_m2"] == pytest.approx(1200.0)
assert parcel["land_category"] == "land_residential"
assert parcel["status"] is None # user_id не передан
assert parcel["last_analysis_date"] is None
# ── Test 2: некорректный bbox → 400 ───────────────────────────────────────
def test_by_bbox_invalid_bbox_returns_400(client: TestClient) -> None:
resp = client.get(
"/api/v1/parcels/by-bbox",
params={
"min_lat": 56.90, # min >= max → ошибка
"min_lon": 60.60,
"max_lat": 56.80,
"max_lon": 60.70,
},
)
assert resp.status_code == 400
assert "bbox" in resp.json()["detail"].lower()
# ── Test 3: user_id → status из БД; без user_id → status null ─────────────
def test_by_bbox_status_overlay_with_user_id(client: TestClient) -> None:
rows = [_make_db_row(user_status="in_work")]
mock_db = _build_mock_db(rows)
with patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])):
resp = client.get(
"/api/v1/parcels/by-bbox",
params={
"min_lat": 56.80,
"min_lon": 60.60,
"max_lat": 56.90,
"max_lon": 60.70,
"user_id": "user-abc-123",
},
)
assert resp.status_code == 200
parcel = resp.json()["parcels"][0]
assert parcel["status"] == "in_work"
def test_by_bbox_no_user_id_status_is_null(client: TestClient) -> None:
rows = [_make_db_row(user_status="favorite")] # DB вернёт статус
mock_db = _build_mock_db(rows)
with patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])):
resp = client.get(
"/api/v1/parcels/by-bbox",
params={
"min_lat": 56.80,
"min_lon": 60.60,
"max_lat": 56.90,
"max_lon": 60.70,
# user_id не передан
},
)
assert resp.status_code == 200
parcel = resp.json()["parcels"][0]
# Без user_id статус принудительно null (endpoint не раскрывает чужие статусы)
assert parcel["status"] is None

View file

@ -0,0 +1,21 @@
BEGIN;
CREATE TABLE IF NOT EXISTS parcel_user_status (
user_id text NOT NULL,
cad_num text NOT NULL,
status text NOT NULL CHECK (status IN ('in_work', 'favorite', 'dismissed')),
notes text,
updated_at timestamptz NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, cad_num)
);
CREATE INDEX IF NOT EXISTS parcel_user_status_cad_idx
ON parcel_user_status (cad_num);
CREATE INDEX IF NOT EXISTS parcel_user_status_user_idx
ON parcel_user_status (user_id, status);
COMMENT ON TABLE parcel_user_status
IS '#307 SF-B1 user-marked parcels (in_work/favorite/dismissed) для карты.';
COMMIT;