stages-back: круды для этапов

This commit is contained in:
tsygankoviva 2026-05-15 14:33:33 +03:00
parent 47fac8acbc
commit 8299b35d07
12 changed files with 570 additions and 16 deletions

View File

@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.domain.schemas import (
BaseListResponse,
BaseSingleResponse,
FormPhaseCreate,
FormPhaseResponse,
FormPhaseUpdate,
)
from src.services.budget_form_service import BudgetFormService
from src.services.form_phase_service import FormPhaseService
from src.api.v1.deps import get_current_active_user_with_set_db, require_admin
from src.db.session import get_db
router = APIRouter(prefix="/stages", tags=["stages"])
@router.get("/form/{form_id}")
async def get_form_phases(
form_id: int,
sheet: str | None = None,
phase_code: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[FormPhaseResponse]:
fp_service = FormPhaseService(db)
phases = await fp_service.get_list(
budget_form_id=form_id,
user=current_user,
sheet=sheet,
phase_code=phase_code,
)
return BaseListResponse(
result=[FormPhaseResponse.model_validate(p) for p in phases],
count=len(phases),
)
@router.post("/form/{form_id}")
async def create_form_phase(
form_id: int,
body: FormPhaseCreate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[FormPhaseResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
phase = await fp_service.create(
budget_form=form,
body=body,
user=current_user,
)
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
@router.patch("/form/{form_id}/{sheet}/{phase_code}")
async def update_form_phase(
form_id: int,
sheet: str,
phase_code: str,
body: FormPhaseUpdate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[FormPhaseResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
phase = await fp_service.update(
budget_form=form,
sheet=sheet,
phase_code=phase_code,
body=body,
user=current_user,
)
if not phase:
raise HTTPException(404, "Этап не найден")
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
@router.delete("/form/{form_id}/{sheet}/{phase_code}")
async def delete_form_phase(
form_id: int,
sheet: str,
phase_code: str,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
deleted = await fp_service.delete(
budget_form_id=form_id,
sheet=sheet,
phase_code=phase_code,
user=current_user,
)
if not deleted:
raise HTTPException(404, "Этап не найден")

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from src.api.v1 import auth, users, admin, forms, projects from src.api.v1 import auth, users, admin, forms, form_phases, projects
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router) api_router.include_router(auth.router)
@ -8,3 +9,4 @@ api_router.include_router(users.router)
api_router.include_router(admin.router) api_router.include_router(admin.router)
api_router.include_router(forms.router) api_router.include_router(forms.router)
api_router.include_router(projects.router) api_router.include_router(projects.router)
api_router.include_router(form_phases.router)

View File

@ -1,6 +1,8 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class ValidationError(BaseModel): class ValidationError(BaseModel):
field: str = Field(...) field: str = Field(...)
description: str = Field(...) description: str = Field(...)
@ -10,6 +12,14 @@ class BasicAppException(Exception):
description: str | None = None description: str | None = None
class ValidationsError(BasicAppException):
errors: dict[str, list[str]]
def __init__(self, errors, *args):
super().__init__(*args)
self.errors = errors
class AccessDeniedException(BasicAppException): class AccessDeniedException(BasicAppException):
description = "Доступ запрещен" description = "Доступ запрещен"

View File

@ -8,6 +8,7 @@ from src.core.errors import (
UserNotFoundException, UserNotFoundException,
UsernameConflictException, UsernameConflictException,
ValidationException, ValidationException,
ValidationsError,
) )
from src.core.CONSTANTS import ( from src.core.CONSTANTS import (
VALIDATION_PREFIXES, VALIDATION_PREFIXES,
@ -141,3 +142,10 @@ def register_exception_handlers(app: FastAPI) -> None:
status_code=status_code, status_code=status_code,
content=_error_payload(code, message, field=field), content=_error_payload(code, message, field=field),
) )
@app.exception_handler(ValidationsError)
async def validations_error_handler(request, exc: ValidationsError):
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content=exc.errors,
)

View File

@ -10,6 +10,7 @@ from src.db.models.collegial_approval import CollegialApproval
from src.db.models.contract_detail import ContractDetail from src.db.models.contract_detail import ContractDetail
from src.db.models.contract_summary import ContractSummary from src.db.models.contract_summary import ContractSummary
from src.db.models.form_limit import FormLimit from src.db.models.form_limit import FormLimit
from src.db.models.form_phase import FormPhase
from src.db.models.form_type import FormType from src.db.models.form_type import FormType
from src.db.models.limit_template import LimitTemplate from src.db.models.limit_template import LimitTemplate
from src.db.models.org_unit import OrgUnit from src.db.models.org_unit import OrgUnit

View File

@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class FormPhase(Base):
__tablename__ = "form_phase"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_form_phase_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_form_phase_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_form_phase_window"),
{"schema": "v3"},
)
budget_form_id: Mapped[int] = mapped_column(
ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True
)
sheet: Mapped[str] = mapped_column(String, primary_key=True)
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View File

@ -49,15 +49,3 @@ class FormType(Base):
@property @property
def section_list(self): def section_list(self):
return self.SECTIONS_BY_FORM_TYPE.get(self.code, []) return self.SECTIONS_BY_FORM_TYPE.get(self.code, [])
# budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="form_type"
# )
# expense_item_form_types: Mapped[list["ExpenseItemFormType"]] = relationship( # type: ignore
# "ExpenseItemFormType", back_populates="form_type"
# )
# phase_templates: Mapped[list["PhaseTemplate"]] = relationship( # type: ignore
# "PhaseTemplate", back_populates="form_type"
# )

View File

@ -2,6 +2,8 @@ from datetime import datetime
import enum import enum
from typing import Any, Generic, Literal, Optional, TypeVar from typing import Any, Generic, Literal, Optional, TypeVar
FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"]
from pydantic import BaseModel, ConfigDict, EmailStr, Field from pydantic import BaseModel, ConfigDict, EmailStr, Field
@ -250,3 +252,31 @@ class AddLineSchema(BaseModel):
justification: Optional[str] = None justification: Optional[str] = None
contract_number: Optional[str] = None contract_number: Optional[str] = None
contract_end_date: Optional[datetime] = None contract_end_date: Optional[datetime] = None
class FormPhaseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
budget_form_id: int
sheet: str
phase_code: str
role: str
column_keys: list[str]
opens_at: datetime
closes_at: datetime
class FormPhaseCreate(BaseModel):
sheet: str
phase_code: str
role: FormPhaseRole
column_keys: list[str]
opens_at: datetime
closes_at: datetime
class FormPhaseUpdate(BaseModel):
role: FormPhaseRole | None = None
column_keys: list[str] | None = None
opens_at: datetime | None = None
closes_at: datetime | None = None

View File

@ -0,0 +1,84 @@
from datetime import datetime
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_phase import FormPhase
class FormPhaseRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
budget_form_id: int,
sheet: str | None = None,
phase_code: str | None = None,
) -> list[FormPhase]:
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
if sheet is not None:
query = query.where(FormPhase.sheet == sheet)
if phase_code is not None:
query = query.where(FormPhase.phase_code == phase_code)
return (await self.db.execute(query)).scalars().all()
async def get(
self, budget_form_id: int, sheet: str, phase_code: str
) -> FormPhase | None:
query = (
select(FormPhase)
.where(FormPhase.budget_form_id == budget_form_id)
.where(FormPhase.sheet == sheet)
.where(FormPhase.phase_code == phase_code)
.limit(1)
)
return (await self.db.execute(query)).scalar_one_or_none()
async def create(
self,
budget_form_id: int,
sheet: str,
phase_code: str,
role: str,
column_keys: list[str],
opens_at: datetime,
closes_at: datetime,
) -> FormPhase:
form_phase = FormPhase(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
role=role,
column_keys=column_keys,
opens_at=opens_at,
closes_at=closes_at,
)
self.db.add(form_phase)
await self.db.flush()
return form_phase
async def update(
self, budget_form_id: int, sheet: str, phase_code: str, data: dict
) -> FormPhase | None:
query = (
update(FormPhase)
.where(FormPhase.budget_form_id == budget_form_id)
.where(FormPhase.sheet == sheet)
.where(FormPhase.phase_code == phase_code)
.values(**data)
.returning(FormPhase)
)
return (await self.db.execute(query)).scalar_one_or_none()
async def delete(
self, budget_form_id: int, sheet: str, phase_code: str
) -> bool:
query = (
delete(FormPhase)
.where(FormPhase.budget_form_id == budget_form_id)
.where(FormPhase.sheet == sheet)
.where(FormPhase.phase_code == phase_code)
)
result = await self.db.execute(query)
return result.rowcount > 0

View File

@ -0,0 +1,139 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.errors import AccessDeniedException, ValidationsError
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.budget_form_repository import BudgetFormRepository
from src.db.models.app_user import AppUser
from src.db.models.form_phase import FormPhase
from src.domain.schemas import FormPhaseCreate, FormPhaseUpdate
from src.repository.form_phase_repository import FormPhaseRepository
class FormPhaseService:
def __init__(self, db: AsyncSession):
self.db = db
self.fp_repo = FormPhaseRepository(db)
self.bf_repo = BudgetFormRepository(db)
async def get_list(
self,
budget_form_id: int,
user: AppUser,
sheet: str | None = None,
phase_code: str | None = None,
) -> list[FormPhase]:
return await self.fp_repo.get_list(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
)
async def get(
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
) -> FormPhase | None:
return await self.fp_repo.get(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
)
def _validate_form_phase(
self,
form_phase: FormPhaseCreate | FormPhaseUpdate,
budget_form: BudgetForm,
sheet: str | None = None,
) -> None:
errors = {}
if budget_form.form_type.sheet_list:
sheet = sheet or form_phase.sheet
if sheet not in budget_form.form_type.sheet_list:
errors["sheet"] = [f"У формы нет листа {sheet}"]
if form_phase.column_keys and budget_form.form_type.section_list:
for column_key in form_phase.column_keys:
section = column_key.split(".")
if section[0] not in budget_form.form_type.section_list:
if "column_keys" in errors:
errors["column_keys"].append(f"Колонка {column_key} некорректна")
else:
errors["column_keys"] = [f"Колонка {column_key} некорректна"]
if errors:
raise ValidationsError(errors=errors)
async def create(
self,
body: FormPhaseCreate,
user: AppUser,
budget_form_id: int | None = None,
budget_form: BudgetForm | None = None,
) -> FormPhase:
assert budget_form or budget_form_id
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException
if not budget_form or budget_form.form_type is None:
budget_form = await self.bf_repo.get(
budget_form_id=budget_form_id or budget_form.id,
load_form_type=True,
)
self._validate_form_phase(
form_phase=body,
budget_form=budget_form,
)
return await self.fp_repo.create(
budget_form_id=budget_form_id or budget_form.id,
sheet=body.sheet,
phase_code=body.phase_code,
role=body.role,
column_keys=body.column_keys,
opens_at=body.opens_at,
closes_at=body.closes_at,
)
async def update(
self,
sheet: str,
phase_code: str,
body: FormPhaseUpdate,
user: AppUser,
budget_form_id: int | None = None,
budget_form: BudgetForm | None = None,
) -> FormPhase:
assert budget_form or budget_form_id
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException
if not budget_form or budget_form.form_type is None:
budget_form = await self.bf_repo.get(
budget_form_id=budget_form_id or budget_form.id,
load_form_type=True,
)
self._validate_form_phase(
form_phase=body,
budget_form=budget_form,
sheet=sheet,
)
data = body.model_dump(exclude_unset=True)
if not data:
return await self.fp_repo.get(
budget_form_id=budget_form_id or budget_form.id,
sheet=sheet,
phase_code=phase_code,
)
return await self.fp_repo.update(
budget_form_id=budget_form_id or budget_form.id,
sheet=sheet,
phase_code=phase_code,
data=data,
)
async def delete(
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
) -> bool:
return await self.fp_repo.delete(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
)

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
from contextlib import contextmanager
import os import os
import pytest import pytest
@ -10,13 +11,12 @@ from src.db.session import get_db
from src.main import app from src.main import app
@pytest.fixture @pytest.fixture
def client(): def client():
db = SessionLocal()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
db = SessionLocal()
try: try:
async def _get_db(): async def _get_db():
try: try:
@ -39,6 +39,12 @@ def admin_password() -> str:
return os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123") return os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123")
@pytest.fixture
def isp_password() -> str:
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
return os.getenv("OPENBAO__TEST_EXECUTOR_PASSWORD", "admin123")
@pytest.fixture @pytest.fixture
def admin_tokens(client, admin_password: str): def admin_tokens(client, admin_password: str):
response = client.post( response = client.post(
@ -47,3 +53,13 @@ def admin_tokens(client, admin_password: str):
) )
assert response.status_code == 200 assert response.status_code == 200
return response.json() return response.json()
@pytest.fixture
def isp_tokens(client, isp_password: str):
response = client.post(
"/api/v1/auth/login",
json={"username": "isp1", "password": isp_password},
)
assert response.status_code == 200
return response.json()

View File

@ -0,0 +1,140 @@
import pytest
def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_form_phases_list_smoke(client, admin_tokens):
response = client.get(
"/api/v1/stages/form/1", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "count" in payload
assert isinstance(payload["result"], list)
def test_form_phases_list_filter_smoke(client, admin_tokens):
response = client.get(
"/api/v1/stages/form/1", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "count" in payload
count = payload["count"]
response = client.get(
"/api/v1/stages/form/1?sheet=AHR&phase_code=test", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "count" in payload
assert payload["count"] <= count
def test_form_phases_create_forbidden(client, isp_tokens):
response = client.post(
"/api/v1/stages/form/1",
json={
"sheet": "AHR",
"phase_code": "new_phase",
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
},
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form_phases_update_forbidden(client, isp_tokens):
response = client.patch(
"/api/v1/stages/form/1/AHR/test",
json={
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
},
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form_phases_delete_forbidden(client, isp_tokens):
response = client.delete(
"/api/v1/stages/form/1/AHR/test",
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form_phases_create(client, admin_tokens):
body = {
"budget_form_id": 1,
"sheet": "AHR",
"phase_code": "new_phase",
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
}
response = client.post(
"/api/v1/stages/form/1",
json=body,
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "success" in payload
assert payload["success"]
assert "result" in payload
assert isinstance(payload["result"], dict)
assert payload["result"] == body
def test_form_phases_update(client, admin_tokens):
body = {
"budget_form_id": 1,
"sheet": "AHR",
"phase_code": "test",
"role": "EXECUTOR_RF",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
}
response = client.patch(
"/api/v1/stages/form/1/AHR/test",
json=body,
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "success" in payload
assert payload["success"]
assert "result" in payload
assert isinstance(payload["result"], dict)
assert payload["result"] == body
def test_form_phases_delete(client, admin_tokens):
response = client.delete(
"/api/v1/stages/form/1/AHR/test",
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200