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