feat(formulas): запись формулы при правке ячейки формы и GET formulas листа (AURORA-1896)
SheetService.update_cell_with_formula/update_cells пишут формулу после значения в одной
транзакции, GET /form/{id}/sheet/{sheet}/formulas. Ошибки БД с обёрткой asyncpg
"<class ...>: invalid_formula: ..." теперь корректно мапятся в 422.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2bf1aba536
commit
5bc6a755bc
@ -11,7 +11,7 @@ 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.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, CellFormulaResponse, 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
|
||||||
from src.api.v1.deps import get_current_active_user_with_set_db, require_admin
|
from src.api.v1.deps import get_current_active_user_with_set_db, require_admin
|
||||||
@ -236,6 +236,7 @@ async def update_cell(
|
|||||||
line_id=cell_body.line_id,
|
line_id=cell_body.line_id,
|
||||||
column=cell_body.column,
|
column=cell_body.column,
|
||||||
value=cell_body.value,
|
value=cell_body.value,
|
||||||
|
formula=cell_body.formula,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
db_ms = (time.perf_counter() - t0) * 1000
|
db_ms = (time.perf_counter() - t0) * 1000
|
||||||
@ -264,6 +265,26 @@ async def update_cell(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{form_id}/sheet/{sheet}/formulas")
|
||||||
|
async def get_sheet_formulas(
|
||||||
|
form_id: int,
|
||||||
|
sheet: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> BaseListResponse[CellFormulaResponse]:
|
||||||
|
form = await BudgetFormService(db).get(
|
||||||
|
user=current_user,
|
||||||
|
budget_form_id=form_id,
|
||||||
|
load_form_type=True,
|
||||||
|
)
|
||||||
|
if not form:
|
||||||
|
raise HTTPException(404, "Форма не найдена")
|
||||||
|
if sheet not in form.form_type.sheet_list:
|
||||||
|
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
|
||||||
|
result = await SheetService(db).list_formulas(form_id=form_id, sheet=sheet)
|
||||||
|
return BaseListResponse(count=len(result), result=result)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{form_id}/sheet/{sheet}/cells")
|
@router.patch("/{form_id}/sheet/{sheet}/cells")
|
||||||
async def update_cells(
|
async def update_cells(
|
||||||
form_id: int,
|
form_id: int,
|
||||||
@ -298,7 +319,10 @@ async def update_cells(
|
|||||||
sheet=sheet,
|
sheet=sheet,
|
||||||
direction=direction.value if direction else None,
|
direction=direction.value if direction else None,
|
||||||
sections=sections,
|
sections=sections,
|
||||||
changes=[c.model_dump() for c in cells_body.changes],
|
changes=[
|
||||||
|
{**c.model_dump(exclude={"formula"}), **({"formula": c.formula} if c.formula is not None else {})}
|
||||||
|
for c in cells_body.changes
|
||||||
|
],
|
||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
db_ms = (time.perf_counter() - t0) * 1000
|
db_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, status
|
from fastapi import FastAPI, HTTPException, status
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@ -28,6 +30,14 @@ def _error_payload(code: int, message: str, field: str = "") -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_ASYNCPG_CLASS_PREFIX = re.compile(r"^<class '[^']+'>:\s*")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_driver_class_prefix(message: str) -> str:
|
||||||
|
"""asyncpg-диалект даёт str(orig) вида "<class '...RaiseError'>: msg"; убираем обёртку."""
|
||||||
|
return _ASYNCPG_CLASS_PREFIX.sub("", message)
|
||||||
|
|
||||||
|
|
||||||
def _parse_prefixed_validation_error(message: str) -> str:
|
def _parse_prefixed_validation_error(message: str) -> str:
|
||||||
for prefix in VALIDATION_PREFIXES:
|
for prefix in VALIDATION_PREFIXES:
|
||||||
if message.lower().startswith(prefix):
|
if message.lower().startswith(prefix):
|
||||||
@ -51,10 +61,11 @@ def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, int, str, str]:
|
|||||||
message = _first_db_message_line(raw) or "Database error"
|
message = _first_db_message_line(raw) or "Database error"
|
||||||
low = message.lower()
|
low = message.lower()
|
||||||
|
|
||||||
if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES):
|
bare_message = _strip_driver_class_prefix(message)
|
||||||
|
if any(bare_message.lower().startswith(prefix) for prefix in VALIDATION_PREFIXES):
|
||||||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
field = _parse_prefixed_validation_error(message)
|
field = _parse_prefixed_validation_error(bare_message)
|
||||||
return status_code, status_code, message, field
|
return status_code, status_code, bare_message, field
|
||||||
if any(error in low for error in PG_404_ERRORS):
|
if any(error in low for error in PG_404_ERRORS):
|
||||||
status_code = status.HTTP_404_NOT_FOUND
|
status_code = status.HTTP_404_NOT_FOUND
|
||||||
return status_code, status_code, message, ""
|
return status_code, status_code, message, ""
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from src.repository.form4_project_repository import Form4ProjectRepository
|
|||||||
from src.repository.user_repository import UserRepository
|
from src.repository.user_repository import UserRepository
|
||||||
from src.db.models.app_user import AppUser
|
from src.db.models.app_user import AppUser
|
||||||
from src.db.models.budget_form import BudgetForm
|
from src.db.models.budget_form import BudgetForm
|
||||||
|
from src.repository.cell_formula_repository import CellFormulaRepository
|
||||||
from src.repository.sheet_repository import SheetRepository, SheetValidationError
|
from src.repository.sheet_repository import SheetRepository, SheetValidationError
|
||||||
|
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ class SheetService:
|
|||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.sheet_repo = SheetRepository(db)
|
self.sheet_repo = SheetRepository(db)
|
||||||
|
self.formula_repo = CellFormulaRepository(db)
|
||||||
self.bf_repo = BudgetFormRepository(db)
|
self.bf_repo = BudgetFormRepository(db)
|
||||||
self.user_repo = UserRepository(db)
|
self.user_repo = UserRepository(db)
|
||||||
self.f4_project_repo = Form4ProjectRepository(db)
|
self.f4_project_repo = Form4ProjectRepository(db)
|
||||||
@ -61,7 +63,7 @@ class SheetService:
|
|||||||
changes: list[dict],
|
changes: list[dict],
|
||||||
user: AppUser | None = None,
|
user: AppUser | None = None,
|
||||||
) -> list[tuple]:
|
) -> list[tuple]:
|
||||||
return await self.sheet_repo.update_cells(
|
rows = await self.sheet_repo.update_cells(
|
||||||
form_id=form_id,
|
form_id=form_id,
|
||||||
sheet=sheet,
|
sheet=sheet,
|
||||||
direction=direction,
|
direction=direction,
|
||||||
@ -69,6 +71,51 @@ class SheetService:
|
|||||||
changes=changes,
|
changes=changes,
|
||||||
user_id=user.id if user else None,
|
user_id=user.id if user else None,
|
||||||
)
|
)
|
||||||
|
if user:
|
||||||
|
await self.formula_repo.set_formulas(
|
||||||
|
budget_form_id=form_id,
|
||||||
|
report_id=None,
|
||||||
|
sheet=sheet,
|
||||||
|
changes=changes,
|
||||||
|
user_id=user.id,
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
async def update_cell_with_formula(
|
||||||
|
self,
|
||||||
|
form_id: int,
|
||||||
|
sheet: str,
|
||||||
|
direction: str | None,
|
||||||
|
sections: list[str] | None,
|
||||||
|
line_id: int,
|
||||||
|
column: str,
|
||||||
|
value: str | int | float | None = None,
|
||||||
|
formula: str | None = None,
|
||||||
|
user: AppUser | None = None,
|
||||||
|
) -> tuple[list[tuple], dict | None]:
|
||||||
|
rows = await self.sheet_repo.update_cell(
|
||||||
|
form_id=form_id,
|
||||||
|
sheet=sheet,
|
||||||
|
direction=direction,
|
||||||
|
sections=sections,
|
||||||
|
line_id=line_id,
|
||||||
|
column=column,
|
||||||
|
value=value,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
return rows, None
|
||||||
|
change = {"line_id": line_id, "column": column, "value": value}
|
||||||
|
if formula is not None:
|
||||||
|
change["formula"] = formula
|
||||||
|
saved = await self.formula_repo.set_formulas(
|
||||||
|
budget_form_id=form_id,
|
||||||
|
report_id=None,
|
||||||
|
sheet=sheet,
|
||||||
|
changes=[change],
|
||||||
|
user_id=user.id,
|
||||||
|
)
|
||||||
|
return rows, (saved[0] if saved else None)
|
||||||
|
|
||||||
async def update_cell(
|
async def update_cell(
|
||||||
self,
|
self,
|
||||||
@ -79,9 +126,10 @@ class SheetService:
|
|||||||
line_id: int,
|
line_id: int,
|
||||||
column: str,
|
column: str,
|
||||||
value: str | int | float | None = None,
|
value: str | int | float | None = None,
|
||||||
|
formula: str | None = None,
|
||||||
user: AppUser | None = None,
|
user: AppUser | None = None,
|
||||||
) -> list[tuple]:
|
) -> list[tuple]:
|
||||||
return await self.sheet_repo.update_cell(
|
rows, _ = await self.update_cell_with_formula(
|
||||||
form_id=form_id,
|
form_id=form_id,
|
||||||
sheet=sheet,
|
sheet=sheet,
|
||||||
direction=direction,
|
direction=direction,
|
||||||
@ -89,7 +137,14 @@ class SheetService:
|
|||||||
line_id=line_id,
|
line_id=line_id,
|
||||||
column=column,
|
column=column,
|
||||||
value=value,
|
value=value,
|
||||||
user_id=user.id if user else None,
|
formula=formula,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
async def list_formulas(self, form_id: int, sheet: str) -> list[dict]:
|
||||||
|
return await self.formula_repo.list_formulas(
|
||||||
|
budget_form_id=form_id, report_id=None, sheet=sheet
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
177
api/tests/integration/test_cell_formula_integration.py
Normal file
177
api/tests/integration/test_cell_formula_integration.py
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"""AURORA-1896: формулы ячеек форм (REST): запись при PATCH и GET formulas."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FORM = "/api/v1/form/1"
|
||||||
|
QS = "?direction=Support§ions=plan,contract_summary"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def headers(admin_tokens, auth_headers):
|
||||||
|
return auth_headers(admin_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_line(client, headers) -> int:
|
||||||
|
r = client.post(
|
||||||
|
f"{FORM}/sheet/AHR/line{QS}",
|
||||||
|
json={"expense_item_id": 120, "direction": "Support"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
inputs = [x for x in r.json()["result"] if x.get("row_type") == "INPUT"]
|
||||||
|
assert len(inputs) == 1
|
||||||
|
return inputs[0]["data"]["line_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(client, headers, body, path="cell"):
|
||||||
|
return client.patch(f"{FORM}/sheet/AHR/{path}{QS}", json=body, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _formulas(client, headers) -> list[dict]:
|
||||||
|
r = client.get(f"{FORM}/sheet/AHR/formulas", headers=headers)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_value(client, headers, line_id, column="plan.q1"):
|
||||||
|
r = client.get(f"{FORM}/sheet/AHR{QS}", headers=headers)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
for row in r.json()["result"]:
|
||||||
|
if row.get("row_type") == "INPUT" and row["data"].get("line_id") == line_id:
|
||||||
|
node = row["data"]
|
||||||
|
for part in column.split("."):
|
||||||
|
node = node.get(part) if isinstance(node, dict) else None
|
||||||
|
return node
|
||||||
|
return "ROW_NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
|
def _line_formulas(client, headers, line_id):
|
||||||
|
return [f for f in _formulas(client, headers) if f["line_id"] == line_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_cell_with_formula_then_get(client, headers, admin_tokens):
|
||||||
|
line_id = _add_line(client, headers)
|
||||||
|
r = _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 150, "formula": " =100+50 "})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
found = _line_formulas(client, headers, line_id)
|
||||||
|
assert len(found) == 1
|
||||||
|
f = found[0]
|
||||||
|
assert f["column"] == "plan.q1"
|
||||||
|
assert f["expression"] == "100+50"
|
||||||
|
assert f["value"] == 150
|
||||||
|
me = client.get("/api/v1/auth/me", headers=headers)
|
||||||
|
if me.status_code == 200:
|
||||||
|
assert f["updated_by"]["id"] == me.json().get("id", f["updated_by"]["id"])
|
||||||
|
assert f["updated_by"]["id"] > 0
|
||||||
|
assert f["updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_without_formula_removes(client, headers):
|
||||||
|
line_id = _add_line(client, headers)
|
||||||
|
assert _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 150, "formula": "100+50"}).status_code == 200
|
||||||
|
assert len(_line_formulas(client, headers, line_id)) == 1
|
||||||
|
assert _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 200}).status_code == 200
|
||||||
|
assert _line_formulas(client, headers, line_id) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_invalid_formula_422_value_unchanged(client, headers):
|
||||||
|
before = _cell_value(client, headers, 1)
|
||||||
|
r = _patch(client, headers, {"line_id": 1, "column": "plan.q1", "value": 777, "formula": "drop table"})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert "invalid_formula" in r.text
|
||||||
|
assert r.json()["field"] == "chars"
|
||||||
|
# сессия теста откатывается целиком (conftest) — GET ниже видит состояние после отката
|
||||||
|
assert _cell_value(client, headers, 1) == before
|
||||||
|
assert _line_formulas(client, headers, 1) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"line_col,value,formula",
|
||||||
|
[("plan.q1", 777, "drop table"), ("plan.q1", None, "1+1"), ("header.justification", "текст", "1+1")],
|
||||||
|
ids=["bad-chars", "null-value", "text-column"],
|
||||||
|
)
|
||||||
|
def test_invalid_formula_does_not_write_value(client, headers, line_col, value, formula):
|
||||||
|
"""Атомарность без опоры на откат фикстуры: ошибку ловим внутри SAVEPOINT."""
|
||||||
|
from sqlalchemy.exc import DBAPIError
|
||||||
|
|
||||||
|
from src.db.models.app_user import AppUser
|
||||||
|
from src.services.sheet_service import SheetService
|
||||||
|
|
||||||
|
line_id = _add_line(client, headers)
|
||||||
|
before = _cell_value(client, headers, line_id, line_col)
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
db = client.db_session
|
||||||
|
user = await db.get(AppUser, 1)
|
||||||
|
try:
|
||||||
|
async with db.begin_nested():
|
||||||
|
await SheetService(db).update_cell(
|
||||||
|
form_id=1, sheet="AHR", direction="Support", sections=["plan", "contract_summary"],
|
||||||
|
line_id=line_id, column=line_col, value=value, formula=formula, user=user,
|
||||||
|
)
|
||||||
|
except DBAPIError as exc:
|
||||||
|
return str(exc.orig)
|
||||||
|
return None
|
||||||
|
|
||||||
|
err = client.portal.call(_run)
|
||||||
|
assert err is not None and "invalid_formula" in err
|
||||||
|
assert _cell_value(client, headers, line_id, line_col) == before
|
||||||
|
assert _line_formulas(client, headers, line_id) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_formula_into_text_column_422(client, headers):
|
||||||
|
line_id = _add_line(client, headers)
|
||||||
|
r = _patch(client, headers, {"line_id": line_id, "column": "header.justification", "value": "текст", "formula": "1+1"})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert "invalid_formula" in r.text
|
||||||
|
assert _line_formulas(client, headers, line_id) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_formula_null_value_422(client, headers):
|
||||||
|
line_id = _add_line(client, headers)
|
||||||
|
r = _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": None, "formula": "1+1"})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert "invalid_formula" in r.text
|
||||||
|
assert _line_formulas(client, headers, line_id) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_formula_forbidden_403_no_formula(client, headers, isp2_tokens, auth_headers):
|
||||||
|
# isp2 в оргединице формы 1, но колонку plan.q2 ему править нельзя (role_not_allowed).
|
||||||
|
# isp1 форму 1 не видит вовсе (404), поэтому для 403 берём isp2.
|
||||||
|
r = client.patch(
|
||||||
|
f"{FORM}/sheet/AHR/cell{QS}",
|
||||||
|
json={"line_id": 1, "column": "plan.q2", "value": 5, "formula": "2+3"},
|
||||||
|
headers=auth_headers(isp2_tokens),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
assert [f for f in _formulas(client, headers) if f["line_id"] == 1] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_cells_mixed(client, headers):
|
||||||
|
l1 = _add_line(client, headers)
|
||||||
|
l2 = _add_line(client, headers)
|
||||||
|
r = _patch(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
{"changes": [
|
||||||
|
{"line_id": l1, "column": "plan.q1", "value": 3, "formula": "1+2"},
|
||||||
|
{"line_id": l2, "column": "plan.q1", "value": 4},
|
||||||
|
]},
|
||||||
|
path="cells",
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert [f["expression"] for f in _line_formulas(client, headers, l1)] == ["1+2"]
|
||||||
|
assert _line_formulas(client, headers, l2) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_line_removes_formulas(client, headers):
|
||||||
|
line_id = _add_line(client, headers)
|
||||||
|
assert _patch(client, headers, {"line_id": line_id, "column": "plan.q1", "value": 3, "formula": "1+2"}).status_code == 200
|
||||||
|
assert len(_line_formulas(client, headers, line_id)) == 1
|
||||||
|
r = client.delete(f"{FORM}/sheet/AHR/line/{line_id}?direction=Support", headers=headers)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert _line_formulas(client, headers, line_id) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_formulas_unknown_form_404(client, headers):
|
||||||
|
r = client.get("/api/v1/form/999/sheet/AHR/formulas", headers=headers)
|
||||||
|
assert r.status_code == 404
|
||||||
Loading…
x
Reference in New Issue
Block a user