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.form_type import FormTypeEnum
|
||||||
from src.db.models.role import UserRoleEnum
|
from src.db.models.role import UserRoleEnum
|
||||||
from src.services.sheet_service import SheetService
|
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.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
|
||||||
from src.db.models.app_user import AppUser
|
from src.db.models.app_user import AppUser
|
||||||
from src.services.budget_form_service import BudgetFormService
|
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(
|
async def get_sheet(
|
||||||
form_id: int,
|
form_id: int,
|
||||||
sheet: str,
|
sheet: str,
|
||||||
response: Response,
|
|
||||||
direction: Optional[DirectionSchemaEnum] = None,
|
direction: Optional[DirectionSchemaEnum] = None,
|
||||||
sections: Optional[str] = None,
|
sections: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
) -> BaseListResponse[SheetResponse]:
|
) -> Response:
|
||||||
sections = _parse_sections_csv(sections)
|
sections = _parse_sections_csv(sections)
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
bf_service = BudgetFormService(db)
|
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)):
|
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
|
||||||
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
|
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
|
||||||
|
|
||||||
result = await sheet_service.get(
|
rows = await sheet_service.get_json(
|
||||||
form_id=form_id,
|
form_id=form_id,
|
||||||
sheet=sheet,
|
sheet=sheet,
|
||||||
direction=direction.value if direction else None,
|
direction=direction.value if direction else None,
|
||||||
sections=sections,
|
sections=sections,
|
||||||
)
|
)
|
||||||
db_ms = (time.perf_counter() - t0) * 1000
|
db_ms = (time.perf_counter() - t0) * 1000
|
||||||
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
|
|
||||||
extra={
|
extra={
|
||||||
"type": "db_time",
|
"type": "db_time",
|
||||||
"time_ms": f"{db_ms:.2f}",
|
"time_ms": f"{db_ms:.2f}",
|
||||||
@ -196,10 +195,7 @@ async def get_sheet(
|
|||||||
f"db_time: {extra}",
|
f"db_time: {extra}",
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
return BaseListResponse(
|
return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"})
|
||||||
count=len(result),
|
|
||||||
result=_rows_to_sheet_response(result),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{form_id}/sheet/{sheet}/cell")
|
@router.patch("/{form_id}/sheet/{sheet}/cell")
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from sqlalchemy import text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.db.models.form_type import FormType
|
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):
|
class DirectionEnum(str, enum.Enum):
|
||||||
@ -28,13 +29,14 @@ class SheetRepository:
|
|||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
async def get(
|
def _view_source(
|
||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
sheet: str,
|
sheet: str,
|
||||||
direction: str | None,
|
direction: str | None,
|
||||||
sections: list[str] | None = None,
|
sections: list[str] | None,
|
||||||
) -> list[tuple]:
|
) -> tuple[str, dict]:
|
||||||
|
"""Валидация и FROM-выражение v3.v_form_view: общие для get (выгрузка) и get_json (GET листа)."""
|
||||||
if any(
|
if any(
|
||||||
[
|
[
|
||||||
not isinstance(form_id, int),
|
not isinstance(form_id, int),
|
||||||
@ -45,27 +47,41 @@ class SheetRepository:
|
|||||||
):
|
):
|
||||||
raise SheetValidationError("Некорректные данные")
|
raise SheetValidationError("Некорректные данные")
|
||||||
|
|
||||||
|
|
||||||
if direction:
|
if direction:
|
||||||
func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)"
|
func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)"
|
||||||
else:
|
else:
|
||||||
func_query = "v3.v_form_view(:form_id, :sheet, :sections)"
|
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 (
|
return (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
text(
|
text(f"SELECT {ROW_COLUMNS} FROM {func_query}"),
|
||||||
f"""
|
params,
|
||||||
SELECT row_type, depth, sort_order, data
|
|
||||||
FROM {func_query}
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"form_id": form_id,
|
|
||||||
"sheet": sheet,
|
|
||||||
"direction": direction,
|
|
||||||
"sections": sections,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
).all()
|
).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(
|
async def update_cell(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -35,6 +35,21 @@ class SheetService:
|
|||||||
sections=sections,
|
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(
|
async def update_cells(
|
||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
|
|||||||
@ -3,11 +3,14 @@
|
|||||||
Эталон — старый путь (методы репозиториев и сервисов, которыми пользуется выгрузка Excel)
|
Эталон — старый путь (методы репозиториев и сервисов, которыми пользуется выгрузка Excel)
|
||||||
в той же сессии теста. Спека: openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md
|
в той же сессии теста. Спека: openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.main import app
|
from src.main import app
|
||||||
|
from src.repository.sheet_repository import SheetRepository
|
||||||
|
|
||||||
ADMIN = SimpleNamespace(id=1, role_id=1) # fixture.sql: admin
|
ADMIN = SimpleNamespace(id=1, role_id=1) # fixture.sql: admin
|
||||||
ISP = SimpleNamespace(id=2, role_id=2) # fixture.sql: isp1, EXECUTOR_DFIP
|
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:
|
for path in HEAVY_GET_PATHS:
|
||||||
schema = spec["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
|
schema = spec["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
|
||||||
assert schema == {"$ref": OPENAPI_REF}, path
|
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