diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py new file mode 100644 index 0000000..bb71a37 --- /dev/null +++ b/api/src/api/v1/form_phases.py @@ -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, "Этап не найден") diff --git a/api/src/api/v1/router.py b/api/src/api/v1/router.py index fb4f8a7..3b9b4b7 100644 --- a/api/src/api/v1/router.py +++ b/api/src/api/v1/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter -from src.api.v1 import auth, users, admin, audit, forms, projects +from src.api.v1 import auth, users, admin, audit, forms, form_phases, projects + api_router = APIRouter() api_router.include_router(auth.router) @@ -8,4 +9,5 @@ api_router.include_router(users.router) api_router.include_router(admin.router) api_router.include_router(audit.router) api_router.include_router(forms.router) -api_router.include_router(projects.router) \ No newline at end of file +api_router.include_router(projects.router) +api_router.include_router(form_phases.router) diff --git a/api/src/core/errors.py b/api/src/core/errors.py index b6dcf05..108540e 100644 --- a/api/src/core/errors.py +++ b/api/src/core/errors.py @@ -1,6 +1,8 @@ from pydantic import BaseModel, Field + + class ValidationError(BaseModel): field: str = Field(...) description: str = Field(...) @@ -10,6 +12,14 @@ class BasicAppException(Exception): 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): description = "Доступ запрещен" diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index b1eaa97..a524dbf 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -8,6 +8,7 @@ from src.core.errors import ( UserNotFoundException, UsernameConflictException, ValidationException, + ValidationsError, ) from src.core.CONSTANTS import ( VALIDATION_PREFIXES, @@ -141,3 +142,10 @@ def register_exception_handlers(app: FastAPI) -> None: status_code=status_code, 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, + ) diff --git a/api/src/db/models/__init__.py b/api/src/db/models/__init__.py index 9205fd7..bd7030a 100644 --- a/api/src/db/models/__init__.py +++ b/api/src/db/models/__init__.py @@ -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_summary import ContractSummary 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.limit_template import LimitTemplate from src.db.models.org_unit import OrgUnit diff --git a/api/src/db/models/form_phase.py b/api/src/db/models/form_phase.py new file mode 100644 index 0000000..32453c5 --- /dev/null +++ b/api/src/db/models/form_phase.py @@ -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)) diff --git a/api/src/db/models/form_type.py b/api/src/db/models/form_type.py index 359eb8e..9194e9a 100644 --- a/api/src/db/models/form_type.py +++ b/api/src/db/models/form_type.py @@ -49,15 +49,3 @@ class FormType(Base): @property def section_list(self): 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" -# ) - - diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index d9230c5..1074598 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -2,6 +2,8 @@ from datetime import datetime import enum from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar +FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"] + from pydantic import BaseModel, ConfigDict, EmailStr, Field @@ -252,6 +254,34 @@ class AddLineSchema(BaseModel): 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 + + class AuditLogBase(BaseModel): """Базовая схема записи аудита (единый формат вывода как у auditlog).""" diff --git a/api/src/repository/form_phase_repository.py b/api/src/repository/form_phase_repository.py new file mode 100644 index 0000000..ba6558c --- /dev/null +++ b/api/src/repository/form_phase_repository.py @@ -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 diff --git a/api/src/services/form_phase_service.py b/api/src/services/form_phase_service.py new file mode 100644 index 0000000..4e80318 --- /dev/null +++ b/api/src/services/form_phase_service.py @@ -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, + ) diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index beb71bf..fb6b480 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -1,4 +1,5 @@ import asyncio +from contextlib import contextmanager import os import pytest @@ -10,13 +11,12 @@ from src.db.session import get_db from src.main import app - - + @pytest.fixture def client(): - db = SessionLocal() loop = asyncio.get_event_loop() + db = SessionLocal() try: async def _get_db(): try: @@ -39,6 +39,12 @@ def admin_password() -> str: return os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123") +@pytest.fixture +def isp_password() -> str: + # Пароль админа берём из окружения, чтобы не хардкодить локальные отличия. + return os.getenv("OPENBAO__TEST_EXECUTOR_PASSWORD", "admin123") + + @pytest.fixture def admin_tokens(client, admin_password: str): response = client.post( @@ -49,6 +55,16 @@ def admin_tokens(client, admin_password: str): 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() + + @pytest.fixture def auth_headers(): def _build(tokens: dict) -> dict: diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py new file mode 100644 index 0000000..92426ab --- /dev/null +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -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 +