AURORA-1888: исполнитель видит только свои этапы, 404 вместо 500 на чужую форму/проект #261
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
62
api/tests/unit/test_form_phase_service.py
Normal file
62
api/tests/unit/test_form_phase_service.py
Normal 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,
|
||||
)
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user