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
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.
144 lines
6 KiB
Python
144 lines
6 KiB
Python
"""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")
|