diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index f8c407d3..f63ed9a2 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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. diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 41103e55..1e6d3c1a 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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 diff --git a/backend/tests/api/v1/test_parcel_by_bbox.py b/backend/tests/api/v1/test_parcel_by_bbox.py new file mode 100644 index 00000000..6c60c9fe --- /dev/null +++ b/backend/tests/api/v1/test_parcel_by_bbox.py @@ -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 diff --git a/data/sql/119_parcel_user_status.sql b/data/sql/119_parcel_user_status.sql new file mode 100644 index 00000000..97392e43 --- /dev/null +++ b/data/sql/119_parcel_user_status.sql @@ -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;