form_124_write: запись в формы 1 2 4 и тесты

This commit is contained in:
tsygankoviva 2026-05-14 13:16:14 +03:00
parent 79ad4a444c
commit 0e639ac433
10 changed files with 723 additions and 47 deletions

View File

@ -5,9 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from src.services.budget_line_service import BudgetLineService
from src.db.models.form_type import FormTypeEnum
from src.services.sheet_service import SheetService
from src.domain.schemas import BaseListResponse, BaseSingleResponse, BudgetFormResponse, DirectionSchemaEnum, SheetFormTypeResponse, SheetResponse
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, SheetFormTypeResponse, SheetResponse
from src.db.models.app_user import AppUser
from src.services.budget_form_service import BudgetFormService
from src.api.v1.deps import get_current_active_user_with_set_db
@ -16,6 +17,17 @@ from src.db.session import get_db
router = APIRouter(prefix="/form", tags=["forms"])
def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]:
return [
SheetResponse(
row_type=el[0],
depth=el[1],
sort_order=el[2],
data=el[3],
) for el in result
]
@router.get("/")
async def get_forms(
offset: int = 0,
@ -99,33 +111,201 @@ async def get_sheet(
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
return BaseListResponse(
count=len(result),
result=[
SheetResponse(
row_type=el[0],
depth=el[1],
sort_order=el[2],
data=el[3],
) for el in result
]
result=_rows_to_sheet_response(result),
)
# try:
# with db_cursor() as cur:
# t0 = time.perf_counter()
# cur.execute(
# "SELECT row_type, depth, sort_order, data "
# "FROM v3.v_form_view(%s, %s, NULL, %s)",
# (form_id, sheet, direction),
# )
# rows = cur.fetchall()
# db_ms = (time.perf_counter() - t0) * 1000
# payload = [
# {"row_type": r[0], "depth": r[1], "sort_order": r[2], "data": r[3]}
# for r in rows
# ]
# return JSONResponse(
# content=payload,
# headers={"X-DB-Time-Ms": f"{db_ms:.2f}"},
# )
# except psycopg2.Error as e:
# raise HTTPException(400, str(e).strip())
@router.patch("/{form_id}/sheet/{sheet}/cell")
async def update_cell(
form_id: int,
sheet: str,
response: Response,
cell_body: CellPatch,
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]:
if sections is not None:
sections = sections.split(",")
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(404, f"Секций {', '.join(rem_sects)} формы {form_id} не найден")
budget_line_service = BudgetLineService(db)
budget_line = await budget_line_service.get(budget_line_id=cell_body.line_id, user=current_user)
if budget_line.budget_form_id != form_id:
raise HTTPException(404, f"Строка формы не найдена")
result = await sheet_service.update_cell(
form_id=form_id,
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
line_id=cell_body.line_id,
column=cell_body.column,
value=cell_body.value,
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)
@router.patch("/{form_id}/sheet/{sheet}/cells")
async def update_cells(
form_id: int,
sheet: str,
response: Response,
cells_body: CellsPatch,
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]:
if sections is not None:
sections = sections.split(",")
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(404, f"Секций {', '.join(rem_sects)} формы {form_id} не найден")
budget_line_service = BudgetLineService(db)
budget_lines = await budget_line_service.get_list(
budget_line_ids=[cb.line_id for cb in cells_body.changes], user=current_user)
for budget_line in budget_lines:
if budget_line.budget_form_id != form_id:
raise HTTPException(404, f"Строка формы не найдена")
result = await sheet_service.update_cells(
form_id=form_id,
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
changes=[c.model_dump() for c in cells_body.changes],
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)
@router.post("/{form_id}/sheet/{sheet}/line")
async def add_line(
form_id: int,
sheet: str,
body: AddLineSchema,
response: Response,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
result = await sheet_service.add_line(
form_id=form_id,
sheet=sheet,
expense_item_id=body.expense_item_id,
item_id=body.item_id,
section_code=body.section_code,
direction=body.direction.value if body.direction else None,
name=body.name,
internal_order=body.internal_order,
vsp_id=body.vsp_id,
project_id=body.project_id,
justification=body.justification,
contract_number=body.contract_number,
contract_end_date=body.contract_end_date,
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)
@router.delete("/{form_id}/sheet/{sheet}/line/{row_id}")
async def delete_line(
form_id: int,
sheet: str,
row_id: int,
response: Response,
direction: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
t0 = time.perf_counter()
sheet_service = SheetService(db)
bf_service = BudgetFormService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
result = await sheet_service.delete_line(
form_id=form_id,
sheet=sheet,
row_id=row_id,
direction=direction,
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)

View File

@ -1,5 +1,8 @@
from fastapi import FastAPI, status
# import psycopg2
from fastapi import FastAPI, HTTPException, status
from fastapi.responses import JSONResponse
import sqlalchemy
from src.core.errors import (
AccessDeniedException,
@ -10,6 +13,7 @@ from src.core.errors import (
)
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AccessDeniedException)
async def access_denied_exception_handler(request, exc: AccessDeniedException):

View File

@ -14,7 +14,7 @@ async def get_db() -> AsyncGenerator:
db = SessionLocal()
try:
yield db
# await db.commit()
await db.commit()
finally:
await db.close()

View File

@ -2,6 +2,7 @@ from datetime import datetime
import enum
from typing import Any, Generic, Literal, Optional, TypeVar
from pydantic import BaseModel, ConfigDict, EmailStr, Field
T = TypeVar("T")
@ -210,4 +211,18 @@ class AddProjectBody(BaseModel):
placement_type: Optional[PlacementTypeLiteral] = None
object_address: Optional[str] = None
staff_count: Optional[int] = None
total_area: Optional[float] = None
total_area: Optional[float] = None
class AddLineSchema(BaseModel):
expense_item_id: Optional[int] = None
item_id: Optional[str] = None
section_code: Optional[str] = None
direction: Optional[DirectionSchemaEnum] = None
name: Optional[str] = None
internal_order: Optional[str] = None
vsp_id: Optional[int] = None
project_id: Optional[int] = None
justification: Optional[str] = None
contract_number: Optional[str] = None
contract_end_date: Optional[datetime] = None

View File

@ -0,0 +1,17 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.budget_line import BudgetLine
class BudgetLineRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, budget_line_id: int) -> BudgetLine | None:
query = select(BudgetLine).where(BudgetLine.id == budget_line_id).limit(1)
return (await self.db.execute(query)).scalar_one_or_none()
async def get_list(self, budget_line_ids: list[int]) -> BudgetLine | None:
query = select(BudgetLine).where(BudgetLine.id.in_(budget_line_ids))
return (await self.db.execute(query)).scalars().all()

View File

@ -1,5 +1,6 @@
import enum
import itertools
import json
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@ -44,26 +45,191 @@ class SheetRepository:
):
raise SheetValidationError("Некорректные данные")
sections_str = 'NULL'
if sections:
sections_str = ",".join([f"'{section}'" for section in sections])
sections_str = f"ARRAY[{sections_str}]"
if direction:
func_query = "v3.v_form_view(%s, '%s', %s, '%s')" % (
form_id, sheet, sections_str, direction,
)
func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)"
else:
func_query = "v3.v_form_view(%s, '%s', %s)" % (
form_id, sheet, sections_str,
)
result = (
func_query = "v3.v_form_view(:form_id, :sheet, :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,
}
)
).all()
async def update_cell(
self,
form_id: int,
sheet: str,
direction: str,
sections: list[str],
line_id: int,
column: str,
value: str | int | float | None = None,
user_id: int = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction not in [s.value for s in DirectionEnum],
sections and (set(sections) - ACCEPTABLE_SECTIONS),
]
):
raise SheetValidationError("Некорректные данные")
if user_id:
func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :user_id, :direction, :sections)"
else:
func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :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,
"line_id": line_id,
"column": column,
"value": json.dumps(value),
"user_id": user_id,
}
)
).all()
async def update_cells(
self,
form_id: int,
sheet: str,
direction: str,
sections: list[str],
changes: list[dict],
user_id: int = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction not in [s.value for s in DirectionEnum],
sections and (set(sections) - ACCEPTABLE_SECTIONS),
]
):
raise SheetValidationError("Некорректные данные")
changes = json.dumps(changes)
if user_id:
func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :user_id, :direction, :sections)"
else:
func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :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,
"changes": changes,
"user_id": user_id,
}
)
).all()
async def add_line(
self,
form_id: int,
sheet: str,
expense_item_id: int | None = None,
item_id: str | None = None,
section_code: str | None = None,
direction: str | None = None,
name: str | None = None,
internal_order: str | None = None,
vsp_id: int | None = None,
project_id: int | None = None,
justification: str | None = None,
contract_number: str | None = None,
contract_end_date: str | None = None,
user_id: int | None = None,
) -> list[tuple]:
return (
await self.db.execute(
text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.add_budget_line(
:p_form_id,
:p_expense_item_id,
:p_sheet,
:p_item_id,
:p_section_code,
:p_direction,
:p_name,
:p_internal_order,
:p_vsp_id,
:p_project_id,
:p_justification,
:p_contract_number,
:p_contract_end_date
)
"""
),
{
"p_form_id": form_id,
"p_expense_item_id": expense_item_id,
"p_sheet": sheet,
"p_item_id": item_id,
"p_section_code": section_code,
"p_direction": direction,
"p_name": name,
"p_internal_order": internal_order,
"p_vsp_id": vsp_id,
"p_project_id": project_id,
"p_justification": justification,
"p_contract_number": contract_number,
"p_contract_end_date": contract_end_date,
}
)
).all()
async def delete_line(
self,
sheet: str,
row_id: int,
direction: str | None = None,
) -> list[tuple]:
return (
await self.db.execute(
text(
"SELECT row_type, depth, sort_order, data "
"FROM v3.del_budget_line(:p_row_id, :p_direction, :p_sheet)"
),
{
"p_row_id": row_id,
"p_direction": direction,
"p_sheet": sheet,
}
)
).all()
return result

View File

@ -0,0 +1,28 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.budget_line import BudgetLine
from src.repository.budget_line_repository import BudgetLineRepository
class BudgetLineService:
def __init__(self, db: AsyncSession):
self.db = db
self.bl_repo = BudgetLineRepository(db)
async def get(
self,
user: AppUser,
budget_line_id: int,
) -> BudgetLine | None:
return await self.bl_repo.get(budget_line_id=budget_line_id)
async def get_list(
self,
user: AppUser,
budget_line_ids: list[int],
) -> list[BudgetLine]:
return await self.bl_repo.get_list(budget_line_ids=budget_line_ids)

View File

@ -1,6 +1,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.budget_form import BudgetForm
from src.repository.sheet_repository import SheetRepository
@ -24,4 +25,115 @@ class SheetService:
sheet=sheet,
direction=direction,
sections=sections,
)
async def update_cell(
self,
form_id: int,
sheet: str,
direction: str,
sections: list[str],
line_id: int,
column: str,
value: str | int | float | None = None,
user: AppUser | None = None,
) -> list[tuple]:
return 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,
)
async def update_cells(
self,
form_id: int,
sheet: str,
direction: str,
sections: list[str],
changes: list[dict],
user: AppUser | None = None,
) -> list[tuple]:
return await self.sheet_repo.update_cells(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
changes=changes,
user_id=user.id if user else None,
)
async def update_cell(
self,
form_id: int,
sheet: str,
direction: str,
sections: list[str],
line_id: int,
column: str,
value: str | int | float | None = None,
user: AppUser | None = None,
) -> list[tuple]:
return 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,
)
async def add_line(
self,
form_id: int,
sheet: str,
expense_item_id: int | None = None,
item_id: str | None = None,
section_code: str | None = None,
direction: str | None = None,
name: str | None = None,
internal_order: str | None = None,
vsp_id: int | None = None,
project_id: int | None = None,
justification: str | None = None,
contract_number: str | None = None,
contract_end_date: str | None = None,
user: AppUser | None = None,
) -> list[tuple]:
return await self.sheet_repo.add_line(
form_id=form_id,
sheet=sheet,
expense_item_id=expense_item_id,
item_id=item_id,
section_code=section_code,
direction=direction,
name=name,
internal_order=internal_order,
vsp_id=vsp_id,
project_id=project_id,
justification=justification,
contract_number=contract_number,
contract_end_date=contract_end_date,
user_id=user.id if user else None,
)
async def delete_line(
self,
form_id: int,
sheet: str,
row_id: int,
direction: str | None = None,
user: AppUser | None = None,
) -> list[tuple]:
return await self.sheet_repo.delete_line(
sheet=sheet,
row_id=row_id,
direction=direction,
)

View File

@ -1,15 +1,36 @@
import asyncio
import os
import pytest
from fastapi.testclient import TestClient
from src.db.base import SessionLocal
from src.db.session import get_db
from src.main import app
@pytest.fixture
def client():
with TestClient(app) as test_client:
yield test_client
db = SessionLocal()
loop = asyncio.get_event_loop()
try:
async def _get_db():
try:
yield db
await db.flush()
finally:
await db.close()
app.dependency_overrides[get_db] = _get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
finally:
loop.run_until_complete(db.rollback())
loop.run_until_complete(db.close())
@pytest.fixture

View File

@ -0,0 +1,133 @@
import pytest
def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_forms_list_smoke(client, admin_tokens):
response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "count" in payload
assert isinstance(payload["result"], list)
def test_forms_sheets_not_found_error_shape(client, admin_tokens):
response = client.get(
"/api/v1/form/999/sheets", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "detail" in payload
assert isinstance(payload["detail"], str)
def test_forms_sheet_get_not_found_error_shape(client, admin_tokens):
response = client.get(
"/api/v1/form/999/sheet/AHR", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "detail" in payload
def test_forms_cell_update_not_found_error_shape(client, admin_tokens):
response = client.patch(
"/api/v1/form/999/sheet/AHR/cell",
json={"line_id": 1, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "detail" in payload
def test_forms_cell_update(client, admin_tokens):
response = client.patch(
"/api/v1/form/1/sheet/AHR/cell?direction=Support&sections=plan,contract_summary",
json={
"line_id": 1,
"column": "plan.q1",
"value": 225
},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, dict)
assert "result" in payload
def test_forms_cells_update_not_found_error_shape(client, admin_tokens):
response = client.patch(
"/api/v1/form/999/sheet/AHR/cells",
json={"changes": [{"line_id": 1, "column": "q1.m1", "value": 100}]},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "detail" in payload
def test_forms_cells_update(client, admin_tokens):
response = client.patch(
"/api/v1/form/1/sheet/AHR/cells?direction=Support&sections=plan,contract_summary",
json={
"changes": [
{
"line_id": 1,
"column": "plan.q1",
"value": 226
}
],
},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, dict)
assert "result" in payload
def test_forms_add_line_not_found_error_shape(client, admin_tokens):
response = client.post(
"/api/v1/form/999/sheet/AHR/line",
json={},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "detail" in payload
def test_forms_add_line(client, admin_tokens):
response = client.post(
"/api/v1/form/1/sheet/AHR/line?direction=Support&sections=plan,contract_summary",
json={
"expense_item_id": 120,
"direction": "Support"
},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, dict)
assert "result" in payload
def test_forms_delete_line_not_found_error_shape(client, admin_tokens):
response = client.delete(
"/api/v1/form/999/sheet/AHR/line/1",
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "detail" in payload