diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py index f43048e..f6efd71 100644 --- a/api/src/api/v1/form_phases.py +++ b/api/src/api/v1/form_phases.py @@ -54,6 +54,10 @@ async def get_form_phases( org_unit_service = OrgUnitService(db) bf_service = BudgetFormService(db) + form = await bf_service.get(user=current_user, budget_form_id=form_id) + if not form: + raise HTTPException(404, "Форма не найдена") + phases = await fp_service.get_list( budget_form_id=form_id, user=current_user, @@ -61,22 +65,18 @@ async def get_form_phases( direction=direction, phase_code=phase_code, ) - if phases: - form = await bf_service.get(user=current_user, budget_form_id=form_id) - org_unit = await org_unit_service.get(org_unit_id=form.org_unit_id) - result = [FormPhaseResponse.model_validate(p) for p in phases] - if org_unit.utc_offset: - tz = timezone_from_offset(org_unit.utc_offset) - for element in result: - if element.opens_at: - element.opens_at = element.opens_at.astimezone(tz) - if element.closes_at: - element.closes_at = element.closes_at.astimezone(tz) - else: - result = [] + org_unit = await org_unit_service.get(org_unit_id=form.org_unit_id) + result = [FormPhaseResponse.model_validate(p) for p in phases] + if org_unit.utc_offset: + tz = timezone_from_offset(org_unit.utc_offset) + for element in result: + if element.opens_at: + element.opens_at = element.opens_at.astimezone(tz) + if element.closes_at: + element.closes_at = element.closes_at.astimezone(tz) return BaseListResponse( result=result, - count=len(phases), + count=len(result), ) @@ -194,6 +194,10 @@ async def get_form3_phases( project_service = ProjectService(db) org_unit_service = OrgUnitService(db) + project = await project_service.get_instance(user=current_user, project_id=project_id) + if not project: + raise HTTPException(404, "Проект не найден") + reports = await report_service.get_list( project_id=project_id, year=year, @@ -203,21 +207,18 @@ async def get_form3_phases( phases = await f3p_service.get_list( report_id=[el.id for el in reports], + user=current_user, phase_code=phase_code, ) - if phases: - project = await project_service.get_instance(user=current_user, project_id=project_id) - org_unit = await org_unit_service.get(org_unit_id=project.org_unit_id) - result = [Form3PhaseResponse.model_validate(p) for p in phases] - if org_unit.utc_offset: - tz = timezone_from_offset(org_unit.utc_offset) - for element in result: - if element.opens_at: - element.opens_at = element.opens_at.astimezone(tz) - if element.closes_at: - element.closes_at = element.closes_at.astimezone(tz) - else: - result = [] + result = [Form3PhaseResponse.model_validate(p) for p in phases] + org_unit = await org_unit_service.get(org_unit_id=project.org_unit_id) + if org_unit.utc_offset: + tz = timezone_from_offset(org_unit.utc_offset) + for element in result: + if element.opens_at: + element.opens_at = element.opens_at.astimezone(tz) + if element.closes_at: + element.closes_at = element.closes_at.astimezone(tz) if result: reports = {report.id: report for report in reports} for i, element in enumerate(result): @@ -225,7 +226,7 @@ async def get_form3_phases( element.report_type = reports[phases[i].rf_project_report_id].report_type return BaseListResponse( result=result, - count=len(phases), + count=len(result), ) diff --git a/api/src/repository/form3_phase_repository.py b/api/src/repository/form3_phase_repository.py index 39b1488..94e6a1b 100644 --- a/api/src/repository/form3_phase_repository.py +++ b/api/src/repository/form3_phase_repository.py @@ -14,6 +14,7 @@ class Form3PhaseRepository: self, report_id: int | list[int], phase_code: str | None = None, + role: str | None = None, ) -> list[Form3Phase]: if isinstance(report_id, int): query = select(Form3Phase).where(Form3Phase.rf_project_report_id == report_id) @@ -21,6 +22,8 @@ class Form3PhaseRepository: query = select(Form3Phase).where(Form3Phase.rf_project_report_id.in_(report_id)) if phase_code is not None: query = query.where(Form3Phase.phase_code == phase_code) + if role is not None: + query = query.where(Form3Phase.role == role) return (await self.db.execute(query)).scalars().all() async def get( diff --git a/api/src/repository/form_phase_repository.py b/api/src/repository/form_phase_repository.py index 38bf8a4..0c96c9f 100644 --- a/api/src/repository/form_phase_repository.py +++ b/api/src/repository/form_phase_repository.py @@ -16,6 +16,7 @@ class FormPhaseRepository: sheet: str | None = None, direction: str | None = None, phase_code: str | None = None, + role: str | None = None, ) -> list[FormPhase]: query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id) if sheet is not None: @@ -23,9 +24,11 @@ class FormPhaseRepository: FormPhase.sheet == sheet, FormPhase.direction.is_(None) if direction is None else FormPhase.direction == direction ) - + if phase_code is not None: query = query.where(FormPhase.phase_code == phase_code) + if role is not None: + query = query.where(FormPhase.role == role) return (await self.db.execute(query)).scalars().all() async def get( diff --git a/api/src/services/form3_phase_service.py b/api/src/services/form3_phase_service.py index 969fd14..3ce924a 100644 --- a/api/src/services/form3_phase_service.py +++ b/api/src/services/form3_phase_service.py @@ -7,6 +7,7 @@ from src.core.errors import AccessDeniedException, ValidationsError from src.db.models.role import UserRoleEnum from src.db.models.app_user import AppUser from src.domain.schemas import Form3PhaseCreate, Form3PhaseUpdate +from src.services.form_phase_service import role_code_for_user class Form3PhaseService: @@ -17,11 +18,13 @@ class Form3PhaseService: async def get_list( self, report_id: int | list[int], + user: AppUser, phase_code: str | None = None, ) -> list[Form3Phase]: return await self.f3p_repo.get_list( report_id=report_id, phase_code=phase_code, + role=role_code_for_user(user), ) async def get( diff --git a/api/src/services/form_phase_service.py b/api/src/services/form_phase_service.py index 5ca14dc..5d3bcd9 100644 --- a/api/src/services/form_phase_service.py +++ b/api/src/services/form_phase_service.py @@ -10,6 +10,21 @@ from src.domain.schemas import FormPhaseCreate, FormPhaseUpdate from src.repository.form_phase_repository import FormPhaseRepository +# Код роли этапа (v3.role.code) по роли пользователя. Единственное место сопоставления, +# используется и FormPhaseService, и Form3PhaseService. Для ADMIN фильтр не нужен — его в +# словаре нет, role_code_for_user возвращает None. +ROLE_CODE_BY_USER_ROLE: dict[UserRoleEnum, str] = { + UserRoleEnum.EXECUTOR_DFIP: "DFIP", + UserRoleEnum.EXECUTOR_RF: "EXECUTOR_RF", + UserRoleEnum.EXECUTOR_DURRS: "DURRS", +} + + +def role_code_for_user(user: AppUser) -> str | None: + """Код роли этапа для фильтрации по пользователю; None — без фильтра (ADMIN).""" + return ROLE_CODE_BY_USER_ROLE.get(user.role_id) + + class FormPhaseService: def __init__(self, db: AsyncSession): self.db = db @@ -29,6 +44,7 @@ class FormPhaseService: sheet=sheet, direction=direction, phase_code=phase_code, + role=role_code_for_user(user), ) async def get( diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py index 9a6938b..30b0377 100644 --- a/api/tests/integration/test_form_phases_api_smoke.py +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -326,3 +326,55 @@ def test_form3_phases_delete(client, admin_tokens): ) assert response.status_code == 200 + +def test_form_phases_role_filter_for_executor(client, admin_tokens, isp_tokens): + # isp1 (role_id=2=DFIP) видит на своей форме (budget_form 4, org_unit 2) только этапы роли DFIP. + response = client.get("/api/v1/stages/form/4", headers=_auth_headers(isp_tokens)) + assert response.status_code == 200 + payload = response.json() + assert payload["result"], "в фикстуре должен быть хотя бы один этап DFIP на форме 4" + assert all(phase["role"] == "DFIP" for phase in payload["result"]) + + # Админ в той же транзакции добавляет на этой же форме этап с ролью EXECUTOR_RF. + create_body = { + "sheet": "AHR", + "phase_code": "role_filter_check", + "role": "EXECUTOR_RF", + "column_keys": ["plan.q1"], + "opens_at": "2026-01-01T00:00:00", + "closes_at": "2026-12-31T00:00:00", + "direction": None, + } + create_response = client.post( + "/api/v1/stages/form/4", json=create_body, headers=_auth_headers(admin_tokens) + ) + assert create_response.status_code == 200 + + # isp1 по-прежнему видит только роль DFIP, новый этап EXECUTOR_RF ему не виден. + isp_response = client.get("/api/v1/stages/form/4", headers=_auth_headers(isp_tokens)) + assert isp_response.status_code == 200 + isp_payload = isp_response.json() + assert all(phase["role"] == "DFIP" for phase in isp_payload["result"]) + assert not any(phase["phase_code"] == "role_filter_check" for phase in isp_payload["result"]) + + # Админ видит все этапы формы: список не урезан по роли. + admin_response = client.get("/api/v1/stages/form/4", headers=_auth_headers(admin_tokens)) + assert admin_response.status_code == 200 + admin_payload = admin_response.json() + admin_roles = {phase["role"] for phase in admin_payload["result"]} + assert "DFIP" in admin_roles + assert "EXECUTOR_RF" in admin_roles + assert admin_payload["count"] == isp_payload["count"] + 1 + + +def test_form_phases_foreign_org_404(client, isp_tokens): + # budget_form 1 принадлежит org_unit 1, isp1 состоит только в org_unit 2. + response = client.get("/api/v1/stages/form/1", headers=_auth_headers(isp_tokens)) + assert response.status_code == 404 + + +def test_form3_phases_foreign_org_404(client, isp_tokens): + # project 2 принадлежит org_unit 1, isp1 состоит только в org_unit 2. + response = client.get("/api/v1/stages/project/2", headers=_auth_headers(isp_tokens)) + assert response.status_code == 404 + diff --git a/api/tests/unit/test_form_phase_service.py b/api/tests/unit/test_form_phase_service.py new file mode 100644 index 0000000..a89db12 --- /dev/null +++ b/api/tests/unit/test_form_phase_service.py @@ -0,0 +1,62 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.db.models.role import UserRoleEnum +from src.services.form_phase_service import FormPhaseService + + +@pytest.fixture +def service_with_mock_repo(monkeypatch): + mock_repo = MagicMock() + mock_repo.get_list = AsyncMock(return_value=[]) + + repo_cls = MagicMock(return_value=mock_repo) + monkeypatch.setattr("src.services.form_phase_service.FormPhaseRepository", repo_cls) + monkeypatch.setattr( + "src.services.form_phase_service.BudgetFormRepository", MagicMock(return_value=MagicMock()) + ) + + service = FormPhaseService(AsyncMock()) + return service, mock_repo + + +@pytest.fixture +def executor_dfip_user(): + return SimpleNamespace(id=2, role_id=UserRoleEnum.EXECUTOR_DFIP.value) + + +@pytest.fixture +def admin_user(): + return SimpleNamespace(id=1, role_id=UserRoleEnum.ADMIN.value) + + +@pytest.mark.asyncio +async def test_get_list_filters_by_role_for_executor_dfip(service_with_mock_repo, executor_dfip_user): + service, mock_repo = service_with_mock_repo + + await service.get_list(budget_form_id=4, user=executor_dfip_user) + + mock_repo.get_list.assert_awaited_once_with( + budget_form_id=4, + sheet=None, + direction=None, + phase_code=None, + role="DFIP", + ) + + +@pytest.mark.asyncio +async def test_get_list_no_role_filter_for_admin(service_with_mock_repo, admin_user): + service, mock_repo = service_with_mock_repo + + await service.get_list(budget_form_id=4, user=admin_user) + + mock_repo.get_list.assert_awaited_once_with( + budget_form_id=4, + sheet=None, + direction=None, + phase_code=None, + role=None, + ) diff --git a/web/src/components/Stages/StagesTable/StagesTable.jsx b/web/src/components/Stages/StagesTable/StagesTable.jsx index d42c6ac..bac0e40 100644 --- a/web/src/components/Stages/StagesTable/StagesTable.jsx +++ b/web/src/components/Stages/StagesTable/StagesTable.jsx @@ -70,9 +70,11 @@ export const StagesTable = ({ stages, onEdit, onDelete }) => { {SHEET_NAME[stage.sheet]} - {stage.phase_code} - - {STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role} - + {isAdmin && ( + + {STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role} + + )} {formatDate(stage.opens_at)}