test-tsygankov

This commit is contained in:
tsygankoviva 2026-05-06 13:16:26 +03:00
parent 455fcd1240
commit 60939a2cf2
24 changed files with 706 additions and 9 deletions

Binary file not shown.

View File

@ -1,12 +1,14 @@
import os import os
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from datetime import datetime, timezone from datetime import datetime, timezone
from .conf import get_settings from .conf import get_settings
from .exceptions import NotFound, PermissionDenied, ValidationError
from .routes import api_route from .routes import api_route
settings = get_settings() settings = get_settings()
@ -20,6 +22,22 @@ app = FastAPI(
root_path=settings.ROOT_PATH, root_path=settings.ROOT_PATH,
) )
@app.exception_handler(NotFound)
async def not_found_handler(request: Request, exc: NotFound):
return JSONResponse(status_code=404, content={"detail": exc.message})
@app.exception_handler(PermissionDenied)
async def permission_denied_handler(request: Request, exc: PermissionDenied):
return JSONResponse(status_code=403, content={"detail": exc.message})
@app.exception_handler(ValidationError)
async def validation_error_handler(request: Request, exc: ValidationError):
return JSONResponse(status_code=422, content={"detail": exc.message})
@app.get( @app.get(
"/healthcheck", "/healthcheck",
status_code=200 status_code=200

View File

@ -10,6 +10,7 @@ from functools import lru_cache
class Settings(BaseSettings): class Settings(BaseSettings):
""" Настройки приложения """ """ Настройки приложения """
ROOT_PATH: str = '/' ROOT_PATH: str = '/'
DATABASE_URL: str = ''
server: ServerSettings = ServerSettings() server: ServerSettings = ServerSettings()

0
api/src/data/__init__.py Normal file
View File

View File

@ -0,0 +1,66 @@
import typing
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.budget_line import BudgetLine
async def update_budget_line(
db: AsyncSession,
line_id: int,
table_name: str,
field_name: str,
value: typing.Any,
quarter_field: str | None = None,
quarter: int | None = None,
) -> typing.Any:
where_text = f"line_id = :line_id"
if quarter and quarter_field:
where_text = f"{where_text} AND {quarter_field} = :quarter"
query_text = f"UPDATE {table_name} SET {field_name} = :value WHERE {where_text} RETURNING *"
query = text(query_text)
result = (
await db.execute(
query,
{"line_id": line_id, "value": value, "quarter": quarter}
)
).fetchone()
return tuple(result) if result else None
async def get_budget_line(
db: AsyncSession,
id: int | None = None,
form_id: int | None = None,
expense_item_id: int | None = None,
) -> BudgetLine | None:
assert id or (expense_item_id and form_id)
where_clause = []
if id:
where_clause.append(BudgetLine.id == id)
if expense_item_id:
where_clause.append(BudgetLine.expense_item_id == expense_item_id)
where_clause.append(BudgetLine.budget_form_id == form_id)
result = await db.execute(select(BudgetLine).where(*where_clause))
return result.scalar_one_or_none()
async def create_budget_line(
db: AsyncSession,
form_id: int,
expense_item_id: int | None = None,
) -> BudgetLine:
result = BudgetLine(
expense_item_id=expense_item_id,
budget_form_id=form_id,
# internal_order: Mapped[str | None] = mapped_column(String, nullable=True)
name="Test"
)
db.add(
result
)
await db.flush()
return result

View File

@ -0,0 +1,17 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import ExpenseItem
from src.db.models.budget_line import BudgetLine
async def get_expense_item_by_budget_line(
db: AsyncSession,
budget_line_id: int,
) -> ExpenseItem | None:
result = await db.execute(
select(ExpenseItem)
.join(BudgetLine, BudgetLine.expense_item_id == ExpenseItem.id)
.where(BudgetLine.id == budget_line_id)
)
return result.scalar_one_or_none()

45
api/src/data/page.py Normal file
View File

@ -0,0 +1,45 @@
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
async def get_page_with_counts(
db: AsyncSession,
form_id: int,
page: str,
offset: int | None = None,
limit: int | None = None,
) -> list:
query_text = "SELECT COUNT(*) OVER(), * FROM v_form1_sheet_jsonb(:form_id, :page)"
if offset is not None:
query_text += " OFFSET :offset"
if limit is not None:
query_text += " LIMIT :limit"
query = text(query_text)
return (
await db.execute(
query,
{"form_id": form_id, "page": page, "limit": limit, "offset": offset}
)
).fetchall()
async def get_page_row(
db: AsyncSession,
form_id: int,
page: str,
budget_line_id: int
) -> list:
query_text = "SELECT * FROM v_form1_sheet_jsonb(:form_id, :page) as res WHERE line_id = :line_id"
query = text(query_text)
return (
await db.execute(
query,
{"form_id": form_id, "page": page, "line_id": budget_line_id}
)
).fetchone()

0
api/src/db/__init__.py Normal file
View File

42
api/src/db/base.py Normal file
View File

@ -0,0 +1,42 @@
from sqlalchemy.ext.asyncio import (AsyncAttrs, async_sessionmaker,
create_async_engine)
from sqlalchemy.orm import DeclarativeBase
from src.conf.settings import get_settings
settings = get_settings()
# Создание движка базы данных
connect_args = {}
# if settings.DATABASE_SCHEMA:
# connect_args["server_settings"] = {
# "search_path": settings.DATABASE_SCHEMA,
# }
if "sqlite" in settings.DATABASE_URL:
connect_args["check_same_thread"] = False
engine = create_async_engine( # create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
# echo=settings.DEBUG,
connect_args=connect_args,
# echo=True,
)
# Создание фабрики сессий
# SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
SessionLocal = async_sessionmaker(
# autocommit=False,
autoflush=False,
bind=engine,
expire_on_commit=False,
# class_=AsyncSession
)
# Базовый класс для моделей
class Base(AsyncAttrs, DeclarativeBase):
pass

View File

@ -0,0 +1,5 @@
from src.db.models.budget_form import BudgetForm
from src.db.models.budget_line import BudgetLine
from src.db.models.expense_item import ExpenseItem
__all__ = ["BudgetForm", "BudgetLine", "ExpenseItem"]

View File

@ -0,0 +1,24 @@
import typing
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class BudgetForm(Base):
__tablename__ = "budget_form"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
form_type_code: Mapped[str] = mapped_column(String, nullable=False)
year: Mapped[typing.Optional[int]] = mapped_column(Integer, nullable=True)
ssp_id: Mapped[typing.Optional[str]] = mapped_column(String, nullable=True)
branch_id: Mapped[typing.Optional[int]] = mapped_column(Integer, nullable=True)
created_by: Mapped[typing.Optional[str]] = mapped_column(String, nullable=True)
created_at: Mapped[typing.Optional[str]] = mapped_column(DateTime, server_default=func.now(), nullable=True)
updated_by: Mapped[typing.Optional[str]] = mapped_column(String, nullable=True)
updated_at: Mapped[typing.Optional[str]] = mapped_column(DateTime, server_default=func.now(), nullable=True)
budget_lines: Mapped[list["BudgetLine"]] = relationship( # type: ignore
"BudgetLine", back_populates="budget_form"
)

View File

@ -0,0 +1,19 @@
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.models.budget_form import BudgetForm
from src.db.base import Base
class BudgetLine(Base):
__tablename__ = "budget_line"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
expense_item_id: Mapped[int] = mapped_column(ForeignKey("expense_item.id"))
budget_form_id: Mapped[int] = mapped_column(ForeignKey("budget_form.id"))
internal_order: Mapped[str | None] = mapped_column(String, nullable=True)
name: Mapped[str | None] = mapped_column(String, nullable=True)
budget_form: Mapped["BudgetForm | None"] = relationship(
"BudgetForm", back_populates="budget_lines"
)

View File

@ -0,0 +1,40 @@
import typing
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class ExpenseItem(Base):
__tablename__ = "expense_item"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
section_code: Mapped[typing.Optional[str]]
item_id: Mapped[typing.Optional[str]]
num_group_id: Mapped[typing.Optional[str]]
name: Mapped[typing.Optional[str]]
sheet: Mapped[typing.Optional[str]]
direction: Mapped[typing.Optional[str]]
parent_id: Mapped[typing.Optional[int]] = mapped_column(ForeignKey("expense_item.id"), nullable=True)
depth: Mapped[typing.Optional[int]] = mapped_column(Integer, default=0, nullable=True)
parent: Mapped["ExpenseItem | None"] = relationship(
"ExpenseItem", remote_side="ExpenseItem.id", back_populates="children"
)
children: Mapped[list["ExpenseItem"]] = relationship(
"ExpenseItem", back_populates="parent"
)
# __table_args__ = (
# CheckConstraint(
# "direction::text = ANY (ARRAY['Support'::character varying, 'Development'::character varying]::text[])",
# name="expense_item_direction_check",
# ),
# CheckConstraint(
# "sheet::text = ANY (ARRAY['AHR'::character varying, 'CAP'::character varying, 'OPER'::character varying]::text[])",
# name="expense_item_sheet_check",
# ),
# Index("ix_expense_item_parent", "parent_id"),
# Index("ix_expense_item_sheet", "sheet"),
# )

26
api/src/db/session.py Normal file
View File

@ -0,0 +1,26 @@
from typing import AsyncGenerator
from src.db.base import SessionLocal
async def get_db() -> AsyncGenerator:
"""Dependency для получения сессии базы данных."""
db = SessionLocal()
try:
yield db
await db.commit()
finally:
await db.close()
# async def run_migrations():
# alembic_cfg = Config("alembic.ini")
# await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
# async def create_tables():
# """Создание таблиц в базе данных."""
# # Исключаем устаревшую таблицу table_data из автосоздания,
# # чтобы она не пересоздавалась после миграций
# if not settings.DEBUG:
# await run_migrations()

21
api/src/exceptions.py Normal file
View File

@ -0,0 +1,21 @@
import dataclasses
class AppError(Exception):
message: str = "Ошибка"
def __init__(self, message: str | None = None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.message = message or self.message
class PermissionDenied(AppError):
message: str = "Нет доступа"
class NotFound(AppError):
message: str = "Не найдено"
class ValidationError(AppError):
message: str = "Ошибка валидации"

View File

@ -0,0 +1,73 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from src.routes.api.schemas.base import BaseApiResponse
from src.routes.api.schemas.budget_line import CreateBudgetLineLSchema, UpdateLinkedBLStructureSchema
from src.routes.api.schemas import ListApiResponse
from src.services import page as page_service, budget_line as bl_service
from src.db.session import get_db
router = APIRouter(prefix="/forms", tags=["forms"])
@router.get("/{form_id}/{page}")
async def get_form_page(
form_id: int,
page: str,
db: AsyncSession = Depends(get_db),
offset: int | None = None,
limit: int | None = None,
) -> ListApiResponse[list]:
count, result = await page_service.get_page(
db=db,
form_id=form_id,
page=page,
offset=offset,
limit=limit,
)
return ListApiResponse(
data=result,
count=count,
)
@router.patch("/{form_id}/{page}/{line_id}")
async def update_form(
form_id: int,
page: str,
line_id: int,
field_info: UpdateLinkedBLStructureSchema,
db: AsyncSession = Depends(get_db),
):
return await bl_service.update_budget_line(
db=db,
model_name=field_info.model_name,
form_id=form_id,
line_id=line_id,
field_name=field_info.field_name,
value=field_info.value,
quarter=field_info.quarter,
)
@router.post("/{form_id}/{page}/")
async def insert_line(
form_id: int,
page: str,
budget_line: CreateBudgetLineLSchema,
db: AsyncSession = Depends(get_db),
) -> BaseApiResponse[tuple]:
return BaseApiResponse(
data=await bl_service.create_budget_line(
db=db,
form_id=form_id,
page=page,
expense_item_id=budget_line.expense_item_id,
)
)

View File

@ -1,6 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from datetime import datetime, timezone from datetime import datetime, timezone
from src.routes.api import form
from .schemas import ApiResponse from .schemas import ApiResponse
route = APIRouter(prefix="/api",tags=['Base']) route = APIRouter(prefix="/api",tags=['Base'])
@ -14,4 +16,6 @@ async def get_data():
return ApiResponse( return ApiResponse(
date_with_time=datetime.now(timezone.utc), date_with_time=datetime.now(timezone.utc),
text="Fast API приложение" text="Fast API приложение"
) )
route.include_router(form.router)

View File

@ -1,7 +0,0 @@
from pydantic import BaseModel
from datetime import datetime
class ApiResponse(BaseModel):
date_with_time: datetime
text: str

View File

@ -0,0 +1 @@
from src.routes.api.schemas.base import *

View File

@ -0,0 +1,22 @@
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
from datetime import datetime
T = TypeVar("T")
class ApiResponse(BaseModel):
date_with_time: datetime
text: str
class ListApiResponse(BaseModel, Generic[T]):
data: list[T]
count: int
errors: list = Field(default_factory=list)
class BaseApiResponse(BaseModel, Generic[T]):
data: T
errors: list = Field(default_factory=list)

View File

@ -0,0 +1,40 @@
import enum
import typing
from pydantic import BaseModel, Field
class LinkedBLStructureEnum(str, enum.Enum):
EXPENSE_ITEM = "expense_item"
PLAN = "plan"
CONTRACT_SUMMARY = "contract_summary"
ALLOCATION = "allocation"
SEQUESTRATION = "sequestration"
RESERVE = "reserve"
COLLEGIAL_APPROVAL = "collegial_approval"
CKK = "ckk"
CONTRACT_DETAIL = "contract_detail"
BUDGET_LINE_QUARTER = "budget_line_quarter"
class UpdateLinkedBLStructureSchema(BaseModel):
model_name: LinkedBLStructureEnum
field_name: str
value: typing.Any
quarter: typing.Optional[int] = Field(default=None, ge=1, le=4)
class CreateBudgetLineLSchema(BaseModel):
expense_item_id: int
# class BudgetLineReadSchema(BaseModel):
# id: int
# expense_item_id: int = mapped_column(ForeignKey("expense_item.id"))
# budget_form_id: Mapped[int] = mapped_column(ForeignKey("budget_form.id"))
# internal_order: Mapped[str | None] = mapped_column(String, nullable=True)
# name: Mapped[str | None] = mapped_column(String, nullable=True)

View File

View File

@ -0,0 +1,187 @@
import typing
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.budget_line import BudgetLine
from src.exceptions import NotFound, PermissionDenied, ValidationError
from src.routes.api.schemas.budget_line import LinkedBLStructureEnum
from src.data import budget_line as bl_repo, page as page_repo
MODEL_FIELDS_TO_DB_MAPPING = {
LinkedBLStructureEnum.EXPENSE_ITEM: {
"name": "name",
"item_id": "item_id",
"section": "section_code",
"num_group": "num_group_id",
},
LinkedBLStructureEnum.PLAN: {
"q1": "plan_q1",
"q2": "plan_q2",
"q3": "plan_q3",
"q4": "plan_q4",
"comment": "comment"
},
LinkedBLStructureEnum.CONTRACT_SUMMARY: {
"total": "total_amount",
"comment": "comment",
"deadline": "deadline",
"future_y1": "future_payments_y1",
"future_y2": "future_payments_y2",
"other_ssp": "other_ssp_amount",
"counterparty": "counterparty"
},
LinkedBLStructureEnum.ALLOCATION: {
"order": "internal_order",
"property": "property_object",
},
LinkedBLStructureEnum.SEQUESTRATION: {
"q1": "adj_q1",
"q2": "adj_q2",
"q3": "adj_q3",
"q4": "adj_q4",
"justification": "justification"
},
LinkedBLStructureEnum.RESERVE: {
"q1": "amount_q1",
"q2": "amount_q2",
"q3": "amount_q3",
"q4": "amount_q4",
"justification": "justification"
},
LinkedBLStructureEnum.COLLEGIAL_APPROVAL: {
"note": "note",
"approved": "approved_amount",
"protocol": "protocol_reference",
},
LinkedBLStructureEnum.CKK: {
"q1": "expenses_q1",
"q2": "expenses_q2",
"q3": "expenses_q3",
"q4": "expenses_q4",
"ceiling": "ceiling_amount",
"comment": "comment",
"deadline": "delivery_deadline",
"proc_plan": "procurement_plan",
"proc_method": "procurement_method",
"rf_schedule": "rf_schedule",
},
LinkedBLStructureEnum.CONTRACT_DETAIL: {
"q1": "expenses_q1",
"q2": "expenses_q2",
"q3": "expenses_q3",
"q4": "expenses_q4",
"act": "act",
"addenda": "addenda",
"ceiling": "ceiling_amount",
"comment": "comment",
"subject": "subject",
"currency": "currency",
"deadline": "deadline",
"vat_rate": "vat_rate",
"reference": "reference",
"rf_schedule": "rf_schedule",
"counterparty": "counterparty",
"exchange_rate": "exchange_rate",
"amount_foreign": "amount_foreign",
"payment_scheme": "payment_scheme",
},
LinkedBLStructureEnum.BUDGET_LINE_QUARTER: {
"adj_rf": "adj_rf",
"payment_ho": "payment_amount_ho",
"pay_rf": "payment_amount_rf",
"adj_ssp": "adj_ssp",
"pay_act": "payment_act",
"pay_date": "payment_date",
"actual_m1": "actual_m1",
"actual_m2": "actual_m2",
"actual_m3": "actual_m3",
"pay_amount": "payment_amount",
"adj_comment": "adj_comment",
"adj_current": "adj_current",
"adj_reserve": "adj_reserve",
"pay_comment": "payment_comment",
"transfer_q2": "transfer_to_q2",
"transfer_q3": "transfer_to_q3",
"transfer_q4": "transfer_to_q4",
},
}
async def update_budget_line(
db: AsyncSession,
form_id: int,
line_id: int,
model_name: LinkedBLStructureEnum,
field_name: str,
value: typing.Any,
quarter: int | None = None,
) -> typing.Any:
quarter_field = None
line = await bl_repo.get_budget_line(
db=db,
id=line_id,
)
if not line or line.budget_form_id != form_id:
raise NotFound
# нужно рефрешить матвью и обновлять name у budget_line
# case LinkedBLStructureEnum.EXPENSE_ITEM:
# expense_item = await expense_item_repo.get_expense_item_by_budget_line(
# db=db,
# budget_line_id=line_id,
# )
# if expense_item.depth != 3:
# raise PermissionDenied(message="У вас нет прав на редактирование этой строки")
# setattr(
# expense_item,
# MODEL_FIELDS_TO_DB_MAPPING[model_name][field_name],
# value,
# )
# await db.flush()
if model_name == LinkedBLStructureEnum.BUDGET_LINE_QUARTER:
quarter_field = "quarter"
db_field = MODEL_FIELDS_TO_DB_MAPPING[model_name].get(field_name)
return await bl_repo.update_budget_line(
db=db,
table_name=model_name.value,
line_id=line_id,
field_name=db_field,
value=value,
quarter_field=quarter_field,
quarter=quarter,
)
async def create_budget_line(
db: AsyncSession,
form_id: int,
page: str,
# line_after_id: int,
expense_item_id: int,
) -> tuple:
line = await bl_repo.get_budget_line(
db=db,
form_id=form_id,
expense_item_id=expense_item_id,
)
if not line:
raise NotFound
budget_line = await bl_repo.create_budget_line(
db=db,
form_id=form_id,
expense_item_id=expense_item_id
)
result = await page_repo.get_page_row(
db=db,
form_id=form_id,
page=page,
budget_line_id=budget_line.id,
)
return tuple(result) if result else None

53
api/src/services/page.py Normal file
View File

@ -0,0 +1,53 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.data.page import get_page_with_counts
async def get_page(
db: AsyncSession,
form_id: int,
page: str,
offset: int | None = None,
limit: int | None = None,
) -> tuple[int, list[tuple]]:
pre_result = await get_page_with_counts(
db=db,
form_id=form_id,
page=page,
offset=offset,
limit=limit,
)
if not pre_result:
return 0, pre_result
count = pre_result[0][0]
return count, [tuple(x[1:]) for x in pre_result]
async def update_line(
db: AsyncSession,
line_id: int,
):
pass
# query_text = "SELECT COUNT(*) OVER(), * FROM v_form1_sheet_jsonb(:form_id, :page)"
# if offset is not None:
# query_text += " OFFSET :offset"
# if limit is not None:
# query_text += " LIMIT :limit"
# query = text(query_text)
# result = (
# await db.execute(
# query,
# {"form_id": form_id, "page": page, "limit": limit, "offset": offset}
# )
# ).fetchall()
# if not result:
# return 0, result
# count = result[0][0]
# return count, [tuple(x[1:]) for x in result]