diff --git a/api/alembic/versions/0024_upd_form4_projects.py b/api/alembic/versions/0024_upd_form4_projects.py new file mode 100644 index 0000000..c52c2df --- /dev/null +++ b/api/alembic/versions/0024_upd_form4_projects.py @@ -0,0 +1,14 @@ +from sql_migration import run_sql_file_migration + +revision = "0024" +down_revision = "0023" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0024_upd_form4_projects.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0024_upd_form4_projects.sql b/api/alembic/versions/sql/0024_upd_form4_projects.sql new file mode 100644 index 0000000..6280379 --- /dev/null +++ b/api/alembic/versions/sql/0024_upd_form4_projects.sql @@ -0,0 +1,155 @@ +DROP FUNCTION IF EXISTS v3.upd_form4_program(INT, VARCHAR, BOOLEAN); +DROP FUNCTION IF EXISTS v3.upd_form4_program(INT, VARCHAR); + +CREATE FUNCTION v3.upd_form4_program(p_program_id INT, p_name VARCHAR) +RETURNS TABLE (project_id INT, row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB) +AS $function$ +#variable_conflict use_column +DECLARE + v_program v3.form4_project%ROWTYPE; +BEGIN + SELECT * INTO v_program FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program'; + IF NOT FOUND THEN + RAISE EXCEPTION 'Программа id=% не найдена', p_program_id; + END IF; + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + + UPDATE v3.form4_project SET name = trim(p_name) WHERE id = p_program_id; + PERFORM v3.log_event( + 'PROJECT_UPDATE', 'PROJECT', + jsonb_build_object('project_id', p_program_id, 'name', trim(p_name), + 'level', 'program', 'form_id', v_program.form_id, 'form_type', 'FORM_4'), + v_program.form_id, NULL, NULL + ); + + RETURN QUERY + SELECT p_program_id, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_program.form_id, v_program.section_code, NULL) v + WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = p_program_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_program.section_code) + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + + +DROP FUNCTION IF EXISTS v3.del_form4_program(INT); + +CREATE FUNCTION v3.del_form4_program(p_program_id INT) +RETURNS TABLE (project_id INT, row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB) +AS $function$ +#variable_conflict use_column +DECLARE + v_program v3.form4_project%ROWTYPE; +BEGIN + SELECT * INTO v_program FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program'; + IF NOT FOUND THEN + RAISE EXCEPTION 'Программа id=% не найдена', p_program_id; + END IF; + IF EXISTS (SELECT 1 FROM v3.form4_project fp WHERE fp.parent_id = p_program_id) THEN + RAISE EXCEPTION 'Нельзя удалить программу, содержащую проекты'; + END IF; + + DELETE FROM v3.form4_project WHERE id = p_program_id; + PERFORM v3.log_event( + 'PROJECT_DELETE', 'PROJECT', + jsonb_build_object('project_id', p_program_id, 'name', v_program.name, + 'level', 'program', 'form_id', v_program.form_id, 'form_type', 'FORM_4'), + v_program.form_id, NULL, NULL + ); + + RETURN QUERY + SELECT p_program_id, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_program.form_id, v_program.section_code, NULL) v + WHERE v.row_type = 'ROOT' + AND v.data->'header'->>'section_code' = v_program.section_code + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + + +DROP FUNCTION IF EXISTS v3.upd_form4_project(INT, VARCHAR, BOOLEAN); +DROP FUNCTION IF EXISTS v3.upd_form4_project(INT, VARCHAR); + +CREATE FUNCTION v3.upd_form4_project(p_project_id INT, p_name VARCHAR) +RETURNS TABLE (project_id INT, row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB) +AS $function$ +#variable_conflict use_column +DECLARE + v_project v3.form4_project%ROWTYPE; + v_program v3.form4_project%ROWTYPE; +BEGIN + SELECT * INTO v_project FROM v3.form4_project fp + WHERE fp.id = p_project_id AND fp.level = 'project'; + IF NOT FOUND THEN + RAISE EXCEPTION 'Проект id=% не найден', p_project_id; + END IF; + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + + SELECT * INTO v_program FROM v3.form4_project fp WHERE fp.id = v_project.parent_id; + UPDATE v3.form4_project SET name = trim(p_name) WHERE id = p_project_id; + PERFORM v3.log_event( + 'PROJECT_UPDATE', 'PROJECT', + jsonb_build_object('project_id', p_project_id, 'name', trim(p_name), + 'level', 'project', 'parent_id', v_project.parent_id, + 'form_id', v_project.form_id, 'form_type', 'FORM_4'), + v_project.form_id, NULL, NULL + ); + + RETURN QUERY + SELECT p_project_id, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_project.form_id, v_program.section_code, NULL) v + WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = p_project_id) + OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_project.parent_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_program.section_code) + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + + +DROP FUNCTION IF EXISTS v3.del_form4_project(INT); + +CREATE FUNCTION v3.del_form4_project(p_project_id INT) +RETURNS TABLE (project_id INT, row_type VARCHAR, depth INT, sort_order BIGINT, data JSONB) +AS $function$ +#variable_conflict use_column +DECLARE + v_project v3.form4_project%ROWTYPE; + v_program v3.form4_project%ROWTYPE; +BEGIN + SELECT * INTO v_project FROM v3.form4_project fp + WHERE fp.id = p_project_id AND fp.level = 'project'; + IF NOT FOUND THEN + RAISE EXCEPTION 'Проект id=% не найден', p_project_id; + END IF; + IF EXISTS (SELECT 1 FROM v3.budget_line bl WHERE bl.project_id = p_project_id) THEN + RAISE EXCEPTION 'Нельзя удалить проект, содержащий строки бюджета'; + END IF; + + SELECT * INTO v_program FROM v3.form4_project fp WHERE fp.id = v_project.parent_id; + DELETE FROM v3.form4_project WHERE id = p_project_id; + PERFORM v3.log_event( + 'PROJECT_DELETE', 'PROJECT', + jsonb_build_object('project_id', p_project_id, 'name', v_project.name, + 'level', 'project', 'parent_id', v_project.parent_id, + 'form_id', v_project.form_id, 'form_type', 'FORM_4'), + v_project.form_id, NULL, NULL + ); + + RETURN QUERY + SELECT p_project_id, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_project.form_id, v_program.section_code, NULL) v + WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_project.parent_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_program.section_code) + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index 5638799..c72b0af 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -389,6 +389,26 @@ class FormEventProcess: event_data=event_data["data"], user=curr_user, ) + case "program_updated": + return await self.__update_form4_program( + event_data=event_data["data"], + user=curr_user, + ) + case "program_deleted": + return await self.__delete_form4_program( + event_data=event_data["data"], + user=curr_user, + ) + case "project_updated": + return await self.__update_form4_project( + event_data=event_data["data"], + user=curr_user, + ) + case "project_deleted": + return await self.__delete_form4_project( + event_data=event_data["data"], + user=curr_user, + ) case _: pass @@ -510,6 +530,48 @@ class FormEventProcess: program_id=event_data["program_id"], user=user, ) + + async def __update_form4_program( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.update_form4_program( + program_id=event_data["program_id"], + name=event_data["name"], + user=user, + ) + + async def __delete_form4_program( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.delete_form4_program( + program_id=event_data["program_id"], + user=user, + ) + + async def __update_form4_project( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.update_form4_project( + project_id=event_data["project_id"], + name=event_data["name"], + user=user, + ) + + async def __delete_form4_project( + self, + event_data: dict, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + return await self.sheet_service.delete_form4_project( + project_id=event_data["project_id"], + user=user, + ) async def __del_row( self, @@ -874,6 +936,10 @@ async def process_websocket( "row_added", "program_added", "project_added", + "program_updated", + "project_updated", + "program_deleted", + "project_deleted", "row_deleted", ]: await manager.broadcast_to_all( diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index bf7605f..e1cde17 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -213,6 +213,92 @@ class SheetRepository: ).all() ] + async def update_form4_program( + self, + program_id: int, + name: str, + ) -> list[tuple]: + return [ + tuple(r) for r in ( + await self.db.execute( + text( + """ + SELECT project_id, row_type, depth, sort_order, data + FROM v3.upd_form4_program( + :p_program_id, + :p_name + ) + """ + ), + { + "p_program_id": program_id, + "p_name": name, + }, + ) + ).all() + ] + + async def delete_form4_program( + self, + program_id: int, + ) -> list[tuple]: + return [ + tuple(r) for r in ( + await self.db.execute( + text( + """ + SELECT project_id, row_type, depth, sort_order, data + FROM v3.del_form4_program(:p_program_id) + """ + ), + {"p_program_id": program_id}, + ) + ).all() + ] + + async def update_form4_project( + self, + project_id: int, + name: str, + ) -> list[tuple]: + return [ + tuple(r) for r in ( + await self.db.execute( + text( + """ + SELECT project_id, row_type, depth, sort_order, data + FROM v3.upd_form4_project( + :p_project_id, + :p_name + ) + """ + ), + { + "p_project_id": project_id, + "p_name": name, + }, + ) + ).all() + ] + + async def delete_form4_project( + self, + project_id: int, + ) -> list[tuple]: + return [ + tuple(r) for r in ( + await self.db.execute( + text( + """ + SELECT project_id, row_type, depth, sort_order, data + FROM v3.del_form4_project(:p_project_id) + """ + ), + {"p_project_id": project_id}, + ) + ).all() + ] + async def add_line( self, form_id: int, @@ -294,4 +380,3 @@ class SheetRepository: ) ).all() ] - diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index a4a0fd0..110f33e 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -170,6 +170,97 @@ class SheetService: return (result[0][0], [tuple(r[1:]) for r in result]) return None + async def update_form4_program( + self, + program_id: int, + name: str, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + await self._validate_form4_node_access( + node_type="program", + node_id=program_id, + user=user, + ) + result = await self.sheet_repo.update_form4_program( + program_id=program_id, + name=name, + ) + return (program_id, [tuple(r[1:]) for r in result]) + + async def delete_form4_program( + self, + program_id: int, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + await self._validate_form4_node_access( + node_type="program", + node_id=program_id, + user=user, + ) + result = await self.sheet_repo.delete_form4_program(program_id=program_id) + return (program_id, [tuple(r[1:]) for r in result]) + + async def update_form4_project( + self, + project_id: int, + name: str, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + await self._validate_form4_node_access( + node_type="project", + node_id=project_id, + user=user, + ) + result = await self.sheet_repo.update_form4_project( + project_id=project_id, + name=name, + ) + return (project_id, [tuple(r[1:]) for r in result]) + + async def delete_form4_project( + self, + project_id: int, + user: AppUser, + ) -> tuple[int, list[tuple]] | None: + await self._validate_form4_node_access( + node_type="project", + node_id=project_id, + user=user, + ) + result = await self.sheet_repo.delete_form4_project(project_id=project_id) + return (project_id, [tuple(r[1:]) for r in result]) + + async def _validate_form4_node_access( + self, + node_type: str, + node_id: int, + user: AppUser, + ) -> None: + if node_type not in {"program", "project"}: + raise SheetValidationError("Некорректный тип узла") + + level = ( + Form4ProjectLevelEnum.PROGRAM + if node_type == "program" + else Form4ProjectLevelEnum.PROJECT + ) + node = await self.f4_project_repo.get(project_id=node_id, level=level) + if not node: + raise SheetValidationError( + "Программа не найдена" if node_type == "program" else "Проект не найден" + ) + + 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=node.form_id, + org_unit=[ou.id for ou in user.org_units], + ) + if not form: + raise SheetValidationError( + "Программа не найдена" if node_type == "program" else "Проект не найден" + ) + async def delete_line( self, form_id: int, @@ -182,4 +273,4 @@ class SheetService: sheet=sheet, row_id=row_id, direction=direction, - ) \ No newline at end of file + )