perf(api): GET листа отдаёт готовый JSON из Postgres без SheetResponse на строку

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:12:09 +04:00
parent 7094fe839f
commit d8dba2f7be
4 changed files with 113 additions and 28 deletions

View File

@ -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")

View File

@ -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,

View File

@ -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,

View File

@ -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))