feat(insights): manual-entry Insight entity CRUD + §19 audit (#948 part A) #1164
8 changed files with 1115 additions and 9 deletions
144
backend/app/api/v1/insights.py
Normal file
144
backend/app/api/v1/insights.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""CRUD API для insight (#948 Part A, ТЗ §7.13/§8.9).
|
||||
|
||||
POST /api/v1/insights → 201 InsightOut (create)
|
||||
GET /api/v1/insights → InsightList (list + filters + pagination)
|
||||
GET /api/v1/insights/{id} → InsightOut (one; 404 если нет)
|
||||
PUT /api/v1/insights/{id} → InsightOut (partial update; 404 если нет)
|
||||
DELETE /api/v1/insights/{id} → 204 (hard delete; 404 если нет)
|
||||
|
||||
Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО
|
||||
/api/v1/admin/* (admin-only). На /api/v1/insights pilot отсекается FRONTEND-овым
|
||||
RouteGuard (allowed_paths из /me) + Caddy, НЕ бэкендом — is_path_allowed() из
|
||||
backend-кода не вызывается. До эндпоинта доходят analyst+admin. is_confidential —
|
||||
хранимая маркировка (per-record backend-gate НЕТ), политика #962: analyst видит
|
||||
конфиденциальные данные. created_by автора берём из X-Authenticated-User (тот же
|
||||
заголовок, что Caddy basic_auth пробрасывает в backend) — НЕ из тела запроса.
|
||||
|
||||
Хэндлеры sync `def` (зеркало custom_pois.py): сервис ходит в БД через sync
|
||||
SQLAlchemy Session, поэтому FastAPI исполняет их в threadpool — event loop не
|
||||
блокируется. (Делать async def + sync db.execute было бы хуже — заблокировало
|
||||
бы loop.)
|
||||
|
||||
Audit (§19): WRITE-запросы (POST/PUT/DELETE) аудируются HTTP-middleware
|
||||
app/core/audit_middleware.py как action='insight_write' — здесь спец-кода нет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.schemas.insight import (
|
||||
InsightCategory,
|
||||
InsightCreate,
|
||||
InsightList,
|
||||
InsightOut,
|
||||
InsightUpdate,
|
||||
)
|
||||
from app.services.insights import (
|
||||
create_insight,
|
||||
delete_insight,
|
||||
get_insight,
|
||||
list_insights,
|
||||
update_insight,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_user(x_authenticated_user: str | None) -> str:
|
||||
"""Вернуть автора из X-Authenticated-User или 401.
|
||||
|
||||
В проде Caddy basic_auth всегда пробрасывает заголовок для авторизованных,
|
||||
а rbac_guard рубит запросы без него ещё до роутера. Эта проверка — defence
|
||||
in depth (+ корректный ответ в test-mode, где rbac_guard выключен).
|
||||
"""
|
||||
if x_authenticated_user and x_authenticated_user.strip():
|
||||
return x_authenticated_user.strip()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="no authenticated user (X-Authenticated-User required)",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=InsightOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_insight_endpoint(
|
||||
payload: InsightCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header()] = None,
|
||||
) -> Any:
|
||||
"""Создать insight. created_by = X-Authenticated-User."""
|
||||
created_by = _require_user(x_authenticated_user)
|
||||
return create_insight(db, created_by, payload)
|
||||
|
||||
|
||||
@router.get("", response_model=InsightList)
|
||||
def list_insights_endpoint(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
district: Annotated[str | None, Query(description="Фильтр по району")] = None,
|
||||
cad_num: Annotated[str | None, Query(description="Фильтр по кадастровому номеру")] = None,
|
||||
category: Annotated[InsightCategory | None, Query(description="Фильтр по категории")] = None,
|
||||
is_confidential: Annotated[
|
||||
bool | None, Query(description="Фильтр по пометке «непублично»")
|
||||
] = None,
|
||||
created_by: Annotated[str | None, Query(description="Фильтр по автору")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Any:
|
||||
"""Список insight'ов с фильтрами + пагинацией."""
|
||||
return list_insights(
|
||||
db,
|
||||
district=district,
|
||||
cad_num=cad_num,
|
||||
category=category,
|
||||
is_confidential=is_confidential,
|
||||
created_by=created_by,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{insight_id}", response_model=InsightOut)
|
||||
def get_insight_endpoint(
|
||||
insight_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> Any:
|
||||
"""Вернуть один insight. 404 если не найден."""
|
||||
insight = get_insight(db, insight_id)
|
||||
if insight is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found")
|
||||
return insight
|
||||
|
||||
|
||||
@router.put("/{insight_id}", response_model=InsightOut)
|
||||
def update_insight_endpoint(
|
||||
insight_id: int,
|
||||
payload: InsightUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header()] = None,
|
||||
) -> Any:
|
||||
"""Partial update insight. 404 если не найден."""
|
||||
_require_user(x_authenticated_user)
|
||||
insight = update_insight(db, insight_id, payload)
|
||||
if insight is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found")
|
||||
return insight
|
||||
|
||||
|
||||
@router.delete("/{insight_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_insight_endpoint(
|
||||
insight_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
"""Удалить insight. 404 если не найден."""
|
||||
_require_user(x_authenticated_user)
|
||||
deleted = delete_insight(db, insight_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Insight not found")
|
||||
|
|
@ -5,12 +5,15 @@ Best-effort аудит ключевых/чувствительных дейст
|
|||
в обратном порядке регистрации, поэтому rbac_guard должен оставаться внешним —
|
||||
аудитим только запросы, которые RBAC уже пропустил).
|
||||
|
||||
Аудируем РОВНО три пути (sensitive actions из ТЗ §19):
|
||||
Аудируем sensitive actions из ТЗ §19:
|
||||
|
||||
* ``POST /api/v1/parcels/{cad}/analyze`` → action='analyze'
|
||||
* ``GET /api/v1/parcels/{cad}/forecast`` → action='forecast'
|
||||
* ``GET /api/v1/parcels/{cad}/forecast/export`` → action='export'
|
||||
* ``POST|PUT|DELETE /api/v1/insights[/{id}]`` → action='insight_write' (#948)
|
||||
|
||||
Insight WRITES (POST/PUT/DELETE) — чувствительные ручные записи (§19); их
|
||||
аудируем. GET-чтения insight'ов НЕ аудируются (read-only, не мутируют).
|
||||
``/health``, статика, любые non-matched пути НЕ аудируются.
|
||||
|
||||
Гарантии:
|
||||
|
|
@ -50,23 +53,36 @@ _CAD = r"(?P<cad>[^/]+)"
|
|||
|
||||
# Порядок ВАЖЕН: forecast/export проверяем раньше forecast, иначе export
|
||||
# заматчится как forecast. Каждый паттерн якорный ($) → точное совпадение пути.
|
||||
# Эти пути аудируются НЕЗАВИСИМО от HTTP-метода (parcels-действия из §19).
|
||||
_AUDIT_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
("export", re.compile(rf"^/api/v1/parcels/{_CAD}/forecast/export$")),
|
||||
("analyze", re.compile(rf"^/api/v1/parcels/{_CAD}/analyze$")),
|
||||
("forecast", re.compile(rf"^/api/v1/parcels/{_CAD}/forecast$")),
|
||||
)
|
||||
|
||||
# Insight WRITES (#948, §7.13/§8.9): /api/v1/insights и /api/v1/insights/{id}.
|
||||
# Аудируем ТОЛЬКО мутации (POST/PUT/DELETE) — GET-чтения insight'ов пропускаем.
|
||||
_INSIGHT_PATH = re.compile(r"^/api/v1/insights(?:/[^/]+)?$")
|
||||
_INSIGHT_WRITE_METHODS = frozenset({"POST", "PUT", "DELETE"})
|
||||
|
||||
def classify_path(path: str) -> tuple[str, str | None] | None:
|
||||
"""Сопоставить *path* с аудируемым действием.
|
||||
|
||||
Returns ``(action, cad_num)`` если путь — один из sensitive endpoints,
|
||||
иначе ``None`` (путь не аудируется). ``cad_num`` извлекается из пути.
|
||||
def classify_path(path: str, method: str | None = None) -> tuple[str, str | None] | None:
|
||||
"""Сопоставить *path* (+ опционально *method*) с аудируемым действием.
|
||||
|
||||
Returns ``(action, cad_num)`` если путь — sensitive endpoint, иначе ``None``.
|
||||
``cad_num`` извлекается из parcels-путей (для insight'ов — ``None``).
|
||||
|
||||
*method* нужен только для insight-эндпоинтов (аудируем POST/PUT/DELETE, но
|
||||
не GET). Parcels-паттерны от метода не зависят. Если *method* не передан
|
||||
(``None``), insight-ветка пропускается — обратная совместимость со старыми
|
||||
вызовами ``classify_path(path)``.
|
||||
"""
|
||||
for action, pattern in _AUDIT_PATTERNS:
|
||||
m = pattern.match(path)
|
||||
if m:
|
||||
return action, m.group("cad")
|
||||
if method is not None and method in _INSIGHT_WRITE_METHODS and _INSIGHT_PATH.match(path):
|
||||
return "insight_write", None
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -140,7 +156,7 @@ async def audit_log_middleware(
|
|||
if settings.testing:
|
||||
return await call_next(request)
|
||||
|
||||
matched = classify_path(request.url.path)
|
||||
matched = classify_path(request.url.path, request.method)
|
||||
|
||||
# Выполняем сам запрос ВСЕГДА (даже non-matched) — сначала получаем response.
|
||||
response = await call_next(request)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from app.api.v1 import (
|
|||
analytics,
|
||||
concepts,
|
||||
custom_pois,
|
||||
insights,
|
||||
landing,
|
||||
me,
|
||||
parcels,
|
||||
|
|
@ -168,6 +169,7 @@ app.include_router(
|
|||
)
|
||||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
||||
app.include_router(custom_pois.router, prefix="/api/v1/custom-pois", tags=["custom-pois"])
|
||||
app.include_router(insights.router, prefix="/api/v1/insights", tags=["insights"])
|
||||
app.include_router(
|
||||
admin_cadastre.router,
|
||||
prefix="/api/v1/admin/cadastre",
|
||||
|
|
|
|||
80
backend/app/schemas/insight.py
Normal file
80
backend/app/schemas/insight.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Pydantic schemas для insight (#948 Part A, ТЗ §7.13/§8.9).
|
||||
|
||||
InsightCreate — тело POST-запроса (title/body обязательны).
|
||||
InsightUpdate — тело PUT-запроса (все поля опциональны — partial update).
|
||||
InsightOut — ответ API (включает id, created_by, created_at, updated_at).
|
||||
|
||||
created_by НЕ принимается из тела — проставляется роутером из X-Authenticated-User.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Рекомендованные категории (мягкий enum, совпадает с CHECK ck_insight_category
|
||||
# в data/sql/145_insight.sql). None допустим.
|
||||
InsightCategory = Literal["competition", "permitting", "demand", "risk", "other"]
|
||||
|
||||
|
||||
class InsightCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=300, description="Заголовок заметки")
|
||||
body: str = Field(..., min_length=1, max_length=20000, description="Тело заметки (intel)")
|
||||
category: InsightCategory | None = Field(
|
||||
None, description="Категория: competition|permitting|demand|risk|other"
|
||||
)
|
||||
is_confidential: bool = Field(
|
||||
False, description="«Непублично» (§7.13): маркировка чувствительной заметки"
|
||||
)
|
||||
district: str | None = Field(
|
||||
None,
|
||||
max_length=200,
|
||||
description="Location ref (район); Part B промоутит в Location FK",
|
||||
)
|
||||
cad_num: str | None = Field(
|
||||
None, max_length=100, description="Опциональный кадастровый номер участка"
|
||||
)
|
||||
lon: float | None = Field(None, ge=-180.0, le=180.0, description="Долгота WGS-84 (map-pin)")
|
||||
lat: float | None = Field(None, ge=-90.0, le=90.0, description="Широта WGS-84 (map-pin)")
|
||||
source: str | None = Field(None, max_length=500, description="Источник intel")
|
||||
|
||||
|
||||
class InsightUpdate(BaseModel):
|
||||
"""Partial update (PUT) — переданные поля заменяются, отсутствующие не трогаются."""
|
||||
|
||||
title: str | None = Field(None, min_length=1, max_length=300)
|
||||
body: str | None = Field(None, min_length=1, max_length=20000)
|
||||
category: InsightCategory | None = None
|
||||
is_confidential: bool | None = None
|
||||
district: str | None = Field(None, max_length=200)
|
||||
cad_num: str | None = Field(None, max_length=100)
|
||||
lon: float | None = Field(None, ge=-180.0, le=180.0)
|
||||
lat: float | None = Field(None, ge=-90.0, le=90.0)
|
||||
source: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class InsightOut(BaseModel):
|
||||
id: int
|
||||
created_by: str
|
||||
title: str
|
||||
body: str
|
||||
category: str | None
|
||||
is_confidential: bool
|
||||
district: str | None
|
||||
cad_num: str | None
|
||||
lon: float | None
|
||||
lat: float | None
|
||||
source: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class InsightList(BaseModel):
|
||||
"""Ответ list-эндпоинта: total + страница строк (как admin_leads)."""
|
||||
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
rows: list[InsightOut]
|
||||
250
backend/app/services/insights.py
Normal file
250
backend/app/services/insights.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""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
|
||||
465
backend/tests/api/v1/test_insights.py
Normal file
465
backend/tests/api/v1/test_insights.py
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
"""Тесты для insight CRUD (#948 Part A, ТЗ §7.13/§8.9).
|
||||
|
||||
Покрывает (зеркало test_custom_pois.py):
|
||||
1. POST /api/v1/insights → 201, created_by из X-Authenticated-User
|
||||
2. POST без title/body → 422 (Pydantic required-field validation)
|
||||
3. POST без X-Authenticated-User → 401
|
||||
4. GET /api/v1/insights → InsightList (total/limit/offset/rows)
|
||||
5. GET /api/v1/insights?<filters> → фильтры (district/cad_num/category/is_confidential)
|
||||
пробрасываются в сервис
|
||||
6. GET /api/v1/insights/{id} → 200; 404 если нет
|
||||
7. PUT /api/v1/insights/{id} → 200 updated; 404 если нет
|
||||
8. DELETE /api/v1/insights/{id} → 204; 404 если нет
|
||||
9. is_confidential хранится и фильтруется
|
||||
10. Service-level: db.commit() в create/update/delete (#261 regression guard)
|
||||
|
||||
Стратегия mock: сервисные функции патчим через unittest.mock.patch,
|
||||
DB — MagicMock (как test_custom_pois.py). settings.testing=True (conftest)
|
||||
отключает rbac_guard, поэтому X-Authenticated-User в тесте проверяет роутерный
|
||||
_require_user напрямую.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.schemas.insight import InsightCreate, InsightList, InsightOut, InsightUpdate
|
||||
|
||||
# ── Константы ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_CAD = "66:41:0204016:10"
|
||||
_USER = "analysttest"
|
||||
_TS = datetime(2026, 6, 7, 10, 0, 0, tzinfo=UTC)
|
||||
_AUTH = {"X-Authenticated-User": _USER}
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_out(
|
||||
insight_id: int = 1,
|
||||
created_by: str = _USER,
|
||||
title: str = "Конкурент строит ЖК рядом",
|
||||
body: str = "Замечен котлован, забор ГК «Х».",
|
||||
category: str | None = "competition",
|
||||
is_confidential: bool = False,
|
||||
district: str | None = "Октябрьский",
|
||||
cad_num: str | None = None,
|
||||
lon: float | None = None,
|
||||
lat: float | None = None,
|
||||
) -> InsightOut:
|
||||
return InsightOut(
|
||||
id=insight_id,
|
||||
created_by=created_by,
|
||||
title=title,
|
||||
body=body,
|
||||
category=category,
|
||||
is_confidential=is_confidential,
|
||||
district=district,
|
||||
cad_num=cad_num,
|
||||
lon=lon,
|
||||
lat=lat,
|
||||
source=None,
|
||||
created_at=_TS,
|
||||
updated_at=_TS,
|
||||
)
|
||||
|
||||
|
||||
# ── Tests: CREATE ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_insight_returns_201_and_sets_created_by() -> None:
|
||||
"""POST /insights → 201; created_by берётся из X-Authenticated-User, не из тела."""
|
||||
expected = _make_out()
|
||||
with patch("app.api.v1.insights.create_insight", return_value=expected) as mock_create:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/insights",
|
||||
json={
|
||||
"title": "Конкурент строит ЖК рядом",
|
||||
"body": "Замечен котлован, забор ГК «Х».",
|
||||
"category": "competition",
|
||||
"district": "Октябрьский",
|
||||
},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["title"] == "Конкурент строит ЖК рядом"
|
||||
assert body["created_by"] == _USER
|
||||
assert body["is_confidential"] is False
|
||||
mock_create.assert_called_once()
|
||||
# created_by пробрасывается из заголовка во 2-й позиционный аргумент сервиса.
|
||||
call_args = mock_create.call_args
|
||||
assert call_args[0][1] == _USER
|
||||
|
||||
|
||||
def test_create_confidential_insight_stores_flag() -> None:
|
||||
"""is_confidential=True в теле → пробрасывается в payload сервиса."""
|
||||
expected = _make_out(is_confidential=True)
|
||||
with patch("app.api.v1.insights.create_insight", return_value=expected) as mock_create:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/insights",
|
||||
json={"title": "Инсайдер", "body": "Закрытая инфа", "is_confidential": True},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["is_confidential"] is True
|
||||
payload: InsightCreate = mock_create.call_args[0][2]
|
||||
assert payload.is_confidential is True
|
||||
|
||||
|
||||
def test_create_insight_missing_title_returns_422() -> None:
|
||||
"""title обязателен → 422."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/insights",
|
||||
json={"body": "только тело без заголовка"},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_create_insight_missing_body_returns_422() -> None:
|
||||
"""body обязателен → 422."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/insights",
|
||||
json={"title": "только заголовок"},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_create_insight_empty_title_returns_422() -> None:
|
||||
"""Пустой title (min_length=1) → 422."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/insights",
|
||||
json={"title": "", "body": "непустое тело"},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_create_insight_bad_category_returns_422() -> None:
|
||||
"""category вне enum → 422 (Literal)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/insights",
|
||||
json={"title": "t", "body": "b", "category": "totally-unknown"},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_create_insight_without_auth_header_returns_401() -> None:
|
||||
"""Нет X-Authenticated-User → 401 (роутерный _require_user; rbac выключен в тестах)."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/insights",
|
||||
json={"title": "t", "body": "b"},
|
||||
)
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
# ── Tests: LIST ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_insights_returns_envelope() -> None:
|
||||
"""GET /insights → InsightList {total, limit, offset, rows}."""
|
||||
listing = InsightList(total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)])
|
||||
with patch("app.api.v1.insights.list_insights", return_value=listing):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/insights", headers=_AUTH)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert body["limit"] == 50
|
||||
assert body["offset"] == 0
|
||||
assert len(body["rows"]) == 2
|
||||
assert body["rows"][0]["id"] == 1
|
||||
|
||||
|
||||
def test_list_insights_passes_filters_to_service() -> None:
|
||||
"""Фильтры district/cad_num/category/is_confidential → в сервис как kwargs."""
|
||||
listing = InsightList(total=0, limit=50, offset=0, rows=[])
|
||||
with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list:
|
||||
client = TestClient(app)
|
||||
resp = client.get(
|
||||
"/api/v1/insights",
|
||||
params={
|
||||
"district": "Октябрьский",
|
||||
"cad_num": _CAD,
|
||||
"category": "risk",
|
||||
"is_confidential": "true",
|
||||
},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
kwargs = mock_list.call_args.kwargs
|
||||
assert kwargs["district"] == "Октябрьский"
|
||||
assert kwargs["cad_num"] == _CAD
|
||||
assert kwargs["category"] == "risk"
|
||||
assert kwargs["is_confidential"] is True
|
||||
|
||||
|
||||
def test_list_insights_filter_confidential_false() -> None:
|
||||
"""is_confidential=false тоже пробрасывается (не путается с «не задано»)."""
|
||||
listing = InsightList(total=0, limit=50, offset=0, rows=[])
|
||||
with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list:
|
||||
client = TestClient(app)
|
||||
resp = client.get(
|
||||
"/api/v1/insights", params={"is_confidential": "false"}, headers=_AUTH
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert mock_list.call_args.kwargs["is_confidential"] is False
|
||||
|
||||
|
||||
# ── Tests: GET one ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_insight_returns_200() -> None:
|
||||
with patch("app.api.v1.insights.get_insight", return_value=_make_out(7)):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/insights/7", headers=_AUTH)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["id"] == 7
|
||||
|
||||
|
||||
def test_get_insight_not_found_returns_404() -> None:
|
||||
with patch("app.api.v1.insights.get_insight", return_value=None):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/insights/999", headers=_AUTH)
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
# ── Tests: UPDATE ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_put_insight_returns_updated() -> None:
|
||||
updated = _make_out(title="Обновлено", is_confidential=True)
|
||||
with patch("app.api.v1.insights.update_insight", return_value=updated):
|
||||
client = TestClient(app)
|
||||
resp = client.put(
|
||||
"/api/v1/insights/1",
|
||||
json={"title": "Обновлено", "is_confidential": True},
|
||||
headers=_AUTH,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["title"] == "Обновлено"
|
||||
assert body["is_confidential"] is True
|
||||
|
||||
|
||||
def test_put_insight_not_found_returns_404() -> None:
|
||||
with patch("app.api.v1.insights.update_insight", return_value=None):
|
||||
client = TestClient(app)
|
||||
resp = client.put(
|
||||
"/api/v1/insights/999", json={"title": "x"}, headers=_AUTH
|
||||
)
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
def test_put_insight_without_auth_returns_401() -> None:
|
||||
client = TestClient(app)
|
||||
resp = client.put("/api/v1/insights/1", json={"title": "x"})
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
# ── Tests: DELETE ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_delete_insight_returns_204() -> None:
|
||||
with patch("app.api.v1.insights.delete_insight", return_value=True):
|
||||
client = TestClient(app)
|
||||
resp = client.delete("/api/v1/insights/1", headers=_AUTH)
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
|
||||
def test_delete_insight_not_found_returns_404() -> None:
|
||||
with patch("app.api.v1.insights.delete_insight", return_value=False):
|
||||
client = TestClient(app)
|
||||
resp = client.delete("/api/v1/insights/999", headers=_AUTH)
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
def test_delete_insight_without_auth_returns_401() -> None:
|
||||
client = TestClient(app)
|
||||
resp = client.delete("/api/v1/insights/1")
|
||||
assert resp.status_code == 401, resp.text
|
||||
|
||||
|
||||
# ── Service-level: commit + mapping (#261 regression guard) ─────────────────────
|
||||
|
||||
|
||||
def _make_row_data(
|
||||
insight_id: int = 1,
|
||||
created_by: str = _USER,
|
||||
title: str = "T",
|
||||
body: str = "B",
|
||||
category: str | None = "competition",
|
||||
is_confidential: bool = False,
|
||||
district: str | None = "Октябрьский",
|
||||
cad_num: str | None = None,
|
||||
lon: float | None = None,
|
||||
lat: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": insight_id,
|
||||
"created_by": created_by,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"category": category,
|
||||
"is_confidential": is_confidential,
|
||||
"district": district,
|
||||
"cad_num": cad_num,
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"source": None,
|
||||
"created_at": _TS,
|
||||
"updated_at": _TS,
|
||||
}
|
||||
|
||||
|
||||
def _make_db_with_row(row_data: dict[str, Any] | None) -> MagicMock:
|
||||
"""MagicMock-сессия: execute().mappings().first() → row_data mapping."""
|
||||
db = MagicMock()
|
||||
row_mock: MagicMock | None = None
|
||||
if row_data is not None:
|
||||
row_mock = MagicMock()
|
||||
row_mock.__getitem__ = lambda self, k: row_data[k]
|
||||
row_mock.get = lambda k, default=None: row_data.get(k, default)
|
||||
|
||||
mapping_mock = MagicMock()
|
||||
mapping_mock.first.return_value = row_mock
|
||||
|
||||
exec_mock = MagicMock()
|
||||
exec_mock.mappings.return_value = mapping_mock
|
||||
exec_mock.first.return_value = row_mock # DELETE RETURNING id (не mappings)
|
||||
|
||||
db.execute.return_value = exec_mock
|
||||
return db
|
||||
|
||||
|
||||
def test_service_create_insight_commits_and_sets_created_by() -> None:
|
||||
"""create_insight вызывает db.commit() и мапит created_by — regression #261."""
|
||||
from app.services.insights import create_insight
|
||||
|
||||
db = _make_db_with_row(_make_row_data(is_confidential=True))
|
||||
payload = InsightCreate(title="T", body="B", is_confidential=True)
|
||||
|
||||
result = create_insight(db, _USER, payload)
|
||||
|
||||
db.commit.assert_called_once()
|
||||
assert result.id == 1
|
||||
assert result.created_by == _USER
|
||||
assert result.is_confidential is True
|
||||
|
||||
|
||||
def test_service_update_insight_commits_when_fields_given() -> None:
|
||||
"""update_insight вызывает db.commit() при наличии изменяемых полей — regression #261."""
|
||||
from app.services.insights import update_insight
|
||||
|
||||
db = _make_db_with_row(_make_row_data(title="new"))
|
||||
payload = InsightUpdate(title="new")
|
||||
|
||||
result = update_insight(db, insight_id=1, payload=payload)
|
||||
|
||||
db.commit.assert_called_once()
|
||||
assert result is not None
|
||||
|
||||
|
||||
def test_service_update_insight_confidential_false_triggers_update() -> None:
|
||||
"""is_confidential=False — это изменение (False is not None) → commit."""
|
||||
from app.services.insights import update_insight
|
||||
|
||||
db = _make_db_with_row(_make_row_data(is_confidential=False))
|
||||
payload = InsightUpdate(is_confidential=False)
|
||||
|
||||
update_insight(db, insight_id=1, payload=payload)
|
||||
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_service_update_insight_no_commit_when_payload_empty() -> None:
|
||||
"""Пустой payload → только updated_at в sets → UPDATE не выполняется, commit не зовётся."""
|
||||
from app.services.insights import update_insight
|
||||
|
||||
db = _make_db_with_row(_make_row_data())
|
||||
update_insight(db, insight_id=1, payload=InsightUpdate())
|
||||
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_service_update_insight_no_commit_when_not_found() -> None:
|
||||
"""update_insight НЕ коммитит если insight не найден."""
|
||||
from app.services.insights import update_insight
|
||||
|
||||
db = _make_db_with_row(None)
|
||||
result = update_insight(db, insight_id=999, payload=InsightUpdate(title="x"))
|
||||
|
||||
db.commit.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_service_delete_insight_commits_when_found() -> None:
|
||||
"""delete_insight коммитит когда строка найдена — regression #261."""
|
||||
from app.services.insights import delete_insight
|
||||
|
||||
db = _make_db_with_row(_make_row_data())
|
||||
db.execute.return_value.first.return_value = MagicMock() # RETURNING id truthy
|
||||
|
||||
assert delete_insight(db, insight_id=1) is True
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_service_delete_insight_no_commit_when_not_found() -> None:
|
||||
"""delete_insight НЕ коммитит если строки нет."""
|
||||
from app.services.insights import delete_insight
|
||||
|
||||
db = _make_db_with_row(None)
|
||||
db.execute.return_value.first.return_value = None # RETURNING id пусто
|
||||
|
||||
assert delete_insight(db, insight_id=999) is False
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_service_list_insights_builds_filtered_query() -> None:
|
||||
"""list_insights собирает WHERE по фильтрам + возвращает InsightList с total."""
|
||||
from app.services.insights import list_insights
|
||||
|
||||
db = MagicMock()
|
||||
row = _make_row_data(1, is_confidential=True)
|
||||
row_mock = MagicMock()
|
||||
row_mock.__getitem__ = lambda self, k: row[k]
|
||||
|
||||
rows_result = MagicMock()
|
||||
rows_result.mappings.return_value.all.return_value = [row_mock]
|
||||
count_result = MagicMock()
|
||||
count_result.scalar_one.return_value = 1
|
||||
db.execute.side_effect = [rows_result, count_result]
|
||||
|
||||
out = list_insights(db, district="Октябрьский", is_confidential=True, limit=10, offset=0)
|
||||
|
||||
assert isinstance(out, InsightList)
|
||||
assert out.total == 1
|
||||
assert out.limit == 10
|
||||
assert len(out.rows) == 1
|
||||
assert out.rows[0].is_confidential is True
|
||||
|
||||
# Первый execute — SELECT с WHERE по district + is_confidential, psycopg v3 CAST.
|
||||
select_sql = str(db.execute.call_args_list[0].args[0])
|
||||
assert "WHERE" in select_sql
|
||||
assert "district = :district" in select_sql
|
||||
assert "is_confidential = CAST(:is_confidential AS boolean)" in select_sql
|
||||
assert "::" not in select_sql
|
||||
select_params = db.execute.call_args_list[0].args[1]
|
||||
assert select_params["district"] == "Октябрьский"
|
||||
assert select_params["is_confidential"] is True
|
||||
|
|
@ -103,6 +103,42 @@ def test_classify_path_unmatched_returns_none() -> None:
|
|||
assert audit_mod.classify_path("/api/v1/parcels/X/forecast/export/extra") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# classify_path — insight writes (#948)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_classify_path_insight_write_methods() -> None:
|
||||
"""POST/PUT/DELETE на /insights[/{id}] → ('insight_write', None)."""
|
||||
assert audit_mod.classify_path("/api/v1/insights", "POST") == ("insight_write", None)
|
||||
assert audit_mod.classify_path("/api/v1/insights/42", "PUT") == ("insight_write", None)
|
||||
assert audit_mod.classify_path("/api/v1/insights/42", "DELETE") == ("insight_write", None)
|
||||
|
||||
|
||||
def test_classify_path_insight_get_not_audited() -> None:
|
||||
"""GET insight'ов (list/one) НЕ аудируется — это read-only."""
|
||||
assert audit_mod.classify_path("/api/v1/insights", "GET") is None
|
||||
assert audit_mod.classify_path("/api/v1/insights/42", "GET") is None
|
||||
|
||||
|
||||
def test_classify_path_insight_without_method_skipped() -> None:
|
||||
"""Без method insight-ветка не срабатывает (обратная совместимость)."""
|
||||
assert audit_mod.classify_path("/api/v1/insights") is None
|
||||
assert audit_mod.classify_path("/api/v1/insights/42") is None
|
||||
|
||||
|
||||
def test_classify_path_insight_nested_path_not_matched() -> None:
|
||||
"""Вложенные пути под /insights/{id}/... не матчатся (якорь $)."""
|
||||
assert audit_mod.classify_path("/api/v1/insights/42/comments", "POST") is None
|
||||
|
||||
|
||||
def test_classify_path_parcels_method_ignored() -> None:
|
||||
"""Parcels-паттерны не зависят от method — analyze матчится при любом методе."""
|
||||
assert audit_mod.classify_path(
|
||||
"/api/v1/parcels/66:41:0204016:10/analyze", "GET"
|
||||
) == ("analyze", "66:41:0204016:10")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_audit_row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
113
data/sql/145_insight.sql
Normal file
113
data/sql/145_insight.sql
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
-- 145_insight.sql
|
||||
-- #948 Part A (ТЗ §7.13/§8.9): manual-entry "insight" — аналитик пишет свободную
|
||||
-- заметку/intel про локацию или участок, с пометкой «непублично» (is_confidential).
|
||||
--
|
||||
-- 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 НЕТ; будущий gate
|
||||
-- публичной выдачи), сейчас просто хранимый+фильтруемый флаг (политика #962: analyst
|
||||
-- видит конфиденциальные данные). created_by фиксирует автора (из X-Authenticated-User).
|
||||
--
|
||||
-- Location ref: пока plain TEXT district + опциональный cad_num (ссылка на участок).
|
||||
-- Part B (#948, §8.2) промоутит district в Location FK — НЕ в этом таске.
|
||||
--
|
||||
-- Audit (§19, интеграция с #962): WRITE-запросы (POST/PUT/DELETE) аудируются
|
||||
-- middleware app/core/audit_middleware.py с action='insight_write'. Поэтому ниже
|
||||
-- (блок 4) аддитивно расширяем CHECK audit_log.action, чтобы 'insight_write'
|
||||
-- проходил констрейнт. Это idempotent ALTER (DROP IF EXISTS + ADD) — НЕ правит
|
||||
-- 144_audit_log.sql (тот уже применён на проде/смёржен; правка применённой
|
||||
-- миграции не переиграется через _schema_migrations).
|
||||
--
|
||||
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=145).
|
||||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded CHECK.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Таблица insight ────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS insight (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_by TEXT NOT NULL, -- автор, из X-Authenticated-User
|
||||
title TEXT NOT NULL, -- заголовок заметки
|
||||
body TEXT NOT NULL, -- тело заметки (free-form intel)
|
||||
category TEXT, -- competition|permitting|demand|risk|other
|
||||
is_confidential BOOLEAN NOT NULL DEFAULT FALSE, -- «непублично» (§7.13)
|
||||
district TEXT, -- location ref (Part B → Location FK)
|
||||
cad_num TEXT, -- опциональная ссылка на участок
|
||||
lon DOUBLE PRECISION CHECK (lon IS NULL OR lon BETWEEN -180 AND 180),
|
||||
lat DOUBLE PRECISION CHECK (lat IS NULL OR lat BETWEEN -90 AND 90),
|
||||
-- Опциональный map-pin: GENERATED из lon/lat (как user_custom_pois.geom #254).
|
||||
-- NULL если координаты не заданы (обе должны быть заданы вместе).
|
||||
geom GEOMETRY(POINT, 4326) GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN lon IS NOT NULL AND lat IS NOT NULL
|
||||
THEN ST_SetSRID(ST_MakePoint(lon, lat), 4326)
|
||||
END
|
||||
) STORED,
|
||||
source TEXT, -- источник intel (опционально)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE insight IS
|
||||
'Manual-entry аналитические заметки/intel про локацию или участок (#948 Part A, '
|
||||
'ТЗ §7.13/§8.9). is_confidential = маркировка «непублично». created_by = автор '
|
||||
'из X-Authenticated-User. district — plain TEXT (Part B промоутит в Location FK).';
|
||||
COMMENT ON COLUMN insight.is_confidential IS
|
||||
'«Непублично» (§7.13): маркировка чувствительной заметки. Сейчас хранимый+'
|
||||
'фильтруемый флаг; будущий gate публичной выдачи.';
|
||||
COMMENT ON COLUMN insight.category IS
|
||||
'Свободная категория (рекоменд.: competition|permitting|demand|risk|other).';
|
||||
COMMENT ON COLUMN insight.geom IS
|
||||
'Опциональный map-pin, GENERATED из lon/lat. NULL если координаты не заданы.';
|
||||
|
||||
-- ── 2. CHECK на category (guarded — idempotent при ре-apply) ──────────────────
|
||||
-- Мягкий enum: разрешаем рекомендованные значения + NULL. Расширять список —
|
||||
-- через новую миграцию (DROP IF EXISTS + ADD), не правкой этой.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'ck_insight_category'
|
||||
) THEN
|
||||
ALTER TABLE insight
|
||||
ADD CONSTRAINT ck_insight_category
|
||||
CHECK (
|
||||
category IS NULL
|
||||
OR category IN ('competition', 'permitting', 'demand', 'risk', 'other')
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ── 3. Индексы: фильтры list-эндпоинта ────────────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_insight_district
|
||||
ON insight (district);
|
||||
CREATE INDEX IF NOT EXISTS idx_insight_cad_num
|
||||
ON insight (cad_num);
|
||||
CREATE INDEX IF NOT EXISTS idx_insight_is_confidential
|
||||
ON insight (is_confidential);
|
||||
CREATE INDEX IF NOT EXISTS idx_insight_created_by
|
||||
ON insight (created_by);
|
||||
-- Опциональный geo-lookup (как user_custom_pois_geom_gist #254).
|
||||
CREATE INDEX IF NOT EXISTS idx_insight_geom_gist
|
||||
ON insight USING GIST (geom);
|
||||
|
||||
-- ── 4. Расширяем CHECK audit_log.action → +'insight_write' (§19, #962) ─────────
|
||||
-- Middleware аудирует WRITE insight'ов как action='insight_write'. 144_audit_log
|
||||
-- разрешал только analyze|forecast|export|other; добавляем 'insight_write'.
|
||||
-- Idempotent: DROP IF EXISTS + ADD пере-создаёт констрейнт с расширенным набором
|
||||
-- (повторный apply этой миграции даст тот же результат). Таблица audit_log создана
|
||||
-- в 144 (она в HEAD/проде до этой миграции) — но guard'имся to_regclass на случай
|
||||
-- среды, где 144 ещё не накатан, чтобы 145 не падал.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.audit_log') IS NOT NULL THEN
|
||||
ALTER TABLE audit_log DROP CONSTRAINT IF EXISTS ck_audit_log_action;
|
||||
ALTER TABLE audit_log
|
||||
ADD CONSTRAINT ck_audit_log_action
|
||||
CHECK (action IN ('analyze', 'forecast', 'export', 'insight_write', 'other'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue