gendesign/backend/app/api/v1/insights.py
Light1YT 6400ebde24
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m22s
CI / backend-tests (push) Successful in 6m35s
CI / backend-tests (pull_request) Successful in 6m32s
feat(insights): manual-entry Insight entity CRUD + §19 audit (#948 part A)
Insight = аналитик пишет free-form intel про локацию/участок с пометкой
«непублично» (is_confidential, §7.13/§8.9). CRUD под /api/v1/insights;
created_by из X-Authenticated-User (не из тела — спуфинг автора невозможен).
Зеркало custom_pois: raw-SQL service + Pydantic v2 schemas, sync def handlers
(threadpool, off event loop).

- data/sql/145_insight.sql: table insight (idempotent, geom GENERATED из lon/lat,
  индексы district/cad_num/is_confidential/created_by/GIST); аддитивно расширяет
  CHECK audit_log.action += insight_write (тот же constraint ck_audit_log_action,
  to_regclass-guarded, superset — не ломает #962-аудит; НЕ правит applied 144).
- audit_middleware.classify_path: method-aware insight_write для POST/PUT/DELETE
  /api/v1/insights (GET-чтения не аудируются); обратно совместимо с parcels.
- ACL: backend rbac_guard hard-блокирует только /api/v1/admin/*; pilot отсекается
  frontend RouteGuard + Caddy (как parcels/custom_pois). is_confidential — стораемая
  пометка без per-record backend-гейта (analyst видит, per #962-политике).
- tests: 28 insight (CRUD+filters+required+#261 commit) + 5 audit. 86 зелёных, ruff.

Part B (Location first-class, §8.2) — отдельный follow-up.

Refs #948.
2026-06-08 12:41:39 +05:00

144 lines
6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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