"""SearchParams + SearchResponse — /api/v1/search (Phase 3.2).""" from __future__ import annotations 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"] # Сегмент рынка (#1188, поверх canon-предиката #1186). # NULL listing_segment (legacy вторичка до миграции 011) трактуется как 'vtorichka'. SegmentKey = Literal["vtorichka", "novostroyki", "all"] class SearchParams(BaseModel): """Фильтры поиска по listings_search_mv (master plan sec 9.1).""" # --- Geo (radius search) --- lat: float | None = Field(default=None, ge=-90.0, le=90.0) lon: float | None = Field(default=None, ge=-180.0, le=180.0) radius_m: int = Field(default=2000, ge=100, le=50000) # --- Property filters --- rooms: int | None = Field(default=None, ge=0, le=10) rooms_in: list[int] | None = None area_m2_min: float | None = Field(default=None, ge=0) area_m2_max: float | None = Field(default=None, ge=0) price_rub_min: int | None = Field(default=None, ge=0) price_rub_max: int | None = Field(default=None, ge=0) price_per_m2_max: int | None = Field(default=None, ge=0) floor_min: int | None = Field(default=None, ge=1) floor_max: int | None = Field(default=None, ge=1) # --- House filters --- year_built_min: int | None = Field(default=None, ge=1800, le=2100) year_built_max: int | None = Field(default=None, ge=1800, le=2100) house_class: list[str] | None = None floors_total_min: int | None = Field(default=None, ge=1) floors_total_max: int | None = Field(default=None, ge=1) # --- Quality / cross-source --- has_kadastr: bool = False sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None multi_source_only: bool = False require_avito: bool = False require_cian: bool = False require_yandex: bool = False # --- Text search --- 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) page_size: int = Field(default=50, ge=1, le=200) @model_validator(mode="after") def _validate_geo_pair(self) -> SearchParams: if (self.lat is None) != (self.lon is None): raise ValueError("lat and lon must be provided together") if self.sort == "dist_asc" and self.lat is None: raise ValueError("sort=dist_asc requires lat+lon") if ( self.price_rub_min is not None and self.price_rub_max is not None and self.price_rub_min > self.price_rub_max ): raise ValueError("price_rub_min > price_rub_max") if ( self.area_m2_min is not None and self.area_m2_max is not None and self.area_m2_min > self.area_m2_max ): raise ValueError("area_m2_min > area_m2_max") if ( self.floor_min is not None and self.floor_max is not None and self.floor_min > self.floor_max ): raise ValueError("floor_min > floor_max") if ( self.year_built_min is not None and self.year_built_max is not None and self.year_built_min > self.year_built_max ): raise ValueError("year_built_min > year_built_max") if ( self.floors_total_min is not None and self.floors_total_max is not None and self.floors_total_min > self.floors_total_max ): raise ValueError("floors_total_min > floors_total_max") return self @property def offset(self) -> int: return (self.page - 1) * self.page_size