Compare commits

...

25 Commits

Author SHA1 Message Date
772afae276 ssp-fix: org_unit в списке форм 2026-05-21 10:39:33 +03:00
7e9fc08056 Merge pull request 'add and fix validations on forms' (#10) from future/AURORA-1009 into test
Reviewed-on: #10
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-18 14:27:43 +03:00
Raykov-MS
a718827c12 add and fix validations on forms 2026-05-15 18:42:05 +03:00
21d474464d Merge pull request 'stages-back: круды для этапов' (#9) from stages-back into test
Reviewed-on: #9
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-15 15:18:41 +03:00
8299b35d07 stages-back: круды для этапов 2026-05-15 15:17:05 +03:00
47fac8acbc Merge pull request 'future/AURORA-1008' (#8) from future/AURORA-1008 into test
Reviewed-on: #8
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-15 10:23:52 +03:00
Raykov-MS
f1f8f5c7b6 resolve src.db.models 2026-05-14 17:40:51 +03:00
Raykov-MS
3d83a55ad8 conflicts 2026-05-14 17:37:27 +03:00
Raykov-MS
2a3827ff91 Add services endpoints 2026-05-14 17:36:35 +03:00
05d1f2afb0 Merge pull request 'refactor tests for src.db.models... and for new code, message, field error response' (#7) from future/AURORA-1007 into test
Reviewed-on: #7
2026-05-14 17:05:56 +03:00
Raykov-MS
13db21c4af refactor tests for src.db.models... and for new code, message, field error response 2026-05-14 16:22:59 +03:00
748d81ef06 Merge pull request 'future/AURORA-1007' (#4) from future/AURORA-1007 into test
Reviewed-on: #4
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-14 16:13:28 +03:00
Raykov-MS
312c8db04f conflicts 2026-05-14 16:10:32 +03:00
Raykov-MS
0f46157cb5 Merge branch 'future/AURORA-1007' of ssh://git.aepif.ru:2222/RSHB/DFiP_Budget_planing into future/AURORA-1007 2026-05-14 16:05:06 +03:00
Raykov-MS
de000905b3 Add Constants 2026-05-14 16:04:39 +03:00
bdea6f9f4b Merge pull request 'form_124' (#6) from form_124 into test
Reviewed-on: #6
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-14 15:56:09 +03:00
a3540e66c0 Merge pull request 'form_124_write: запись в формы 1 2 4 и тесты' (#5) from form_124_write into form_124
Reviewed-on: #5
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-14 15:31:55 +03:00
0e639ac433 form_124_write: запись в формы 1 2 4 и тесты 2026-05-14 15:04:25 +03:00
79ad4a444c Merge pull request 'F3 Read/Write endpoints' (#2) from form_3 into form_124
Reviewed-on: #2
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-14 11:26:54 +03:00
Raykov-MS
7b78a817cd delete models.py 2026-05-14 11:25:53 +03:00
Raykov-MS
539bd8c0f9 Refactor for specs requirements 2026-05-14 11:21:26 +03:00
Raykov-MS
280dabfd95 Refactor contract of errors 2026-05-14 11:16:35 +03:00
Raykov-MS
52c0d1806a F3 Read/Write endpoints 2026-05-13 12:28:00 +03:00
Raykov-MS
3d134fb7f9 srez 2026-05-08 13:16:31 +03:00
c67c94ba1f WIP: READ ручки для форм 1 2 4 2026-05-08 12:33:05 +03:00
72 changed files with 4722 additions and 217 deletions

141
api/alembic.ini Normal file
View File

@ -0,0 +1,141 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
api/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

78
api/alembic/env.py Normal file
View File

@ -0,0 +1,78 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

25
api/src/api/v1/admin.py Normal file
View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from src.api.v1.deps import require_admin
from src.db.base import engine
from src.db.models.app_user import AppUser
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/refresh-tree")
async def refresh_tree(current_user: AppUser = Depends(require_admin)):
"""Обновляет MV дерева статей расходов."""
try:
async with engine.connect() as conn:
# CONCURRENTLY требует autocommit-режим.
conn = await conn.execution_options(isolation_level="AUTOCOMMIT")
await conn.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree"))
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Не удалось обновить mv_expense_item_tree: {exc}",
) from exc
return {"status": "ok", "message": "mv_expense_item_tree refreshed"}

View File

@ -5,7 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.core.config import settings
from src.core.security import verify_token
from src.db.session import get_db
from src.domain.models import UserRole, Users
from src.db.models.app_user import AppUser
from src.db.models.role import UserRoleEnum
from src.repository.user_repository import UserRepository
security = HTTPBearer()
@ -16,7 +17,7 @@ if settings.DEBUG:
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> Users:
) -> AppUser:
token = credentials.credentials
payload = verify_token(token)
if payload is None:
@ -52,7 +53,7 @@ else:
async def get_current_user(
user: UserInfo = Depends(get_user_dependency),
db: AsyncSession = Depends(get_db),
) -> Users:
) -> AppUser:
user_repo = UserRepository(db)
db_user = await user_repo.get_by_email(user.email)
if db_user is None:
@ -64,12 +65,22 @@ else:
return db_user
async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users:
async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
return current_user
def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users:
if current_user.role_id != UserRole.ADMIN:
async def get_current_active_user_with_set_db(
current_user: AppUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> AppUser:
user_repo = UserRepository(db)
await user_repo.set_app_user_id(current_user.id)
return current_user
def require_admin(current_user: AppUser = Depends(get_current_active_user)) -> AppUser:
if current_user.role_id != UserRoleEnum.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав",
@ -77,11 +88,11 @@ def require_admin(current_user: Users = Depends(get_current_active_user)) -> Use
return current_user
def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users:
def require_executor(current_user: AppUser = Depends(get_current_active_user)) -> AppUser:
if current_user.role_id not in [
UserRole.ADMIN,
UserRole.EXECUTOR_DFIP,
UserRole.EXECUTOR_RF,
UserRoleEnum.ADMIN,
UserRoleEnum.EXECUTOR_DFIP,
UserRoleEnum.EXECUTOR_RF,
]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -90,8 +101,8 @@ def require_executor(current_user: Users = Depends(get_current_active_user)) ->
return current_user
def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users:
if current_user.role_id not in [UserRole.ADMIN, UserRole.EXECUTOR_DFIP]:
def require_executor_dfip(current_user: AppUser = Depends(get_current_active_user)) -> AppUser:
if current_user.role_id not in [UserRoleEnum.ADMIN, UserRoleEnum.EXECUTOR_DFIP]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав",

View File

@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.domain.schemas import (
BaseListResponse,
BaseSingleResponse,
FormPhaseCreate,
FormPhaseResponse,
FormPhaseUpdate,
)
from src.services.budget_form_service import BudgetFormService
from src.services.form_phase_service import FormPhaseService
from src.api.v1.deps import get_current_active_user_with_set_db, require_admin
from src.db.session import get_db
router = APIRouter(prefix="/stages", tags=["stages"])
@router.get("/form/{form_id}")
async def get_form_phases(
form_id: int,
sheet: str | None = None,
phase_code: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[FormPhaseResponse]:
fp_service = FormPhaseService(db)
phases = await fp_service.get_list(
budget_form_id=form_id,
user=current_user,
sheet=sheet,
phase_code=phase_code,
)
return BaseListResponse(
result=[FormPhaseResponse.model_validate(p) for p in phases],
count=len(phases),
)
@router.post("/form/{form_id}")
async def create_form_phase(
form_id: int,
body: FormPhaseCreate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[FormPhaseResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
phase = await fp_service.create(
budget_form=form,
body=body,
user=current_user,
)
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
@router.patch("/form/{form_id}/{sheet}/{phase_code}")
async def update_form_phase(
form_id: int,
sheet: str,
phase_code: str,
body: FormPhaseUpdate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[FormPhaseResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
phase = await fp_service.update(
budget_form=form,
sheet=sheet,
phase_code=phase_code,
body=body,
user=current_user,
)
if not phase:
raise HTTPException(404, "Этап не найден")
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
@router.delete("/form/{form_id}/{sheet}/{phase_code}")
async def delete_form_phase(
form_id: int,
sheet: str,
phase_code: str,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
deleted = await fp_service.delete(
budget_form_id=form_id,
sheet=sheet,
phase_code=phase_code,
user=current_user,
)
if not deleted:
raise HTTPException(404, "Этап не найден")

339
api/src/api/v1/forms.py Normal file
View File

@ -0,0 +1,339 @@
import time
from typing import Optional
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 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
from src.db.session import get_db
router = APIRouter(prefix="/form", tags=["forms"])
SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"}
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
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
]
def _parse_sections_csv(sections: Optional[str]) -> Optional[list[str]]:
if sections is None:
return None
parsed = [section.strip() for section in sections.split(",") if section.strip()]
return parsed or None
def _validate_sheet_query_params(
form_type_code: FormTypeEnum,
sheet: str,
direction: Optional[DirectionSchemaEnum],
sections: Optional[list[str]],
) -> None:
if form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS and not direction:
raise HTTPException(400, "direction обязателен для FORM_1 листов AHR/CAP")
if direction and not (
form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS
):
raise HTTPException(400, "direction допустим только для FORM_1 листов AHR/CAP")
if sections and sheet not in SHEETS_WITH_SECTIONS:
raise HTTPException(400, f"sections не поддерживается для листа {sheet}")
@router.get("/")
async def get_forms(
offset: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[BudgetFormResponse]:
"""Все строки v3.budget_form для UI-выпадашки."""
bf_service = BudgetFormService(db)
count, forms = await bf_service.get_list(
user=current_user,
with_count=True,
offset=offset,
limit=limit,
load_org=True,
)
return BaseListResponse(
result = [
BudgetFormResponse.model_validate(form) for form in forms
],
count=count,
)
@router.get("/{form_id}/sheets")
async def get_form_sheets(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseSingleResponse[SheetFormTypeResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
return BaseSingleResponse(
result=SheetFormTypeResponse(
form_type=form.form_type.code,
sheets=form.form_type.sheet_list,
)
)
@router.get("/{form_id}/sheet/{sheet}")
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]:
sections = _parse_sections_csv(sections)
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} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
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(
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}"
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)
@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]:
sections = _parse_sections_csv(sections)
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} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
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]:
sections = _parse_sections_csv(sections)
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} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
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} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, body.direction, None)
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: Optional[DirectionSchemaEnum] = 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} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, None)
result = await sheet_service.delete_line(
form_id=form_id,
sheet=sheet,
row_id=row_id,
direction=direction.value if direction else None,
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),
)

247
api/src/api/v1/projects.py Normal file
View File

@ -0,0 +1,247 @@
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_active_user_with_set_db
from src.db.models.app_user import AppUser
from src.db.session import get_db
from src.domain.schemas import (
AddForm3LineBody,
AddProjectBody,
BaseListResponse,
BaseSingleResponse,
CellPatch,
CellsPatch,
SheetResponse,
UpdProjectBody,
)
from src.services.project_service import ProjectService
router = APIRouter(tags=["forms"])
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
def _parse_sections(sections: Optional[str]) -> Optional[list[str]]:
if not sections:
return None
parsed = [s.strip() for s in sections.split(",") if s.strip()]
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
if invalid:
raise HTTPException(400, f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
return parsed
def _rows_to_payload(rows: list[tuple]) -> list[dict]:
return [
{"row_type": row[0], "depth": row[1], "sort_order": row[2], "data": row[3]}
for row in rows
]
@router.get("/projects")
async def get_projects(
offset: int = 0,
limit: int = 100,
year: Optional[int] = None,
branch_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[dict]:
project_service = ProjectService(db)
count, projects = await project_service.get_list(
user=current_user,
with_count=True,
year=year,
branch_id=branch_id,
offset=offset,
limit=limit,
)
return BaseListResponse(result=projects, count=count)
@router.get("/projects/{project_id}")
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseSingleResponse[dict]:
project_service = ProjectService(db)
project = await project_service.get(project_id=project_id, user=current_user)
if not project:
raise HTTPException(404, "Project not found")
return BaseSingleResponse(result=project)
@router.get("/projects/{project_id}/report/{year}/{report_type}")
async def get_project_report(
project_id: int,
year: int,
report_type: str,
response: Response,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
project_service = ProjectService(db)
t0 = time.perf_counter()
rows = await project_service.get_report_rows(
project_id=project_id,
year=year,
report_type=report_type,
sections=_parse_sections(sections),
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
return BaseListResponse(count=len(rows), result=_rows_to_payload(rows))
@router.get("/rf-rollup/{branch_id}/{year}")
async def get_rf_rollup(
branch_id: int,
year: int,
response: Response,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
project_service = ProjectService(db)
t0 = time.perf_counter()
rows = await project_service.get_rf_rollup_rows(
branch_id=branch_id,
year=year,
sections=_parse_sections(sections),
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
return BaseListResponse(count=len(rows), result=_rows_to_payload(rows))
@router.patch("/projects/{project_id}/report/{year}/{report_type}/cell")
async def upd_form3_cell(
project_id: int,
year: int,
report_type: str,
body: CellPatch,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.upd_form3_cell(
project_id=project_id,
year=year,
report_type=report_type,
line_id=body.line_id,
column=body.column,
value=body.value,
user=current_user,
)
return _rows_to_payload(rows)
@router.patch("/projects/{project_id}/report/{year}/{report_type}/cells")
async def upd_form3_cells(
project_id: int,
year: int,
report_type: str,
body: CellsPatch,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.upd_form3_cells(
project_id=project_id,
year=year,
report_type=report_type,
changes=[change.model_dump() for change in body.changes],
user=current_user,
)
return _rows_to_payload(rows)
@router.post("/projects/{project_id}/report/{year}/{report_type}/line")
async def add_form3_line(
project_id: int,
year: int,
report_type: str,
body: AddForm3LineBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.add_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
expense_item_id=body.expense_item_id,
user=current_user,
)
return _rows_to_payload(rows)
@router.delete("/projects/{project_id}/report/{year}/{report_type}/line/{line_id}")
async def del_form3_line(
project_id: int,
year: int,
report_type: str,
line_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.del_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
line_id=line_id,
user=current_user,
)
return _rows_to_payload(rows)
@router.patch("/project/{project_id}")
async def upd_project(
project_id: int,
body: UpdProjectBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> dict:
project_service = ProjectService(db)
result = await project_service.upd_project(
project_id=project_id,
column=body.column,
value=body.value,
user=current_user,
)
return result
@router.post("/projects")
async def add_project(
body: AddProjectBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> dict:
project_service = ProjectService(db)
project_id, limit_report_id, current_expenses_report_id = await project_service.add_project(
name=body.name,
year=body.year,
org_unit_id=body.branch_id,
level=body.level,
parent_id=body.parent_id,
project_type=body.project_type,
vsp_format=body.vsp_format,
placement_type=body.placement_type,
object_address=body.object_address,
staff_count=body.staff_count,
total_area=body.total_area,
)
return {
"project_id": project_id,
"limit_report_id": limit_report_id,
"current_expenses_report_id": current_expenses_report_id,
}

View File

@ -1,7 +1,12 @@
from fastapi import APIRouter
from src.api.v1 import auth, users
from src.api.v1 import auth, users, admin, forms, form_phases, projects
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(admin.router)
api_router.include_router(forms.router)
api_router.include_router(projects.router)
api_router.include_router(form_phases.router)

View File

@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_active_user, require_admin, require_executor_dfip
from src.db.session import get_db
from src.domain.models import Users
from src.db.models.app_user import AppUser
from src.domain.schemas import User as UserSchema
from src.domain.schemas import (
SSPIDList,
@ -21,7 +21,7 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserSchema)
async def get_current_user_info(current_user: Users = Depends(get_current_active_user)):
async def get_current_user_info(current_user: AppUser = Depends(get_current_active_user)):
return current_user
@ -30,7 +30,7 @@ async def get_users(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
users = await user_service.get_all(current_user, skip=skip, limit=limit)
@ -41,7 +41,7 @@ async def get_users(
async def get_many_ssp(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_executor_dfip),
current_user: AppUser = Depends(require_executor_dfip),
):
user_service = UserService(db)
ssp_list = await user_service.get_many_ssp(user_id, current_user)
@ -57,7 +57,7 @@ async def set_many_ssp(
user_id: int,
ssp_ids_data: SSPIDList,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user)
@ -72,7 +72,7 @@ async def unset_many_ssp(
user_id: int,
ssp_ids_data: SSPIDList,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
success = await user_service.unset_many_ssp(
@ -88,7 +88,7 @@ async def unset_many_ssp(
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
user = await user_service.get(user_id, current_user)
@ -104,7 +104,7 @@ async def get_user(
async def create_user(
user_data: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
user = await user_service.create_user(user_data=user_data, creator=current_user)
@ -116,7 +116,7 @@ async def update_user(
user_id: int,
user_data: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
user = await user_service.update_user(
@ -131,7 +131,7 @@ async def update_user(
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
await user_service.delete_user(user_id=user_id, user=current_user)

31
api/src/core/CONSTANTS.py Normal file
View File

@ -0,0 +1,31 @@
VALIDATION_PREFIXES = (
"computed_field:",
"normative_field:",
"unknown_column:",
"key_field:",
"structural_field:",
"bad_column_format:",
"bad_booking_year:",
"bad_booking_quarter:",
"unsupported_scope:",
)
PG_404_ERRORS = (
"не существует",
"не принадлежит",
"not found",
)
PG_409_ERRORS = (
"unique",
"duplicate",
"foreign key",
)
PG_400_ERRORS = (
"violates check constraint",
)
PG_403_ERRORS = (
"permission denied",
"insufficient privilege",
"role_not_allowed:",
"window_closed:",
)

View File

@ -1,6 +1,8 @@
from pydantic import BaseModel, Field
class ValidationError(BaseModel):
field: str = Field(...)
description: str = Field(...)
@ -10,6 +12,14 @@ class BasicAppException(Exception):
description: str | None = None
class ValidationsError(BasicAppException):
errors: dict[str, list[str]]
def __init__(self, errors, *args):
super().__init__(*args)
self.errors = errors
class AccessDeniedException(BasicAppException):
description = "Доступ запрещен"

View File

@ -1,5 +1,6 @@
from fastapi import FastAPI, status
from fastapi import FastAPI, HTTPException, status
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
from src.core.errors import (
AccessDeniedException,
@ -7,41 +8,144 @@ from src.core.errors import (
UserNotFoundException,
UsernameConflictException,
ValidationException,
ValidationsError,
)
from src.core.CONSTANTS import (
VALIDATION_PREFIXES,
PG_404_ERRORS,
PG_409_ERRORS,
PG_400_ERRORS,
PG_403_ERRORS,
)
def _error_payload(code: str | int, message: str, field: str | None = None) -> dict:
"""Формирует структурированный payload ошибки по спеке."""
return {
"code": code,
"field": field,
"message": message,
}
def _parse_prefixed_validation_error(message: str) -> tuple[str, str | None]:
for prefix in VALIDATION_PREFIXES:
if message.lower().startswith(prefix):
code = prefix[:-1]
field = message.split(":", 1)[1].strip() if ":" in message else None
return code, field or None
return "validation_error", None
def _first_db_message_line(message: str) -> str:
"""Возвращает первую непустую строку текста ошибки БД."""
for line in message.splitlines():
line = line.strip()
if line:
return line
return message.strip()
def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | None]:
"""Маппинг ошибок SQLAlchemy в HTTP-статус + code/message/field."""
raw = str(getattr(exc, "orig", exc) or exc).strip()
message = _first_db_message_line(raw) or "Database error"
low = message.lower()
if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES):
code, field = _parse_prefixed_validation_error(message)
return status.HTTP_422_UNPROCESSABLE_ENTITY, code, message, field
if any(error in low for error in PG_404_ERRORS):
return status.HTTP_404_NOT_FOUND, "not_found", message, None
if any(error in low for error in PG_403_ERRORS):
return status.HTTP_403_FORBIDDEN, "access_denied", message, None
if isinstance(exc, IntegrityError):
if any(error in low for error in PG_409_ERRORS):
return status.HTTP_409_CONFLICT, "conflict", message, None
if any(error in low for error in PG_400_ERRORS):
return status.HTTP_400_BAD_REQUEST, "db_error", message, None
return status.HTTP_400_BAD_REQUEST, "db_error", message, None
if isinstance(exc, OperationalError):
return status.HTTP_503_SERVICE_UNAVAILABLE, "db_unavailable", "Database unavailable", None
return status.HTTP_400_BAD_REQUEST, "db_error", message, None
def map_http_exception(exc: HTTPException) -> tuple[int, str | int, str, str | None]:
"""Маппинг HTTPException в единый формат ошибки API."""
status_code = int(exc.status_code)
detail = exc.detail
if isinstance(detail, dict):
message = str(detail.get("message") or detail.get("detail") or "HTTP error")
field = detail.get("field")
if field is not None:
field = str(field)
code = detail.get("code", status_code)
return status_code, code, message, field
message = str(detail) if detail else "HTTP error"
return status_code, status_code, message, None
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc: HTTPException):
status_code, code, message, field = map_http_exception(exc)
return JSONResponse(
status_code=status_code,
content=_error_payload(code, message, field=field),
headers=exc.headers,
)
@app.exception_handler(AccessDeniedException)
async def access_denied_exception_handler(request, exc: AccessDeniedException):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": exc.description},
content=_error_payload("access_denied", exc.description),
)
@app.exception_handler(ValidationException)
async def validation_exception_handler(request, exc: ValidationException):
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.description},
content=_error_payload("validation_error", exc.description, field=exc.field),
)
@app.exception_handler(UserNotFoundException)
async def user_not_found_exception_handler(request, exc: UserNotFoundException):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": exc.description},
content=_error_payload("user_not_found", exc.description),
)
@app.exception_handler(UsernameConflictException)
async def username_conflict_exception_handler(request, exc: UsernameConflictException):
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": exc.description},
content=_error_payload("username_conflict", exc.description),
)
@app.exception_handler(BasicAppException)
async def basic_app_exception_handler(request, exc: BasicAppException):
message = exc.description or "Application error"
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.description or "Application error"},
content=_error_payload("application_error", message),
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_exception_handler(request, exc: SQLAlchemyError):
status_code, code, message, field = map_sqlalchemy_error(exc)
return JSONResponse(
status_code=status_code,
content=_error_payload(code, message, field=field),
)
@app.exception_handler(ValidationsError)
async def validations_error_handler(request, exc: ValidationsError):
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content=exc.errors,
)

View File

@ -7,12 +7,15 @@ 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(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
connect_args=connect_args,
)

View File

@ -0,0 +1,30 @@
from src.db.models.allocation import Allocation
from src.db.models.app_user import AppUser
from src.db.models.audit_log import AuditLog
from src.db.models.booking import Booking
from src.db.models.budget_form import BudgetForm
from src.db.models.budget_line import BudgetLine
from src.db.models.budget_line_quarter import BudgetLineQuarter
from src.db.models.ckk import Ckk
from src.db.models.collegial_approval import CollegialApproval
from src.db.models.contract_detail import ContractDetail
from src.db.models.contract_summary import ContractSummary
from src.db.models.form_limit import FormLimit
from src.db.models.form_phase import FormPhase
from src.db.models.form_type import FormType
from src.db.models.limit_template import LimitTemplate
from src.db.models.org_unit import OrgUnit
from src.db.models.phase_template import PhaseTemplate
from src.db.models.plan import Plan
from src.db.models.project import Project
from src.db.models.rent_detail import RentDetail
from src.db.models.reserve import Reserve
from src.db.models.rf_project_report import RfProjectReport
from src.db.models.rf_project_report_line import RfProjectReportLine
from src.db.models.rf_project_report_quarter import RfProjectReportQuarter
from src.db.models.role import Role
from src.db.models.security_detail import SecurityDetail
from src.db.models.sequestration import Sequestration
from src.db.models.user_org import UserOrg
from src.db.models.utility_detail import UtilityDetail
from src.db.models.vsp import Vsp

View File

@ -0,0 +1,19 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Allocation(Base):
__tablename__ = "allocation"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
internal_order: Mapped[str | None] = mapped_column(String)
property_object: Mapped[str | None] = mapped_column(String)
contract_ref: Mapped[str | None] = mapped_column(String)
allocation_purpose: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,56 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class AppUser(Base):
__tablename__ = "app_user"
__table_args__ = (
Index(
"ix_v4_app_user_active",
"is_active",
postgresql_where="is_active",
),
Index("ix_v4_app_user_role", "role_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String)
username: Mapped[str] = mapped_column(String)
hashed_password: Mapped[str] = mapped_column(String)
full_name: Mapped[str | None] = mapped_column(String)
role_id: Mapped[int] = mapped_column(ForeignKey("v3.role.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# role: Mapped["Role"] = relationship("Role", back_populates="users") # type: ignore
# created_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="creator", foreign_keys="BudgetForm.created_by"
# )
# updated_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="updater", foreign_keys="BudgetForm.updated_by"
# )
# created_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
# "RfProjectReport",
# back_populates="creator",
# foreign_keys="RfProjectReport.created_by",
# )
# updated_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
# "RfProjectReport",
# back_populates="updater",
# foreign_keys="RfProjectReport.updated_by",
# )
# audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore
# "AuditLog", back_populates="user"
# )
user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="user")
org_units: Mapped[list["OrgUnit"]] = relationship("OrgUnit", secondary="v3.user_org", viewonly=True)

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
JSON,
String,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class AuditLog(Base):
__tablename__ = "audit_log"
__table_args__ = (
Index("ix_v3_audit_log_dt", text("event_dt DESC")),
Index("ix_v3_audit_log_event", "event"),
Index(
"ix_v3_audit_log_form",
"form_id",
postgresql_where="form_id IS NOT NULL",
),
Index("ix_v3_audit_log_user", "user_id"),
Index("ix_v4_audit_log_dt", text("event_dt DESC")),
Index("ix_v4_audit_log_event", "event"),
Index(
"ix_v4_audit_log_form",
"form_id",
postgresql_where="form_id IS NOT NULL",
),
Index("ix_v4_audit_log_user", "user_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("v3.app_user.id", ondelete="SET NULL")
)
org_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("v3.org_unit.id", ondelete="SET NULL")
)
task_id: Mapped[int | None] = mapped_column()
form_id: Mapped[int | None] = mapped_column()
event_dt: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
event: Mapped[str] = mapped_column(String)
event_type: Mapped[str] = mapped_column(String)
event_data: Mapped[dict | None] = mapped_column(JSON)
# user: Mapped["AppUser | None"] = relationship(
# "AppUser", back_populates="audit_logs"
# )
# org_unit: Mapped["OrgUnit | None"] = relationship(
# "OrgUnit", back_populates="audit_logs"
# )

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Booking(Base):
__tablename__ = "booking"
__table_args__ = (
CheckConstraint("quarter BETWEEN 1 AND 4", name="ck_v3_booking_quarter"),
CheckConstraint("source IN ('CKK','CONTRACT')", name="ck_v3_booking_source"),
Index("ix_v3_booking_line", "line_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
quarter: Mapped[int] = mapped_column(SmallInteger)
source: Mapped[str] = mapped_column(String)
booked_amount_ckk: Mapped[float | None] = mapped_column(Numeric)
booked_amount_contract: Mapped[float | None] = mapped_column(Numeric)
# line = relationship("BudgetLine")

View File

@ -0,0 +1,57 @@
from datetime import datetime
import typing
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.models.form_type import FormTypeEnum
from src.db.base import Base
class BudgetForm(Base):
__tablename__ = "budget_form"
__table_args__ = {"schema": "v3"}
id: Mapped[int] = mapped_column(primary_key=True)
form_type_code: Mapped[FormTypeEnum] = mapped_column( #type: ignore
String, ForeignKey("v3.form_type.code")
)
year: Mapped[int | None] = mapped_column(Integer)
created_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_created_by")
)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, server_default=func.now()
)
updated_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_updated_by")
)
updated_at: Mapped[datetime | None] = mapped_column(DateTime)
org_unit_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
)
form_type: Mapped["FormType"] = relationship(
"FormType", #back_populates="budget_forms"
)
# creator: Mapped[typing.Optional["AppUser"]] = relationship(
# "AppUser", back_populates="created_budget_forms", foreign_keys=[created_by]
# )
# updater: Mapped[typing.Optional["AppUser"]] = relationship(
# "AppUser", back_populates="updated_budget_forms", foreign_keys=[updated_by]
# )
org_unit: Mapped[typing.Optional["OrgUnit"]] = relationship(
"OrgUnit", #back_populates="budget_forms"
)
# budget_lines: Mapped[list["BudgetLine"]] = relationship(
# "BudgetLine", back_populates="budget_form"
# )
# fixed_asset_reports: Mapped[list["FixedAssetReport"]] = relationship(
# "FixedAssetReport", back_populates="budget_form"
# )
# form_phases: Mapped[list["FormPhase"]] = relationship(
# "FormPhase", back_populates="budget_form"
# )
# form_limits: Mapped[list["FormLimit"]] = relationship(
# "FormLimit", back_populates="budget_form"
# )

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class BudgetLine(Base):
__tablename__ = "budget_line"
__table_args__ = (
Index("ix_v3_budget_line_form_item", "budget_form_id", "expense_item_id"),
Index("ix_v3_budget_line_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id"))
expense_item_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
name: Mapped[str | None] = mapped_column(String)
internal_order: Mapped[str | None] = mapped_column(String)
vsp_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
project_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
justification: Mapped[str | None] = mapped_column(String)
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
direction: Mapped[str | None] = mapped_column(String)
# budget_form = relationship("BudgetForm", back_populates="budget_lines")
# expense_item = relationship("ExpenseItem", back_populates="budget_lines")
# vsp = relationship("Vsp", back_populates="budget_lines")
# project = relationship("Project", back_populates="budget_lines")
# ckk = relationship("Ckk", uselist=False)
# collegial_approval = relationship("CollegialApproval", uselist=False)
# contract_detail = relationship("ContractDetail", uselist=False)
# contract_summary = relationship("ContractSummary", uselist=False)
# plan = relationship("Plan", uselist=False)
# reserve = relationship("Reserve", uselist=False)
# allocation = relationship("Allocation", uselist=False)
# quarters: Mapped[list["BudgetLineQuarter"]] = relationship("BudgetLineQuarter", back_populates="line") # type: ignore
# sequestration_records: Mapped[list["Sequestration"]] = relationship("Sequestration", back_populates="budget_line") # type: ignore
# booking_records: Mapped[list["Booking"]] = relationship("Booking", back_populates="budget_line") # type: ignore
# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="budget_line") # type: ignore
# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="budget_line") # type: ignore
# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="budget_line") # type: ignore

View File

@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class BudgetLineQuarter(Base):
__tablename__ = "budget_line_quarter"
__table_args__ = (
CheckConstraint("quarter >= 1 AND quarter <= 4", name="chk_budget_line_quarter_quarter"),
{"schema": "v3"},
)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
quarter: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
adj_current: Mapped[float | None] = mapped_column(Numeric)
adj_ssp: Mapped[float | None] = mapped_column(Numeric)
adj_rf: Mapped[float | None] = mapped_column(Numeric)
adj_reserve: Mapped[float | None] = mapped_column(Numeric)
adj_comment: Mapped[str | None] = mapped_column(String)
target_change: Mapped[float | None] = mapped_column(Numeric)
base_plan_correction: Mapped[float | None] = mapped_column(Numeric)
base_plan_correction_comment: Mapped[str | None] = mapped_column(String)
payment_date: Mapped[date | None] = mapped_column(Date)
payment_amount: Mapped[float | None] = mapped_column(Numeric)
payment_amount_ho: Mapped[float | None] = mapped_column(Numeric)
payment_amount_rf: Mapped[float | None] = mapped_column(Numeric)
payment_comment: Mapped[str | None] = mapped_column(String)
payment_act: Mapped[str | None] = mapped_column(String)
actual_m1: Mapped[float | None] = mapped_column(Numeric)
actual_m2: Mapped[float | None] = mapped_column(Numeric)
actual_m3: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q2: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q3: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q4: Mapped[float | None] = mapped_column(Numeric)
transfer_to_economy: Mapped[float | None] = mapped_column(Numeric)
transfer_delay_acts: Mapped[float | None] = mapped_column(Numeric)
transfer_delay_procurement: Mapped[float | None] = mapped_column(Numeric)
transfer_economy_rf: Mapped[float | None] = mapped_column(Numeric)
transfer_next_comment: Mapped[str | None] = mapped_column(String)
transfer_far_comment: Mapped[str | None] = mapped_column(String)
booking_amount: Mapped[float | None] = mapped_column(Numeric)
plan_revision_eco_change: Mapped[float | None] = mapped_column(Numeric)
plan_revision_item_adj: Mapped[float | None] = mapped_column(Numeric)
plan_revision_increase: Mapped[float | None] = mapped_column(Numeric)
plan_revision_sequester: Mapped[float | None] = mapped_column(Numeric)
plan_revision_comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", back_populates="quarters")

29
api/src/db/models/ckk.py Normal file
View File

@ -0,0 +1,29 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Ckk(Base):
__tablename__ = "ckk"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q4: Mapped[float | None] = mapped_column(Numeric)
rf_schedule: Mapped[str | None] = mapped_column(String)
delivery_deadline: Mapped[str | None] = mapped_column(String)
procurement_plan: Mapped[str | None] = mapped_column(String)
procurement_method: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,18 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class CollegialApproval(Base):
__tablename__ = "collegial_approval"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
approved_amount: Mapped[float | None] = mapped_column(Numeric)
protocol_reference: Mapped[str | None] = mapped_column(String)
note: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,34 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class ContractDetail(Base):
__tablename__ = "contract_detail"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
counterparty: Mapped[str | None] = mapped_column(String)
reference: Mapped[str | None] = mapped_column(String)
addenda: Mapped[str | None] = mapped_column(String)
subject: Mapped[str | None] = mapped_column(String)
currency: Mapped[str | None] = mapped_column(String)
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
rf_schedule: Mapped[str | None] = mapped_column(String)
vat_rate: Mapped[str | None] = mapped_column(String)
exchange_rate: Mapped[float | None] = mapped_column(Numeric)
amount_foreign: Mapped[float | None] = mapped_column(Numeric)
deadline: Mapped[str | None] = mapped_column(String)
payment_scheme: Mapped[str | None] = mapped_column(String)
act: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
contract_date: Mapped[str | None] = mapped_column(Date)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class ContractSummary(Base):
__tablename__ = "contract_summary"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
total_amount: Mapped[float | None] = mapped_column(Numeric)
counterparty: Mapped[str | None] = mapped_column(String)
reference: Mapped[str | None] = mapped_column(String)
deadline: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
future_payments_y1: Mapped[float | None] = mapped_column(Numeric)
future_payments_y2: Mapped[float | None] = mapped_column(Numeric)
other_ssp_amount: Mapped[float | None] = mapped_column(Numeric)
centralized_flag: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class FormLimit(Base):
__tablename__ = "form_limit"
__table_args__ = (
Index("ix_v3_form_limit_form", "budget_form_id"),
{"schema": "v3"},
)
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True)
template_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.limit_template.id", ondelete="CASCADE"), primary_key=True)
qty_q1: Mapped[int | None] = mapped_column(Integer)
qty_q2: Mapped[int | None] = mapped_column(Integer)
qty_q3: Mapped[int | None] = mapped_column(Integer)
qty_q4: Mapped[int | None] = mapped_column(Integer)
comment: Mapped[str | None] = mapped_column(String)
# budget_form = relationship("BudgetForm")
# template = relationship("LimitTemplate")

View File

@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class FormPhase(Base):
__tablename__ = "form_phase"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_form_phase_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_form_phase_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_form_phase_window"),
{"schema": "v3"},
)
budget_form_id: Mapped[int] = mapped_column(
ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True
)
sheet: Mapped[str] = mapped_column(String, primary_key=True)
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View File

@ -0,0 +1,51 @@
import enum
from sqlalchemy import CheckConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class FormTypeEnum(str, enum.Enum):
FORM_1 = "FORM_1"
FORM_2 = "FORM_2"
FORM_3 = "FORM_3"
FORM_4 = "FORM_4"
class FormType(Base):
__tablename__ = "form_type"
__table_args__ = (
CheckConstraint("scope IN ('GO','PD','RF')", name="ck_form_type_scope"),
CheckConstraint(
"storage_entity IN ('BUDGET_FORM','RF_PROJECT_REPORT')",
name="ck_form_type_storage_entity",
),
{"schema": "v3"},
)
code: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str | None] = mapped_column(String)
scope: Mapped[str | None] = mapped_column(String)
storage_entity: Mapped[str | None] = mapped_column(String)
SHEETS_BY_FORM_TYPE = {
FormTypeEnum.FORM_1: ["AHR", "CAP", "OPER", "SMETA"],
FormTypeEnum.FORM_2: ["AHR", "CAP", "OPER", "AHR_LIMIT", "AHR_RENT",
"AHR_UTILITY", "AHR_SECURITY", "OTCH9F", "SMETA"],
FormTypeEnum.FORM_4: ["AHR", "CAP", "OPER", "STRUCTURE", "SMETA"],
}
SECTIONS_BY_FORM_TYPE = {
FormTypeEnum.FORM_1: ["plan", "contract_summary", "allocation", "sequestration", "reserve", "approved", "collegial", "ckk", "contract_detail", "q1", "q2", "q3", "q4", "totals"],
FormTypeEnum.FORM_2: ["plan", "seq_dfip", "seq_ssp", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals"],
FormTypeEnum.FORM_4: ["plan", "seq_dfip", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals", "contract_summary", "allocation", "reserve", "collegial", "ckk"],
}
@property
def sheet_list(self):
return self.SHEETS_BY_FORM_TYPE.get(self.code, [])
@property
def section_list(self):
return self.SECTIONS_BY_FORM_TYPE.get(self.code, [])

View File

@ -0,0 +1,33 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class LimitTemplate(Base):
__tablename__ = "limit_template"
__table_args__ = (
CheckConstraint(
"row_type IN ('SECTION','GROUP','LEAF')",
name="ck_v3_limit_template_row_type",
),
Index("ix_v3_limit_template_parent", "parent_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.limit_template.id"))
sort_order: Mapped[int] = mapped_column(Integer)
row_type: Mapped[str] = mapped_column(String)
section_no: Mapped[str | None] = mapped_column(String)
expense_item_code: Mapped[str | None] = mapped_column(String)
name: Mapped[str] = mapped_column(String)
unit: Mapped[str | None] = mapped_column(String)
limit_with_vat: Mapped[float | None] = mapped_column(Numeric)
limit_without_vat: Mapped[float | None] = mapped_column(Numeric)
# parent = relationship("LimitTemplate", remote_side="LimitTemplate.id")
# children: Mapped[list[LimitTemplate]] = relationship("LimitTemplate")
# form_limits: Mapped[list[FormLimit]] = relationship("FormLimit")

View File

@ -0,0 +1,35 @@
from __future__ import annotations
from sqlalchemy import Boolean, Index, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class OrgUnit(Base):
__tablename__ = "org_unit"
__table_args__ = (
Index(
"ix_v4_org_unit_active",
"is_active",
postgresql_where="is_active",
),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_ssp: Mapped[bool] = mapped_column(Boolean)
# budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="org_unit"
# )
# audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore
# "AuditLog", back_populates="org_unit"
# )
# projects: Mapped[list["Project"]] = relationship( # type: ignore
# "Project", back_populates="org_unit"
# )
# vsps: Mapped[list["Vsp"]] = relationship("Vsp", back_populates="org_unit") # type: ignore
user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="org_unit")

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class PhaseTemplate(Base):
__tablename__ = "phase_template"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_phase_template_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_phase_template_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_phase_template_window"),
{"schema": "v3"},
)
form_type: Mapped[str] = mapped_column(String, ForeignKey("v3.form_type.code"), primary_key=True)
sheet: Mapped[str] = mapped_column(String, primary_key=True)
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
# form_type_rel = relationship("FormType", foreign_keys=[form_type])
# role_rel = relationship("Role", foreign_keys=[role], back_populates="phase_templates")

20
api/src/db/models/plan.py Normal file
View File

@ -0,0 +1,20 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Plan(Base):
__tablename__ = "plan"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,49 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Project(Base):
__tablename__ = "project"
__table_args__ = (
CheckConstraint("level IN ('program', 'project')", name="project_level_check"),
CheckConstraint(
"placement_type IS NULL OR placement_type IN ('own', 'rent', 'other')",
name="project_placement_type_check",
),
CheckConstraint(
"project_type IS NULL OR project_type IN ('current', 'development')",
name="project_project_type_check",
),
CheckConstraint(
"vsp_format IS NULL OR vsp_format IN ('office', 'standalone', 'atm', 'other')",
name="project_vsp_format_check",
),
CheckConstraint(
text("name IS NOT NULL AND name <> ''"),
name="chk_v3_project_name",
),
Index("ix_v3_project_ssp", "org_unit_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str | None] = mapped_column(String)
level: Mapped[str | None] = mapped_column(String)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
project_type: Mapped[str | None] = mapped_column(String)
vsp_format: Mapped[str | None] = mapped_column(String)
placement_type: Mapped[str | None] = mapped_column(String)
object_address: Mapped[str | None] = mapped_column(String)
staff_count: Mapped[int | None] = mapped_column(Integer)
total_area: Mapped[float | None] = mapped_column(Numeric)
org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
# parent = relationship("Project", remote_side="Project.id", back_populates="children")
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore
# org_unit = relationship("OrgUnit", back_populates="projects")
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="project") # type: ignore
# rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RentDetail(Base):
__tablename__ = "rent_detail"
__table_args__ = (
Index("ix_v3_rent_detail_line", "line_id"),
Index("ix_v3_rent_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

View File

@ -0,0 +1,20 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Reserve(Base):
__tablename__ = "reserve"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
amount_q1: Mapped[float | None] = mapped_column(Numeric)
amount_q2: Mapped[float | None] = mapped_column(Numeric)
amount_q3: Mapped[float | None] = mapped_column(Numeric)
amount_q4: Mapped[float | None] = mapped_column(Numeric)
justification: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReport(Base):
__tablename__ = "rf_project_report"
__table_args__ = (
UniqueConstraint("project_id", "year", "report_type", name="uq_v3_rf_project_report"),
Index("ix_v3_rf_project_report_proj_year", "project_id", "year"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id"))
year: Mapped[int] = mapped_column(Integer)
report_type: Mapped[str] = mapped_column(String, CheckConstraint("report_type IN ('LIMIT','CURRENT_EXPENSES')"))
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
# project: Mapped[Project] = relationship("Project", back_populates="rf_project_reports")
# creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by])
# updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by])
# lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report")

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReportLine(Base):
__tablename__ = "rf_project_report_line"
__table_args__ = (
UniqueConstraint(
"rf_project_report_id", "expense_item_id", name="uq_v3_rf_project_report_line"
),
Index("ix_v3_rf_project_report_line_rep", "rf_project_report_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rf_project_report_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_report.id"))
expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
# report: Mapped[RfProjectReport] = relationship("RfProjectReport", back_populates="lines")
# expense_item: Mapped[ExpenseItem] = relationship("ExpenseItem")
# quarters: Mapped[list[RfProjectReportQuarter]] = relationship(
# "RfProjectReportQuarter", back_populates="line"
# )

View File

@ -0,0 +1,30 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Numeric, SmallInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReportQuarter(Base):
__tablename__ = "rf_project_report_quarter"
__table_args__ = {"schema": "v3"}
rf_project_report_line_id: Mapped[int] = mapped_column(
Integer, ForeignKey("v3.rf_project_report_line.id"), primary_key=True
)
quarter: Mapped[int] = mapped_column(
SmallInteger,
CheckConstraint("quarter >= 1 AND quarter <= 4"),
primary_key=True,
)
adj_by_items: Mapped[float | None] = mapped_column(Numeric)
adj_increase: Mapped[float | None] = mapped_column(Numeric)
actual_m1: Mapped[float | None] = mapped_column(Numeric)
actual_m2: Mapped[float | None] = mapped_column(Numeric)
actual_m3: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
# line: Mapped[RfProjectReportLine] = relationship(
# "RfProjectReportLine", back_populates="quarters"
# )

31
api/src/db/models/role.py Normal file
View File

@ -0,0 +1,31 @@
import enum
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UserRoleEnum(int, enum.Enum):
ADMIN = 1
EXECUTOR_DFIP = 2
EXECUTOR_RF = 3
class Role(Base):
__tablename__ = "role"
__table_args__ = (
UniqueConstraint("code", name="uq_role_code"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String)
# users: Mapped[list["AppUser"]] = relationship("AppUser", back_populates="role") # type: ignore
# form_phases: Mapped[list["FormPhase"]] = relationship( # type: ignore
# "FormPhase", back_populates="role"
# )
# phase_templates: Mapped[list["PhaseTemplate"]] = relationship( # type: ignore
# "PhaseTemplate", back_populates="role"
# )

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class SecurityDetail(Base):
__tablename__ = "security_detail"
__table_args__ = (
Index("ix_v3_security_detail_line", "line_id"),
Index("ix_v3_security_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Sequestration(Base):
__tablename__ = "sequestration"
__table_args__ = (
CheckConstraint("actor IN ('DFIP','SSP_GO')", name="ck_v3_sequestration_actor"),
UniqueConstraint("line_id", "actor", name="uq_v3_sequestration_line_actor"),
Index("ix_v3_sequestration_line_actor", "line_id", "actor"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
actor: Mapped[str] = mapped_column(String)
adj_q1: Mapped[float | None] = mapped_column(Numeric)
adj_q2: Mapped[float | None] = mapped_column(Numeric)
adj_q3: Mapped[float | None] = mapped_column(Numeric)
adj_q4: Mapped[float | None] = mapped_column(Numeric)
justification: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UserOrg(Base):
__tablename__ = "user_org"
__table_args__ = (
UniqueConstraint("user_id", "org_unit_id"),
Index("ix_v4_user_org_org", "org_unit_id"),
Index("ix_v4_user_org_user", "user_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.app_user.id", ondelete="CASCADE"))
org_unit_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id", ondelete="CASCADE"))
user: Mapped[AppUser] = relationship("AppUser", back_populates="user_orgs", lazy="selectin")
org_unit: Mapped[OrgUnit] = relationship("OrgUnit", back_populates="user_orgs", lazy="selectin")

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UtilityDetail(Base):
__tablename__ = "utility_detail"
__table_args__ = (
Index("ix_v3_utility_detail_line", "line_id"),
Index("ix_v3_utility_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

30
api/src/db/models/vsp.py Normal file
View File

@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Vsp(Base):
__tablename__ = "vsp"
__table_args__ = {"schema": "v3"}
id: Mapped[int] = mapped_column(primary_key=True)
branch_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
reg_number: Mapped[str | None] = mapped_column(String)
address: Mapped[str | None] = mapped_column(String)
format: Mapped[str | None] = mapped_column(String)
opened_at: Mapped[date | None] = mapped_column(Date)
placement_type: Mapped[str | None] = mapped_column(String)
staff_count: Mapped[int | None] = mapped_column(Integer)
total_area: Mapped[float | None] = mapped_column(Numeric)
closed_at: Mapped[date | None] = mapped_column(Date)
# org_unit = relationship("OrgUnit", back_populates="vsps")
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="vsp") # type: ignore
# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="vsp")
# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="vsp")
# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="vsp")

View File

@ -5,6 +5,8 @@ from alembic import command
from alembic.config import Config
from src.core.config import settings
from typing import AsyncGenerator
from src.db.base import SessionLocal
@ -12,6 +14,7 @@ async def get_db() -> AsyncGenerator:
db = SessionLocal()
try:
yield db
await db.commit()
finally:
await db.close()

View File

@ -1,70 +0,0 @@
import enum
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
UniqueConstraint)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from src.db.base import Base
class UserRole(int, enum.Enum):
ADMIN = 1
EXECUTOR_DFIP = 2
EXECUTOR_RF = 3
class Roles(Base):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
role = Column(String, nullable=False)
user_role = relationship("Users", back_populates="role", lazy="select")
class Users(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=True)
full_name = Column(String, nullable=True)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
role = relationship(
"Roles",
foreign_keys=[role_id],
back_populates="user_role",
lazy="selectin",
)
user_ssp_link = relationship("UserSSPLink", back_populates="users", lazy="select")
class SSP(Base):
__tablename__ = "ssp"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
is_ssp = Column(Boolean, default=True, nullable=False, server_default="true")
user_ssp_link = relationship("UserSSPLink", back_populates="ssp", lazy="select")
class UserSSPLink(Base):
__tablename__ = "user_ssp_link"
__table_args__ = (
UniqueConstraint("user_id", "ssp_id", name="uq_user_ssp_link_user_id_ssp_id"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False)
users = relationship("Users", back_populates="user_ssp_link", lazy="select")
ssp = relationship("SSP", back_populates="user_ssp_link", lazy="select")

View File

@ -1,39 +1,105 @@
from datetime import datetime
from typing import Optional
import enum
from typing import Any, Generic, Literal, Optional, TypeVar
FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"]
from pydantic import BaseModel, ConfigDict, EmailStr, Field
T = TypeVar("T")
ProjectTypeLiteral = Literal[
"Открытие ВСП",
"Закрытие ВСП",
"Переезд ВСП",
"Реновация РФ",
"Открытие УРМ",
]
VspFormatLiteral = Literal[
"Флагманский",
"Типовой",
"Розничный",
"МСБ",
"Лёгкий",
"Мини",
"Розничный-киоск",
"МБО",
"Офис самообслуживания",
"Другое",
]
PlacementTypeLiteral = Literal[
"Собственность",
"Аренда",
"Субаренда",
]
class ResponseBase(BaseModel):
success: Optional[bool] = Field(None)
message: Optional[str] = Field(None)
success: Optional[bool] = Field(True)
message: Optional[str] = None
class BaseSingleResponse(ResponseBase, Generic[T]):
result: Optional[T] = None
class BaseListResponse(ResponseBase, Generic[T]):
result: Optional[list[T]] = None
count: Optional[int] = None
class UserBase(BaseModel):
email: EmailStr
email: EmailStr = Field(..., examples=["user@example.com"])
username: str = Field(
...,
min_length=3,
max_length=64,
pattern=r"^[A-Za-z0-9_.-]+$",
examples=["user_1"],
)
full_name: Optional[str] = Field(
None,
max_length=255,
pattern=r"^[^<>]*$",
examples=["Иван Иванов"],
)
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
role_id: int = 3
class UserCreate(UserBase):
password: str | None = None
password: str | None = Field(default=None, examples=["pass123"])
model_config = ConfigDict(
json_schema_extra={
"example": {
"email": "user@example.com",
"username": "user_1",
"full_name": "Иван Иванов",
"role_id": 3,
"password": "pass123",
}
}
)
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"])
username: Optional[str] = Field(
None,
min_length=3,
max_length=64,
pattern=r"^[A-Za-z0-9_.-]+$",
examples=["user_1"],
)
full_name: Optional[str] = Field(
None,
max_length=255,
pattern=r"^[^<>]*$",
examples=["Иван Иванов"],
)
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
role_id: Optional[int] = None
is_active: Optional[bool] = True
@ -97,3 +163,130 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel):
refresh_token: str
class FormTypeSchemaEnum(str, enum.Enum):
FORM_1 = "FORM_1"
FORM_2 = "FORM_2"
FORM_3 = "FORM_3"
FORM_4 = "FORM_4"
class OrgUnitSchema(BaseModel):
id: int
title: str
is_active: bool
is_ssp: bool
class Config:
from_attributes = True
class BudgetFormResponse(BaseModel):
id: int
created_at: datetime
created_by: int | None
updated_at: datetime
form_type_code: FormTypeSchemaEnum
year: int
org_unit_id: int
org_unit: OrgUnitSchema | None = None
class Config:
from_attributes = True
class SheetFormTypeResponse(BaseModel):
form_type: FormTypeSchemaEnum
sheets: list[str]
class DirectionSchemaEnum(str, enum.Enum):
SUPPORT = "Support"
DEVELOPMENT = "Development"
class SheetResponse(BaseModel):
row_type: str
depth: int
data: dict
sort_order: int | None = None
class CellPatch(BaseModel):
line_id: int
column: str
value: Any = None
class CellsPatch(BaseModel):
changes: list[CellPatch]
class AddForm3LineBody(BaseModel):
expense_item_id: int
class UpdProjectBody(BaseModel):
column: str
value: Any = None
class AddProjectBody(BaseModel):
name: str = Field(
...,
max_length=30,
pattern=r"^[^+\-\/\\=&*\s]{1,30}$",
)
year: int
branch_id: int
level: Literal["project", "program"] = "project"
parent_id: Optional[int] = None
ssp_id: Optional[int] = None
project_type: Optional[ProjectTypeLiteral] = None
vsp_format: Optional[VspFormatLiteral] = None
placement_type: Optional[PlacementTypeLiteral] = None
object_address: Optional[str] = None
staff_count: Optional[int] = 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
class FormPhaseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
budget_form_id: int
sheet: str
phase_code: str
role: str
column_keys: list[str]
opens_at: datetime
closes_at: datetime
class FormPhaseCreate(BaseModel):
sheet: str
phase_code: str
role: FormPhaseRole
column_keys: list[str]
opens_at: datetime
closes_at: datetime
class FormPhaseUpdate(BaseModel):
role: FormPhaseRole | None = None
column_keys: list[str] | None = None
opens_at: datetime | None = None
closes_at: datetime | None = None

View File

@ -2,8 +2,10 @@ import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
@ -69,6 +71,41 @@ async def healthcheck2():
"port": settings.PORT,
}
@app.get("/healthz", status_code=200)
async def healthz():
return {"status": "ok", "uptime": UP_TIME, "version": VERSION}
@app.get("/readyz")
async def readyz(response: Response):
result = {"status": "ok", "db_status": "ok", "mv_expense_item_tree": "ok"}
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
if "postgresql" in settings.DATABASE_URL:
my_exists = (
await conn.execute(
text(
"""
SELECT 1
FROM pg_matviews
WHERE schemaname = 'v3'
AND matviewname = 'mv_expense_item_tree'
"""
)
)
).scalar_one_or_none()
if my_exists is None:
result["status"] = "fail"
result["mv_expense_item_tree"] = "missing"
response.status_code = 503
except SQLAlchemyError as exc:
result["status"] = "fail"
result["db"] = f"error: {exc.__class__.__name__}"
response.status_code = 503
return result
if not settings.DEBUG:
app.add_middleware(
@ -83,6 +120,16 @@ if not settings.DEBUG:
path=settings.ROOT_PATH + "/healthcheck",
type_search=SearchType.ABSOLUTE,
),
OpenEndpoint(path="/healthz", type_search=SearchType.ABSOLUTE),
OpenEndpoint(
path=settings.ROOT_PATH + "/healthz",
type_search=SearchType.ABSOLUTE,
),
OpenEndpoint(path="/readyz", type_search=SearchType.ABSOLUTE),
OpenEndpoint(
path=settings.ROOT_PATH + "/readyz",
type_search=SearchType.ABSOLUTE,
),
OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
OpenEndpoint(
path=settings.ROOT_PATH + "/openapi.json",

View File

@ -0,0 +1,80 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.budget_form import BudgetForm
from sqlalchemy.orm import joinedload
class BudgetFormRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
offset: int | None = None,
limit: int | None = None,
org_unit: int | list[int] | None = None,
with_count: bool = False,
load_org: bool = False,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if with_count:
query = select(func.count().over().label("total_count"), BudgetForm)
else:
query = select(BudgetForm)
where = []
if org_unit is not None:
if isinstance(org_unit, int):
where.append(BudgetForm.org_unit_id == org_unit)
else:
where.append(BudgetForm.org_unit_id.in_(org_unit))
query = query.where(*where)
if load_org:
query = query.options(
joinedload(BudgetForm.org_unit)
)
query = query.order_by(BudgetForm.id)
if offset is not None:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
if with_count:
result = (await self.db.execute(query)).all()
if result:
return result[0][0], [res[1] for res in result]
else:
if query.whereclause is not None:
count_query = select(func.count(BudgetForm.id)).where(
query.whereclause
)
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().all()
async def get(
self,
budget_form_id: int,
org_unit: int | list[int] | None = None,
load_form_type: bool = False,
) -> BudgetForm | None:
query = select(BudgetForm)
where = [BudgetForm.id == budget_form_id]
if org_unit is not None:
if isinstance(org_unit, int):
where.append(BudgetForm.org_unit_id == org_unit)
else:
where.append(BudgetForm.org_unit_id.in_(org_unit))
query = query.where(*where)
if load_form_type:
query = query.options(
joinedload(BudgetForm.form_type)
)
return (await self.db.execute(query)).scalars().first()

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

@ -0,0 +1,84 @@
from datetime import datetime
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_phase import FormPhase
class FormPhaseRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
budget_form_id: int,
sheet: str | None = None,
phase_code: str | None = None,
) -> list[FormPhase]:
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
if sheet is not None:
query = query.where(FormPhase.sheet == sheet)
if phase_code is not None:
query = query.where(FormPhase.phase_code == phase_code)
return (await self.db.execute(query)).scalars().all()
async def get(
self, budget_form_id: int, sheet: str, phase_code: str
) -> FormPhase | None:
query = (
select(FormPhase)
.where(FormPhase.budget_form_id == budget_form_id)
.where(FormPhase.sheet == sheet)
.where(FormPhase.phase_code == phase_code)
.limit(1)
)
return (await self.db.execute(query)).scalar_one_or_none()
async def create(
self,
budget_form_id: int,
sheet: str,
phase_code: str,
role: str,
column_keys: list[str],
opens_at: datetime,
closes_at: datetime,
) -> FormPhase:
form_phase = FormPhase(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
role=role,
column_keys=column_keys,
opens_at=opens_at,
closes_at=closes_at,
)
self.db.add(form_phase)
await self.db.flush()
return form_phase
async def update(
self, budget_form_id: int, sheet: str, phase_code: str, data: dict
) -> FormPhase | None:
query = (
update(FormPhase)
.where(FormPhase.budget_form_id == budget_form_id)
.where(FormPhase.sheet == sheet)
.where(FormPhase.phase_code == phase_code)
.values(**data)
.returning(FormPhase)
)
return (await self.db.execute(query)).scalar_one_or_none()
async def delete(
self, budget_form_id: int, sheet: str, phase_code: str
) -> bool:
query = (
delete(FormPhase)
.where(FormPhase.budget_form_id == budget_form_id)
.where(FormPhase.sheet == sheet)
.where(FormPhase.phase_code == phase_code)
)
result = await self.db.execute(query)
return result.rowcount > 0

View File

@ -0,0 +1,360 @@
import json
from sqlalchemy import exists, false, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.org_unit import OrgUnit
from src.db.models.project import Project
from src.db.models.rf_project_report import RfProjectReport
from src.db.models.rf_project_report_line import RfProjectReportLine
class ProjectRepository:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
def _serialize_project(project: Project, org_unit_name: str | None, report_count: int) -> dict:
return {
"id": project.id,
"name": project.name,
"level": project.level,
"parent_id": project.parent_id,
"project_type": project.project_type,
"vsp_format": project.vsp_format,
"placement_type": project.placement_type,
"object_address": project.object_address,
"staff_count": project.staff_count,
"total_area": float(project.total_area) if project.total_area is not None else None,
"org_unit_id": project.org_unit_id,
"org_unit_name": org_unit_name,
"report_count": int(report_count),
}
@staticmethod
def _apply_project_filters(query, year: int | None, branch_id: int | None, org_unit_ids: list[int] | None):
if year is not None:
query = query.where(
exists(
select(1).where(
RfProjectReport.project_id == Project.id,
RfProjectReport.year == year,
)
)
)
if branch_id is not None:
query = query.where(Project.org_unit_id == branch_id)
if org_unit_ids is not None:
if len(org_unit_ids) == 0:
query = query.where(false())
else:
query = query.where(Project.org_unit_id.in_(org_unit_ids))
return query
async def get_list(
self,
year: int | None = None,
branch_id: int | None = None,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
org_unit_ids: list[int] | None = None,
) -> list[dict] | tuple[int, list[dict]]:
report_count_subq = (
select(func.count(RfProjectReport.id))
.where(RfProjectReport.project_id == Project.id)
.scalar_subquery()
)
query = (
select(
Project,
OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"),
)
.outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id)
.order_by(Project.id)
)
query = self._apply_project_filters(query, year, branch_id, org_unit_ids)
if offset is not None:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
rows = (await self.db.execute(query)).all()
payload = [self._serialize_project(row[0], row[1], row[2]) for row in rows]
if not with_count:
return payload
count_query = select(func.count(Project.id))
count_query = self._apply_project_filters(count_query, year, branch_id, org_unit_ids)
total = int((await self.db.execute(count_query)).scalar() or 0)
return total, payload
async def get(
self,
project_id: int,
org_unit_ids: list[int] | None = None,
) -> dict | None:
report_count_subq = (
select(func.count(RfProjectReport.id))
.where(RfProjectReport.project_id == Project.id)
.scalar_subquery()
)
query = (
select(
Project,
OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"),
)
.outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id)
.where(Project.id == project_id)
)
if org_unit_ids is not None:
if len(org_unit_ids) == 0:
return None
query = query.where(Project.org_unit_id.in_(org_unit_ids))
row = (await self.db.execute(query)).first()
if not row:
return None
return self._serialize_project(row[0], row[1], row[2])
async def get_reports(self, project_id: int) -> list[dict]:
line_count_subq = (
select(func.count(RfProjectReportLine.id))
.where(RfProjectReportLine.rf_project_report_id == RfProjectReport.id)
.scalar_subquery()
)
query = (
select(
RfProjectReport.id,
RfProjectReport.year,
RfProjectReport.report_type,
RfProjectReport.created_by,
RfProjectReport.created_at,
RfProjectReport.updated_by,
RfProjectReport.updated_at,
line_count_subq.label("line_count"),
)
.where(RfProjectReport.project_id == project_id)
.order_by(RfProjectReport.year, RfProjectReport.report_type)
)
rows = (await self.db.execute(query)).all()
reports: list[dict] = []
for row in rows:
reports.append(
{
"id": row[0],
"year": row[1],
"report_type": row[2],
"created_by": row[3],
"created_at": row[4].isoformat() if row[4] is not None else None,
"updated_by": row[5],
"updated_at": row[6].isoformat() if row[6] is not None else None,
"line_count": int(row[7]),
}
)
return reports
async def resolve_report_id(
self,
project_id: int,
year: int,
report_type: str,
org_unit_ids: list[int] | None = None,
) -> int | None:
query = (
select(RfProjectReport.id)
.join(Project, Project.id == RfProjectReport.project_id)
.where(
Project.id == project_id,
RfProjectReport.year == year,
RfProjectReport.report_type == report_type,
)
)
if org_unit_ids is not None:
if len(org_unit_ids) == 0:
return None
query = query.where(Project.org_unit_id.in_(org_unit_ids))
return (await self.db.execute(query)).scalar_one_or_none()
async def get_report_rows(self, report_id: int, sections: list[str] | None = None) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.v_form3_report_jsonb(CAST(:report_id AS INT), CAST(:sections AS TEXT[]))
"""
)
return (await self.db.execute(query, {"report_id": report_id, "sections": sections})).all()
async def get_rf_rollup_rows(
self,
branch_id: int,
year: int,
sections: list[str] | None = None,
) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.v_form3_rf_rollup_jsonb(CAST(:branch_id AS INT), CAST(:year AS INT), CAST(:sections AS TEXT[]))
"""
)
return (
await self.db.execute(
query,
{"branch_id": branch_id, "year": year, "sections": sections},
)
).all()
async def upd_form3_cell(
self,
report_id: int,
line_id: int,
column: str,
value,
) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.upd_form3_cell(
CAST(:report_id AS INT),
CAST(:line_id AS INT),
CAST(:column AS TEXT),
CAST(:value AS JSONB)
)
"""
)
rows = (
await self.db.execute(
query,
{
"report_id": report_id,
"line_id": line_id,
"column": column,
"value": json.dumps(value),
},
)
).all()
await self.db.commit()
return rows
async def upd_form3_cells(self, report_id: int, changes: list[dict]) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.upd_form3_cells(CAST(:report_id AS INT), CAST(:changes AS JSONB))
"""
)
rows = (
await self.db.execute(
query,
{
"report_id": report_id,
"changes": json.dumps(changes),
},
)
).all()
await self.db.commit()
return rows
async def add_form3_line(self, report_id: int, expense_item_id: int) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.add_form3_line(CAST(:report_id AS INT), CAST(:expense_item_id AS INT))
"""
)
rows = (
await self.db.execute(
query,
{
"report_id": report_id,
"expense_item_id": expense_item_id,
},
)
).all()
await self.db.commit()
return rows
async def del_form3_line(self, line_id: int) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.del_form3_line(CAST(:line_id AS INT))
"""
)
rows = (await self.db.execute(query, {"line_id": line_id})).all()
await self.db.commit()
return rows
async def upd_project(self, project_id: int, column: str, value):
query = text(
"""
SELECT v3.upd_project(CAST(:project_id AS INT), CAST(:column AS TEXT), CAST(:value AS JSONB))
"""
)
result = (
await self.db.execute(
query,
{
"project_id": project_id,
"column": column,
"value": json.dumps(value),
},
)
).scalar_one()
await self.db.commit()
return result
async def add_project(
self,
name: str,
year: int,
org_unit_id: int,
level: str = "project",
parent_id: int | None = None,
project_type: str | None = None,
vsp_format: str | None = None,
placement_type: str | None = None,
object_address: str | None = None,
staff_count: int | None = None,
total_area: float | None = None,
) -> tuple[int, int, int]:
query = text(
"""
SELECT project_id, limit_report_id, current_expenses_report_id
FROM v3.add_project(
CAST(:name AS VARCHAR),
CAST(:year AS INT),
CAST(:org_unit_id AS INT),
CAST(:level AS VARCHAR),
CAST(:parent_id AS INT),
CAST(:project_type AS VARCHAR),
CAST(:vsp_format AS VARCHAR),
CAST(:placement_type AS VARCHAR),
CAST(:object_address AS VARCHAR),
CAST(:staff_count AS INT),
CAST(:total_area AS NUMERIC)
)
"""
)
row = (
await self.db.execute(
query,
{
"name": name,
"year": year,
"org_unit_id": org_unit_id,
"level": level,
"parent_id": parent_id,
"project_type": project_type,
"vsp_format": vsp_format,
"placement_type": placement_type,
"object_address": object_address,
"staff_count": staff_count,
"total_area": total_area,
},
)
).first()
await self.db.commit()
return int(row[0]), int(row[1]), int(row[2])

View File

@ -0,0 +1,235 @@
import enum
import itertools
import json
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormType
class DirectionEnum(str, enum.Enum):
SUPPORT = "Support"
DEVELOPMENT = "Development"
ACCEPTABLE_SHEETS = set(itertools.chain(*FormType.SHEETS_BY_FORM_TYPE.values()))
ACCEPTABLE_SECTIONS = set(itertools.chain(*FormType.SECTIONS_BY_FORM_TYPE.values()))
class SheetValidationError(Exception):
def __init__(self, message, *args):
super().__init__(*args)
self.message = message
class SheetRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction is not None and direction not in [s.value for s in DirectionEnum],
sections and (set(sections) - ACCEPTABLE_SECTIONS),
]
):
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 (
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 | None,
sections: list[str] | None,
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 is not None and 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 | None,
sections: list[str] | None,
changes: list[dict],
user_id: int = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction is not None and 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()

View File

@ -1,64 +1,85 @@
from typing import Optional
from sqlalchemy import delete, select, update
from sqlalchemy import delete, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.db.models.org_unit import OrgUnit
from src.db.models.role import Role
from src.db.models.user_org import UserOrg
from src.db.models.app_user import AppUser
from src.core.security import get_password_hash, verify_password
from src.domain.models import Roles, SSP, UserSSPLink, Users
class UserRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, user_id: int) -> Optional[Users]:
async def get(self, user_id: int, load_orgs: bool = False) -> Optional[AppUser]:
query = select(AppUser).where(AppUser.id == user_id)
if load_orgs:
query = query.options(
selectinload(AppUser.org_units),
)
return (
(
await self.db.execute(
select(Users).where(Users.id == user_id).limit(1)
query.limit(1)
)
)
.scalars()
.first()
)
async def get_by_email(self, email: str) -> Optional[Users]:
async def get_by_email(self, email: str) -> Optional[AppUser]:
return (
(
await self.db.execute(
select(Users).where(Users.email == email).limit(1)
select(AppUser).where(AppUser.email == email).limit(1)
)
)
.scalars()
.first()
)
async def get_by_username(self, username: str) -> Optional[Users]:
async def get_by_username(self, username: str) -> Optional[AppUser]:
return (
(
await self.db.execute(
select(Users).where(Users.username == username).limit(1)
select(AppUser).where(AppUser.username == username).limit(1)
)
)
.scalars()
.first()
)
async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]:
async def set_app_user_id(self, user_id: int) -> None:
if isinstance(user_id, int):
await self.db.execute(
text(
f"SET LOCAL app.user_id = {user_id}"
),
)
async def get_list(self, skip: int = 0, limit: int = 100) -> list[AppUser]:
query = (
select(Users)
.where(Users.is_active.is_(True))
select(AppUser)
.where(AppUser.is_active.is_(True))
.offset(skip)
.limit(limit)
.order_by(Users.id)
.order_by(AppUser.id)
)
return (await self.db.execute(query)).scalars().all()
async def create(self, user_data: dict) -> Users:
async def create(self, user_data: dict) -> AppUser:
hashed_password = (
get_password_hash(user_data["password"]) if user_data.get("password") else None
)
db_user = Users(
db_user = AppUser(
email=user_data["email"],
username=user_data["username"],
hashed_password=hashed_password,
@ -70,7 +91,7 @@ class UserRepository:
await self.db.refresh(db_user)
return db_user
async def update(self, user_id: int, user_data: dict) -> Optional[Users]:
async def update(self, user_id: int, user_data: dict) -> Optional[AppUser]:
user = await self.get(user_id)
if not user:
return None
@ -85,12 +106,12 @@ class UserRepository:
return user
async def logical_delete(self, user_id: int) -> bool:
query = update(Users).where(Users.id == user_id).values(is_active=False)
query = update(AppUser).where(AppUser.id == user_id).values(is_active=False)
result = await self.db.execute(query)
await self.db.commit()
return result.rowcount > 0
async def authenticate(self, username: str, password: str) -> Optional[Users]:
async def authenticate(self, username: str, password: str) -> Optional[AppUser]:
user = await self.get_by_username(username)
if not user:
return None
@ -98,26 +119,26 @@ class UserRepository:
return None
return user
async def authenticate_via_email(self, email: str) -> Optional[Users]:
async def authenticate_via_email(self, email: str) -> Optional[AppUser]:
return await self.get_by_email(email)
async def get_roles(self) -> list[Roles]:
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all()
async def get_roles(self) -> list[Role]:
return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
async def get_many_ssp(self, user_id: int) -> list[SSP]:
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where(
UserSSPLink.user_id == user_id
async def get_many_ssp(self, user_id: int) -> list[OrgUnit]:
query = select(OrgUnit).join(UserOrg, UserOrg.ssp_id == OrgUnit.id).where(
UserOrg.user_id == user_id
)
return (await self.db.execute(query)).scalars().all()
async def get_many_ssp_ids(self, user_id: int) -> list[int]:
query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id)
query = select(UserOrg.ssp_id).where(UserOrg.user_id == user_id)
return (await self.db.execute(query)).scalars().all()
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try:
for ssp_id in ssp_ids:
link = UserSSPLink(user_id=user_id, ssp_id=ssp_id)
link = UserOrg(user_id=user_id, ssp_id=ssp_id)
self.db.add(link)
await self.db.commit()
return True
@ -128,8 +149,8 @@ class UserRepository:
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try:
for ssp_id in ssp_ids:
query = delete(UserSSPLink).where(
(UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id)
query = delete(UserOrg).where(
(UserOrg.user_id == user_id) & (UserOrg.ssp_id == ssp_id)
)
await self.db.execute(query)
await self.db.commit()
@ -140,7 +161,7 @@ class UserRepository:
async def clear_many_ssp(self, user_id: int) -> bool:
try:
query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id)
query = delete(UserOrg).where(UserOrg.user_id == user_id)
await self.db.execute(query)
await self.db.commit()
return True

View File

@ -0,0 +1,46 @@
import enum
import typing
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.user_repository import UserRepository
class AccessEnum(int, enum.Enum):
RESTRICT = 0
VIEW = 1
EDIT = 2
class AccessControlService:
def __init__(self, db: AsyncSession, user: AppUser):
self.db = db
self.user_repo = UserRepository(db)
async def _check_budget_form(
self,
user: AppUser,
budget_form: BudgetForm,
) -> AccessEnum:
if user.role_id == UserRoleEnum.ADMIN:
return AccessEnum.EDIT
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
if budget_form.org_unit_id in [ou.id for ou in user.org_units]:
return AccessEnum.EDIT
return AccessEnum.RESTRICT
async def check_object_access(
self,
user: AppUser,
object: typing.Any
) -> AccessEnum:
if isinstance(object, BudgetForm):
return await self._check_budget_form(user=user,budget_form=object)
return AccessEnum.EDIT

View File

@ -0,0 +1,64 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.user_repository import UserRepository
from src.db.models.app_user import AppUser
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.budget_form_repository import BudgetFormRepository
class BudgetFormService:
def __init__(self, db: AsyncSession):
self.db = db
self.bf_repo = BudgetFormRepository(db)
self.user_repo = UserRepository(db)
async def get_list(
self,
user: AppUser,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
load_org: bool = False,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if user.role_id == UserRoleEnum.ADMIN:
return await self.bf_repo.get_list(
offset=offset,
limit=limit,
with_count=with_count,
load_org=load_org,
)
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
return await self.bf_repo.get_list(
offset=offset,
limit=limit,
org_unit=[ou.id for ou in user.org_units],
with_count=with_count,
load_org=load_org,
)
async def get(
self,
budget_form_id: int,
user: AppUser,
load_form_type: bool = False,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if user.role_id == UserRoleEnum.ADMIN:
return await self.bf_repo.get(
budget_form_id=budget_form_id,
load_form_type=load_form_type,
)
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
return await self.bf_repo.get(
budget_form_id=budget_form_id,
org_unit=[ou.id for ou in user.org_units],
load_form_type=load_form_type,
)

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

@ -0,0 +1,139 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.errors import AccessDeniedException, ValidationsError
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.budget_form_repository import BudgetFormRepository
from src.db.models.app_user import AppUser
from src.db.models.form_phase import FormPhase
from src.domain.schemas import FormPhaseCreate, FormPhaseUpdate
from src.repository.form_phase_repository import FormPhaseRepository
class FormPhaseService:
def __init__(self, db: AsyncSession):
self.db = db
self.fp_repo = FormPhaseRepository(db)
self.bf_repo = BudgetFormRepository(db)
async def get_list(
self,
budget_form_id: int,
user: AppUser,
sheet: str | None = None,
phase_code: str | None = None,
) -> list[FormPhase]:
return await self.fp_repo.get_list(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
)
async def get(
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
) -> FormPhase | None:
return await self.fp_repo.get(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
)
def _validate_form_phase(
self,
form_phase: FormPhaseCreate | FormPhaseUpdate,
budget_form: BudgetForm,
sheet: str | None = None,
) -> None:
errors = {}
if budget_form.form_type.sheet_list:
sheet = sheet or form_phase.sheet
if sheet not in budget_form.form_type.sheet_list:
errors["sheet"] = [f"У формы нет листа {sheet}"]
if form_phase.column_keys and budget_form.form_type.section_list:
for column_key in form_phase.column_keys:
section = column_key.split(".")
if section[0] not in budget_form.form_type.section_list:
if "column_keys" in errors:
errors["column_keys"].append(f"Колонка {column_key} некорректна")
else:
errors["column_keys"] = [f"Колонка {column_key} некорректна"]
if errors:
raise ValidationsError(errors=errors)
async def create(
self,
body: FormPhaseCreate,
user: AppUser,
budget_form_id: int | None = None,
budget_form: BudgetForm | None = None,
) -> FormPhase:
assert budget_form or budget_form_id
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException
if not budget_form or budget_form.form_type is None:
budget_form = await self.bf_repo.get(
budget_form_id=budget_form_id or budget_form.id,
load_form_type=True,
)
self._validate_form_phase(
form_phase=body,
budget_form=budget_form,
)
return await self.fp_repo.create(
budget_form_id=budget_form_id or budget_form.id,
sheet=body.sheet,
phase_code=body.phase_code,
role=body.role,
column_keys=body.column_keys,
opens_at=body.opens_at,
closes_at=body.closes_at,
)
async def update(
self,
sheet: str,
phase_code: str,
body: FormPhaseUpdate,
user: AppUser,
budget_form_id: int | None = None,
budget_form: BudgetForm | None = None,
) -> FormPhase:
assert budget_form or budget_form_id
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException
if not budget_form or budget_form.form_type is None:
budget_form = await self.bf_repo.get(
budget_form_id=budget_form_id or budget_form.id,
load_form_type=True,
)
self._validate_form_phase(
form_phase=body,
budget_form=budget_form,
sheet=sheet,
)
data = body.model_dump(exclude_unset=True)
if not data:
return await self.fp_repo.get(
budget_form_id=budget_form_id or budget_form.id,
sheet=sheet,
phase_code=phase_code,
)
return await self.fp_repo.update(
budget_form_id=budget_form_id or budget_form.id,
sheet=sheet,
phase_code=phase_code,
data=data,
)
async def delete(
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
) -> bool:
return await self.fp_repo.delete(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
)

View File

@ -0,0 +1,191 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.role import UserRoleEnum
from src.core.errors import AccessDeniedException, ValidationException
from src.repository.project_repository import ProjectRepository
from src.repository.user_repository import UserRepository
class ProjectService:
def __init__(self, db: AsyncSession):
self.db = db
self.project_repo = ProjectRepository(db)
self.user_repo = UserRepository(db)
async def get_list(
self,
user: AppUser,
year: int | None = None,
branch_id: int | None = None,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
with_org_unit: bool = False,
) -> list[dict] | tuple[int, list[dict]]:
org_unit_ids = await self._allowed_org_unit_ids(user)
return await self.project_repo.get_list(
year=year,
branch_id=branch_id,
offset=offset,
limit=limit,
with_count=with_count,
org_unit_ids=org_unit_ids,
)
async def get(self, project_id: int, user: AppUser) -> dict | None:
org_unit_ids = await self._allowed_org_unit_ids(user)
project = await self.project_repo.get(project_id=project_id, org_unit_ids=org_unit_ids)
if not project:
return None
project["reports"] = await self.project_repo.get_reports(project_id=project_id)
return project
async def resolve_report_id(
self,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> int | None:
normalized_type = report_type.upper()
if normalized_type not in ("LIMIT", "CURRENT_EXPENSES"):
raise ValidationException("report_type должен быть LIMIT или CURRENT_EXPENSES")
org_unit_ids = await self._allowed_org_unit_ids(user)
return await self.project_repo.resolve_report_id(
project_id=project_id,
year=year,
report_type=normalized_type,
org_unit_ids=org_unit_ids,
)
async def get_report_rows(
self,
project_id: int,
year: int,
report_type: str,
sections: list[str] | None,
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.get_report_rows(report_id=report_id, sections=sections)
async def get_rf_rollup_rows(
self,
branch_id: int,
year: int,
sections: list[str] | None,
user: AppUser,
) -> list[tuple]:
org_unit_ids = await self._allowed_org_unit_ids(user)
if org_unit_ids is not None and branch_id not in org_unit_ids:
raise AccessDeniedException()
return await self.project_repo.get_rf_rollup_rows(
branch_id=branch_id,
year=year,
sections=sections,
)
async def upd_form3_cell(
self,
project_id: int,
year: int,
report_type: str,
line_id: int,
column: str,
value,
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.upd_form3_cell(report_id, line_id, column, value)
async def upd_form3_cells(
self,
project_id: int,
year: int,
report_type: str,
changes: list[dict],
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.upd_form3_cells(report_id, changes)
async def add_form3_line(
self,
project_id: int,
year: int,
report_type: str,
expense_item_id: int,
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.add_form3_line(report_id, expense_item_id)
async def del_form3_line(
self,
project_id: int,
year: int,
report_type: str,
line_id: int,
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.del_form3_line(line_id)
async def upd_project(
self,
project_id: int,
column: str,
value,
user: AppUser,
):
project = await self.get(project_id, user)
if not project:
raise ValidationException("Проект не найден")
return await self.project_repo.upd_project(project_id, column, value)
async def add_project(
self,
name: str,
year: int,
org_unit_id: int,
level: str = "project",
parent_id: int | None = None,
project_type: str | None = None,
vsp_format: str | None = None,
placement_type: str | None = None,
object_address: str | None = None,
staff_count: int | None = None,
total_area: float | None = None,
) -> tuple[int, int, int]:
return await self.project_repo.add_project(
name=name,
year=year,
org_unit_id=org_unit_id,
level=level,
parent_id=parent_id,
project_type=project_type,
vsp_format=vsp_format,
placement_type=placement_type,
object_address=object_address,
staff_count=staff_count,
total_area=total_area,
)
async def _allowed_org_unit_ids(self, user: AppUser) -> list[int] | None:
if user.role_id == UserRoleEnum.ADMIN:
return None
db_user = await self.user_repo.get(user_id=user.id, load_orgs=True)
if not db_user:
return []
return [ou.id for ou in db_user.org_units]

View File

@ -0,0 +1,139 @@
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
class SheetService:
def __init__(self, db: AsyncSession):
self.db = db
self.sheet_repo = SheetRepository(db)
async def get(
self,
sheet: str,
direction: str | None,
sections: list[str] | None = None,
form_id: int | None = None,
form: BudgetForm | None = None
) -> list[tuple]:
return await self.sheet_repo.get(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
)
async def update_cell(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None,
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 | None,
sections: list[str] | None,
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 | None,
sections: list[str] | None,
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,12 +1,13 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.role import UserRoleEnum
from src.core.errors import (
AccessDeniedException,
UserNotFoundException,
ValidationException,
UsernameConflictException,
)
from src.domain.models import UserRole, Users
from src.domain.schemas import UserCreate, UserUpdate
from src.repository.user_repository import UserRepository
@ -16,23 +17,23 @@ class UserService:
self.db = db
self.user_repo = UserRepository(db)
async def get_all(self, current_user: Users, skip: int = 0, limit: int = 100):
if current_user.role_id != UserRole.ADMIN:
async def get_all(self, current_user: AppUser, skip: int = 0, limit: int = 100):
if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException()
return await self.user_repo.get_list(skip=skip, limit=limit)
async def get(self, user_id: int, current_user: Users):
if current_user.role_id != UserRole.ADMIN:
async def get(self, user_id: int, current_user: AppUser):
if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException()
return await self.user_repo.get(user_id)
async def create_user(self, user_data: UserCreate, creator: Users):
if creator.role_id != UserRole.ADMIN:
async def create_user(self, user_data: UserCreate, creator: AppUser):
if creator.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException()
role_id = user_data.role_id or UserRole.EXECUTOR_RF.value
role_id = user_data.role_id or UserRoleEnum.EXECUTOR_RF.value
try:
role = UserRole(role_id)
role = UserRoleEnum(role_id)
except ValueError:
raise ValidationException("Роль не найдена", field="role_id")
user_data.role_id = role.value
@ -43,8 +44,8 @@ class UserService:
payload = user_data.model_dump(mode="json")
return await self.user_repo.create(payload)
async def update_user(self, user_id: int, user_data: UserUpdate, user: Users):
if user.role_id != UserRole.ADMIN:
async def update_user(self, user_id: int, user_data: UserUpdate, user: AppUser):
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException()
target = await self.user_repo.get(user_id)
@ -57,7 +58,7 @@ class UserService:
role = None
if role_id is not None:
try:
role = UserRole(role_id)
role = UserRoleEnum(role_id)
except ValueError:
raise ValidationException("Роль не найдена", field="role_id")
@ -76,43 +77,43 @@ class UserService:
if role_id is not None and role is not None:
had_many_ssp_role = previous_role_id in (
UserRole.ADMIN.value,
UserRole.EXECUTOR_DFIP.value,
UserRoleEnum.ADMIN.value,
UserRoleEnum.EXECUTOR_DFIP.value,
)
has_many_ssp_role_now = role.value in (
UserRole.ADMIN.value,
UserRole.EXECUTOR_DFIP.value,
UserRoleEnum.ADMIN.value,
UserRoleEnum.EXECUTOR_DFIP.value,
)
is_executor_to_dfip_transition = (
previous_role_id == UserRole.EXECUTOR_RF.value
and role.value == UserRole.EXECUTOR_DFIP.value
previous_role_id == UserRoleEnum.EXECUTOR_RF.value
and role.value == UserRoleEnum.EXECUTOR_DFIP.value
)
if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition:
await self.user_repo.clear_many_ssp(user_id)
return updated
async def delete_user(self, user_id: int, user: Users):
if user.role_id != UserRole.ADMIN:
async def delete_user(self, user_id: int, user: AppUser):
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException()
deleted = await self.user_repo.logical_delete(user_id)
if not deleted:
raise UserNotFoundException()
async def get_roles(self, current_user: Users):
if current_user.role_id != UserRole.ADMIN:
async def get_roles(self, current_user: AppUser):
if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException()
return await self.user_repo.get_roles()
async def get_many_ssp(self, user_id: int, current_user: Users):
if current_user.role_id == UserRole.ADMIN:
async def get_many_ssp(self, user_id: int, current_user: AppUser):
if current_user.role_id == UserRoleEnum.ADMIN:
return await self.user_repo.get_many_ssp(user_id)
if current_user.id != user_id:
raise AccessDeniedException()
return await self.user_repo.get_many_ssp(user_id)
async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users):
if current_user.role_id != UserRole.ADMIN:
async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser):
if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException()
user = await self.user_repo.get(user_id)
@ -121,9 +122,9 @@ class UserService:
target_role_id = self._normalize_role_id(user.role_id)
if target_role_id not in (
UserRole.ADMIN.value,
UserRole.EXECUTOR_DFIP.value,
UserRole.EXECUTOR_RF.value,
UserRoleEnum.ADMIN.value,
UserRoleEnum.EXECUTOR_DFIP.value,
UserRoleEnum.EXECUTOR_RF.value,
):
err = AccessDeniedException()
err.description = (
@ -132,7 +133,7 @@ class UserService:
raise err
curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id)
if target_role_id == UserRole.EXECUTOR_RF.value:
if target_role_id == UserRoleEnum.EXECUTOR_RF.value:
merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids)
if len(merged_ssp_ids) > 1:
raise ValidationException(
@ -141,8 +142,8 @@ class UserService:
new_ssp_ids = [ssp_id for ssp_id in ssp_ids if ssp_id not in curr_ssp_ids]
return await self.user_repo.set_many_ssp(user.id, new_ssp_ids)
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users):
if current_user.role_id != UserRole.ADMIN:
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser):
if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException()
user = await self.user_repo.get(user_id)
@ -151,9 +152,9 @@ class UserService:
target_role_id = self._normalize_role_id(user.role_id)
if target_role_id not in (
UserRole.ADMIN.value,
UserRole.EXECUTOR_DFIP.value,
UserRole.EXECUTOR_RF.value,
UserRoleEnum.ADMIN.value,
UserRoleEnum.EXECUTOR_DFIP.value,
UserRoleEnum.EXECUTOR_RF.value,
):
err = AccessDeniedException()
err.description = (
@ -163,5 +164,5 @@ class UserService:
return await self.user_repo.unset_many_ssp(user_id, ssp_ids)
@staticmethod
def _normalize_role_id(role_id: int | UserRole) -> int:
return role_id.value if isinstance(role_id, UserRole) else int(role_id)
def _normalize_role_id(role_id: int | UserRoleEnum) -> int:
return role_id.value if isinstance(role_id, UserRoleEnum) else int(role_id)

View File

@ -1,49 +1,65 @@
import asyncio
from contextlib import contextmanager
import os
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient
# Keep a single sqlite target for app + alembic during tests.
TEST_DATABASE_URL = "sqlite+aiosqlite:///./app.db"
os.environ["DATABASE_URL"] = TEST_DATABASE_URL
os.environ["OPENBAO__SETTINGS__DATABASE_URL"] = TEST_DATABASE_URL
os.environ["OPENBAO__SETTINGS_TEST__DATABASE_URL"] = TEST_DATABASE_URL
os.environ["OPENBAO__SETTINGS__DEBUG"] = "true"
os.environ["OPENBAO__SETTINGS_TEST__DEBUG"] = "true"
from src.db.base import SessionLocal
from src.db.session import get_db
from src.main import app
@pytest.fixture(scope="session", autouse=True)
def migrate_db():
db_files = [Path("app.db"), Path("dfip_budget_planing.db"), Path("dfip_new_schema.db")]
for db_file in db_files:
if db_file.exists():
os.remove(db_file)
alembic_cfg = Config("alembic.ini")
command.upgrade(alembic_cfg, "head")
yield
for db_file in db_files:
if db_file.exists():
os.remove(db_file)
@pytest.fixture
def client():
with TestClient(app) as test_client:
yield test_client
loop = asyncio.get_event_loop()
db = SessionLocal()
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
def admin_tokens(client):
def admin_password() -> str:
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
return os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123")
@pytest.fixture
def isp_password() -> str:
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
return os.getenv("OPENBAO__TEST_EXECUTOR_PASSWORD", "admin123")
@pytest.fixture
def admin_tokens(client, admin_password: str):
response = client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "admin"},
json={"username": "admin", "password": admin_password},
)
assert response.status_code == 200
return response.json()
@pytest.fixture
def isp_tokens(client, isp_password: str):
response = client.post(
"/api/v1/auth/login",
json={"username": "isp1", "password": isp_password},
)
assert response.status_code == 200
return response.json()

View File

@ -1,7 +1,7 @@
def test_login_success(client):
def test_login_success(client, admin_password):
response = client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "admin"},
json={"username": "admin", "password": admin_password},
)
assert response.status_code == 200
data = response.json()
@ -9,10 +9,10 @@ def test_login_success(client):
assert "refresh_token" in data
def test_login_invalid_password(client):
def test_login_invalid_password(client, admin_password):
response = client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "wrong"},
json={"username": "admin", "password": f"{admin_password}_wrong"},
)
assert response.status_code == 401

View File

@ -0,0 +1,140 @@
import pytest
def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_form_phases_list_smoke(client, admin_tokens):
response = client.get(
"/api/v1/stages/form/1", 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_form_phases_list_filter_smoke(client, admin_tokens):
response = client.get(
"/api/v1/stages/form/1", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "count" in payload
count = payload["count"]
response = client.get(
"/api/v1/stages/form/1?sheet=AHR&phase_code=test", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "count" in payload
assert payload["count"] <= count
def test_form_phases_create_forbidden(client, isp_tokens):
response = client.post(
"/api/v1/stages/form/1",
json={
"sheet": "AHR",
"phase_code": "new_phase",
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
},
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form_phases_update_forbidden(client, isp_tokens):
response = client.patch(
"/api/v1/stages/form/1/AHR/test",
json={
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
},
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form_phases_delete_forbidden(client, isp_tokens):
response = client.delete(
"/api/v1/stages/form/1/AHR/test",
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form_phases_create(client, admin_tokens):
body = {
"budget_form_id": 1,
"sheet": "AHR",
"phase_code": "new_phase",
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
}
response = client.post(
"/api/v1/stages/form/1",
json=body,
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "success" in payload
assert payload["success"]
assert "result" in payload
assert isinstance(payload["result"], dict)
assert payload["result"] == body
def test_form_phases_update(client, admin_tokens):
body = {
"budget_form_id": 1,
"sheet": "AHR",
"phase_code": "test",
"role": "EXECUTOR_RF",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
}
response = client.patch(
"/api/v1/stages/form/1/AHR/test",
json=body,
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "success" in payload
assert payload["success"]
assert "result" in payload
assert isinstance(payload["result"], dict)
assert payload["result"] == body
def test_form_phases_delete(client, admin_tokens):
response = client.delete(
"/api/v1/stages/form/1/AHR/test",
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200

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 "message" 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 "message" 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 "message" 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 "message" 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 "message" in payload

View File

@ -0,0 +1,123 @@
import uuid
import pytest
def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_projects_list_smoke(client, admin_tokens):
response = client.get("/api/v1/projects", 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 _create_project(client, admin_tokens) -> tuple[int, str]:
name = f"PT_{uuid.uuid4().hex[:10]}"
response = client.post(
"/api/v1/projects",
json={"name": name, "year": 2026, "branch_id": 1},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
return payload["project_id"], name
def _add_line(client, admin_tokens, project_id: int) -> int:
for expense_item_id in range(1, 80):
response = client.post(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
json={"expense_item_id": expense_item_id},
headers=_auth_headers(admin_tokens),
)
if response.status_code != 200:
continue
rows = response.json()
input_row = next(
(
row
for row in rows
if row.get("row_type") == "INPUT" and row.get("data", {}).get("line_id")
),
None,
)
if input_row:
return int(input_row["data"]["line_id"])
pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД")
def test_projects_write_smoke(client, admin_tokens):
project_id, name = _create_project(client, admin_tokens)
upd_project_response = client.patch(
f"/api/v1/project/{project_id}",
json={"column": "name", "value": f"{name}_U"},
headers=_auth_headers(admin_tokens),
)
assert upd_project_response.status_code == 200
line_id = _add_line(client, admin_tokens, project_id)
upd_cell_response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens),
)
assert upd_cell_response.status_code == 200
assert isinstance(upd_cell_response.json(), list)
upd_cells_response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cells",
json={
"changes": [
{"line_id": line_id, "column": "q1.m2", "value": 200},
{"line_id": line_id, "column": "q1.m3", "value": 300},
]
},
headers=_auth_headers(admin_tokens),
)
assert upd_cells_response.status_code == 200
assert isinstance(upd_cells_response.json(), list)
del_line_response = client.delete(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}",
headers=_auth_headers(admin_tokens),
)
assert del_line_response.status_code == 200
def test_projects_write_invalid_report_type_error_shape(client, admin_tokens):
project_id, _ = _create_project(client, admin_tokens)
line_id = _add_line(client, admin_tokens, project_id)
response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
assert isinstance(payload["message"], str)
def test_projects_write_report_not_found_error_shape(client, admin_tokens):
project_id, _ = _create_project(client, admin_tokens)
line_id = _add_line(client, admin_tokens, project_id)
response = client.patch(
f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert isinstance(payload, dict)
assert payload.get("message") == "Отчёт не найден"

View File

@ -1,3 +1,9 @@
import uuid
import uuid
def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
@ -8,23 +14,26 @@ def test_users_me(client, admin_tokens):
assert response.json()["username"] == "admin"
def test_users_create_and_list(client, admin_tokens):
def test_users_create_smoke(client, admin_tokens):
suffix = uuid.uuid4().hex[:8]
username = f"user_{suffix}"
create_payload = {
"email": "user1@example.com",
"username": "user1",
"email": f"{username}@example.com",
"username": username,
"password": "pass123",
"full_name": "User One",
"role_id": 2,
}
created = client.put(
"/api/v1/users/",
json=create_payload,
json={
"email": f"{username}@example.com",
"username": username,
"password": "pass123",
"full_name": "User One",
"role_id": 2,
},
headers=_auth_headers(admin_tokens),
)
assert created.status_code == 200
listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens))
assert listed.status_code == 200
usernames = [row["username"] for row in listed.json()["result"]]
assert "admin" in usernames
assert "user1" in usernames
assert created.json()["result"]["username"] == username

View File

@ -0,0 +1,155 @@
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
from src.core.exception_handlers import (
_error_payload,
map_http_exception,
map_sqlalchemy_error,
)
def test_map_sqlalchemy_error_unique_conflict():
exc = IntegrityError(
statement="INSERT INTO users ...",
params={},
orig=Exception("duplicate key value violates unique constraint users_username_key"),
)
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_409_CONFLICT
assert code == "conflict"
assert "duplicate key value" in message
assert field is None
def test_map_sqlalchemy_error_check_constraint_is_400():
exc = IntegrityError(
statement="UPDATE v3.project ...",
params={},
orig=Exception("new row for relation violates check constraint chk_v3_project_name"),
)
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_400_BAD_REQUEST
assert code == "db_error"
assert "violates check constraint" in message
assert field is None
def test_map_sqlalchemy_error_validation_prefix():
exc = SQLAlchemyError()
exc.orig = Exception("unknown_column: q5.plan")
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert code == "unknown_column"
assert message == "unknown_column: q5.plan"
assert field == "q5.plan"
def test_map_sqlalchemy_error_not_found():
exc = SQLAlchemyError()
exc.orig = Exception("budget_form #99 не существует")
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_404_NOT_FOUND
assert code == "not_found"
assert "не существует" in message
assert field is None
def test_map_sqlalchemy_error_role_window_prefixes():
role_exc = SQLAlchemyError()
role_exc.orig = Exception("role_not_allowed: q1.m1")
status_code, code, message, field = map_sqlalchemy_error(role_exc)
assert status_code == status.HTTP_403_FORBIDDEN
assert code == "access_denied"
assert message == "role_not_allowed: q1.m1"
assert field is None
window_exc = SQLAlchemyError()
window_exc.orig = Exception("window_closed: q1.m1 (closed 2026-05-01T00:00:00Z)")
status_code, code, message, field = map_sqlalchemy_error(window_exc)
assert status_code == status.HTTP_403_FORBIDDEN
assert code == "access_denied"
assert message.startswith("window_closed:")
assert field is None
def test_map_sqlalchemy_error_operational():
exc = OperationalError(
statement="SELECT 1",
params={},
orig=Exception("connection refused"),
)
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_503_SERVICE_UNAVAILABLE
assert code == "db_unavailable"
assert message == "Database unavailable"
assert field is None
def test_error_payload_matches_spec_shape():
payload = _error_payload("unknown_column", "unknown_column: q5.plan", "q5.plan")
assert payload == {
"code": "unknown_column",
"field": "q5.plan",
"message": "unknown_column: q5.plan",
}
def test_map_http_exception_validation_prefix():
exc = HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="unknown_column: q5.plan",
)
status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert message == "unknown_column: q5.plan"
assert field is None
def test_map_http_exception_from_structured_detail():
exc = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "validation_error", "message": "bad input", "field": "username"},
)
status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_400_BAD_REQUEST
assert code == "validation_error"
assert message == "bad input"
assert field == "username"
def test_map_http_exception_unauthorized():
exc = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен")
status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_401_UNAUTHORIZED
assert code == status.HTTP_401_UNAUTHORIZED
assert message == "Недействительный токен"
assert field is None
def test_map_http_exception_not_found_maps_code_from_status():
exc = HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_404_NOT_FOUND
assert code == status.HTTP_404_NOT_FOUND
assert message == "not found"
assert field is None

View File

@ -0,0 +1,68 @@
from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from sqlalchemy.exc import SQLAlchemyError
from src.core.exception_handlers import register_exception_handlers
def _make_app() -> FastAPI:
app = FastAPI()
register_exception_handlers(app)
@app.get("/http-401")
async def http_401():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
)
@app.get("/http-422-prefixed")
async def http_422_prefixed():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="unknown_column: q5.plan",
)
@app.get("/sql-error")
async def sql_error():
exc = SQLAlchemyError()
exc.orig = Exception("budget_form #99 не существует")
raise exc
return app
def test_http_exception_returns_structured_payload():
client = TestClient(_make_app())
response = client.get("/http-401")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"code": status.HTTP_401_UNAUTHORIZED,
"field": None,
"message": "Недействительный токен",
}
def test_http_validation_exception_extracts_field():
client = TestClient(_make_app())
response = client.get("/http-422-prefixed")
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert response.json() == {
"code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"field": None,
"message": "unknown_column: q5.plan",
}
def test_sqlalchemy_exception_returns_structured_payload():
client = TestClient(_make_app())
response = client.get("/sql-error")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"code": "not_found",
"field": None,
"message": "budget_form #99 не существует",
}

View File

@ -0,0 +1,74 @@
import pytest
from fastapi import HTTPException
from src.api.v1.forms import _parse_sections_csv, _validate_sheet_query_params
from src.api.v1.projects import _parse_sections
from src.db.models.form_type import FormTypeEnum
from src.domain.schemas import DirectionSchemaEnum
def test_parse_sections_csv_returns_none_for_empty_values():
assert _parse_sections_csv(None) is None
assert _parse_sections_csv("") is None
assert _parse_sections_csv(" , , ") is None
def test_parse_sections_csv_splits_and_trims():
assert _parse_sections_csv("plan, q1, q2 ") == ["plan", "q1", "q2"]
def test_validate_sheet_query_requires_direction_for_form1_ahr_cap():
with pytest.raises(HTTPException) as exc:
_validate_sheet_query_params(FormTypeEnum.FORM_1, "AHR", None, None)
assert exc.value.status_code == 400
def test_validate_sheet_query_rejects_direction_for_non_form1_or_non_ahr_cap():
with pytest.raises(HTTPException) as exc1:
_validate_sheet_query_params(
FormTypeEnum.FORM_2,
"AHR",
DirectionSchemaEnum.SUPPORT,
None,
)
assert exc1.value.status_code == 400
with pytest.raises(HTTPException) as exc2:
_validate_sheet_query_params(
FormTypeEnum.FORM_1,
"SMETA",
DirectionSchemaEnum.SUPPORT,
None,
)
assert exc2.value.status_code == 400
def test_validate_sheet_query_rejects_sections_for_non_main_sheets():
with pytest.raises(HTTPException) as exc:
_validate_sheet_query_params(FormTypeEnum.FORM_1, "SMETA", None, ["q1"])
assert exc.value.status_code == 400
def test_validate_sheet_query_accepts_valid_combinations():
_validate_sheet_query_params(
FormTypeEnum.FORM_1,
"AHR",
DirectionSchemaEnum.SUPPORT,
["plan", "q1"],
)
_validate_sheet_query_params(
FormTypeEnum.FORM_2,
"AHR",
None,
["plan", "q1"],
)
def test_parse_form3_sections_accepts_supported_values():
assert _parse_sections("q1,q2,year") == ["q1", "q2", "year"]
def test_parse_form3_sections_rejects_invalid_values():
with pytest.raises(HTTPException) as exc:
_parse_sections("q1,q5")
assert exc.value.status_code == 400