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
269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""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
|