gendesign/backend/app/api/v1/own_projects.py
Light1YT df34e55ab4
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
feat(site-finder): own-portfolio data source for §25.3 cannibalization (#1169 PR1)
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
2026-06-08 16:21:53 +05:00

139 lines
5.9 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 для 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"
)