gendesign/backend/app/schemas/own_project.py
Light1YT df34e55ab4
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
feat(site-finder): own-portfolio data source for §25.3 cannibalization (#1169 PR1)
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
2026-06-08 16:21:53 +05:00

122 lines
5.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.

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