Merge pull request 'feat(site-finder): snapshot.pdf exclude_dev param — hide competitor by developer name' (#1756) from feat/snapshot-exclude-dev into main
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 2m11s
Deploy / build-frontend (push) Successful in 3m18s
Deploy / build-worker (push) Successful in 3m35s
Deploy / deploy (push) Successful in 1m24s

Reviewed-on: #1756
This commit is contained in:
lekss361 2026-06-18 13:11:29 +00:00
commit 5b14d88290
3 changed files with 247 additions and 1 deletions

View file

@ -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 = (

View 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()

View file

@ -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;