perf(api): GET листа отдаёт готовый JSON из Postgres без SheetResponse на строку
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7094fe839f
commit
d8dba2f7be
@ -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")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user