All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m22s
CI / backend-tests (push) Successful in 6m35s
CI / backend-tests (pull_request) Successful in 6m32s
Insight = аналитик пишет free-form intel про локацию/участок с пометкой «непублично» (is_confidential, §7.13/§8.9). CRUD под /api/v1/insights; created_by из X-Authenticated-User (не из тела — спуфинг автора невозможен). Зеркало custom_pois: raw-SQL service + Pydantic v2 schemas, sync def handlers (threadpool, off event loop). - data/sql/145_insight.sql: table insight (idempotent, geom GENERATED из lon/lat, индексы district/cad_num/is_confidential/created_by/GIST); аддитивно расширяет CHECK audit_log.action += insight_write (тот же constraint ck_audit_log_action, to_regclass-guarded, superset — не ломает #962-аудит; НЕ правит applied 144). - audit_middleware.classify_path: method-aware insight_write для POST/PUT/DELETE /api/v1/insights (GET-чтения не аудируются); обратно совместимо с parcels. - ACL: backend rbac_guard hard-блокирует только /api/v1/admin/*; pilot отсекается frontend RouteGuard + Caddy (как parcels/custom_pois). is_confidential — стораемая пометка без per-record backend-гейта (analyst видит, per #962-политике). - tests: 28 insight (CRUD+filters+required+#261 commit) + 5 audit. 86 зелёных, ruff. Part B (Location first-class, §8.2) — отдельный follow-up. Refs #948.
250 lines
9 KiB
Python
250 lines
9 KiB
Python
"""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
|