Merge pull request 'feat(tradein): segment=vtorichka|novostroyki|all on listings search (#1188)' (#1710) from feat/listings-segment-param-1188 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 37s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 45s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 37s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 45s
This commit is contained in:
commit
5b8ee994d5
3 changed files with 70 additions and 3 deletions
|
|
@ -6,9 +6,11 @@ from typing import Literal
|
|||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
SortKey = Literal[
|
||||
"price_asc", "price_desc", "area_desc", "area_asc", "date_desc", "dist_asc"
|
||||
]
|
||||
SortKey = Literal["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):
|
||||
|
|
@ -49,6 +51,19 @@ class SearchParams(BaseModel):
|
|||
address_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: SortKey = "date_desc"
|
||||
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]]:
|
||||
"""Возвращает (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:
|
||||
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:
|
||||
where.append("sources && CAST(:sources AS text[])")
|
||||
args["sources"] = params.sources
|
||||
|
|
|
|||
|
|
@ -62,6 +62,42 @@ def test_build_query_sources_array():
|
|||
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():
|
||||
sql, args = build_count_query(SearchParams(rooms=2, page=3))
|
||||
assert "count(*)" in sql
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue