feat(sf-b1): GET /parcels/by-bbox — map entry endpoint + parcel_user_status table (#336)
This commit is contained in:
parent
dbeed80b26
commit
c61c8c86af
4 changed files with 293 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
152
backend/tests/api/v1/test_parcel_by_bbox.py
Normal file
152
backend/tests/api/v1/test_parcel_by_bbox.py
Normal 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
|
||||
21
data/sql/119_parcel_user_status.sql
Normal file
21
data/sql/119_parcel_user_status.sql
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue