feat(tradein): segment=vtorichka|novostroyki|all query-param on listings (#1188)
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
This commit is contained in:
parent
584aa46f6f
commit
b64334d7bd
3 changed files with 70 additions and 3 deletions
|
|
@ -6,9 +6,11 @@ from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
SortKey = Literal[
|
SortKey = Literal["price_asc", "price_desc", "area_desc", "area_asc", "date_desc", "dist_asc"]
|
||||||
"price_asc", "price_desc", "area_desc", "area_asc", "date_desc", "dist_asc"
|
|
||||||
]
|
# Сегмент рынка (#1188, поверх canon-предиката #1186).
|
||||||
|
# NULL listing_segment (legacy вторичка до миграции 011) трактуется как 'vtorichka'.
|
||||||
|
SegmentKey = Literal["vtorichka", "novostroyki", "all"]
|
||||||
|
|
||||||
|
|
||||||
class SearchParams(BaseModel):
|
class SearchParams(BaseModel):
|
||||||
|
|
@ -49,6 +51,19 @@ class SearchParams(BaseModel):
|
||||||
address_query: str | None = Field(default=None, max_length=200)
|
address_query: str | None = Field(default=None, max_length=200)
|
||||||
description_query: str | None = Field(default=None, max_length=200)
|
description_query: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
# --- Market segment (#1188) ---
|
||||||
|
segment: SegmentKey = Field(
|
||||||
|
default="vtorichka",
|
||||||
|
description=(
|
||||||
|
"Сегмент рынка для фильтрации listings. "
|
||||||
|
"`vtorichka` (по умолчанию, back-compat) — вторичка; "
|
||||||
|
"строки с listing_segment=NULL (legacy до миграции 011) "
|
||||||
|
"считаются вторичкой согласно canon-предикату #1186. "
|
||||||
|
"`novostroyki` — только первичка. "
|
||||||
|
"`all` — без фильтра по сегменту."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# --- Sort + pagination ---
|
# --- Sort + pagination ---
|
||||||
sort: SortKey = "date_desc"
|
sort: SortKey = "date_desc"
|
||||||
page: int = Field(default=1, ge=1, le=1000)
|
page: int = Field(default=1, ge=1, le=1000)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,18 @@ _SORT_SQL: dict[str, str] = {
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Сегмент рынка (#1188). listings_search_mv не несёт колонку listing_segment,
|
||||||
|
# поэтому фильтруем через подзапрос к базовой таблице listings, переиспользуя
|
||||||
|
# canon-предикат #1186: NULL = legacy вторичка до миграции 011.
|
||||||
|
_VTORICHKA_GUARD = "(listing_segment IS NULL OR listing_segment = 'vtorichka')"
|
||||||
|
_SEGMENT_SQL: dict[str, str | None] = {
|
||||||
|
"vtorichka": (f"listing_id IN (SELECT id FROM listings WHERE {_VTORICHKA_GUARD})"),
|
||||||
|
"novostroyki": (
|
||||||
|
"listing_id IN (SELECT id FROM listings WHERE listing_segment = 'novostroyki')"
|
||||||
|
),
|
||||||
|
"all": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
|
def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
|
||||||
"""Возвращает (sql, args) для SELECT из listings_search_mv."""
|
"""Возвращает (sql, args) для SELECT из listings_search_mv."""
|
||||||
|
|
@ -86,6 +98,10 @@ def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
|
||||||
if params.has_kadastr:
|
if params.has_kadastr:
|
||||||
where.append("cadastral_number IS NOT NULL")
|
where.append("cadastral_number IS NOT NULL")
|
||||||
|
|
||||||
|
segment_clause = _SEGMENT_SQL[params.segment]
|
||||||
|
if segment_clause is not None:
|
||||||
|
where.append(segment_clause)
|
||||||
|
|
||||||
if params.sources:
|
if params.sources:
|
||||||
where.append("sources && CAST(:sources AS text[])")
|
where.append("sources && CAST(:sources AS text[])")
|
||||||
args["sources"] = params.sources
|
args["sources"] = params.sources
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,42 @@ def test_build_query_sources_array():
|
||||||
assert args["sources"] == ["avito", "cian"]
|
assert args["sources"] == ["avito", "cian"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_segment_default_vtorichka():
|
||||||
|
"""Без параметра segment — back-compat: canon-предикат вторички (#1186/#1188)."""
|
||||||
|
sql, _ = build_search_query(SearchParams())
|
||||||
|
assert SearchParams().segment == "vtorichka"
|
||||||
|
assert "listing_id IN (SELECT id FROM listings WHERE" in sql
|
||||||
|
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_segment_novostroyki():
|
||||||
|
"""segment=novostroyki — только первичка, без NULL-вторички."""
|
||||||
|
sql, _ = build_search_query(SearchParams(segment="novostroyki"))
|
||||||
|
assert "listing_segment = 'novostroyki'" in sql
|
||||||
|
# Не должен попасть canon-guard вторички.
|
||||||
|
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" not in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_segment_all_no_filter():
|
||||||
|
"""segment=all — без фильтрации по сегменту."""
|
||||||
|
sql, _ = build_search_query(SearchParams(segment="all"))
|
||||||
|
assert "listing_segment" not in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_count_query_inherits_segment():
|
||||||
|
"""COUNT-запрос наследует segment-фильтр из build_search_query."""
|
||||||
|
sql, _ = build_count_query(SearchParams(segment="novostroyki"))
|
||||||
|
assert "listing_segment = 'novostroyki'" in sql
|
||||||
|
sql_v, _ = build_count_query(SearchParams())
|
||||||
|
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" in sql_v
|
||||||
|
|
||||||
|
|
||||||
|
def test_segment_invalid_rejected():
|
||||||
|
"""Невалидное значение segment отклоняется валидацией."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
SearchParams(segment="kommercia")
|
||||||
|
|
||||||
|
|
||||||
def test_build_count_query_strips_limit():
|
def test_build_count_query_strips_limit():
|
||||||
sql, args = build_count_query(SearchParams(rooms=2, page=3))
|
sql, args = build_count_query(SearchParams(rooms=2, page=3))
|
||||||
assert "count(*)" in sql
|
assert "count(*)" in sql
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue