gendesign/tradein-mvp/backend/app/schemas/search.py

112 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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