feat(site-finder): snapshot.pdf exclude_dev param — hide competitor by developer name #1756
3 changed files with 247 additions and 1 deletions
|
|
@ -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 = (
|
||||
|
|
|
|||
203
backend/tests/api/v1/test_parcel_snapshot_exclude_dev.py
Normal file
203
backend/tests/api/v1/test_parcel_snapshot_exclude_dev.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -5240,7 +5240,10 @@ export interface operations {
|
|||
};
|
||||
parcel_snapshot_pdf_api_v1_parcels__cad_num__snapshot_pdf_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
/** @description Скрыть конкурентов этих застройщиков (dev_name) из блока «Конкуренты». Несколько — через запятую. Регистронезависимо. Пример: exclude_dev=Практика */
|
||||
exclude_dev?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
cad_num: string;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue