"""Pydantic schemas для own_planned_project (#1169 PR1, ТЗ §25.3). OwnPlannedProjectCreate — тело POST (name обязателен). OwnPlannedProjectUpdate — тело PUT (все поля опциональны — partial update). OwnPlannedProjectOut — ответ API (включает id, created_by, created_at, updated_at). created_by НЕ принимается из тела — проставляется роутером из X-Authenticated-User (зеркало insight #948). Это «наш будущий/планируемый проект» приватного пайплайна: второй источник §25.3-движка каннибализации (парный к «текущим» из domrf). """ from __future__ import annotations from datetime import date, datetime from typing import Literal from pydantic import BaseModel, Field, field_validator, model_validator # Класс проекта — мягкий enum (зеркалит class-вокабуляр forecasting SegmentSpec # и domrf obj_class). None допустим (класс может быть ещё не определён). Матчинг в # PR2 регистронезависимый, поэтому здесь фиксируем «человеческий» Title-case-набор. OwnProjectClass = Literal["Стандарт", "Комфорт", "Бизнес", "Премиум"] def _validate_unit_mix(value: dict[str, float] | None) -> dict[str, float] | None: """Проверить, что доли квартирографии — числа в [0,1]. None допустим. Сумму ≈1 НЕ навязываем жёстко (округления / нежилая часть) — зеркалит CHECK миграции 148. Возвращает значение как есть (после проверки). """ if value is None: return None for key, share in value.items(): if not isinstance(share, int | float) or isinstance(share, bool): raise ValueError( f"unit_mix['{key}'] должно быть числом, получено {type(share).__name__}" ) if share < 0 or share > 1: raise ValueError(f"unit_mix['{key}']={share} вне диапазона [0,1]") return value class OwnPlannedProjectCreate(BaseModel): name: str = Field(..., min_length=1, max_length=300, description="Название проекта/ЖК") district: str | None = Field(None, max_length=200, description="Район (location ref)") obj_class: OwnProjectClass | None = Field( None, description="Класс: Стандарт|Комфорт|Бизнес|Премиум" ) planned_release_month: date | None = Field( None, description="Планируемый месяц выхода в продажу (нормализуется к 1-му числу)" ) price_min_per_m2: float | None = Field( None, ge=0, description="Нижняя граница цены, ₽/м² (≥0)" ) price_max_per_m2: float | None = Field( None, ge=0, description="Верхняя граница цены, ₽/м² (≥0)" ) unit_mix: dict[str, float] | None = Field( None, description='Квартирография: доли по форматам {"studio":0.3,"1k":0.4,...} (каждая в [0,1])', ) lon: float | None = Field(None, ge=-180.0, le=180.0, description="Долгота WGS-84 (центроид)") lat: float | None = Field(None, ge=-90.0, le=90.0, description="Широта WGS-84 (центроид)") @field_validator("unit_mix") @classmethod def _check_unit_mix(cls, value: dict[str, float] | None) -> dict[str, float] | None: return _validate_unit_mix(value) @model_validator(mode="after") def _check_price_range(self) -> OwnPlannedProjectCreate: if ( self.price_min_per_m2 is not None and self.price_max_per_m2 is not None and self.price_min_per_m2 > self.price_max_per_m2 ): raise ValueError("price_min_per_m2 не может быть больше price_max_per_m2") return self class OwnPlannedProjectUpdate(BaseModel): """Partial update (PUT) — переданные поля заменяются, отсутствующие не трогаются.""" name: str | None = Field(None, min_length=1, max_length=300) district: str | None = Field(None, max_length=200) obj_class: OwnProjectClass | None = None planned_release_month: date | None = None price_min_per_m2: float | None = Field(None, ge=0) price_max_per_m2: float | None = Field(None, ge=0) unit_mix: dict[str, float] | None = None lon: float | None = Field(None, ge=-180.0, le=180.0) lat: float | None = Field(None, ge=-90.0, le=90.0) @field_validator("unit_mix") @classmethod def _check_unit_mix(cls, value: dict[str, float] | None) -> dict[str, float] | None: return _validate_unit_mix(value) class OwnPlannedProjectOut(BaseModel): id: int created_by: str name: str district: str | None obj_class: str | None planned_release_month: date | None price_min_per_m2: float | None price_max_per_m2: float | None unit_mix: dict[str, float] | None lon: float | None lat: float | None created_at: datetime updated_at: datetime class OwnPlannedProjectList(BaseModel): """Ответ list-эндпоинта: total + страница строк (как insight).""" total: int limit: int offset: int rows: list[OwnPlannedProjectOut]