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_guard должен оставаться внешним —
|
||||||
аудитим только запросы, которые RBAC уже пропустил).
|
аудитим только запросы, которые RBAC уже пропустил).
|
||||||
|
|
||||||
Аудируем РОВНО три пути (sensitive actions из ТЗ §19):
|
Аудируем sensitive actions из ТЗ §19:
|
||||||
|
|
||||||
* ``POST /api/v1/parcels/{cad}/analyze`` → action='analyze'
|
* ``POST /api/v1/parcels/{cad}/analyze`` → action='analyze'
|
||||||
* ``GET /api/v1/parcels/{cad}/forecast`` → action='forecast'
|
* ``GET /api/v1/parcels/{cad}/forecast`` → action='forecast'
|
||||||
* ``GET /api/v1/parcels/{cad}/forecast/export`` → action='export'
|
* ``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 пути НЕ аудируются.
|
``/health``, статика, любые non-matched пути НЕ аудируются.
|
||||||
|
|
||||||
Гарантии:
|
Гарантии:
|
||||||
|
|
@ -50,23 +53,36 @@ _CAD = r"(?P<cad>[^/]+)"
|
||||||
|
|
||||||
# Порядок ВАЖЕН: forecast/export проверяем раньше forecast, иначе export
|
# Порядок ВАЖЕН: forecast/export проверяем раньше forecast, иначе export
|
||||||
# заматчится как forecast. Каждый паттерн якорный ($) → точное совпадение пути.
|
# заматчится как forecast. Каждый паттерн якорный ($) → точное совпадение пути.
|
||||||
|
# Эти пути аудируются НЕЗАВИСИМО от HTTP-метода (parcels-действия из §19).
|
||||||
_AUDIT_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
_AUDIT_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||||
("export", re.compile(rf"^/api/v1/parcels/{_CAD}/forecast/export$")),
|
("export", re.compile(rf"^/api/v1/parcels/{_CAD}/forecast/export$")),
|
||||||
("analyze", re.compile(rf"^/api/v1/parcels/{_CAD}/analyze$")),
|
("analyze", re.compile(rf"^/api/v1/parcels/{_CAD}/analyze$")),
|
||||||
("forecast", re.compile(rf"^/api/v1/parcels/{_CAD}/forecast$")),
|
("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,
|
def classify_path(path: str, method: str | None = None) -> tuple[str, str | None] | None:
|
||||||
иначе ``None`` (путь не аудируется). ``cad_num`` извлекается из пути.
|
"""Сопоставить *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:
|
for action, pattern in _AUDIT_PATTERNS:
|
||||||
m = pattern.match(path)
|
m = pattern.match(path)
|
||||||
if m:
|
if m:
|
||||||
return action, m.group("cad")
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -140,7 +156,7 @@ async def audit_log_middleware(
|
||||||
if settings.testing:
|
if settings.testing:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
matched = classify_path(request.url.path)
|
matched = classify_path(request.url.path, request.method)
|
||||||
|
|
||||||
# Выполняем сам запрос ВСЕГДА (даже non-matched) — сначала получаем response.
|
# Выполняем сам запрос ВСЕГДА (даже non-matched) — сначала получаем response.
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from app.api.v1 import (
|
||||||
analytics,
|
analytics,
|
||||||
concepts,
|
concepts,
|
||||||
custom_pois,
|
custom_pois,
|
||||||
|
insights,
|
||||||
landing,
|
landing,
|
||||||
me,
|
me,
|
||||||
parcels,
|
parcels,
|
||||||
|
|
@ -168,6 +169,7 @@ app.include_router(
|
||||||
)
|
)
|
||||||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
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(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(
|
app.include_router(
|
||||||
admin_cadastre.router,
|
admin_cadastre.router,
|
||||||
prefix="/api/v1/admin/cadastre",
|
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
|
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
|
# 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