gendesign/backend/app/services/insights.py
Light1YT 6400ebde24
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
feat(insights): manual-entry Insight entity CRUD + §19 audit (#948 part A)
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.
2026-06-08 12:41:39 +05:00

250 lines
9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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