feat(insights): manual-entry Insight entity CRUD + §19 audit (#948 part A) #1164

Merged
bot-backend merged 1 commit from feat/insight-entity-948a into main 2026-06-08 07:42:16 +00:00
8 changed files with 1115 additions and 9 deletions
Showing only changes of commit 6400ebde24 - Show all commits

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

View file

@ -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 /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)

View file

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

View 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]

View 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

View 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

View file

@ -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
View 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;