feat(site-finder): own-portfolio data source for §25.3 cannibalization (#1169 PR1)
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
This commit is contained in:
Light1YT 2026-06-08 16:21:53 +05:00
parent 4af7ba5a40
commit df34e55ab4
10 changed files with 1769 additions and 2 deletions

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

View file

@ -1,8 +1,9 @@
import os import os
import warnings import warnings
from typing import Annotated
from pydantic import model_validator from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
@ -22,6 +23,48 @@ class Settings(BaseSettings):
# где запросы идут по app мимо Caddy и не несут X-Authenticated-User. # где запросы идут по app мимо Caddy и не несут X-Authenticated-User.
testing: bool = False 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") @model_validator(mode="after")
def _promote_legacy_sentry_dsn(self) -> "Settings": def _promote_legacy_sentry_dsn(self) -> "Settings":
"""Backward-compat: legacy SENTRY_DSN → glitchtip_dsn ТОЛЬКО для self-hosted. """Backward-compat: legacy SENTRY_DSN → glitchtip_dsn ТОЛЬКО для self-hosted.

View file

@ -31,6 +31,7 @@ from app.api.v1 import (
landing, landing,
locations, locations,
me, me,
own_projects,
parcels, parcels,
photos, photos,
pilot, 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(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(locations.router, prefix="/api/v1/locations", tags=["locations"])
app.include_router(insights.router, prefix="/api/v1/insights", tags=["insights"]) 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( app.include_router(
admin_cadastre.router, admin_cadastre.router,
prefix="/api/v1/admin/cadastre", prefix="/api/v1/admin/cadastre",

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

View 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

View 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

View 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"] == "Комфорт"

View 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 == []

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

View 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;