"""CRUD API для own_planned_project (#1169 PR1, ТЗ §25.3). POST /api/v1/own-projects → 201 OwnPlannedProjectOut (create) GET /api/v1/own-projects → OwnPlannedProjectList (list + filters + pagination) GET /api/v1/own-projects/{id} → OwnPlannedProjectOut (one; 404 если нет) PUT /api/v1/own-projects/{id} → OwnPlannedProjectOut (partial update; 404 если нет) DELETE /api/v1/own-projects/{id} → 204 (hard delete; 404 если нет) «Наши будущие/планируемые проекты» приватного пайплайна — второй источник §25.3-движка каннибализации (парный к «текущим» из domrf, см. site_finder/own_portfolio.py). Access control: backend rbac_guard (app/main.py) хард-блокирует ТОЛЬКО /api/v1/admin/*. На /api/v1/own-projects pilot отсекается FRONTEND-овым RouteGuard + Caddy, НЕ бэкендом; до эндпоинта доходят analyst+admin. created_by автора берём из X-Authenticated-User (тот же заголовок, что Caddy basic_auth пробрасывает в backend) — НЕ из тела (зеркало insight). Хэндлеры sync `def` (зеркало insights.py): сервис ходит в БД через sync SQLAlchemy Session, поэтому FastAPI исполняет их в threadpool — event loop не блокируется. """ 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.own_project import ( OwnPlannedProjectCreate, OwnPlannedProjectList, OwnPlannedProjectOut, OwnPlannedProjectUpdate, OwnProjectClass, ) from app.services.own_projects import ( create_own_project, delete_own_project, get_own_project, list_own_projects, update_own_project, ) logger = logging.getLogger(__name__) router = APIRouter() def _require_user(x_authenticated_user: str | None) -> str: """Вернуть автора из X-Authenticated-User или 401 (зеркало insights._require_user). В проде 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=OwnPlannedProjectOut, status_code=status.HTTP_201_CREATED) def create_own_project_endpoint( payload: OwnPlannedProjectCreate, db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header()] = None, ) -> Any: """Создать планируемый проект. created_by = X-Authenticated-User.""" created_by = _require_user(x_authenticated_user) return create_own_project(db, created_by, payload) @router.get("", response_model=OwnPlannedProjectList) def list_own_projects_endpoint( db: Annotated[Session, Depends(get_db)], district: Annotated[str | None, Query(description="Фильтр по району")] = None, obj_class: Annotated[OwnProjectClass | 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: """Список планируемых проектов с фильтрами + пагинацией.""" return list_own_projects( db, district=district, obj_class=obj_class, created_by=created_by, limit=limit, offset=offset, ) @router.get("/{project_id}", response_model=OwnPlannedProjectOut) def get_own_project_endpoint( project_id: int, db: Annotated[Session, Depends(get_db)], ) -> Any: """Вернуть один планируемый проект. 404 если не найден.""" project = get_own_project(db, project_id) if project is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Own planned project not found" ) return project @router.put("/{project_id}", response_model=OwnPlannedProjectOut) def update_own_project_endpoint( project_id: int, payload: OwnPlannedProjectUpdate, db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header()] = None, ) -> Any: """Partial update планируемого проекта. 404 если не найден.""" _require_user(x_authenticated_user) project = update_own_project(db, project_id, payload) if project is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Own planned project not found" ) return project @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_own_project_endpoint( project_id: int, db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header()] = None, ) -> None: """Удалить планируемый проект. 404 если не найден.""" _require_user(x_authenticated_user) deleted = delete_own_project(db, project_id) if not deleted: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Own planned project not found" )