diff --git a/api/src/api/v1/forms.py b/api/src/api/v1/forms.py index 23b0962..9e120d4 100644 --- a/api/src/api/v1/forms.py +++ b/api/src/api/v1/forms.py @@ -10,6 +10,7 @@ from src.services.org_unit_service import OrgUnitService from src.db.models.form_type import FormTypeEnum from src.db.models.role import UserRoleEnum from src.services.sheet_service import SheetService +from src.api.v1.json_response import list_json_response from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse from src.db.models.app_user import AppUser from src.services.budget_form_service import BudgetFormService @@ -147,16 +148,15 @@ async def get_form_sheets( ) -@router.get("/{form_id}/sheet/{sheet}") +@router.get("/{form_id}/sheet/{sheet}", response_model=BaseListResponse[SheetResponse]) async def get_sheet( form_id: int, sheet: str, - response: Response, direction: Optional[DirectionSchemaEnum] = None, sections: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[SheetResponse]: +) -> Response: sections = _parse_sections_csv(sections) t0 = time.perf_counter() bf_service = BudgetFormService(db) @@ -174,14 +174,13 @@ async def get_sheet( if sections and (rem_sects := set(sections) - set(form.form_type.section_list)): raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}") - result = await sheet_service.get( + rows = await sheet_service.get_json( form_id=form_id, sheet=sheet, direction=direction.value if direction else None, sections=sections, ) db_ms = (time.perf_counter() - t0) * 1000 - response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" extra={ "type": "db_time", "time_ms": f"{db_ms:.2f}", @@ -196,10 +195,7 @@ async def get_sheet( f"db_time: {extra}", extra=extra, ) - return BaseListResponse( - count=len(result), - result=_rows_to_sheet_response(result), - ) + return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}) @router.patch("/{form_id}/sheet/{sheet}/cell") diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index 834cf84..3fc0ed1 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -6,6 +6,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.form_type import FormType +from src.repository.row_json import ROW_COLUMNS, fetch_row_texts, row_json_select class DirectionEnum(str, enum.Enum): @@ -28,13 +29,14 @@ class SheetRepository: def __init__(self, db: AsyncSession): self.db = db - async def get( - self, - form_id: int, - sheet: str, + def _view_source( + self, + form_id: int, + sheet: str, direction: str | None, - sections: list[str] | None = None, - ) -> list[tuple]: + sections: list[str] | None, + ) -> tuple[str, dict]: + """Валидация и FROM-выражение v3.v_form_view: общие для get (выгрузка) и get_json (GET листа).""" if any( [ not isinstance(form_id, int), @@ -45,27 +47,41 @@ class SheetRepository: ): raise SheetValidationError("Некорректные данные") - if direction: func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)" else: func_query = "v3.v_form_view(:form_id, :sheet, :sections)" + return func_query, { + "form_id": form_id, + "sheet": sheet, + "direction": direction, + "sections": sections, + } + + async def get( + self, + form_id: int, + sheet: str, + direction: str | None, + sections: list[str] | None = None, + ) -> list[tuple]: + func_query, params = self._view_source(form_id, sheet, direction, sections) return ( await self.db.execute( - text( - f""" - SELECT row_type, depth, sort_order, data - FROM {func_query} - """ - ), - { - "form_id": form_id, - "sheet": sheet, - "direction": direction, - "sections": sections, - } + text(f"SELECT {ROW_COLUMNS} FROM {func_query}"), + params, ) ).all() + + async def get_json( + self, + form_id: int, + sheet: str, + direction: str | None, + sections: list[str] | None = None, + ) -> list[str]: + func_query, params = self._view_source(form_id, sheet, direction, sections) + return await fetch_row_texts(self.db, f"{row_json_select()} FROM {func_query}", params) async def update_cell( self, diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index 51bdab9..06a5eb9 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -35,6 +35,21 @@ class SheetService: sections=sections, ) + async def get_json( + self, + sheet: str, + direction: str | None, + sections: list[str] | None = None, + form_id: int | None = None, + form: BudgetForm | None = None + ) -> list[str]: + return await self.sheet_repo.get_json( + form_id=form_id, + sheet=sheet, + direction=direction, + sections=sections, + ) + async def update_cells( self, form_id: int, diff --git a/api/tests/integration/test_heavy_get_json.py b/api/tests/integration/test_heavy_get_json.py index 6b16a28..b6158db 100644 --- a/api/tests/integration/test_heavy_get_json.py +++ b/api/tests/integration/test_heavy_get_json.py @@ -3,11 +3,14 @@ Эталон — старый путь (методы репозиториев и сервисов, которыми пользуется выгрузка Excel) в той же сессии теста. Спека: openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md """ +import json from types import SimpleNamespace import pytest +from sqlalchemy import text from src.main import app +from src.repository.sheet_repository import SheetRepository ADMIN = SimpleNamespace(id=1, role_id=1) # fixture.sql: admin ISP = SimpleNamespace(id=2, role_id=2) # fixture.sql: isp1, EXECUTOR_DFIP @@ -64,3 +67,58 @@ def test_openapi_heavy_get_schema_unchanged(): for path in HEAVY_GET_PATHS: schema = spec["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] assert schema == {"$ref": OPENAPI_REF}, path + + +SHEET_CASES = [ + (1, "AHR", "Support", None), # FORM_1, лист с direction + (4, "AHR", None, None), # FORM_2, без direction + (4, "AHR", None, ["plan", "q1"]), # FORM_2, с sections +] + + +def _sheet_params(direction, sections): + params = {} + if direction: + params["direction"] = direction + if sections: + params["sections"] = ",".join(sections) + return params + + +@pytest.mark.parametrize("form_id,sheet,direction,sections", SHEET_CASES) +def test_sheet_matches_old_path(client, db_session, admin_tokens, auth_headers, form_id, sheet, direction, sections): + result = get_ok(client, f"/api/v1/form/{form_id}/sheet/{sheet}", auth_headers(admin_tokens), _sheet_params(direction, sections)) + old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=form_id, sheet=sheet, direction=direction, sections=sections)) + assert result + assert result[0]["row_type"] == "META" and result[0]["depth"] == -1 + assert result == expected_sheet(old) + + +TRICKY = 'Кавычка " и \\ слэш\nперенос\tтаб' + + +def test_sheet_text_escaping_matches_old_path(client, db_session, admin_tokens, auth_headers): + # В фикстуре у FORM_2 нет budget_line: вставляем строку с TRICKY в name (header.name листа). + # justification в ответе листа не выводится; вставка откатывается вместе с сессией теста. + async def _set(): + await db_session.execute( + text( + "INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) " + "SELECT 4, t.id, :t, 'Support' FROM v3.mv_expense_item_tree t " + "JOIN v3.expense_item_form_type eft ON eft.expense_item_id = t.id " + "WHERE eft.form_type_code = 'FORM_2' AND t.sheet = 'AHR' ORDER BY t.id LIMIT 1" + ), + {"t": TRICKY}, + ) + client.portal.call(_set) + result = get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens)) + old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=4, sheet="AHR", direction=None)) + assert result == expected_sheet(old) + assert any((r["data"].get("header") or {}).get("name") == TRICKY for r in result) + + +def test_sheet_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch): + async def boom(*args, **kwargs): + raise AssertionError("GET листа не должен вызывать SheetRepository.get") + monkeypatch.setattr(SheetRepository, "get", boom) + get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens))