Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 772afae276 | |||
| 7e9fc08056 | |||
|
|
a718827c12 | ||
| 21d474464d | |||
| 8299b35d07 | |||
| 47fac8acbc | |||
|
|
f1f8f5c7b6 | ||
|
|
3d83a55ad8 | ||
|
|
2a3827ff91 | ||
| 05d1f2afb0 | |||
|
|
13db21c4af | ||
| 748d81ef06 | |||
|
|
312c8db04f | ||
|
|
0f46157cb5 | ||
|
|
de000905b3 | ||
| bdea6f9f4b | |||
| a3540e66c0 | |||
| 0e639ac433 | |||
| 79ad4a444c | |||
|
|
7b78a817cd | ||
|
|
539bd8c0f9 | ||
|
|
280dabfd95 | ||
|
|
52c0d1806a | ||
|
|
3d134fb7f9 | ||
| c67c94ba1f |
141
api/alembic.ini
Normal file
141
api/alembic.ini
Normal 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
1
api/alembic/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
78
api/alembic/env.py
Normal file
78
api/alembic/env.py
Normal 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()
|
||||
28
api/alembic/script.py.mako
Normal file
28
api/alembic/script.py.mako
Normal 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
25
api/src/api/v1/admin.py
Normal 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"}
|
||||
@ -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="Недостаточно прав",
|
||||
|
||||
110
api/src/api/v1/form_phases.py
Normal file
110
api/src/api/v1/form_phases.py
Normal 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
339
api/src/api/v1/forms.py
Normal 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
247
api/src/api/v1/projects.py
Normal 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,
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
31
api/src/core/CONSTANTS.py
Normal 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:",
|
||||
)
|
||||
@ -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 = "Доступ запрещен"
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
30
api/src/db/models/__init__.py
Normal file
30
api/src/db/models/__init__.py
Normal 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
|
||||
19
api/src/db/models/allocation.py
Normal file
19
api/src/db/models/allocation.py
Normal 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)
|
||||
56
api/src/db/models/app_user.py
Normal file
56
api/src/db/models/app_user.py
Normal 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)
|
||||
63
api/src/db/models/audit_log.py
Normal file
63
api/src/db/models/audit_log.py
Normal 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"
|
||||
# )
|
||||
25
api/src/db/models/booking.py
Normal file
25
api/src/db/models/booking.py
Normal 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")
|
||||
57
api/src/db/models/budget_form.py
Normal file
57
api/src/db/models/budget_form.py
Normal 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"
|
||||
# )
|
||||
51
api/src/db/models/budget_line.py
Normal file
51
api/src/db/models/budget_line.py
Normal 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
|
||||
54
api/src/db/models/budget_line_quarter.py
Normal file
54
api/src/db/models/budget_line_quarter.py
Normal 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
29
api/src/db/models/ckk.py
Normal 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)
|
||||
18
api/src/db/models/collegial_approval.py
Normal file
18
api/src/db/models/collegial_approval.py
Normal 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)
|
||||
34
api/src/db/models/contract_detail.py
Normal file
34
api/src/db/models/contract_detail.py
Normal 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)
|
||||
24
api/src/db/models/contract_summary.py
Normal file
24
api/src/db/models/contract_summary.py
Normal 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)
|
||||
25
api/src/db/models/form_limit.py
Normal file
25
api/src/db/models/form_limit.py
Normal 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")
|
||||
26
api/src/db/models/form_phase.py
Normal file
26
api/src/db/models/form_phase.py
Normal 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))
|
||||
51
api/src/db/models/form_type.py
Normal file
51
api/src/db/models/form_type.py
Normal 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, [])
|
||||
33
api/src/db/models/limit_template.py
Normal file
33
api/src/db/models/limit_template.py
Normal 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")
|
||||
35
api/src/db/models/org_unit.py
Normal file
35
api/src/db/models/org_unit.py
Normal 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")
|
||||
29
api/src/db/models/phase_template.py
Normal file
29
api/src/db/models/phase_template.py
Normal 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
20
api/src/db/models/plan.py
Normal 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)
|
||||
49
api/src/db/models/project.py
Normal file
49
api/src/db/models/project.py
Normal 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
|
||||
43
api/src/db/models/rent_detail.py
Normal file
43
api/src/db/models/rent_detail.py
Normal 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")
|
||||
20
api/src/db/models/reserve.py
Normal file
20
api/src/db/models/reserve.py
Normal 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)
|
||||
31
api/src/db/models/rf_project_report.py
Normal file
31
api/src/db/models/rf_project_report.py
Normal 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")
|
||||
27
api/src/db/models/rf_project_report_line.py
Normal file
27
api/src/db/models/rf_project_report_line.py
Normal 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"
|
||||
# )
|
||||
30
api/src/db/models/rf_project_report_quarter.py
Normal file
30
api/src/db/models/rf_project_report_quarter.py
Normal 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
31
api/src/db/models/role.py
Normal 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"
|
||||
# )
|
||||
43
api/src/db/models/security_detail.py
Normal file
43
api/src/db/models/security_detail.py
Normal 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")
|
||||
27
api/src/db/models/sequestration.py
Normal file
27
api/src/db/models/sequestration.py
Normal 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")
|
||||
23
api/src/db/models/user_org.py
Normal file
23
api/src/db/models/user_org.py
Normal 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")
|
||||
43
api/src/db/models/utility_detail.py
Normal file
43
api/src/db/models/utility_detail.py
Normal 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
30
api/src/db/models/vsp.py
Normal 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")
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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")
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
80
api/src/repository/budget_form_repository.py
Normal file
80
api/src/repository/budget_form_repository.py
Normal 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()
|
||||
|
||||
17
api/src/repository/budget_line_repository.py
Normal file
17
api/src/repository/budget_line_repository.py
Normal 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()
|
||||
84
api/src/repository/form_phase_repository.py
Normal file
84
api/src/repository/form_phase_repository.py
Normal 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
|
||||
360
api/src/repository/project_repository.py
Normal file
360
api/src/repository/project_repository.py
Normal 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])
|
||||
235
api/src/repository/sheet_repository.py
Normal file
235
api/src/repository/sheet_repository.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
46
api/src/services/access_control_service.py
Normal file
46
api/src/services/access_control_service.py
Normal 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
|
||||
|
||||
64
api/src/services/budget_form_service.py
Normal file
64
api/src/services/budget_form_service.py
Normal 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,
|
||||
)
|
||||
28
api/src/services/budget_line_service.py
Normal file
28
api/src/services/budget_line_service.py
Normal 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)
|
||||
139
api/src/services/form_phase_service.py
Normal file
139
api/src/services/form_phase_service.py
Normal 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,
|
||||
)
|
||||
191
api/src/services/project_service.py
Normal file
191
api/src/services/project_service.py
Normal 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]
|
||||
139
api/src/services/sheet_service.py
Normal file
139
api/src/services/sheet_service.py
Normal 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,
|
||||
)
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
140
api/tests/integration/test_form_phases_api_smoke.py
Normal file
140
api/tests/integration/test_form_phases_api_smoke.py
Normal 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
|
||||
|
||||
133
api/tests/integration/test_forms_api_smoke.py
Normal file
133
api/tests/integration/test_forms_api_smoke.py
Normal 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§ions=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§ions=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§ions=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
|
||||
123
api/tests/integration/test_projects_api_smoke.py
Normal file
123
api/tests/integration/test_projects_api_smoke.py
Normal 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") == "Отчёт не найден"
|
||||
@ -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
|
||||
|
||||
155
api/tests/unit/test_exception_handlers.py
Normal file
155
api/tests/unit/test_exception_handlers.py
Normal 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
|
||||
68
api/tests/unit/test_exception_handlers_http_shape.py
Normal file
68
api/tests/unit/test_exception_handlers_http_shape.py
Normal 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 не существует",
|
||||
}
|
||||
74
api/tests/unit/test_query_validation.py
Normal file
74
api/tests/unit/test_query_validation.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user