AURORA-1888: исполнитель видит только свои этапы, 404 вместо 500 на чужую форму/проект #261

Open
aep wants to merge 1 commits from feat/250-aurora-1888-stages-role-filter into test
8 changed files with 174 additions and 32 deletions

View File

@ -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),
)

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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

View File

@ -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,
)

View File

@ -70,9 +70,11 @@ export const StagesTable = ({ stages, onEdit, onDelete }) => {
<Typography variant='body2'>
{SHEET_NAME[stage.sheet]} - {stage.phase_code}
</Typography>
<Typography variant='caption' color='text.secondary' sx={{ fontSize: '0.65rem' }}>
{STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role}
</Typography>
{isAdmin && (
<Typography variant='caption' color='text.secondary' sx={{ fontSize: '0.65rem' }}>
{STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role}
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant='body2'>{formatDate(stage.opens_at)}</Typography>