feat(site-finder): own-portfolio data source for §25.3 cannibalization (#1169 PR1) #1170
10 changed files with 1769 additions and 2 deletions
139
backend/app/api/v1/own_projects.py
Normal file
139
backend/app/api/v1/own_projects.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""CRUD API для own_planned_project (#1169 PR1, ТЗ §25.3).
|
||||
|
||||
POST /api/v1/own-projects → 201 OwnPlannedProjectOut (create)
|
||||
GET /api/v1/own-projects → OwnPlannedProjectList (list + filters + pagination)
|
||||
GET /api/v1/own-projects/{id} → OwnPlannedProjectOut (one; 404 если нет)
|
||||
PUT /api/v1/own-projects/{id} → OwnPlannedProjectOut (partial update; 404 если нет)
|
||||
DELETE /api/v1/own-projects/{id} → 204 (hard delete; 404 если нет)
|
||||
|
||||
«Наши будущие/планируемые проекты» приватного пайплайна — второй источник §25.3-движка
|
||||
каннибализации (парный к «текущим» из domrf, см. site_finder/own_portfolio.py).
|
||||
|
||||
Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО /api/v1/admin/*.
|
||||
На /api/v1/own-projects pilot отсекается FRONTEND-овым RouteGuard + Caddy, НЕ бэкендом;
|
||||
до эндпоинта доходят analyst+admin. created_by автора берём из X-Authenticated-User (тот
|
||||
же заголовок, что Caddy basic_auth пробрасывает в backend) — НЕ из тела (зеркало insight).
|
||||
|
||||
Хэндлеры sync `def` (зеркало insights.py): сервис ходит в БД через sync SQLAlchemy
|
||||
Session, поэтому FastAPI исполняет их в threadpool — event loop не блокируется.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.schemas.own_project import (
|
||||
OwnPlannedProjectCreate,
|
||||
OwnPlannedProjectList,
|
||||
OwnPlannedProjectOut,
|
||||
OwnPlannedProjectUpdate,
|
||||
OwnProjectClass,
|
||||
)
|
||||
from app.services.own_projects import (
|
||||
create_own_project,
|
||||
delete_own_project,
|
||||
get_own_project,
|
||||
list_own_projects,
|
||||
update_own_project,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_user(x_authenticated_user: str | None) -> str:
|
||||
"""Вернуть автора из X-Authenticated-User или 401 (зеркало insights._require_user).
|
||||
|
||||
В проде Caddy basic_auth всегда пробрасывает заголовок для авторизованных, а
|
||||
rbac_guard рубит запросы без него ещё до роутера. Эта проверка — defence in depth
|
||||
(+ корректный ответ в test-mode, где rbac_guard выключен).
|
||||
"""
|
||||
if x_authenticated_user and x_authenticated_user.strip():
|
||||
return x_authenticated_user.strip()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="no authenticated user (X-Authenticated-User required)",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=OwnPlannedProjectOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_own_project_endpoint(
|
||||
payload: OwnPlannedProjectCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header()] = None,
|
||||
) -> Any:
|
||||
"""Создать планируемый проект. created_by = X-Authenticated-User."""
|
||||
created_by = _require_user(x_authenticated_user)
|
||||
return create_own_project(db, created_by, payload)
|
||||
|
||||
|
||||
@router.get("", response_model=OwnPlannedProjectList)
|
||||
def list_own_projects_endpoint(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
district: Annotated[str | None, Query(description="Фильтр по району")] = None,
|
||||
obj_class: Annotated[OwnProjectClass | None, Query(description="Фильтр по классу")] = None,
|
||||
created_by: Annotated[str | None, Query(description="Фильтр по автору")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Any:
|
||||
"""Список планируемых проектов с фильтрами + пагинацией."""
|
||||
return list_own_projects(
|
||||
db,
|
||||
district=district,
|
||||
obj_class=obj_class,
|
||||
created_by=created_by,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=OwnPlannedProjectOut)
|
||||
def get_own_project_endpoint(
|
||||
project_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> Any:
|
||||
"""Вернуть один планируемый проект. 404 если не найден."""
|
||||
project = get_own_project(db, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Own planned project not found"
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=OwnPlannedProjectOut)
|
||||
def update_own_project_endpoint(
|
||||
project_id: int,
|
||||
payload: OwnPlannedProjectUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header()] = None,
|
||||
) -> Any:
|
||||
"""Partial update планируемого проекта. 404 если не найден."""
|
||||
_require_user(x_authenticated_user)
|
||||
project = update_own_project(db, project_id, payload)
|
||||
if project is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Own planned project not found"
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_own_project_endpoint(
|
||||
project_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
"""Удалить планируемый проект. 404 если не найден."""
|
||||
_require_user(x_authenticated_user)
|
||||
deleted = delete_own_project(db, project_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Own planned project not found"
|
||||
)
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import os
|
||||
import warnings
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
|
@ -22,6 +23,48 @@ class Settings(BaseSettings):
|
|||
# где запросы идут по app мимо Caddy и не несут X-Authenticated-User.
|
||||
testing: bool = False
|
||||
|
||||
# ── §25.3 «own portfolio» (#1169) ─────────────────────────────────────────
|
||||
# Целочисленные DOM.РФ developer-id «наших» застройщиков (env OWN_DEVELOPER_IDS,
|
||||
# comma-separated → list[int]; пусто/не задано → []). Источник «текущих» проектов
|
||||
# для движка каннибализации §25.3: domrf_kn_objects фильтруется по этим id (по
|
||||
# числовому префиксу composite dev_id '<devId>_<suffix>', см.
|
||||
# site_finder/own_portfolio.py).
|
||||
#
|
||||
# СТРОГО default [] и НИ ОДНОГО реального id в коде — конфигурируется только через
|
||||
# окружение прод-контейнера. Пустой список = «текущих» проектов нет: §25.3 own-
|
||||
# portfolio деградирует ИЗЯЩНО (get_own_portfolio вернёт только manual/future-строки,
|
||||
# а при их отсутствии — пустой список; PR2-движок честно отдаёт прокси/None вместо
|
||||
# фабрикации), а НЕ падает.
|
||||
#
|
||||
# NoDecode: отключает дефолтный JSON-предпарсинг сложного типа (pydantic-settings для
|
||||
# list[int] иначе ждёт JSON-литерал '[1,2]' и падает на «человеческой» строке
|
||||
# '12345,67890'). С NoDecode сырое env-значение доходит до _parse_own_developer_ids
|
||||
# (mode='before'), где и парсится comma-separated.
|
||||
own_developer_ids: Annotated[list[int], NoDecode] = []
|
||||
|
||||
@field_validator("own_developer_ids", mode="before")
|
||||
@classmethod
|
||||
def _parse_own_developer_ids(cls, value: object) -> object:
|
||||
"""Распарсить OWN_DEVELOPER_IDS из comma-separated env-строки в list[int].
|
||||
|
||||
pydantic-settings по умолчанию ждёт для list[int] JSON-литерал ('[1,2]'),
|
||||
поэтому «человеческую» строку '12345,67890' нужно распарсить вручную. Правила:
|
||||
• пусто / не задано / '' → [] (graceful default, §25.3 деградирует изящно);
|
||||
• '12345, 67890 ,42' → [12345, 67890, 42] (trim + пропуск пустых токенов);
|
||||
• уже list (тесты/JSON-env) → нормализуем элементы в int as-is.
|
||||
Нечисловой токен → ValueError (явная ошибка конфигурации, а не тихий дроп).
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return []
|
||||
return [int(tok.strip()) for tok in stripped.split(",") if tok.strip()]
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [int(item) for item in value]
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _promote_legacy_sentry_dsn(self) -> "Settings":
|
||||
"""Backward-compat: legacy SENTRY_DSN → glitchtip_dsn ТОЛЬКО для self-hosted.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from app.api.v1 import (
|
|||
landing,
|
||||
locations,
|
||||
me,
|
||||
own_projects,
|
||||
parcels,
|
||||
photos,
|
||||
pilot,
|
||||
|
|
@ -172,6 +173,7 @@ app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
|||
app.include_router(custom_pois.router, prefix="/api/v1/custom-pois", tags=["custom-pois"])
|
||||
app.include_router(locations.router, prefix="/api/v1/locations", tags=["locations"])
|
||||
app.include_router(insights.router, prefix="/api/v1/insights", tags=["insights"])
|
||||
app.include_router(own_projects.router, prefix="/api/v1/own-projects", tags=["own-projects"])
|
||||
app.include_router(
|
||||
admin_cadastre.router,
|
||||
prefix="/api/v1/admin/cadastre",
|
||||
|
|
|
|||
122
backend/app/schemas/own_project.py
Normal file
122
backend/app/schemas/own_project.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""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]
|
||||
269
backend/app/services/own_projects.py
Normal file
269
backend/app/services/own_projects.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""CRUD-сервис для own_planned_project (#1169 PR1, ТЗ §25.3).
|
||||
|
||||
API:
|
||||
- create_own_project(db, created_by, payload) → OwnPlannedProjectOut
|
||||
- list_own_projects(db, filters..., limit, offset) → OwnPlannedProjectList
|
||||
- get_own_project(db, project_id) → OwnPlannedProjectOut | None
|
||||
- update_own_project(db, project_id, payload) → OwnPlannedProjectOut | None
|
||||
- delete_own_project(db, project_id) → bool
|
||||
|
||||
Паттерн: raw SQL через SQLAlchemy text() + CAST(:x AS type), psycopg v3 (зеркало
|
||||
app/services/insights.py #948). db.commit() после каждой мутации (regression #261).
|
||||
|
||||
«Будущие/планируемые» проекты приватного пайплайна — второй источник §25.3-движка
|
||||
каннибализации (парный к «текущим» из domrf, см. site_finder/own_portfolio.py).
|
||||
created_by берётся из X-Authenticated-User (НЕ из тела). planned_release_month
|
||||
нормализуется к ПЕРВОМУ числу месяца (ось тайминга §25.3) через date_trunc в SQL.
|
||||
|
||||
Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО /api/v1/admin/*.
|
||||
На /api/v1/own-projects pilot отсекается FRONTEND-овым RouteGuard + Caddy, НЕ бэкендом.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.schemas.own_project import (
|
||||
OwnPlannedProjectCreate,
|
||||
OwnPlannedProjectList,
|
||||
OwnPlannedProjectOut,
|
||||
OwnPlannedProjectUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── SQL ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# geom не отдаём (бинарь); lon/lat достаточно для фронта/карты. unit_mix — jsonb,
|
||||
# psycopg3 отдаёт его как dict/None напрямую.
|
||||
_SELECT_COLS = """
|
||||
id, created_by, name, district, obj_class, planned_release_month,
|
||||
price_min_per_m2, price_max_per_m2, unit_mix, lon, lat, created_at, updated_at
|
||||
"""
|
||||
|
||||
# planned_release_month нормализуем к 1-му числу месяца (date_trunc → ось тайминга).
|
||||
# unit_mix передаём как jsonb через CAST(:unit_mix AS jsonb) — bind принимает
|
||||
# JSON-строку (сериализуем в сервисе), psycopg v3 CAST (НИКОГДА :x::type).
|
||||
_INSERT_SQL = f"""
|
||||
INSERT INTO own_planned_project
|
||||
(created_by, name, district, obj_class, planned_release_month,
|
||||
price_min_per_m2, price_max_per_m2, unit_mix, lon, lat)
|
||||
VALUES
|
||||
(:created_by, :name, :district, :obj_class,
|
||||
CAST(date_trunc('month', CAST(:planned_release_month AS date)) AS date),
|
||||
CAST(:price_min_per_m2 AS numeric), CAST(:price_max_per_m2 AS numeric),
|
||||
CAST(:unit_mix AS jsonb),
|
||||
CAST(:lon AS double precision), CAST(:lat AS double precision))
|
||||
RETURNING {_SELECT_COLS}
|
||||
"""
|
||||
|
||||
_SELECT_BY_ID = f"""
|
||||
SELECT {_SELECT_COLS}
|
||||
FROM own_planned_project
|
||||
WHERE id = :project_id
|
||||
"""
|
||||
|
||||
_DELETE_SQL = """
|
||||
DELETE FROM own_planned_project
|
||||
WHERE id = :project_id
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
|
||||
# ── Row mapper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _row_to_out(r: Any) -> OwnPlannedProjectOut:
|
||||
return OwnPlannedProjectOut(
|
||||
id=int(r["id"]),
|
||||
created_by=r["created_by"],
|
||||
name=r["name"],
|
||||
district=r["district"],
|
||||
obj_class=r["obj_class"],
|
||||
planned_release_month=r["planned_release_month"],
|
||||
price_min_per_m2=(
|
||||
float(r["price_min_per_m2"]) if r["price_min_per_m2"] is not None else None
|
||||
),
|
||||
price_max_per_m2=(
|
||||
float(r["price_max_per_m2"]) if r["price_max_per_m2"] is not None else None
|
||||
),
|
||||
unit_mix=r["unit_mix"],
|
||||
lon=float(r["lon"]) if r["lon"] is not None else None,
|
||||
lat=float(r["lat"]) if r["lat"] is not None else None,
|
||||
created_at=r["created_at"],
|
||||
updated_at=r["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def _unit_mix_param(unit_mix: dict[str, float] | None) -> str | None:
|
||||
"""Сериализовать unit_mix в JSON-строку для CAST(:unit_mix AS jsonb). None → None."""
|
||||
if unit_mix is None:
|
||||
return None
|
||||
return json.dumps(unit_mix)
|
||||
|
||||
|
||||
# ── CRUD ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_own_project(
|
||||
db: Any, created_by: str, payload: OwnPlannedProjectCreate
|
||||
) -> OwnPlannedProjectOut:
|
||||
"""Создать own_planned_project. created_by — автор из X-Authenticated-User."""
|
||||
row = (
|
||||
db.execute(
|
||||
text(_INSERT_SQL),
|
||||
{
|
||||
"created_by": created_by,
|
||||
"name": payload.name,
|
||||
"district": payload.district,
|
||||
"obj_class": payload.obj_class,
|
||||
"planned_release_month": payload.planned_release_month,
|
||||
"price_min_per_m2": payload.price_min_per_m2,
|
||||
"price_max_per_m2": payload.price_max_per_m2,
|
||||
"unit_mix": _unit_mix_param(payload.unit_mix),
|
||||
"lon": payload.lon,
|
||||
"lat": payload.lat,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
db.commit()
|
||||
assert row is not None, "INSERT RETURNING вернул пустой результат"
|
||||
logger.info(
|
||||
"own_planned_project created: id=%s by=%s class=%s district=%s release=%s",
|
||||
row["id"],
|
||||
created_by,
|
||||
payload.obj_class,
|
||||
payload.district,
|
||||
payload.planned_release_month,
|
||||
)
|
||||
return _row_to_out(row)
|
||||
|
||||
|
||||
def list_own_projects(
|
||||
db: Any,
|
||||
*,
|
||||
district: str | None = None,
|
||||
obj_class: str | None = None,
|
||||
created_by: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> OwnPlannedProjectList:
|
||||
"""Список планируемых проектов с фильтрами + пагинацией (зеркало list_insights)."""
|
||||
where: list[str] = []
|
||||
params: dict[str, Any] = {"lim": limit, "off": offset}
|
||||
if district is not None:
|
||||
where.append("district = :district")
|
||||
params["district"] = district
|
||||
if obj_class is not None:
|
||||
where.append("obj_class = :obj_class")
|
||||
params["obj_class"] = obj_class
|
||||
if created_by is not None:
|
||||
where.append("created_by = :created_by")
|
||||
params["created_by"] = created_by
|
||||
|
||||
where_sql = "WHERE " + " AND ".join(where) if where else ""
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
f"SELECT {_SELECT_COLS} FROM own_planned_project {where_sql} "
|
||||
"ORDER BY created_at DESC LIMIT :lim OFFSET :off"
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
total = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM own_planned_project {where_sql}"),
|
||||
{k: v for k, v in params.items() if k not in ("lim", "off")},
|
||||
).scalar_one()
|
||||
|
||||
return OwnPlannedProjectList(
|
||||
total=int(total or 0),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
rows=[_row_to_out(r) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
def get_own_project(db: Any, project_id: int) -> OwnPlannedProjectOut | None:
|
||||
"""Вернуть один планируемый проект по id."""
|
||||
row = db.execute(text(_SELECT_BY_ID), {"project_id": project_id}).mappings().first()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_to_out(row)
|
||||
|
||||
|
||||
def update_own_project(
|
||||
db: Any, project_id: int, payload: OwnPlannedProjectUpdate
|
||||
) -> OwnPlannedProjectOut | None:
|
||||
"""Partial update. Возвращает None если не найден (зеркало update_insight).
|
||||
|
||||
Динамический SET по переданным полям. `is None` как «не передано» — для всех
|
||||
полей корректно (нет boolean-полей, где False ≠ None имеет значение).
|
||||
"""
|
||||
existing = get_own_project(db, project_id)
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
sets: list[str] = ["updated_at = NOW()"]
|
||||
params: dict[str, Any] = {"project_id": project_id}
|
||||
|
||||
if payload.name is not None:
|
||||
sets.append("name = :name")
|
||||
params["name"] = payload.name
|
||||
if payload.district is not None:
|
||||
sets.append("district = :district")
|
||||
params["district"] = payload.district
|
||||
if payload.obj_class is not None:
|
||||
sets.append("obj_class = :obj_class")
|
||||
params["obj_class"] = payload.obj_class
|
||||
if payload.planned_release_month is not None:
|
||||
# Нормализуем к 1-му числу месяца (как INSERT) — ось тайминга §25.3.
|
||||
sets.append(
|
||||
"planned_release_month = "
|
||||
"CAST(date_trunc('month', CAST(:planned_release_month AS date)) AS date)"
|
||||
)
|
||||
params["planned_release_month"] = payload.planned_release_month
|
||||
if payload.price_min_per_m2 is not None:
|
||||
sets.append("price_min_per_m2 = CAST(:price_min_per_m2 AS numeric)")
|
||||
params["price_min_per_m2"] = payload.price_min_per_m2
|
||||
if payload.price_max_per_m2 is not None:
|
||||
sets.append("price_max_per_m2 = CAST(:price_max_per_m2 AS numeric)")
|
||||
params["price_max_per_m2"] = payload.price_max_per_m2
|
||||
if payload.unit_mix is not None:
|
||||
sets.append("unit_mix = CAST(:unit_mix AS jsonb)")
|
||||
params["unit_mix"] = _unit_mix_param(payload.unit_mix)
|
||||
# lon/lat меняем независимо — geom GENERATED пересчитается (NULL если одна NULL).
|
||||
if payload.lon is not None:
|
||||
sets.append("lon = CAST(:lon AS double precision)")
|
||||
params["lon"] = payload.lon
|
||||
if payload.lat is not None:
|
||||
sets.append("lat = CAST(:lat AS double precision)")
|
||||
params["lat"] = payload.lat
|
||||
|
||||
if len(sets) > 1:
|
||||
db.execute(
|
||||
text(f"UPDATE own_planned_project SET {', '.join(sets)} WHERE id = :project_id"),
|
||||
params,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return get_own_project(db, project_id)
|
||||
|
||||
|
||||
def delete_own_project(db: Any, project_id: int) -> bool:
|
||||
"""Удалить планируемый проект (hard delete — зеркало insight). True если удалён."""
|
||||
result = db.execute(text(_DELETE_SQL), {"project_id": project_id}).first()
|
||||
if result is None:
|
||||
return False
|
||||
db.commit()
|
||||
logger.info("own_planned_project deleted: id=%s", project_id)
|
||||
return True
|
||||
255
backend/app/services/site_finder/own_portfolio.py
Normal file
255
backend/app/services/site_finder/own_portfolio.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
"""Унифицированный источник «наших проектов» для §25.3 каннибализации (#1169 PR1).
|
||||
|
||||
`get_own_portfolio(db) -> list[OwnProject]` сводит ДВА источника «нашего портфеля» в
|
||||
общий нормализованный shape `OwnProject`, чтобы движок пересечения §25.3 (PR2) мог
|
||||
сравнивать кандидата с нашими проектами по 4 осям: аудитория/класс, тайминг (месяц
|
||||
выхода), цена (₽/м²), квартирография (unit_mix).
|
||||
|
||||
Два источника (origins):
|
||||
1. «Текущие» (source='current') ← domrf_kn_objects, отфильтрованные по
|
||||
settings.own_developer_ids — целочисленные DOM.РФ developer-id «наших»
|
||||
застройщиков. КАВЕАТ ФОРМАТА: domrf_kn_objects.dev_id — composite TEXT
|
||||
'<numericDevId>_<suffix>' (напр. '12345_0'), а конфиг хранит ИНТЫ. Поэтому
|
||||
матчим по ЧИСЛОВОМУ ПРЕФИКСУ dev_id (split_part(dev_id,'_',1)::int = ANY(:ids)).
|
||||
Пустой own_developer_ids → НЕ запрашиваем БД, возвращаем [] (graceful, без ошибки).
|
||||
2. «Будущие/планируемые» (source='future') ← все строки own_planned_project
|
||||
(manual-entry приватного пайплайна, миграция 148).
|
||||
|
||||
Оба нормализуются в OwnProject. Дисциплина None-not-0: отсутствующее поле → None
|
||||
(а НЕ 0/«» — «нет данных» ≠ «ноль», чтобы PR2 деградировал честно). class-вокабуляр
|
||||
и district-нейминг зеркалят forecasting SegmentSpec (sales_series.py) — OwnProject
|
||||
напрямую сопоставим с кандидатом-сегментом в PR2.
|
||||
|
||||
Graceful degradation (дух market_metrics / sales_series):
|
||||
• own_developer_ids пусто И own_planned_project пуст → get_own_portfolio вернёт [].
|
||||
• own_developer_ids пусто, но manual-строки есть → только future-строки.
|
||||
• сбой одного из запросов → этот источник даёт [], второй считается (НЕ crash).
|
||||
PR2-движок при пустом портфеле честно отдаёт прокси/None вместо фабрикации сигнала.
|
||||
|
||||
psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) — никогда
|
||||
:x::type. Чистого SQL-каста (split_part(...)::int) избегаем в bind-контексте: пишем
|
||||
CAST(... AS integer).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OwnProjectSource = Literal["current", "future"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnProject:
|
||||
"""Нормализованный «наш проект» — общий shape для §25.3 (current + future).
|
||||
|
||||
Дисциплина None-not-0: любое неизвестное поле = None (НЕ 0/«»). Цена — вилка
|
||||
min/max ₽/м² (любая граница может быть None). unit_mix — доли квартирографии
|
||||
{"studio":0.3,...} или None. release_month — ПЕРВОЕ число месяца (тайминг §25.3).
|
||||
lon/lat — центроид (оба None, если координат нет). obj_class — «человеческий»
|
||||
класс (зеркало forecasting SegmentSpec; матчинг в PR2 регистронезависимый).
|
||||
"""
|
||||
|
||||
name: str
|
||||
source: OwnProjectSource
|
||||
obj_class: str | None
|
||||
release_month: date | None
|
||||
price_min_per_m2: float | None
|
||||
price_max_per_m2: float | None
|
||||
unit_mix: dict[str, float] | None
|
||||
district: str | None
|
||||
lon: float | None
|
||||
lat: float | None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"source": self.source,
|
||||
"obj_class": self.obj_class,
|
||||
"release_month": self.release_month.isoformat() if self.release_month else None,
|
||||
"price_min_per_m2": self.price_min_per_m2,
|
||||
"price_max_per_m2": self.price_max_per_m2,
|
||||
"unit_mix": dict(self.unit_mix) if self.unit_mix is not None else None,
|
||||
"district": self.district,
|
||||
"lon": self.lon,
|
||||
"lat": self.lat,
|
||||
}
|
||||
|
||||
|
||||
# ── SQL ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# «Текущие» проекты: последний snapshot domrf_kn_objects «наших» застройщиков.
|
||||
# Матчинг по числовому префиксу composite dev_id (см. module docstring). Берём
|
||||
# только последний snapshot на dev_id, чтобы не дублировать объект по истории.
|
||||
# ready_dt → release_month (нормализуем к 1-му числу). Цена ₽/м² — вилка
|
||||
# price_per_m2_min/max (22e-колонки; могут быть NULL → None). district_name (56/57).
|
||||
_CURRENT_SQL = text(
|
||||
"""
|
||||
WITH ours AS (
|
||||
SELECT o.*
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.dev_id IS NOT NULL
|
||||
AND o.dev_id ~ '^[0-9]+'
|
||||
AND CAST(split_part(o.dev_id, '_', 1) AS integer) = ANY(CAST(:ids AS integer[]))
|
||||
),
|
||||
latest AS (
|
||||
SELECT obj_id, MAX(snapshot_date) AS snap
|
||||
FROM ours
|
||||
GROUP BY obj_id
|
||||
)
|
||||
SELECT o.obj_id,
|
||||
o.comm_name,
|
||||
o.obj_class,
|
||||
o.district_name,
|
||||
o.ready_dt,
|
||||
o.price_per_m2_min,
|
||||
o.price_per_m2_max,
|
||||
o.latitude,
|
||||
o.longitude
|
||||
FROM ours o
|
||||
JOIN latest l ON l.obj_id = o.obj_id AND l.snap = o.snapshot_date
|
||||
ORDER BY o.ready_dt DESC NULLS LAST, o.obj_id
|
||||
"""
|
||||
)
|
||||
|
||||
# «Будущие/планируемые»: все строки own_planned_project (миграция 148). unit_mix —
|
||||
# jsonb (psycopg3 → dict). planned_release_month уже нормализован к 1-му числу.
|
||||
_FUTURE_SQL = text(
|
||||
"""
|
||||
SELECT id,
|
||||
name,
|
||||
obj_class,
|
||||
district,
|
||||
planned_release_month,
|
||||
price_min_per_m2,
|
||||
price_max_per_m2,
|
||||
unit_mix,
|
||||
lon,
|
||||
lat
|
||||
FROM own_planned_project
|
||||
ORDER BY planned_release_month DESC NULLS LAST, id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _f(value: Any) -> float | None:
|
||||
"""NUMERIC/Decimal/float → float | None (None-not-0: NULL остаётся None)."""
|
||||
if value is None:
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def _month_start(value: date | None) -> date | None:
|
||||
"""Нормализовать дату к ПЕРВОМУ числу месяца. None → None (None-not-0)."""
|
||||
if value is None:
|
||||
return None
|
||||
return value.replace(day=1)
|
||||
|
||||
|
||||
def _query_current(db: Session) -> list[OwnProject]:
|
||||
"""«Текущие» проекты из domrf по settings.own_developer_ids. Graceful → [].
|
||||
|
||||
Пустой own_developer_ids → СРАЗУ [] (не идём в БД — нечего матчить, это штатная
|
||||
деградация, а не ошибка). Сбой запроса → [] (второй источник не должен страдать).
|
||||
"""
|
||||
ids = list(settings.own_developer_ids)
|
||||
if not ids:
|
||||
logger.info(
|
||||
"own_portfolio: own_developer_ids пуст → current-проектов нет "
|
||||
"(graceful; §25.3 деградирует на future/None)"
|
||||
)
|
||||
return []
|
||||
try:
|
||||
rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("own_portfolio: current (domrf) query failed → []")
|
||||
return []
|
||||
out: list[OwnProject] = []
|
||||
for r in rows:
|
||||
out.append(
|
||||
OwnProject(
|
||||
name=r["comm_name"] or f"obj {r['obj_id']}",
|
||||
source="current",
|
||||
obj_class=r["obj_class"],
|
||||
release_month=_month_start(r["ready_dt"]),
|
||||
price_min_per_m2=_f(r["price_per_m2_min"]),
|
||||
price_max_per_m2=_f(r["price_per_m2_max"]),
|
||||
# domrf-объект не несёт долей квартирографии в этом запросе → None
|
||||
# (PR2 может добрать из domrf_kn_flats отдельно; здесь не фабрикуем).
|
||||
unit_mix=None,
|
||||
district=r["district_name"],
|
||||
lon=_f(r["longitude"]),
|
||||
lat=_f(r["latitude"]),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _query_future(db: Session) -> list[OwnProject]:
|
||||
"""«Будущие/планируемые» проекты из own_planned_project. Graceful → [].
|
||||
|
||||
Сбой/отсутствие таблицы (cold-start до миграции 148) → [] (НЕ crash).
|
||||
"""
|
||||
try:
|
||||
rows = db.execute(_FUTURE_SQL).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("own_portfolio: future (own_planned_project) query failed → []")
|
||||
return []
|
||||
out: list[OwnProject] = []
|
||||
for r in rows:
|
||||
unit_mix = r["unit_mix"]
|
||||
out.append(
|
||||
OwnProject(
|
||||
name=r["name"],
|
||||
source="future",
|
||||
obj_class=r["obj_class"],
|
||||
release_month=_month_start(r["planned_release_month"]),
|
||||
price_min_per_m2=_f(r["price_min_per_m2"]),
|
||||
price_max_per_m2=_f(r["price_max_per_m2"]),
|
||||
unit_mix=dict(unit_mix) if isinstance(unit_mix, dict) else None,
|
||||
district=r["district"],
|
||||
lon=_f(r["lon"]),
|
||||
lat=_f(r["lat"]),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def get_own_portfolio(db: Session) -> list[OwnProject]:
|
||||
"""Собрать унифицированный «наш портфель» (current + future) для §25.3 (PR2).
|
||||
|
||||
Объединяет два источника, нормализованных в OwnProject:
|
||||
• current ← domrf_kn_objects по settings.own_developer_ids (пусто → [], graceful);
|
||||
• future ← все строки own_planned_project (manual-entry).
|
||||
|
||||
Каждый источник в собственном try/except (см. _query_current/_query_future): сбой
|
||||
одного НЕ валит второй. Дисциплина None-not-0 соблюдается в маппинге. Возвращается
|
||||
ВСЕГДА (никогда не crash); пустой список — штатная деградация, когда own_developer_ids
|
||||
пуст И manual-строк нет. PR2-движок при пустом портфеле честно отдаёт прокси/None.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
|
||||
Returns:
|
||||
list[OwnProject] — объединённый портфель (current впереди future). Может быть [].
|
||||
"""
|
||||
current = _query_current(db)
|
||||
future = _query_future(db)
|
||||
portfolio = current + future
|
||||
logger.info(
|
||||
"own_portfolio: собрано %d проектов (current=%d future=%d, own_developer_ids=%d)",
|
||||
len(portfolio),
|
||||
len(current),
|
||||
len(future),
|
||||
len(settings.own_developer_ids),
|
||||
)
|
||||
return portfolio
|
||||
464
backend/tests/api/v1/test_own_projects.py
Normal file
464
backend/tests/api/v1/test_own_projects.py
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
"""Тесты для own_planned_project CRUD (#1169 PR1, ТЗ §25.3).
|
||||
|
||||
Покрывает (зеркало test_insights.py):
|
||||
1. POST /api/v1/own-projects → 201, created_by из X-Authenticated-User (не из тела)
|
||||
2. POST без name → 422; bad obj_class → 422; price_min>max → 422; unit_mix вне [0,1] → 422
|
||||
3. POST без X-Authenticated-User → 401
|
||||
4. GET /api/v1/own-projects → OwnPlannedProjectList (total/limit/offset/rows)
|
||||
5. GET /api/v1/own-projects?<filters> → district/obj_class/created_by в сервис
|
||||
6. GET /api/v1/own-projects/{id} → 200; 404 если нет
|
||||
7. PUT /api/v1/own-projects/{id} → 200 updated; 404 если нет; 401 без заголовка
|
||||
8. DELETE /api/v1/own-projects/{id} → 204; 404 если нет; 401 без заголовка
|
||||
9. Service-level: db.commit() в create/update/delete (#261 regression guard)
|
||||
|
||||
Стратегия mock: сервисные функции патчим через unittest.mock.patch, DB — MagicMock
|
||||
(как test_insights.py). settings.testing=True (conftest) отключает rbac_guard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.schemas.own_project import (
|
||||
OwnPlannedProjectCreate,
|
||||
OwnPlannedProjectList,
|
||||
OwnPlannedProjectOut,
|
||||
OwnPlannedProjectUpdate,
|
||||
)
|
||||
|
||||
# ── Константы ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_USER = "analysttest"
|
||||
_TS = datetime(2026, 6, 7, 10, 0, 0, tzinfo=UTC)
|
||||
_AUTH = {"X-Authenticated-User": _USER}
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_out(
|
||||
project_id: int = 1,
|
||||
created_by: str = _USER,
|
||||
name: str = "ЖК Наш Будущий",
|
||||
district: str | None = "Октябрьский",
|
||||
obj_class: str | None = "Комфорт",
|
||||
planned_release_month: date | None = date(2027, 3, 1),
|
||||
price_min_per_m2: float | None = 140000.0,
|
||||
price_max_per_m2: float | None = 180000.0,
|
||||
unit_mix: dict[str, float] | None = None,
|
||||
lon: float | None = None,
|
||||
lat: float | None = None,
|
||||
) -> OwnPlannedProjectOut:
|
||||
return OwnPlannedProjectOut(
|
||||
id=project_id,
|
||||
created_by=created_by,
|
||||
name=name,
|
||||
district=district,
|
||||
obj_class=obj_class,
|
||||
planned_release_month=planned_release_month,
|
||||
price_min_per_m2=price_min_per_m2,
|
||||
price_max_per_m2=price_max_per_m2,
|
||||
unit_mix=unit_mix,
|
||||
lon=lon,
|
||||
lat=lat,
|
||||
created_at=_TS,
|
||||
updated_at=_TS,
|
||||
)
|
||||
|
||||
|
||||
# ── Tests: CREATE ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_own_project_returns_201_and_sets_created_by() -> None:
|
||||
"""POST → 201; created_by берётся из X-Authenticated-User, не из тела."""
|
||||
expected = _make_out()
|
||||
with patch(
|
||||
"app.api.v1.own_projects.create_own_project", return_value=expected
|
||||
) as mock_create:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/own-projects",
|
||||
json={
|
||||
"name": "ЖК Наш Будущий",
|
||||
"district": "Октябрьский",
|
||||
"obj_class": "Комфорт",
|
||||
"planned_release_month": "2027-03-15",
|
||||
"price_min_per_m2": 140000,
|
||||
"price_max_per_m2": 180000,
|
||||
},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["name"] == "ЖК Наш Будущий"
|
||||
assert body["created_by"] == _USER
|
||||
mock_create.assert_called_once()
|
||||
# created_by пробрасывается из заголовка во 2-й позиционный аргумент сервиса.
|
||||
assert mock_create.call_args[0][1] == _USER
|
||||
|
||||
|
||||
def test_create_own_project_with_unit_mix() -> None:
|
||||
"""unit_mix в теле → пробрасывается в payload сервиса."""
|
||||
mix = {"studio": 0.3, "1k": 0.4, "2k": 0.2, "3k": 0.1}
|
||||
expected = _make_out(unit_mix=mix)
|
||||
with patch(
|
||||
"app.api.v1.own_projects.create_own_project", return_value=expected
|
||||
) as mock_create:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/own-projects",
|
||||
json={"name": "ЖК Микс", "unit_mix": mix},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
payload: OwnPlannedProjectCreate = mock_create.call_args[0][2]
|
||||
assert payload.unit_mix == mix
|
||||
|
||||
|
||||
def test_create_own_project_missing_name_returns_422() -> None:
|
||||
"""name обязателен → 422."""
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/own-projects", json={"district": "X"}, headers=_AUTH)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_create_own_project_bad_class_returns_422() -> None:
|
||||
"""obj_class вне enum → 422 (Literal)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/own-projects",
|
||||
json={"name": "X", "obj_class": "Люкс-Делюкс"},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_create_own_project_price_min_above_max_returns_422() -> None:
|
||||
"""price_min_per_m2 > price_max_per_m2 → 422 (model_validator)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/own-projects",
|
||||
json={"name": "X", "price_min_per_m2": 200000, "price_max_per_m2": 100000},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_create_own_project_negative_price_returns_422() -> None:
|
||||
"""Отрицательная цена → 422 (ge=0)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/own-projects",
|
||||
json={"name": "X", "price_min_per_m2": -5},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_create_own_project_unit_mix_out_of_range_returns_422() -> None:
|
||||
"""Доля unit_mix вне [0,1] → 422 (field_validator)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/own-projects",
|
||||
json={"name": "X", "unit_mix": {"studio": 1.5}},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_create_own_project_without_auth_header_returns_401() -> None:
|
||||
"""Нет X-Authenticated-User → 401 (роутерный _require_user; rbac выключен в тестах)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/own-projects", json={"name": "X"})
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
# ── Tests: LIST ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_own_projects_returns_envelope() -> None:
|
||||
"""GET → OwnPlannedProjectList {total, limit, offset, rows}."""
|
||||
listing = OwnPlannedProjectList(
|
||||
total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)]
|
||||
)
|
||||
with patch("app.api.v1.own_projects.list_own_projects", return_value=listing):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/own-projects", headers=_AUTH)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert len(body["rows"]) == 2
|
||||
assert body["rows"][0]["id"] == 1
|
||||
|
||||
|
||||
def test_list_own_projects_passes_filters_to_service() -> None:
|
||||
"""Фильтры district/obj_class/created_by → в сервис как kwargs."""
|
||||
listing = OwnPlannedProjectList(total=0, limit=50, offset=0, rows=[])
|
||||
with patch(
|
||||
"app.api.v1.own_projects.list_own_projects", return_value=listing
|
||||
) as mock_list:
|
||||
client = TestClient(app)
|
||||
resp = client.get(
|
||||
"/api/v1/own-projects",
|
||||
params={"district": "Октябрьский", "obj_class": "Бизнес", "created_by": _USER},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
kwargs = mock_list.call_args.kwargs
|
||||
assert kwargs["district"] == "Октябрьский"
|
||||
assert kwargs["obj_class"] == "Бизнес"
|
||||
assert kwargs["created_by"] == _USER
|
||||
|
||||
|
||||
# ── Tests: GET one ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_own_project_returns_200() -> None:
|
||||
with patch("app.api.v1.own_projects.get_own_project", return_value=_make_out(7)):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/own-projects/7", headers=_AUTH)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["id"] == 7
|
||||
|
||||
|
||||
def test_get_own_project_not_found_returns_404() -> None:
|
||||
with patch("app.api.v1.own_projects.get_own_project", return_value=None):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/own-projects/999", headers=_AUTH)
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
# ── Tests: UPDATE ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_put_own_project_returns_updated() -> None:
|
||||
updated = _make_out(name="Переименовано")
|
||||
with patch("app.api.v1.own_projects.update_own_project", return_value=updated):
|
||||
client = TestClient(app)
|
||||
resp = client.put(
|
||||
"/api/v1/own-projects/1", json={"name": "Переименовано"}, headers=_AUTH
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["name"] == "Переименовано"
|
||||
|
||||
|
||||
def test_put_own_project_not_found_returns_404() -> None:
|
||||
with patch("app.api.v1.own_projects.update_own_project", return_value=None):
|
||||
client = TestClient(app)
|
||||
resp = client.put("/api/v1/own-projects/999", json={"name": "x"}, headers=_AUTH)
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
def test_put_own_project_without_auth_returns_401() -> None:
|
||||
client = TestClient(app)
|
||||
resp = client.put("/api/v1/own-projects/1", json={"name": "x"})
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
# ── Tests: DELETE ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_delete_own_project_returns_204() -> None:
|
||||
with patch("app.api.v1.own_projects.delete_own_project", return_value=True):
|
||||
client = TestClient(app)
|
||||
resp = client.delete("/api/v1/own-projects/1", headers=_AUTH)
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
|
||||
def test_delete_own_project_not_found_returns_404() -> None:
|
||||
with patch("app.api.v1.own_projects.delete_own_project", return_value=False):
|
||||
client = TestClient(app)
|
||||
resp = client.delete("/api/v1/own-projects/999", headers=_AUTH)
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
def test_delete_own_project_without_auth_returns_401() -> None:
|
||||
client = TestClient(app)
|
||||
resp = client.delete("/api/v1/own-projects/1")
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
# ── Service-level: commit + mapping (#261 regression guard) ─────────────────────
|
||||
|
||||
|
||||
def _make_row_data(
|
||||
project_id: int = 1,
|
||||
created_by: str = _USER,
|
||||
name: str = "ЖК Наш",
|
||||
district: str | None = "Октябрьский",
|
||||
obj_class: str | None = "Комфорт",
|
||||
planned_release_month: date | None = date(2027, 3, 1),
|
||||
price_min_per_m2: Any = 140000,
|
||||
price_max_per_m2: Any = 180000,
|
||||
unit_mix: dict[str, float] | None = None,
|
||||
lon: float | None = None,
|
||||
lat: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": project_id,
|
||||
"created_by": created_by,
|
||||
"name": name,
|
||||
"district": district,
|
||||
"obj_class": obj_class,
|
||||
"planned_release_month": planned_release_month,
|
||||
"price_min_per_m2": price_min_per_m2,
|
||||
"price_max_per_m2": price_max_per_m2,
|
||||
"unit_mix": unit_mix,
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"created_at": _TS,
|
||||
"updated_at": _TS,
|
||||
}
|
||||
|
||||
|
||||
def _make_db_with_row(row_data: dict[str, Any] | None) -> MagicMock:
|
||||
"""MagicMock-сессия: execute().mappings().first() → row_data mapping."""
|
||||
db = MagicMock()
|
||||
row_mock: MagicMock | None = None
|
||||
if row_data is not None:
|
||||
row_mock = MagicMock()
|
||||
row_mock.__getitem__ = lambda self, k: row_data[k]
|
||||
row_mock.get = lambda k, default=None: row_data.get(k, default)
|
||||
|
||||
mapping_mock = MagicMock()
|
||||
mapping_mock.first.return_value = row_mock
|
||||
|
||||
exec_mock = MagicMock()
|
||||
exec_mock.mappings.return_value = mapping_mock
|
||||
exec_mock.first.return_value = row_mock # DELETE RETURNING id (не mappings)
|
||||
|
||||
db.execute.return_value = exec_mock
|
||||
return db
|
||||
|
||||
|
||||
def test_service_create_own_project_commits_and_sets_created_by() -> None:
|
||||
"""create_own_project вызывает db.commit() и мапит created_by — regression #261."""
|
||||
from app.services.own_projects import create_own_project
|
||||
|
||||
db = _make_db_with_row(_make_row_data())
|
||||
payload = OwnPlannedProjectCreate(name="ЖК Наш", obj_class="Комфорт")
|
||||
|
||||
result = create_own_project(db, _USER, payload)
|
||||
|
||||
db.commit.assert_called_once()
|
||||
assert result.id == 1
|
||||
assert result.created_by == _USER
|
||||
assert result.obj_class == "Комфорт"
|
||||
|
||||
|
||||
def test_service_create_own_project_serialises_unit_mix() -> None:
|
||||
"""unit_mix сериализуется в JSON-строку для CAST(:unit_mix AS jsonb)."""
|
||||
from app.services.own_projects import create_own_project
|
||||
|
||||
mix = {"studio": 0.5, "1k": 0.5}
|
||||
db = _make_db_with_row(_make_row_data(unit_mix=mix))
|
||||
payload = OwnPlannedProjectCreate(name="ЖК Микс", unit_mix=mix)
|
||||
|
||||
create_own_project(db, _USER, payload)
|
||||
|
||||
# unit_mix-параметр INSERT — JSON-строка, не dict (psycopg CAST AS jsonb).
|
||||
insert_params = db.execute.call_args_list[0].args[1]
|
||||
assert insert_params["unit_mix"] == '{"studio": 0.5, "1k": 0.5}'
|
||||
|
||||
|
||||
def test_service_create_own_project_uses_cast_not_double_colon() -> None:
|
||||
"""INSERT использует CAST(:x AS type), никогда :x::type (psycopg v3)."""
|
||||
from app.services.own_projects import create_own_project
|
||||
|
||||
db = _make_db_with_row(_make_row_data())
|
||||
create_own_project(db, _USER, OwnPlannedProjectCreate(name="X"))
|
||||
|
||||
insert_sql = str(db.execute.call_args_list[0].args[0])
|
||||
assert "CAST(" in insert_sql
|
||||
assert "::" not in insert_sql
|
||||
|
||||
|
||||
def test_service_update_own_project_commits_when_fields_given() -> None:
|
||||
"""update_own_project вызывает db.commit() при наличии изменяемых полей — #261."""
|
||||
from app.services.own_projects import update_own_project
|
||||
|
||||
db = _make_db_with_row(_make_row_data(name="new"))
|
||||
result = update_own_project(db, project_id=1, payload=OwnPlannedProjectUpdate(name="new"))
|
||||
|
||||
db.commit.assert_called_once()
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_service_update_own_project_no_commit_when_payload_empty() -> None:
|
||||
"""Пустой payload → только updated_at → UPDATE не выполняется, commit не зовётся."""
|
||||
from app.services.own_projects import update_own_project
|
||||
|
||||
db = _make_db_with_row(_make_row_data())
|
||||
update_own_project(db, project_id=1, payload=OwnPlannedProjectUpdate())
|
||||
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_service_update_own_project_no_commit_when_not_found() -> None:
|
||||
"""update_own_project НЕ коммитит если проект не найден."""
|
||||
from app.services.own_projects import update_own_project
|
||||
|
||||
db = _make_db_with_row(None)
|
||||
result = update_own_project(db, project_id=999, payload=OwnPlannedProjectUpdate(name="x"))
|
||||
|
||||
db.commit.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_service_delete_own_project_commits_when_found() -> None:
|
||||
"""delete_own_project коммитит когда строка найдена — regression #261."""
|
||||
from app.services.own_projects import delete_own_project
|
||||
|
||||
db = _make_db_with_row(_make_row_data())
|
||||
db.execute.return_value.first.return_value = MagicMock() # RETURNING id truthy
|
||||
|
||||
assert delete_own_project(db, project_id=1) is True
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_service_delete_own_project_no_commit_when_not_found() -> None:
|
||||
"""delete_own_project НЕ коммитит если строки нет."""
|
||||
from app.services.own_projects import delete_own_project
|
||||
|
||||
db = _make_db_with_row(None)
|
||||
db.execute.return_value.first.return_value = None # RETURNING id пусто
|
||||
|
||||
assert delete_own_project(db, project_id=999) is False
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_service_list_own_projects_builds_filtered_query() -> None:
|
||||
"""list_own_projects собирает WHERE по фильтрам + возвращает list с total."""
|
||||
from app.services.own_projects import list_own_projects
|
||||
|
||||
db = MagicMock()
|
||||
row = _make_row_data(1)
|
||||
row_mock = MagicMock()
|
||||
row_mock.__getitem__ = lambda self, k: row[k]
|
||||
|
||||
rows_result = MagicMock()
|
||||
rows_result.mappings.return_value.all.return_value = [row_mock]
|
||||
count_result = MagicMock()
|
||||
count_result.scalar_one.return_value = 1
|
||||
db.execute.side_effect = [rows_result, count_result]
|
||||
|
||||
out = list_own_projects(db, district="Октябрьский", obj_class="Комфорт", limit=10, offset=0)
|
||||
|
||||
assert isinstance(out, OwnPlannedProjectList)
|
||||
assert out.total == 1
|
||||
assert out.limit == 10
|
||||
assert len(out.rows) == 1
|
||||
|
||||
select_sql = str(db.execute.call_args_list[0].args[0])
|
||||
assert "WHERE" in select_sql
|
||||
assert "district = :district" in select_sql
|
||||
assert "obj_class = :obj_class" in select_sql
|
||||
assert "::" not in select_sql
|
||||
select_params = db.execute.call_args_list[0].args[1]
|
||||
assert select_params["district"] == "Октябрьский"
|
||||
assert select_params["obj_class"] == "Комфорт"
|
||||
274
backend/tests/services/site_finder/test_own_portfolio.py
Normal file
274
backend/tests/services/site_finder/test_own_portfolio.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""Тесты get_own_portfolio — унифицированный источник «наших проектов» (#1169 PR1).
|
||||
|
||||
Покрывает:
|
||||
1. own_developer_ids пусто → current не запрашивается; только future-строки
|
||||
2. own_developer_ids пусто И future пуст → [] (graceful, §25.3 деградирует на None)
|
||||
3. current: мокнутые domrf-строки → OwnProject(source='current') + None-not-0
|
||||
4. future: мокнутые own_planned_project-строки → OwnProject(source='future')
|
||||
5. ready_dt / planned_release_month нормализуются к 1-му числу месяца
|
||||
6. current-запрос параметризуется списком own_developer_ids (ANY(:ids))
|
||||
7. current-SQL использует CAST(... AS integer[]), не ::int[] (psycopg v3)
|
||||
8. сбой current-запроса не валит future (graceful per-source)
|
||||
|
||||
Mock-стратегия: DB — MagicMock; own_developer_ids патчим на singleton settings
|
||||
(сервис читает его при вызове). domrf/own_planned_project запросы различаем по
|
||||
db.execute.side_effect (current первым, future вторым).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.services.site_finder import own_portfolio
|
||||
from app.services.site_finder.own_portfolio import OwnProject, get_own_portfolio
|
||||
|
||||
|
||||
def _row(data: dict[str, Any]) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.__getitem__ = lambda self, k: data[k]
|
||||
return m
|
||||
|
||||
|
||||
def _result(rows: list[MagicMock]) -> MagicMock:
|
||||
"""execute(...).mappings().all() → rows."""
|
||||
res = MagicMock()
|
||||
res.mappings.return_value.all.return_value = rows
|
||||
return res
|
||||
|
||||
|
||||
def _current_row(
|
||||
obj_id: int = 100,
|
||||
comm_name: str | None = "ЖК Текущий",
|
||||
obj_class: str | None = "Комфорт",
|
||||
district_name: str | None = "Октябрьский",
|
||||
ready_dt: date | None = date(2025, 11, 20),
|
||||
price_per_m2_min: Any = Decimal("130000"),
|
||||
price_per_m2_max: Any = Decimal("160000"),
|
||||
latitude: Any = Decimal("56.84"),
|
||||
longitude: Any = Decimal("60.61"),
|
||||
) -> MagicMock:
|
||||
return _row(
|
||||
{
|
||||
"obj_id": obj_id,
|
||||
"comm_name": comm_name,
|
||||
"obj_class": obj_class,
|
||||
"district_name": district_name,
|
||||
"ready_dt": ready_dt,
|
||||
"price_per_m2_min": price_per_m2_min,
|
||||
"price_per_m2_max": price_per_m2_max,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _future_row(
|
||||
project_id: int = 1,
|
||||
name: str = "ЖК Будущий",
|
||||
obj_class: str | None = "Бизнес",
|
||||
district: str | None = "Верх-Исетский",
|
||||
planned_release_month: date | None = date(2027, 6, 1),
|
||||
price_min_per_m2: Any = Decimal("180000"),
|
||||
price_max_per_m2: Any = Decimal("220000"),
|
||||
unit_mix: dict[str, float] | None = None,
|
||||
lon: Any = None,
|
||||
lat: Any = None,
|
||||
) -> MagicMock:
|
||||
return _row(
|
||||
{
|
||||
"id": project_id,
|
||||
"name": name,
|
||||
"obj_class": obj_class,
|
||||
"district": district,
|
||||
"planned_release_month": planned_release_month,
|
||||
"price_min_per_m2": price_min_per_m2,
|
||||
"price_max_per_m2": price_max_per_m2,
|
||||
"unit_mix": unit_mix,
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── empty own_developer_ids → future only / [] ──────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_developer_ids_skips_current_returns_future_only(monkeypatch) -> None:
|
||||
"""own_developer_ids пусто → current НЕ запрашивается; только future-строки."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
|
||||
db = MagicMock()
|
||||
# Единственный execute — future-запрос (current не идёт в БД).
|
||||
db.execute.return_value = _result([_future_row()])
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
assert db.execute.call_count == 1 # только future
|
||||
assert len(out) == 1
|
||||
assert out[0].source == "future"
|
||||
assert out[0].name == "ЖК Будущий"
|
||||
|
||||
|
||||
def test_empty_developer_ids_and_no_future_returns_empty(monkeypatch) -> None:
|
||||
"""own_developer_ids пусто И future пуст → [] (graceful, не crash)."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _result([])
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
assert out == []
|
||||
|
||||
|
||||
# ── current (domrf) mapping ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_current_rows_mapped_to_own_project(monkeypatch) -> None:
|
||||
"""Мокнутые domrf-строки → OwnProject(source='current') с корректным маппингом."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
|
||||
db = MagicMock()
|
||||
# current первым, future вторым (пустой).
|
||||
db.execute.side_effect = [_result([_current_row()]), _result([])]
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
assert len(out) == 1
|
||||
p = out[0]
|
||||
assert isinstance(p, OwnProject)
|
||||
assert p.source == "current"
|
||||
assert p.name == "ЖК Текущий"
|
||||
assert p.obj_class == "Комфорт"
|
||||
assert p.district == "Октябрьский"
|
||||
assert p.release_month == date(2025, 11, 1) # ready_dt → 1-е число
|
||||
assert p.price_min_per_m2 == 130000.0
|
||||
assert p.price_max_per_m2 == 160000.0
|
||||
assert p.lat == 56.84
|
||||
assert p.lon == 60.61
|
||||
# current-проект не несёт квартирографии в этом запросе → None (None-not-0).
|
||||
assert p.unit_mix is None
|
||||
|
||||
|
||||
def test_current_none_fields_stay_none(monkeypatch) -> None:
|
||||
"""Отсутствующие поля domrf → None (None-not-0), не 0/«»."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
|
||||
db = MagicMock()
|
||||
row = _current_row(
|
||||
comm_name="ЖК Без Цены",
|
||||
obj_class=None,
|
||||
district_name=None,
|
||||
ready_dt=None,
|
||||
price_per_m2_min=None,
|
||||
price_per_m2_max=None,
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
)
|
||||
db.execute.side_effect = [_result([row]), _result([])]
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
p = out[0]
|
||||
assert p.obj_class is None
|
||||
assert p.district is None
|
||||
assert p.release_month is None
|
||||
assert p.price_min_per_m2 is None
|
||||
assert p.price_max_per_m2 is None
|
||||
assert p.lat is None
|
||||
assert p.lon is None
|
||||
|
||||
|
||||
def test_current_query_parametrised_with_developer_ids(monkeypatch) -> None:
|
||||
"""current-запрос параметризуется списком own_developer_ids (ANY(:ids))."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345, 67890])
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = [_result([]), _result([])]
|
||||
|
||||
get_own_portfolio(db)
|
||||
|
||||
current_params = db.execute.call_args_list[0].args[1]
|
||||
assert current_params["ids"] == [12345, 67890]
|
||||
|
||||
|
||||
def test_current_sql_uses_cast_not_double_colon(monkeypatch) -> None:
|
||||
"""current-SQL использует CAST(:ids AS integer[]), никогда :ids::int[] (psycopg v3)."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = [_result([]), _result([])]
|
||||
|
||||
get_own_portfolio(db)
|
||||
|
||||
current_sql = str(db.execute.call_args_list[0].args[0])
|
||||
assert "CAST(" in current_sql
|
||||
assert "::" not in current_sql
|
||||
|
||||
|
||||
# ── future (own_planned_project) mapping ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_future_rows_mapped_with_unit_mix(monkeypatch) -> None:
|
||||
"""future-строки → OwnProject(source='future') с unit_mix-долями."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
|
||||
mix = {"studio": 0.3, "1k": 0.4, "2k": 0.3}
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _result([_future_row(unit_mix=mix)])
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
p = out[0]
|
||||
assert p.source == "future"
|
||||
assert p.obj_class == "Бизнес"
|
||||
assert p.release_month == date(2027, 6, 1)
|
||||
assert p.price_min_per_m2 == 180000.0
|
||||
assert p.price_max_per_m2 == 220000.0
|
||||
assert p.unit_mix == mix
|
||||
|
||||
|
||||
def test_future_release_month_normalised_to_first(monkeypatch) -> None:
|
||||
"""planned_release_month с НЕ-первым числом → нормализуется к 1-му (тайминг §25.3)."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _result([_future_row(planned_release_month=date(2027, 6, 17))])
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
assert out[0].release_month == date(2027, 6, 1)
|
||||
|
||||
|
||||
# ── combined + graceful ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_combined_current_and_future(monkeypatch) -> None:
|
||||
"""current + future объединяются (current впереди future)."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = [_result([_current_row()]), _result([_future_row()])]
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
assert len(out) == 2
|
||||
assert out[0].source == "current"
|
||||
assert out[1].source == "future"
|
||||
|
||||
|
||||
def test_current_query_failure_does_not_break_future(monkeypatch) -> None:
|
||||
"""Сбой current-запроса → current=[], future всё равно считается (graceful per-source)."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = [RuntimeError("boom on domrf"), _result([_future_row()])]
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
assert len(out) == 1
|
||||
assert out[0].source == "future"
|
||||
|
||||
|
||||
def test_future_query_failure_returns_empty_gracefully(monkeypatch) -> None:
|
||||
"""Сбой future-запроса (нет таблицы до миграции 148) → [] для future, не crash."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("relation own_planned_project does not exist")
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
assert out == []
|
||||
50
backend/tests/services/test_own_developer_ids_config.py
Normal file
50
backend/tests/services/test_own_developer_ids_config.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Config-parse тесты для own_developer_ids (#1169 PR1, ТЗ §25.3).
|
||||
|
||||
Критично: own_developer_ids СТРОГО default [] (пусто), НИ ОДНОГО реального id в коде.
|
||||
OWN_DEVELOPER_IDS читается из env как comma-separated → list[int]; пусто/не задано → [].
|
||||
Пустой список = §25.3 own-portfolio деградирует изящно (proxy/None), а не падает.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
def test_own_developer_ids_empty_by_default() -> None:
|
||||
"""Default [] — ни одного id не зашито; §25.3 деградирует изящно без конфигурации."""
|
||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
assert s.own_developer_ids == []
|
||||
|
||||
|
||||
def test_own_developer_ids_parses_comma_separated(monkeypatch) -> None:
|
||||
"""'12345,67890,42' → [12345, 67890, 42] (fake-id, не реальные)."""
|
||||
monkeypatch.setenv("OWN_DEVELOPER_IDS", "12345,67890,42")
|
||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
assert s.own_developer_ids == [12345, 67890, 42]
|
||||
|
||||
|
||||
def test_own_developer_ids_trims_whitespace_and_skips_empties(monkeypatch) -> None:
|
||||
"""' 12345 , 67890 ,, ' → [12345, 67890] (trim + пропуск пустых токенов)."""
|
||||
monkeypatch.setenv("OWN_DEVELOPER_IDS", " 12345 , 67890 ,, ")
|
||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
assert s.own_developer_ids == [12345, 67890]
|
||||
|
||||
|
||||
def test_own_developer_ids_empty_string_is_empty_list(monkeypatch) -> None:
|
||||
"""Пустая строка env → [] (а не падение / [0])."""
|
||||
monkeypatch.setenv("OWN_DEVELOPER_IDS", "")
|
||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
assert s.own_developer_ids == []
|
||||
|
||||
|
||||
def test_own_developer_ids_single_value(monkeypatch) -> None:
|
||||
"""Один id без запятой → [12345]."""
|
||||
monkeypatch.setenv("OWN_DEVELOPER_IDS", "12345")
|
||||
s = Settings(_env_file=None) # type: ignore[call-arg]
|
||||
assert s.own_developer_ids == [12345]
|
||||
|
||||
|
||||
def test_own_developer_ids_accepts_list_input() -> None:
|
||||
"""list-вход (JSON-env / прямая инициализация) нормализуется в list[int]."""
|
||||
s = Settings(_env_file=None, own_developer_ids=[12345, 67890]) # type: ignore[call-arg]
|
||||
assert s.own_developer_ids == [12345, 67890]
|
||||
149
data/sql/148_own_planned_project.sql
Normal file
149
data/sql/148_own_planned_project.sql
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
-- 148_own_planned_project.sql
|
||||
-- #1169 PR 1 (Foundation, ТЗ §25.3): manual-entry «наш будущий/планируемый проект» —
|
||||
-- девелопер сам заводит проект из закрытого пайплайна (ещё НЕ в DOM.РФ), чтобы движок
|
||||
-- каннибализации §25.3 (PR 2) мог сравнивать кандидата с НАШИМ портфелем по 4 осям:
|
||||
-- аудитория/класс, тайминг (месяц выхода), цена (₽/м²), квартирография (unit_mix).
|
||||
--
|
||||
-- Парный источник к «текущим» проектам: те читаются из domrf_kn_objects по
|
||||
-- settings.own_developer_ids (см. app/services/site_finder/own_portfolio.py). Эта
|
||||
-- таблица — ВТОРОЙ источник («будущие», приватный пайплайн), оба нормализуются в
|
||||
-- общий OwnProject. created_by фиксирует автора (из X-Authenticated-User), как insight.
|
||||
--
|
||||
-- Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО /api/v1/admin/*.
|
||||
-- На /api/v1/own-projects pilot отсекается FRONTEND-овым RouteGuard + Caddy, НЕ бэкендом;
|
||||
-- до эндпоинта доходят analyst+admin (зеркало insight #948).
|
||||
--
|
||||
-- Соглашение по осям (зеркало forecasting SegmentSpec / sales_series.py): obj_class —
|
||||
-- «человеческий» класс (Стандарт/Комфорт/Бизнес/Премиум — мягкий enum, NULL допустим;
|
||||
-- матчинг в PR2 регистронезависимый). planned_release_month — ПЕРВОЕ число месяца
|
||||
-- (YYYY-MM-01), как report_month рядов продаж. price_*_per_m2 — ₽/м² (единый масштаб с
|
||||
-- objective price_per_m2_rub). unit_mix — доли квартирографии {"studio":0.3,"1k":0.4,...}
|
||||
-- (каждая доля в [0,1]; сумма ≈ 1 — мягко, не констрейнтим точную сумму, т.к. возможны
|
||||
-- округления/нежилое).
|
||||
--
|
||||
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=148).
|
||||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded CHECK (DO-блоки).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Таблица own_planned_project ────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS own_planned_project (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_by TEXT NOT NULL, -- автор, из X-Authenticated-User
|
||||
name TEXT NOT NULL, -- название проекта/ЖК
|
||||
district TEXT, -- район (location ref, как insight)
|
||||
obj_class TEXT, -- Стандарт|Комфорт|Бизнес|Премиум
|
||||
-- Тайминг: планируемый месяц выхода в продажу. Хранится как ПЕРВОЕ число месяца
|
||||
-- (YYYY-MM-01) — нормализуется на уровне сервиса/SQL; ось тайминга §25.3.
|
||||
planned_release_month DATE,
|
||||
-- Цена ₽/м² (вилка). min/max независимы; оба ≥0 (guarded CHECK ниже).
|
||||
price_min_per_m2 NUMERIC,
|
||||
price_max_per_m2 NUMERIC,
|
||||
-- Квартирография: доли по форматам {"studio":0.3,"1k":0.4,"2k":0.2,"3k":0.1}.
|
||||
-- JSONB-объект; каждое значение в [0,1] (guarded CHECK ниже). Сумму ≈1 НЕ
|
||||
-- констрейнтим жёстко (округления / нежилая часть).
|
||||
unit_mix JSONB,
|
||||
lon DOUBLE PRECISION CHECK (lon IS NULL OR lon BETWEEN -180 AND 180),
|
||||
lat DOUBLE PRECISION CHECK (lat IS NULL OR lat BETWEEN -90 AND 90),
|
||||
-- Опциональный центроид проекта: GENERATED из lon/lat (как insight.geom #948).
|
||||
-- NULL если координаты не заданы (обе должны быть заданы вместе).
|
||||
geom GEOMETRY(POINT, 4326) GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN lon IS NOT NULL AND lat IS NOT NULL
|
||||
THEN ST_SetSRID(ST_MakePoint(lon, lat), 4326)
|
||||
END
|
||||
) STORED,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE own_planned_project IS
|
||||
'Manual-entry «наш будущий/планируемый проект» приватного пайплайна (#1169 PR1, '
|
||||
'ТЗ §25.3). Источник «будущих» проектов для движка каннибализации §25.3 (PR2) — '
|
||||
'парный к «текущим» из domrf_kn_objects по settings.own_developer_ids. created_by = '
|
||||
'автор из X-Authenticated-User. Нормализуется в OwnProject (own_portfolio.py).';
|
||||
COMMENT ON COLUMN own_planned_project.obj_class IS
|
||||
'Класс проекта (мягкий enum: Стандарт|Комфорт|Бизнес|Премиум; NULL допустим). '
|
||||
'Зеркалит class-вокабуляр forecasting SegmentSpec; матчинг в PR2 регистронезависимый.';
|
||||
COMMENT ON COLUMN own_planned_project.planned_release_month IS
|
||||
'Планируемый месяц выхода в продажу — ПЕРВОЕ число месяца (YYYY-MM-01). Ось тайминга §25.3.';
|
||||
COMMENT ON COLUMN own_planned_project.price_min_per_m2 IS 'Нижняя граница цены, ₽/м² (≥0).';
|
||||
COMMENT ON COLUMN own_planned_project.price_max_per_m2 IS 'Верхняя граница цены, ₽/м² (≥0).';
|
||||
COMMENT ON COLUMN own_planned_project.unit_mix IS
|
||||
'Квартирография: JSONB долей по форматам {"studio":0.3,"1k":0.4,...}; каждая доля в [0,1].';
|
||||
COMMENT ON COLUMN own_planned_project.geom IS
|
||||
'Опциональный центроид, GENERATED из lon/lat. NULL если координаты не заданы.';
|
||||
|
||||
-- ── 2. CHECK на цены ≥0 (guarded — idempotent при ре-apply) ───────────────────
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'ck_own_planned_project_price_nonneg'
|
||||
) THEN
|
||||
ALTER TABLE own_planned_project
|
||||
ADD CONSTRAINT ck_own_planned_project_price_nonneg
|
||||
CHECK (
|
||||
(price_min_per_m2 IS NULL OR price_min_per_m2 >= 0)
|
||||
AND (price_max_per_m2 IS NULL OR price_max_per_m2 >= 0)
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ── 3. CHECK на unit_mix: объект, все значения числовые в [0,1] (guarded) ──────
|
||||
-- Разрешаем NULL (не задано). Если задан — обязан быть JSON-объектом, и каждое
|
||||
-- значение должно быть числом в [0,1] (доля).
|
||||
--
|
||||
-- ВАЖНО: PostgreSQL ЗАПРЕЩАЕТ подзапросы в CHECK-констрейнтах («cannot use subquery
|
||||
-- in check constraint»), а перебор произвольных ключей JSONB требует jsonb_each →
|
||||
-- подзапрос. Поэтому валидацию выносим в IMMUTABLE-функцию и зовём её из CHECK
|
||||
-- (канонический паттерн для JSONB-валидации в констрейнте). Функция чистая
|
||||
-- (детерминирована от входа) → IMMUTABLE безопасно.
|
||||
CREATE OR REPLACE FUNCTION own_planned_project_unit_mix_valid(mix jsonb)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT mix IS NULL
|
||||
OR (
|
||||
jsonb_typeof(mix) = 'object'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(mix) AS kv(k, v)
|
||||
WHERE jsonb_typeof(v) <> 'number'
|
||||
OR (v)::numeric < 0
|
||||
OR (v)::numeric > 1
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION own_planned_project_unit_mix_valid(jsonb) IS
|
||||
'TRUE если unit_mix = NULL ИЛИ JSON-объект, все значения которого — числа в [0,1] '
|
||||
'(доли квартирографии). Вынесено из CHECK, т.к. подзапросы в CHECK запрещены (#1169).';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'ck_own_planned_project_unit_mix_shares'
|
||||
) THEN
|
||||
ALTER TABLE own_planned_project
|
||||
ADD CONSTRAINT ck_own_planned_project_unit_mix_shares
|
||||
CHECK (own_planned_project_unit_mix_valid(unit_mix));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ── 4. Индексы: фильтры list-эндпоинта + §25.3 чтение ─────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_own_planned_project_district
|
||||
ON own_planned_project (district);
|
||||
CREATE INDEX IF NOT EXISTS idx_own_planned_project_obj_class
|
||||
ON own_planned_project (obj_class);
|
||||
CREATE INDEX IF NOT EXISTS idx_own_planned_project_created_by
|
||||
ON own_planned_project (created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_own_planned_project_release_month
|
||||
ON own_planned_project (planned_release_month);
|
||||
-- Опциональный geo-lookup (как insight geom GIST #948).
|
||||
CREATE INDEX IF NOT EXISTS idx_own_planned_project_geom_gist
|
||||
ON own_planned_project USING GIST (geom);
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue