All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m22s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m12s
Foundation PR: unified "our projects" source the §25.3 overlap engine (PR2) will consume. Two origins normalized to OwnProject (class/timing/price/unit-mix): - current <- domrf_kn_objects filtered by settings.own_developer_ids (numeric prefix of composite dev_id; empty -> [] graceful, no DB hit, no hardcoded id) - future <- new manual-entry own_planned_project entity (migration 148) Adds OWN_DEVELOPER_IDS config (comma-sep -> list[int], default []), own_planned_project table (range/unit_mix CHECKs via IMMUTABLE helper, generated geom), /api/v1/own-projects CRUD (created_by from X-Authenticated-User), and get_own_portfolio(db). Per-source graceful degradation; psycopg-v3 CAST clean. Does not touch special_indices.py or parcels.py (out of scope). Refs #1169
122 lines
5.5 KiB
Python
122 lines
5.5 KiB
Python
"""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]
|