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