All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m22s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m12s
Foundation PR: unified "our projects" source the §25.3 overlap engine (PR2) will consume. Two origins normalized to OwnProject (class/timing/price/unit-mix): - current <- domrf_kn_objects filtered by settings.own_developer_ids (numeric prefix of composite dev_id; empty -> [] graceful, no DB hit, no hardcoded id) - future <- new manual-entry own_planned_project entity (migration 148) Adds OWN_DEVELOPER_IDS config (comma-sep -> list[int], default []), own_planned_project table (range/unit_mix CHECKs via IMMUTABLE helper, generated geom), /api/v1/own-projects CRUD (created_by from X-Authenticated-User), and get_own_portfolio(db). Per-source graceful degradation; psycopg-v3 CAST clean. Does not touch special_indices.py or parcels.py (out of scope). Refs #1169
139 lines
5.9 KiB
Python
139 lines
5.9 KiB
Python
"""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"
|
||
)
|