diff --git a/backend/app/api/v1/own_projects.py b/backend/app/api/v1/own_projects.py new file mode 100644 index 00000000..f2ce1496 --- /dev/null +++ b/backend/app/api/v1/own_projects.py @@ -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" + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 60f12a38..d796fb37 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 '_', см. + # 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. diff --git a/backend/app/main.py b/backend/app/main.py index 4838a4ae..43ca2548 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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", diff --git a/backend/app/schemas/own_project.py b/backend/app/schemas/own_project.py new file mode 100644 index 00000000..3d878d7e --- /dev/null +++ b/backend/app/schemas/own_project.py @@ -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] diff --git a/backend/app/services/own_projects.py b/backend/app/services/own_projects.py new file mode 100644 index 00000000..af80622b --- /dev/null +++ b/backend/app/services/own_projects.py @@ -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 diff --git a/backend/app/services/site_finder/own_portfolio.py b/backend/app/services/site_finder/own_portfolio.py new file mode 100644 index 00000000..5841fcf0 --- /dev/null +++ b/backend/app/services/site_finder/own_portfolio.py @@ -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 + '_' (напр. '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 diff --git a/backend/tests/api/v1/test_own_projects.py b/backend/tests/api/v1/test_own_projects.py new file mode 100644 index 00000000..06e67314 --- /dev/null +++ b/backend/tests/api/v1/test_own_projects.py @@ -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? → 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"] == "Комфорт" diff --git a/backend/tests/services/site_finder/test_own_portfolio.py b/backend/tests/services/site_finder/test_own_portfolio.py new file mode 100644 index 00000000..9d825c1c --- /dev/null +++ b/backend/tests/services/site_finder/test_own_portfolio.py @@ -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 == [] diff --git a/backend/tests/services/test_own_developer_ids_config.py b/backend/tests/services/test_own_developer_ids_config.py new file mode 100644 index 00000000..c019aaaf --- /dev/null +++ b/backend/tests/services/test_own_developer_ids_config.py @@ -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] diff --git a/data/sql/148_own_planned_project.sql b/data/sql/148_own_planned_project.sql new file mode 100644 index 00000000..797e8ba2 --- /dev/null +++ b/data/sql/148_own_planned_project.sql @@ -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;