diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 74ae4a21..2dc3c141 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -3454,6 +3454,32 @@ async def get_parcel_best_layouts_pdf( raise HTTPException(status_code=500, detail="Internal server error") from exc +def _filter_competitors_by_dev( + rows: list[dict[str, Any]], exclude_dev: str | None +) -> list[dict[str, Any]]: + """Отфильтровать строки-конкуренты по имени застройщика (dev_name). + + `exclude_dev` — CSV нормализованных (casefold) имён застройщиков, которых + нужно скрыть из блока «Конкуренты» PDF-снимка. Сравнение регистронезависимо. + Пустой/None `exclude_dev` → список возвращается без изменений. Строки с + пустым/None dev_name НЕ исключаются (исключаем только явные совпадения). + """ + if not exclude_dev: + return rows + excluded = {s.strip().casefold() for s in exclude_dev.split(",") if s.strip()} + if not excluded: + return rows + kept = [r for r in rows if (r.get("dev_name") or "").strip().casefold() not in excluded] + dropped = len(rows) - len(kept) + if dropped: + logger.info( + "snapshot_pdf: excluded %d competitors by dev_name filter %s", + dropped, + sorted(excluded), + ) + return kept + + @router.get( "/{cad_num}/snapshot.pdf", summary="1-page PDF snapshot участка (НСПД + POI + конкуренты)", @@ -3461,6 +3487,15 @@ async def get_parcel_best_layouts_pdf( def parcel_snapshot_pdf( cad_num: str, db: Annotated[Session, Depends(get_db)], + exclude_dev: Annotated[ + str | None, + Query( + description=( + "Скрыть конкурентов этих застройщиков (dev_name) из блока «Конкуренты». " + "Несколько — через запятую. Регистронезависимо. Пример: exclude_dev=Практика" + ) + ), + ] = None, ) -> Response: """Генерирует одностраничный PDF-снимок участка (A4). @@ -3596,6 +3631,11 @@ def parcel_snapshot_pdf( .all() ] + # 4b) Опционально скрыть конкурентов конкретных застройщиков (client-кейс: + # убрать оффер девелопера X из отчёта для девелопера Y). Без exclude_dev — + # список идентичен прежнему (zero behavior change). + competitor_rows = _filter_competitors_by_dev(competitor_rows, exclude_dev) + # 5) Получить district (через пересечение с ekb_districts) district: str | None = None district_row = ( diff --git a/backend/tests/api/v1/test_parcel_snapshot_exclude_dev.py b/backend/tests/api/v1/test_parcel_snapshot_exclude_dev.py new file mode 100644 index 00000000..abfab3b0 --- /dev/null +++ b/backend/tests/api/v1/test_parcel_snapshot_exclude_dev.py @@ -0,0 +1,203 @@ +"""Тесты для query-параметра exclude_dev в GET /parcels/{cad}/snapshot.pdf. + +Скрывает конкурентов конкретных застройщиков (dev_name) из блока «Конкуренты» +PDF-снимка. Client-кейс: убрать оффер девелопера X из отчёта для девелопера Y. + +Юнит-тесты — над чистой функцией `_filter_competitors_by_dev`. +Плюс 1 endpoint smoke (mock DB + patched WeasyPrint-рендер). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient + +from app.api.v1.parcels import _filter_competitors_by_dev +from app.main import app + + +def _rows() -> list[dict[str, Any]]: + return [ + {"dev_name": "Практика", "comm_name": "ЖК-А", "flat_count": 100, "distance_m": 200.0}, + {"dev_name": "Брусника", "comm_name": "ЖК-Б", "flat_count": 200, "distance_m": 400.0}, + {"dev_name": "Атлас", "comm_name": "ЖК-В", "flat_count": 50, "distance_m": 800.0}, + {"dev_name": None, "comm_name": "ЖК-Г", "flat_count": 30, "distance_m": 900.0}, + ] + + +# ── Unit: _filter_competitors_by_dev ────────────────────────────────────────── + + +def test_filter_none_is_noop() -> None: + """exclude_dev=None → список идентичен исходному (zero behavior change).""" + rows = _rows() + assert _filter_competitors_by_dev(rows, None) == rows + + +def test_filter_empty_string_is_noop() -> None: + """Пустая строка / только запятые → ничего не исключаем.""" + rows = _rows() + assert _filter_competitors_by_dev(rows, "") == rows + assert _filter_competitors_by_dev(rows, " , ,") == rows + + +def test_filter_single_dev() -> None: + """exclude_dev=Практика → строки этого застройщика исключены, остальные есть.""" + kept = _filter_competitors_by_dev(_rows(), "Практика") + names = [r["dev_name"] for r in kept] + assert "Практика" not in names + assert "Брусника" in names + assert "Атлас" in names + + +def test_filter_case_insensitive() -> None: + """Регистронезависимо: 'практика' / 'ПРАКТИКА' исключают 'Практика'.""" + for variant in ("практика", "ПРАКТИКА", "ПрАкТиКа"): + kept = _filter_competitors_by_dev(_rows(), variant) + assert "Практика" not in [r["dev_name"] for r in kept] + + +def test_filter_multi_dev() -> None: + """exclude_dev=Практика,Брусника → обе исключены, прочие остаются.""" + kept = _filter_competitors_by_dev(_rows(), "Практика,Брусника") + names = [r["dev_name"] for r in kept] + assert "Практика" not in names + assert "Брусника" not in names + assert "Атлас" in names + assert None in names # dev_name=None не исключается + + +def test_filter_none_dev_name_not_excluded() -> None: + """Строка с dev_name=None не падает и не исключается даже при фильтре.""" + kept = _filter_competitors_by_dev(_rows(), "Практика") + assert any(r["dev_name"] is None for r in kept) + + +def test_filter_whitespace_in_csv() -> None: + """Пробелы вокруг имён в CSV тримятся.""" + kept = _filter_competitors_by_dev(_rows(), " Практика , Брусника ") + names = [r["dev_name"] for r in kept] + assert "Практика" not in names + assert "Брусника" not in names + + +# ── Endpoint smoke: mock DB + patched render ────────────────────────────────── + + +def _parcel_row() -> MagicMock: + r = MagicMock() + r.__getitem__ = lambda self, k: { + "address": "ул. Тестовая, 1", + "area_m2": 5000.0, + "land_category": "земли населённых пунктов", + "vri": "многоэтажная застройка", + "cadastral_cost": 10_000_000.0, + "last_update": None, + }[k] + return r + + +def _geom_row() -> MagicMock: + r = MagicMock() + r.__getitem__ = lambda self, k: {"wkt": "POINT(60.605 56.838)"}[k] + return r + + +def _comp_mapping(dev_name: str | None, obj_id: int) -> dict[str, Any]: + return { + "obj_id": obj_id, + "comm_name": f"ЖК-{obj_id}", + "dev_name": dev_name, + "obj_class": "comfort", + "flat_count": 100, + "distance_m": 100.0 * obj_id, + } + + +def _make_snapshot_db() -> MagicMock: + """Порядок execute в parcel_snapshot_pdf: + 1. parcel_row → .mappings().first() + 2. geom → .mappings().first() + 3. poi → .mappings().all() + 4. competitors→ .mappings().all() + 5. district → .mappings().first() + """ + db = MagicMock() + + res_parcel = MagicMock() + res_parcel.mappings.return_value.first.return_value = _parcel_row() + + res_geom = MagicMock() + res_geom.mappings.return_value.first.return_value = _geom_row() + + res_poi = MagicMock() + res_poi.mappings.return_value.all.return_value = [] + + res_comp = MagicMock() + res_comp.mappings.return_value.all.return_value = [ + _comp_mapping("Практика", 1), + _comp_mapping("Брусника", 2), + ] + + res_district = MagicMock() + res_district.mappings.return_value.first.return_value = None + + db.execute.side_effect = [res_parcel, res_geom, res_poi, res_comp, res_district] + return db + + +def _override_db(db: MagicMock): + def _get_db_override(): + yield db + + return _get_db_override + + +def test_snapshot_endpoint_exclude_dev_filters_render_input(monkeypatch) -> None: + """С exclude_dev=Практика конкурент с dev_name='Практика' не доходит до рендера.""" + captured: dict[str, Any] = {} + + def _fake_render(**kwargs: Any) -> bytes: + captured["competitor_rows"] = kwargs.get("competitor_rows") + return b"%PDF-1.4 fake" + + monkeypatch.setattr("app.api.v1.parcels.generate_snapshot_pdf", _fake_render) + + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(_make_snapshot_db()) + try: + client = TestClient(app) + resp = client.get("/api/v1/parcels/66:41:0303161:5/snapshot.pdf?exclude_dev=Практика") + assert resp.status_code == 200, resp.text + devs = [r["dev_name"] for r in captured["competitor_rows"]] + assert "Практика" not in devs + assert "Брусника" in devs + finally: + app.dependency_overrides.clear() + + +def test_snapshot_endpoint_no_param_keeps_all(monkeypatch) -> None: + """Без exclude_dev все конкуренты доходят до рендера (baseline).""" + captured: dict[str, Any] = {} + + def _fake_render(**kwargs: Any) -> bytes: + captured["competitor_rows"] = kwargs.get("competitor_rows") + return b"%PDF-1.4 fake" + + monkeypatch.setattr("app.api.v1.parcels.generate_snapshot_pdf", _fake_render) + + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(_make_snapshot_db()) + try: + client = TestClient(app) + resp = client.get("/api/v1/parcels/66:41:0303161:5/snapshot.pdf") + assert resp.status_code == 200, resp.text + devs = [r["dev_name"] for r in captured["competitor_rows"]] + assert "Практика" in devs + assert "Брусника" in devs + finally: + app.dependency_overrides.clear()