"""CRUD-сервис для insight (#948 Part A, ТЗ §7.13/§8.9). API: - create_insight(db, created_by, payload) → InsightOut - list_insights(db, filters..., limit, offset) → InsightList - get_insight(db, insight_id) → InsightOut | None - update_insight(db, insight_id, payload) → InsightOut | None - delete_insight(db, insight_id) → bool Паттерн: raw SQL через SQLAlchemy text() + CAST(:x AS type), psycopg v3 (зеркало app/services/site_finder/custom_pois.py #254). db.commit() после каждой мутации (regression guard #261). Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО /api/v1/admin/*. На /api/v1/insights pilot отсекается FRONTEND-овым RouteGuard (allowed_paths из /me) + Caddy, НЕ бэкендом. До эндпоинта доходят analyst+admin. is_confidential здесь — хранимый+фильтруемый флаг (per-record backend-gate НЕТ), НЕ скрывает строки от analyst (политика #962: analyst видит конфиденциальные insight'ы). """ from __future__ import annotations import logging from typing import Any from sqlalchemy import text from app.schemas.insight import InsightCreate, InsightList, InsightOut, InsightUpdate logger = logging.getLogger(__name__) # ── SQL ──────────────────────────────────────────────────────────────────────── # geom не отдаём (бинарь); lon/lat достаточно для фронта/карты. _SELECT_COLS = """ id, created_by, title, body, category, is_confidential, district, cad_num, lon, lat, source, created_at, updated_at """ _INSERT_SQL = f""" INSERT INTO insight (created_by, title, body, category, is_confidential, district, cad_num, lon, lat, source) VALUES (:created_by, :title, :body, :category, CAST(:is_confidential AS boolean), :district, :cad_num, CAST(:lon AS double precision), CAST(:lat AS double precision), :source) RETURNING {_SELECT_COLS} """ _SELECT_BY_ID = f""" SELECT {_SELECT_COLS} FROM insight WHERE id = :insight_id """ _DELETE_SQL = """ DELETE FROM insight WHERE id = :insight_id RETURNING id """ # ── Row mapper ───────────────────────────────────────────────────────────────── def _row_to_out(r: Any) -> InsightOut: return InsightOut( id=int(r["id"]), created_by=r["created_by"], title=r["title"], body=r["body"], category=r["category"], is_confidential=bool(r["is_confidential"]), district=r["district"], cad_num=r["cad_num"], lon=float(r["lon"]) if r["lon"] is not None else None, lat=float(r["lat"]) if r["lat"] is not None else None, source=r["source"], created_at=r["created_at"], updated_at=r["updated_at"], ) # ── CRUD ─────────────────────────────────────────────────────────────────────── def create_insight(db: Any, created_by: str, payload: InsightCreate) -> InsightOut: """Создать insight. created_by — автор из X-Authenticated-User.""" row = ( db.execute( text(_INSERT_SQL), { "created_by": created_by, "title": payload.title, "body": payload.body, "category": payload.category, "is_confidential": payload.is_confidential, "district": payload.district, "cad_num": payload.cad_num, "lon": payload.lon, "lat": payload.lat, "source": payload.source, }, ) .mappings() .first() ) db.commit() assert row is not None, "INSERT RETURNING вернул пустой результат" logger.info( "insight created: id=%s by=%s confidential=%s district=%s cad=%s", row["id"], created_by, payload.is_confidential, payload.district, payload.cad_num, ) return _row_to_out(row) def list_insights( db: Any, *, district: str | None = None, cad_num: str | None = None, category: str | None = None, is_confidential: bool | None = None, created_by: str | None = None, limit: int = 50, offset: int = 0, ) -> InsightList: """Список insight'ов с фильтрами + пагинацией (зеркало admin_leads). analyst видит ВСЁ, включая is_confidential=True (политика #962); фильтр is_confidential — это сужение выборки, НЕ access-gate. """ where: list[str] = [] params: dict[str, Any] = {"lim": limit, "off": offset} if district is not None: where.append("district = :district") params["district"] = district if cad_num is not None: where.append("cad_num = :cad_num") params["cad_num"] = cad_num if category is not None: where.append("category = :category") params["category"] = category if is_confidential is not None: where.append("is_confidential = CAST(:is_confidential AS boolean)") params["is_confidential"] = is_confidential 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 insight {where_sql} " "ORDER BY created_at DESC LIMIT :lim OFFSET :off" ), params, ) .mappings() .all() ) total = db.execute( text(f"SELECT COUNT(*) FROM insight {where_sql}"), {k: v for k, v in params.items() if k not in ("lim", "off")}, ).scalar_one() return InsightList( total=int(total or 0), limit=limit, offset=offset, rows=[_row_to_out(r) for r in rows], ) def get_insight(db: Any, insight_id: int) -> InsightOut | None: """Вернуть один insight по id.""" row = db.execute(text(_SELECT_BY_ID), {"insight_id": insight_id}).mappings().first() if row is None: return None return _row_to_out(row) def update_insight(db: Any, insight_id: int, payload: InsightUpdate) -> InsightOut | None: """Partial update insight. Возвращает None если не найден. Зеркало update_custom_poi: динамический SET по переданным полям. `is None` как «не передано» — корректно для is_confidential, т.к. False is not None. """ existing = get_insight(db, insight_id) if existing is None: return None sets: list[str] = ["updated_at = NOW()"] params: dict[str, Any] = {"insight_id": insight_id} if payload.title is not None: sets.append("title = :title") params["title"] = payload.title if payload.body is not None: sets.append("body = :body") params["body"] = payload.body if payload.category is not None: sets.append("category = :category") params["category"] = payload.category if payload.is_confidential is not None: sets.append("is_confidential = CAST(:is_confidential AS boolean)") params["is_confidential"] = payload.is_confidential if payload.district is not None: sets.append("district = :district") params["district"] = payload.district if payload.cad_num is not None: sets.append("cad_num = :cad_num") params["cad_num"] = payload.cad_num # 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 payload.source is not None: sets.append("source = :source") params["source"] = payload.source if len(sets) > 1: db.execute( text(f"UPDATE insight SET {', '.join(sets)} WHERE id = :insight_id"), params, ) db.commit() return get_insight(db, insight_id) def delete_insight(db: Any, insight_id: int) -> bool: """Удалить insight (hard delete — зеркало custom_pois). True если удалён.""" result = db.execute(text(_DELETE_SQL), {"insight_id": insight_id}).first() if result is None: return False db.commit() logger.info("insight deleted: id=%s", insight_id) return True