From 69c4a8f9c996db5a4ada8cb62b60ced5bf71486b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 22:27:16 +0400 Subject: [PATCH] =?UTF-8?q?AURORA-1984:=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20project=5Fadded=20=D0=B1=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/websocket.py | 10 ++- api/src/repository/sheet_repository.py | 12 +++- api/src/services/sheet_service.py | 65 +++++++++++++------ .../test_form4_standalone_project.py | 21 ++++++ .../test_sheet_service_form4_standalone.py | 54 +++++++++++++++ 5 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 api/tests/unit/test_sheet_service_form4_standalone.py diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index c4902c6..c0e3122 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -482,7 +482,9 @@ class FormEventProcess: case "project_added": return await self.__add_project( event_data=event_data["data"], + form_id=form_id, user=curr_user, + sheet=sheet, ) case "program_updated": return await self.__update_form4_program( @@ -610,17 +612,21 @@ class FormEventProcess: async def __add_project( self, event_data: dict, + form_id: int, + sheet: str, user: AppUser, ) -> list[tuple]: """ data = { name: str, - program_id: int + program_id?: int } """ return await self.sheet_service.add_project( name=event_data["name"], - program_id=event_data["program_id"], + program_id=event_data.get("program_id"), + form_id=form_id, + sheet=sheet, user=user, ) diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index 22795d6..c76ccc1 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -208,7 +208,9 @@ class SheetRepository: async def add_project( self, name: str, - program_id: int, + program_id: int | None = None, + form_id: int | None = None, + section_code: str | None = None, ) -> list[tuple]: return [ tuple(r) for r in ( @@ -218,13 +220,17 @@ class SheetRepository: SELECT project_id, row_type, depth, sort_order, data FROM v3.add_form4_project( :p_name, - :p_program_id + :p_program_id, + :p_form_id, + :p_section_code ) """ ), { - "p_program_id": program_id, "p_name": name, + "p_program_id": program_id, + "p_form_id": form_id, + "p_section_code": section_code, } ) ).all() diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index f1b24b4..decd1d6 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -158,31 +158,54 @@ class SheetService: async def add_project( self, name: str, - program_id: int, + program_id: int | None = None, + form_id: int | None = None, + sheet: str | None = None, user: AppUser | None = None, - ) -> tuple[int, list[tuple]]: - if user.role_id != UserRoleEnum.ADMIN: - program = await self.f4_project_repo.get( - project_id=program_id, - level=Form4ProjectLevelEnum.PROGRAM, + ) -> tuple[int, list[tuple]] | None: + if program_id is None: + if user.role_id != UserRoleEnum.ADMIN: + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + form = await self.bf_repo.get( + budget_form_id=form_id, + org_unit=[ou.id for ou in user.org_units], + ) + if not form: + raise SheetValidationError("Форма не найдена") + result = await self.sheet_repo.add_project( + name=name, + program_id=None, + form_id=form_id, + section_code=sheet, ) - if not program: - raise SheetValidationError("Программа не найдена") + else: + if user.role_id != UserRoleEnum.ADMIN: + program = await self.f4_project_repo.get( + project_id=program_id, + level=Form4ProjectLevelEnum.PROGRAM, + ) + if not program: + raise SheetValidationError("Программа не найдена") - user = await self.user_repo.get( - user_id=user.id, - load_orgs=True, + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + form = await self.bf_repo.get( + budget_form_id=program.form_id, + org_unit=[ou.id for ou in user.org_units], + ) + if not form: + raise SheetValidationError("Программа не найдена") + result = await self.sheet_repo.add_project( + name=name, + program_id=program_id, + form_id=None, + section_code=None, ) - form = await self.bf_repo.get( - budget_form_id=program.form_id, - org_unit=[ou.id for ou in user.org_units], - ) - if not form: - raise SheetValidationError("Программа не найдена") - result = await self.sheet_repo.add_project( - program_id=program_id, - name=name, - ) if result: return (result[0][0], [tuple(r[1:]) for r in result]) return None diff --git a/api/tests/integration/test_form4_standalone_project.py b/api/tests/integration/test_form4_standalone_project.py index f247c25..44e87b9 100644 --- a/api/tests/integration/test_form4_standalone_project.py +++ b/api/tests/integration/test_form4_standalone_project.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest from sheet_contract import strip_nulls +from src.repository.sheet_repository import SheetRepository from test_form4_sheet_sums import F4, SHEET, _loads @@ -169,3 +170,23 @@ def test_delete_standalone_with_line_rejected(f4): solo = standalone(f4); f4.line(solo) with pytest.raises(Exception, match="Нельзя удалить проект, содержащий строки бюджета"): f4.sql("SELECT * FROM v3.del_form4_project(:p)", p=solo) + + +def test_rename_standalone_with_root_returns_item_and_root(f4): + solo = standalone(f4); f4.line(solo) + rows = f4.sql("SELECT row_type, data::text FROM v3.upd_form4_project(:p, 'Новое')", p=solo) + assert sorted(r[0] for r in rows) == ["ITEM", "ROOT"] + + +def test_delete_empty_standalone_with_root_returns_root_only(f4): + first = standalone(f4, "А"); f4.line(first) + second = standalone(f4, "Б") + rows = f4.sql("SELECT row_type FROM v3.del_form4_project(:p)", p=second) + assert [r[0] for r in rows] == ["ROOT"] + + +def test_repository_creates_in_section(f4): + async def _run(): + return await SheetRepository(f4.db).add_project(name="Р", form_id=f4.form_id, section_code="CAP") + rows = f4.client.portal.call(_run) + assert [r[1] for r in rows] == ["ITEM"] diff --git a/api/tests/unit/test_sheet_service_form4_standalone.py b/api/tests/unit/test_sheet_service_form4_standalone.py new file mode 100644 index 0000000..11fa314 --- /dev/null +++ b/api/tests/unit/test_sheet_service_form4_standalone.py @@ -0,0 +1,54 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.db.models.role import UserRoleEnum +from src.repository.sheet_repository import SheetValidationError +from src.services.sheet_service import SheetService + +ADMIN = SimpleNamespace(id=1, role_id=UserRoleEnum.ADMIN) +EXECUTOR = SimpleNamespace(id=5, role_id=UserRoleEnum.EXECUTOR_RF) + + +def make_service() -> SheetService: + service = SheetService(AsyncMock()) + service.sheet_repo = AsyncMock() + service.bf_repo = AsyncMock() + service.user_repo = AsyncMock() + service.f4_project_repo = AsyncMock() + service.user_repo.get.return_value = SimpleNamespace(org_units=[]) + return service + + +@pytest.mark.asyncio +async def test_admin_creates_in_section(): + service = make_service() + service.sheet_repo.add_project.return_value = [(9, "ITEM", 2, 1, {})] + result = await service.add_project(name="П", form_id=7, sheet="CAP", user=ADMIN) + assert result == (9, [("ITEM", 2, 1, {})]) + service.sheet_repo.add_project.assert_awaited_once_with( + name="П", program_id=None, form_id=7, section_code="CAP" + ) + + +@pytest.mark.asyncio +async def test_foreign_executor_cannot_create_in_section(): + service = make_service() + service.bf_repo.get.return_value = None + with pytest.raises(SheetValidationError): + await service.add_project(name="П", form_id=7, sheet="CAP", user=EXECUTOR) + service.sheet_repo.add_project.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["update_form4_project", "delete_form4_project"]) +async def test_foreign_executor_cannot_change_standalone(method): + service = make_service() + service.f4_project_repo.get.return_value = SimpleNamespace(form_id=7, parent_id=None) + service.bf_repo.get.return_value = None + kwargs = {"project_id": 3, "user": EXECUTOR} + if method == "update_form4_project": + kwargs["name"] = "Н" + with pytest.raises(SheetValidationError): + await getattr(service, method)(**kwargs)