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

Open
aep wants to merge 1 commits from feat/250-aurora-1888-stages-role-filter into test
Owner

Closes #250.

Проблема

GET /stages/form/{form_id} и GET /stages/project/{project_id} возвращали этапы всех ролей (DFIP, EXECUTOR_RF, DURRS) любому авторизованному пользователю, без фильтрации по роли. Доступ к форме/проекту проверялся только после загрузки этапов и только если они нашлись — для чужой формы/проекта (или формы без этапов) bf_service.get/project_service.get_instance возвращали None, обращение к .org_unit_id падало AttributeError → 500 вместо 404.

На фронте подпись роли в таблице этапов показывалась всем, хотя у исполнителя теперь всегда будет своя единственная роль.

Что сделано

  • api/src/services/form_phase_service.py — добавлена функция role_code_for_user(user) (и словарь ROLE_CODE_BY_USER_ROLE): единственное место сопоставления роли пользователя (UserRoleEnum.EXECUTOR_DFIP/EXECUTOR_RF/EXECUTOR_DURRS) коду роли этапа (v3.role.code: DFIP/EXECUTOR_RF/DURRS). Для ADMIN возвращает None — без фильтра. FormPhaseService.get_list передаёт role=role_code_for_user(user) в репозиторий.
  • api/src/services/form3_phase_service.py — Form3PhaseService.get_list получил параметр user, импортирует и использует ту же role_code_for_user.
  • api/src/repository/form_phase_repository.py, api/src/repository/form3_phase_repository.py — get_list получил необязательный параметр role; если задан — .where(<Model>.role == role).
  • api/src/api/v1/form_phases.py:
    • get_form_phases — сначала bf_service.get(user=current_user, budget_form_id=form_id), при None → HTTPException(404, "Форма не найдена"), только потом загрузка этапов (с ролевым фильтром). Убрана ветка if phases: ... else: result = []; count = len(result).
    • get_form3_phases — аналогично: сначала project_service.get_instance(user=current_user, project_id=project_id), при None → 404, потом отчёты и этапы с ролевым фильтром. Сопоставление phases[i] → reports по rf_project_report_id сохранено (фильтр стоит в самом SQL-запросе).
  • web/src/components/Stages/StagesTable/StagesTable.jsx — подпись роли этапа (STAGE_ROLE_RUSSIAN_NAME) показывается только админу (isAdmin &&). Кнопка «Этапы» не тронута.

Тесты

  • api/tests/unit/test_form_phase_service.py (новый) — FormPhaseService.get_list с моком репозитория: EXECUTOR_DFIP → репозиторий вызван с role='DFIP'; ADMIN → role=None.
  • api/tests/integration/test_form_phases_api_smoke.py (дополнен):
    • test_form_phases_role_filter_for_executor — под isp1 (role_id=2=DFIP) на своей форме (budget_form 4) все этапы с role == 'DFIP'; в той же транзакции админ добавляет этап EXECUTOR_RF на этой же форме — isp1 его не видит (список не меняется по составу ролей), админ видит оба этапа (список не урезан).
    • test_form_phases_foreign_org_404 — isp1 → GET /stages/form/1 (форма чужого org_unit) → 404 (было 500).
    • test_form3_phases_foreign_org_404 — isp1 → GET /stages/project/2 (проект чужого org_unit) → 404 (было 500).

Прогон (venv /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api, Postgres 16 perf-pg:55432, БД dfip_1888, pytest --init_db один раз для накатки миграций+сида, затем обычный прогон):

pytest tests/unit         -> 60 passed, 2 failed
pytest tests/integration  -> 145 passed, 1 failed, 4 skipped
pytest (весь набор)       -> 205 passed, 3 failed, 4 skipped

Все 3 падения — подтверждённо пред-существующие на test и не связаны с этой веткой (проверено прогоном тех же тестов на коде до правки, git checkout <tip test> -- api/src api/tests, тот же результат): test_db_functions_integration.py::test_backend_calls_form3_add_project_and_add_line_functions, test_export_service_query_params.py::test_export_full_form_ignores_non_applicable_params_for_other_sheets, test_export_service_query_params.py::test_export_form_payload_forwards_ignore_flag. Все тесты, затронутые этой веткой (form_phases/form3_phases), зелёные.

Rulings (решения без обсуждения)

  1. Функцию сопоставления роли пользователя коду роли этапа (role_code_for_user) разместил в form_phase_service.py и импортировал в form3_phase_service.py — тикет предлагал это как один из двух равноценных вариантов («либо функция в form_phase_service, либо читать Role.code по user.role_id»). Выбрал хардкод-словарь вместо похода в БД за Role.code: AppUser не хранит загруженной связи на Role (только role_id), а коды ролей зафиксированы в сидах миграций (0003_initial_data.sql, 0025_new_role.py) и уже захардкожены в этом же файле как Literal["DFIP", "EXECUTOR_RF", "ADMIN", "DURRS"] (FormPhaseRole в domain/schemas.py) — дополнительный асинхронный запрос не добавляет корректности, но добавляет round-trip.
  2. В get_form3_phases, по аналогии с get_form_phases (п.4 тикета), убрал ветку if phases: ... else: result = [] целиком, а не только переставил проверку проекта наверх — тикет явно просил это только для get_form_phases (п.4), для get_form3_phases (п.5) формулировка менее явная («сначала project_service.get_instance..., потом загрузить отчёты и этапы»). Применил ту же упрощённую структуру для единообразия: раз проект проверен в начале и гарантированно не None, отдельная ветка не нужна.
  3. Текст сообщения 404 для get_form3_phases — тикет не задавал точную формулировку. Использовал "Проект не найден" (по аналогии с остальными русскоязычными 404 в этом файле), а не "Project not found", как в projects.py:238 — последнее выглядит точечной опечаткой/несогласованностью в существующем коде, а не стилем, который стоит повторять.
  4. Интеграционный тест на ролевой фильтр объединил в одну тестовую функцию (вместо трёх отдельных), чтобы гарантированно работать в одной транзакции (фикстуры client/admin_tokens/isp_tokens — per-test, общая БД-сессия только внутри одного теста) — это ровно то, что требует сценарий тикета («в той же транзакции под админом создать этап... и убедиться, что isp1 его не видит»).
  5. Для Form3PhaseService.get_list отдельный unit-тест с моками не писал — тикет явно требовал unit-тест только для FormPhaseService.get_list; ролевая фильтрация для Form3 покрыта интеграционным путём (через GET /stages/project/{id} и косвенно через отсутствие побочных эффектов на существующих form3-тестах).

🤖 Generated with Claude Code

Closes #250. ## Проблема GET /stages/form/{form_id} и GET /stages/project/{project_id} возвращали этапы всех ролей (DFIP, EXECUTOR_RF, DURRS) любому авторизованному пользователю, без фильтрации по роли. Доступ к форме/проекту проверялся только после загрузки этапов и только если они нашлись — для чужой формы/проекта (или формы без этапов) `bf_service.get`/`project_service.get_instance` возвращали `None`, обращение к `.org_unit_id` падало `AttributeError` → 500 вместо 404. На фронте подпись роли в таблице этапов показывалась всем, хотя у исполнителя теперь всегда будет своя единственная роль. ## Что сделано - `api/src/services/form_phase_service.py` — добавлена функция `role_code_for_user(user)` (и словарь `ROLE_CODE_BY_USER_ROLE`): единственное место сопоставления роли пользователя (`UserRoleEnum.EXECUTOR_DFIP/EXECUTOR_RF/EXECUTOR_DURRS`) коду роли этапа (`v3.role.code`: `DFIP`/`EXECUTOR_RF`/`DURRS`). Для ADMIN возвращает `None` — без фильтра. `FormPhaseService.get_list` передаёт `role=role_code_for_user(user)` в репозиторий. - `api/src/services/form3_phase_service.py` — `Form3PhaseService.get_list` получил параметр `user`, импортирует и использует ту же `role_code_for_user`. - `api/src/repository/form_phase_repository.py`, `api/src/repository/form3_phase_repository.py` — `get_list` получил необязательный параметр `role`; если задан — `.where(<Model>.role == role)`. - `api/src/api/v1/form_phases.py`: - `get_form_phases` — сначала `bf_service.get(user=current_user, budget_form_id=form_id)`, при `None` → `HTTPException(404, "Форма не найдена")`, только потом загрузка этапов (с ролевым фильтром). Убрана ветка `if phases: ... else: result = []`; `count = len(result)`. - `get_form3_phases` — аналогично: сначала `project_service.get_instance(user=current_user, project_id=project_id)`, при `None` → 404, потом отчёты и этапы с ролевым фильтром. Сопоставление `phases[i] → reports` по `rf_project_report_id` сохранено (фильтр стоит в самом SQL-запросе). - `web/src/components/Stages/StagesTable/StagesTable.jsx` — подпись роли этапа (`STAGE_ROLE_RUSSIAN_NAME`) показывается только админу (`isAdmin &&`). Кнопка «Этапы» не тронута. ## Тесты - `api/tests/unit/test_form_phase_service.py` (новый) — `FormPhaseService.get_list` с моком репозитория: `EXECUTOR_DFIP` → репозиторий вызван с `role='DFIP'`; `ADMIN` → `role=None`. - `api/tests/integration/test_form_phases_api_smoke.py` (дополнен): - `test_form_phases_role_filter_for_executor` — под isp1 (role_id=2=DFIP) на своей форме (budget_form 4) все этапы с `role == 'DFIP'`; в той же транзакции админ добавляет этап `EXECUTOR_RF` на этой же форме — isp1 его не видит (список не меняется по составу ролей), админ видит оба этапа (список не урезан). - `test_form_phases_foreign_org_404` — isp1 → `GET /stages/form/1` (форма чужого org_unit) → 404 (было 500). - `test_form3_phases_foreign_org_404` — isp1 → `GET /stages/project/2` (проект чужого org_unit) → 404 (было 500). Прогон (venv `/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api`, Postgres 16 `perf-pg:55432`, БД `dfip_1888`, `pytest --init_db` один раз для накатки миграций+сида, затем обычный прогон): ``` pytest tests/unit -> 60 passed, 2 failed pytest tests/integration -> 145 passed, 1 failed, 4 skipped pytest (весь набор) -> 205 passed, 3 failed, 4 skipped ``` Все 3 падения — подтверждённо пред-существующие на `test` и не связаны с этой веткой (проверено прогоном тех же тестов на коде до правки, `git checkout <tip test> -- api/src api/tests`, тот же результат): `test_db_functions_integration.py::test_backend_calls_form3_add_project_and_add_line_functions`, `test_export_service_query_params.py::test_export_full_form_ignores_non_applicable_params_for_other_sheets`, `test_export_service_query_params.py::test_export_form_payload_forwards_ignore_flag`. Все тесты, затронутые этой веткой (form_phases/form3_phases), зелёные. ## Rulings (решения без обсуждения) 1. Функцию сопоставления роли пользователя коду роли этапа (`role_code_for_user`) разместил в `form_phase_service.py` и импортировал в `form3_phase_service.py` — тикет предлагал это как один из двух равноценных вариантов («либо функция в form_phase_service, либо читать Role.code по user.role_id»). Выбрал хардкод-словарь вместо похода в БД за `Role.code`: `AppUser` не хранит загруженной связи на `Role` (только `role_id`), а коды ролей зафиксированы в сидах миграций (`0003_initial_data.sql`, `0025_new_role.py`) и уже захардкожены в этом же файле как `Literal["DFIP", "EXECUTOR_RF", "ADMIN", "DURRS"]` (`FormPhaseRole` в `domain/schemas.py`) — дополнительный асинхронный запрос не добавляет корректности, но добавляет round-trip. 2. В `get_form3_phases`, по аналогии с `get_form_phases` (п.4 тикета), убрал ветку `if phases: ... else: result = []` целиком, а не только переставил проверку проекта наверх — тикет явно просил это только для `get_form_phases` (п.4), для `get_form3_phases` (п.5) формулировка менее явная («сначала project_service.get_instance..., потом загрузить отчёты и этапы»). Применил ту же упрощённую структуру для единообразия: раз проект проверен в начале и гарантированно не `None`, отдельная ветка не нужна. 3. Текст сообщения 404 для `get_form3_phases` — тикет не задавал точную формулировку. Использовал `"Проект не найден"` (по аналогии с остальными русскоязычными 404 в этом файле), а не `"Project not found"`, как в `projects.py:238` — последнее выглядит точечной опечаткой/несогласованностью в существующем коде, а не стилем, который стоит повторять. 4. Интеграционный тест на ролевой фильтр объединил в одну тестовую функцию (вместо трёх отдельных), чтобы гарантированно работать в одной транзакции (фикстуры `client`/`admin_tokens`/`isp_tokens` — per-test, общая БД-сессия только внутри одного теста) — это ровно то, что требует сценарий тикета («в той же транзакции под админом создать этап... и убедиться, что isp1 его не видит»). 5. Для `Form3PhaseService.get_list` отдельный unit-тест с моками не писал — тикет явно требовал unit-тест только для `FormPhaseService.get_list`; ролевая фильтрация для Form3 покрыта интеграционным путём (через `GET /stages/project/{id}` и косвенно через отсутствие побочных эффектов на существующих form3-тестах). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
aep added 1 commit 2026-10-30 23:06:15 +03:00
- FormPhaseRepository.get_list и Form3PhaseRepository.get_list: новый
  необязательный параметр role для фильтрации по v3.role.code.
- FormPhaseService.get_list и Form3PhaseService.get_list: для не-админа
  передают в репозиторий role по коду роли пользователя (единая функция
  role_code_for_user в form_phase_service.py), для ADMIN фильтр не нужен.
- GET /stages/form/{form_id} и GET /stages/project/{project_id}: сначала
  проверяют доступ к форме/проекту (BudgetFormService.get /
  ProjectService.get_instance), при отсутствии доступа — 404 вместо
  AttributeError → 500.
- StagesTable.jsx: подпись роли этапа показывается только админу.

Closes #250.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This pull request can be merged automatically.
You are not authorized to merge this pull request.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/250-aurora-1888-stages-role-filter:feat/250-aurora-1888-stages-role-filter
git checkout feat/250-aurora-1888-stages-role-filter
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RSHB/DFiP_Budget_planing#261
No description provided.