Compare commits
No commits in common. "sql-funcs2-fix" and "main" have entirely different histories.
sql-funcs2
...
main
141
api/alembic.ini
141
api/alembic.ini
@ -1,141 +0,0 @@
|
|||||||
# 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 +0,0 @@
|
|||||||
Generic single-database configuration.
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import pool
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
from src.core.config import settings
|
|
||||||
from src.db.base import Base
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
url = settings.DATABASE_URL
|
|
||||||
context.configure(
|
|
||||||
url=url,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
literal_binds=True,
|
|
||||||
dialect_opts={"paramstyle": "named"},
|
|
||||||
include_schemas=True,
|
|
||||||
version_table_schema="v3",
|
|
||||||
)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection):
|
|
||||||
context.configure(
|
|
||||||
connection=connection,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
include_schemas=True,
|
|
||||||
version_table_schema="v3",
|
|
||||||
)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.execute(f"CREATE SCHEMA IF NOT EXISTS v3")
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_async_migrations() -> None:
|
|
||||||
connectable = create_async_engine(
|
|
||||||
settings.DATABASE_URL,
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
async with connectable.connect() as connection:
|
|
||||||
await connection.run_sync(do_run_migrations)
|
|
||||||
await connectable.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
asyncio.run(run_async_migrations())
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
"""${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"}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,79 +0,0 @@
|
|||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "0002"
|
|
||||||
down_revision = "0001"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
|
||||||
|
|
||||||
|
|
||||||
def _find_dollar_tag(line: str) -> Optional[str]:
|
|
||||||
m = _DOLLAR_TAG_RE.search(line.strip())
|
|
||||||
return m.group(0) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def _split_statements(sql: str) -> List[str]:
|
|
||||||
statements: List[str] = []
|
|
||||||
current: List[str] = []
|
|
||||||
in_dollar = False
|
|
||||||
dollar_tag: Optional[str] = None
|
|
||||||
|
|
||||||
for line in sql.split("\n"):
|
|
||||||
stripped = line.strip()
|
|
||||||
|
|
||||||
if not in_dollar:
|
|
||||||
tag = _find_dollar_tag(stripped)
|
|
||||||
if tag and tag.endswith("$") and tag.startswith("$"):
|
|
||||||
dollar_tag = tag
|
|
||||||
in_dollar = True
|
|
||||||
current.append(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
|
|
||||||
after = stripped[len(dollar_tag):].strip()
|
|
||||||
if after == ";" or after == "":
|
|
||||||
in_dollar = False
|
|
||||||
dollar_tag = None
|
|
||||||
if after == ";":
|
|
||||||
current.append(line)
|
|
||||||
statements.append("\n".join(current))
|
|
||||||
current = []
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not in_dollar and stripped.rstrip().endswith(";"):
|
|
||||||
current.append(line)
|
|
||||||
statements.append("\n".join(current))
|
|
||||||
current = []
|
|
||||||
continue
|
|
||||||
|
|
||||||
current.append(line)
|
|
||||||
|
|
||||||
remaining = "\n".join(current).strip()
|
|
||||||
if remaining:
|
|
||||||
statements.append(remaining)
|
|
||||||
|
|
||||||
return statements
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0002_functions.sql")
|
|
||||||
with open(ddl_path) as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
statements = _split_statements(content)
|
|
||||||
for stmt in statements:
|
|
||||||
stripped = stmt.strip().rstrip(";").strip()
|
|
||||||
if not stripped:
|
|
||||||
continue
|
|
||||||
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
|
|
||||||
continue
|
|
||||||
op.execute(stripped)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
pass
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "0003"
|
|
||||||
down_revision = "0002"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
has_data = conn.execute(sa.text("SELECT 1 FROM v3.role LIMIT 1")).scalar()
|
|
||||||
|
|
||||||
if has_data:
|
|
||||||
return
|
|
||||||
sql_path = os.path.join(os.path.dirname(__file__), "sql", "0003_initial_data.sql")
|
|
||||||
with open(sql_path) as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
for stmt in content.split(";\n"):
|
|
||||||
stripped = stmt.strip()
|
|
||||||
if not stripped:
|
|
||||||
continue
|
|
||||||
op.execute(stripped)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
pass
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -10,6 +10,8 @@ python-multipart==0.0.9
|
|||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
alembic==1.16.1
|
alembic==1.16.1
|
||||||
|
aiosqlite==0.20.0
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
|
pytest==8.3.2
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
raisa-fastapi-protected-api==1.0.0
|
raisa-fastapi-protected-api==1.0.0
|
||||||
openpyxl
|
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
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"}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.api.v1.deps import require_admin
|
|
||||||
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 AuditLog, AuditLogQueryParams, BaseListResponse
|
|
||||||
from src.services.auditlog_service import AuditLogService
|
|
||||||
|
|
||||||
router = APIRouter(tags=["audit"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/audit-logs",
|
|
||||||
response_model=BaseListResponse[AuditLog],
|
|
||||||
status_code=status.HTTP_200_OK,
|
|
||||||
summary="Получение журнала аудита",
|
|
||||||
description="Возвращает список записей аудита с возможностью фильтрации. Доступно только администраторам.",
|
|
||||||
)
|
|
||||||
async def get_audit_logs(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
params: AuditLogQueryParams = Depends(),
|
|
||||||
):
|
|
||||||
offset = (params.page - 1) * params.limit
|
|
||||||
audit_service = AuditLogService(db)
|
|
||||||
|
|
||||||
logs, count = await audit_service.get_all(
|
|
||||||
user=current_user,
|
|
||||||
limit=params.limit,
|
|
||||||
offset=offset,
|
|
||||||
user_id=params.user_id,
|
|
||||||
org_unit_id=params.org_unit_id,
|
|
||||||
task_id=params.task_id,
|
|
||||||
form_id=params.form_id,
|
|
||||||
event_type=params.event_type,
|
|
||||||
event=params.event,
|
|
||||||
date_from=datetime.combine(params.date_from, datetime.min.time()) if params.date_from else None,
|
|
||||||
date_to=datetime.combine(params.date_to, datetime.max.time()) if params.date_to else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = [audit_service.orm_log_to_response(log) for log in logs]
|
|
||||||
return BaseListResponse(result=result, count=count)
|
|
||||||
@ -6,46 +6,46 @@ from src.db.session import get_db
|
|||||||
from src.domain.schemas import LoginRequest, RefreshRequest, Token
|
from src.domain.schemas import LoginRequest, RefreshRequest, Token
|
||||||
from src.services.auth_service import AuthService
|
from src.services.auth_service import AuthService
|
||||||
|
|
||||||
# if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
# if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
async def login(
|
async def login(
|
||||||
login_data: LoginRequest,
|
login_data: LoginRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
auth_service = AuthService(db)
|
auth_service = AuthService(db)
|
||||||
token = await auth_service.authenticate_user(login_data.username, login_data.password)
|
token = await auth_service.authenticate_user(login_data.username, login_data.password)
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Неверное имя пользователя или пароль",
|
detail="Неверное имя пользователя или пароль",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
# else:
|
else:
|
||||||
|
|
||||||
# @router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
# async def login(
|
async def login(
|
||||||
# login_data: LoginRequest,
|
login_data: LoginRequest,
|
||||||
# db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
# user: UserInfo = Depends(get_user_dependency),
|
user: UserInfo = Depends(get_user_dependency),
|
||||||
# ):
|
):
|
||||||
# auth_service = AuthService(db)
|
auth_service = AuthService(db)
|
||||||
# token = await auth_service.authenticate_user_via_email(user.email)
|
token = await auth_service.authenticate_user_via_email(user.email)
|
||||||
# if not token:
|
if not token:
|
||||||
# raise HTTPException(
|
raise HTTPException(
|
||||||
# status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
# detail="Неверное имя пользователя или пароль",
|
detail="Неверное имя пользователя или пароль",
|
||||||
# headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
# )
|
)
|
||||||
# return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login-form", response_model=Token)
|
@router.post("/login-form", response_model=Token)
|
||||||
|
|||||||
@ -5,89 +5,71 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from src.core.security import verify_token
|
from src.core.security import verify_token
|
||||||
from src.db.session import get_db
|
from src.db.session import get_db
|
||||||
from src.db.models.app_user import AppUser
|
from src.domain.models import UserRole, Users
|
||||||
from src.db.models.role import UserRoleEnum
|
|
||||||
from src.repository.user_repository import UserRepository
|
from src.repository.user_repository import UserRepository
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
# if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|
||||||
async def get_user_by_token(
|
async def get_current_user(
|
||||||
token: str,
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
db: AsyncSession,
|
|
||||||
) -> AppUser:
|
|
||||||
payload = verify_token(token)
|
|
||||||
if payload is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Недействительный токен",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
username: str | None = payload.get("sub")
|
|
||||||
if username is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Недействительный токен",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
user_repo = UserRepository(db)
|
|
||||||
user = await user_repo.get_by_username(username)
|
|
||||||
if user is None:
|
|
||||||
user = await user_repo.get_by_email(username)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Пользователь не найден",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> AppUser:
|
|
||||||
return await get_user_by_token(token=credentials.credentials, db=db)
|
|
||||||
|
|
||||||
# else:
|
|
||||||
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
|
||||||
|
|
||||||
# async def get_current_user(
|
|
||||||
# user: UserInfo = Depends(get_user_dependency),
|
|
||||||
# db: AsyncSession = Depends(get_db),
|
|
||||||
# ) -> AppUser:
|
|
||||||
# user_repo = UserRepository(db)
|
|
||||||
# db_user = await user_repo.get_by_email(user.email)
|
|
||||||
# if db_user is None:
|
|
||||||
# raise HTTPException(
|
|
||||||
# status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
# detail="Пользователь не найден",
|
|
||||||
# headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
# )
|
|
||||||
# return db_user
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_active_user_with_set_db(
|
|
||||||
current_user: AppUser = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> AppUser:
|
) -> Users:
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = verify_token(token)
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Недействительный токен",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
user_repo = UserRepository(db)
|
username: str | None = payload.get("sub")
|
||||||
await user_repo.set_app_user_id(current_user.id)
|
if username is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Недействительный токен",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_repo = UserRepository(db)
|
||||||
|
user = await user_repo.get_by_username(username)
|
||||||
|
if user is None:
|
||||||
|
user = await user_repo.get_by_email(username)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Пользователь не найден",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
else:
|
||||||
|
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
user: UserInfo = Depends(get_user_dependency),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Users:
|
||||||
|
user_repo = UserRepository(db)
|
||||||
|
db_user = await user_repo.get_by_email(user.email)
|
||||||
|
if db_user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Пользователь не найден",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users:
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def require_admin(
|
def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users:
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
if current_user.role_id != UserRole.ADMIN:
|
||||||
) -> AppUser:
|
|
||||||
if current_user.role_id != UserRoleEnum.ADMIN:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Недостаточно прав",
|
detail="Недостаточно прав",
|
||||||
@ -95,13 +77,11 @@ def require_admin(
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def require_executor(
|
def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users:
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
|
||||||
) -> AppUser:
|
|
||||||
if current_user.role_id not in [
|
if current_user.role_id not in [
|
||||||
UserRoleEnum.ADMIN,
|
UserRole.ADMIN,
|
||||||
UserRoleEnum.EXECUTOR_DFIP,
|
UserRole.EXECUTOR_DFIP,
|
||||||
UserRoleEnum.EXECUTOR_RF,
|
UserRole.EXECUTOR_RF,
|
||||||
]:
|
]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
@ -110,10 +90,8 @@ def require_executor(
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def require_executor_dfip(
|
def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users:
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
if current_user.role_id not in [UserRole.ADMIN, UserRole.EXECUTOR_DFIP]:
|
||||||
) -> AppUser:
|
|
||||||
if current_user.role_id not in [UserRoleEnum.ADMIN, UserRoleEnum.EXECUTOR_DFIP]:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Недостаточно прав",
|
detail="Недостаточно прав",
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.services.expense_item_service import ExpenseItemService
|
|
||||||
from src.db.models.app_user import AppUser
|
|
||||||
from src.api.v1.deps import require_admin
|
|
||||||
from src.db.session import get_db
|
|
||||||
from src.domain.schemas import (
|
|
||||||
BaseSingleResponse,
|
|
||||||
ExpenseItemResponseSchema,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/expense-item", tags=["expense-item"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{item_id}", response_model=BaseSingleResponse[ExpenseItemResponseSchema])
|
|
||||||
async def get_expense_item_by_id(
|
|
||||||
item_id: int,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
):
|
|
||||||
"""Получение записи expense item по ID."""
|
|
||||||
service = ExpenseItemService(db)
|
|
||||||
result = await service.get(item_id, user=current_user)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Expense Item не найден",
|
|
||||||
)
|
|
||||||
return BaseSingleResponse(
|
|
||||||
success=True,
|
|
||||||
message="Запись Expense Item",
|
|
||||||
result=ExpenseItemResponseSchema.model_validate(result),
|
|
||||||
)
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
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 DirectionSchemaEnum, ExportBulkRequest
|
|
||||||
from src.services.export_service import ExportService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/export", tags=["export"])
|
|
||||||
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_sections_csv(sections: str | None) -> list[str] | None:
|
|
||||||
if sections is None:
|
|
||||||
return None
|
|
||||||
raw = [section.strip() for section in sections.split(",") if section.strip()]
|
|
||||||
normalized: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
quarter_tokens = {"q1", "q2", "q3", "q4", "totals"}
|
|
||||||
for section in raw:
|
|
||||||
if "." in section:
|
|
||||||
base_section, nested_section = section.split(".", 1)
|
|
||||||
nested_section = nested_section.strip()
|
|
||||||
tokens = [base_section.strip()]
|
|
||||||
if nested_section in quarter_tokens:
|
|
||||||
tokens.append(nested_section)
|
|
||||||
for token in tokens:
|
|
||||||
if token and token not in seen:
|
|
||||||
seen.add(token)
|
|
||||||
normalized.append(token)
|
|
||||||
continue
|
|
||||||
if section not in seen:
|
|
||||||
seen.add(section)
|
|
||||||
normalized.append(section)
|
|
||||||
return normalized or None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_form3_sections_csv(sections: str | None) -> list[str] | None:
|
|
||||||
if not sections:
|
|
||||||
return None
|
|
||||||
parsed = [section.strip() for section in sections.split(",") if section.strip()]
|
|
||||||
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
|
|
||||||
if invalid:
|
|
||||||
raise ValueError(f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/form/{form_id}")
|
|
||||||
async def export_form(
|
|
||||||
form_id: int,
|
|
||||||
direction: DirectionSchemaEnum | None = None,
|
|
||||||
sections: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
|
||||||
):
|
|
||||||
export_service = ExportService(db)
|
|
||||||
try:
|
|
||||||
stream, filename, media_type = await export_service.export_form_payload(
|
|
||||||
form_id=form_id,
|
|
||||||
current_user=current_user,
|
|
||||||
direction=direction.value if direction else None,
|
|
||||||
sections=_parse_sections_csv(sections),
|
|
||||||
ignore_non_applicable_query_params=True,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
||||||
return StreamingResponse(
|
|
||||||
stream,
|
|
||||||
media_type=media_type,
|
|
||||||
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/form/{form_id}/sheet/{sheet}")
|
|
||||||
async def export_form_sheet(
|
|
||||||
form_id: int,
|
|
||||||
sheet: str,
|
|
||||||
direction: DirectionSchemaEnum | None = None,
|
|
||||||
sections: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
|
||||||
):
|
|
||||||
export_service = ExportService(db)
|
|
||||||
try:
|
|
||||||
stream, filename, media_type = await export_service.export_form_payload(
|
|
||||||
form_id=form_id,
|
|
||||||
current_user=current_user,
|
|
||||||
sheet=sheet,
|
|
||||||
direction=direction.value if direction else None,
|
|
||||||
sections=_parse_sections_csv(sections),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
||||||
return StreamingResponse(
|
|
||||||
stream,
|
|
||||||
media_type=media_type,
|
|
||||||
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bulk")
|
|
||||||
async def export_forms_bulk(
|
|
||||||
payload: ExportBulkRequest,
|
|
||||||
direction: DirectionSchemaEnum | None = None,
|
|
||||||
sections: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
|
||||||
):
|
|
||||||
export_service = ExportService(db)
|
|
||||||
try:
|
|
||||||
stream, filename, media_type = await export_service.export_bulk_payload(
|
|
||||||
form_ids=payload.form_ids,
|
|
||||||
current_user=current_user,
|
|
||||||
skip_failed=payload.skip_failed,
|
|
||||||
direction=direction.value if direction else None,
|
|
||||||
sections=_parse_sections_csv(sections),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
||||||
return StreamingResponse(
|
|
||||||
stream,
|
|
||||||
media_type=media_type,
|
|
||||||
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/project/{project_id}/report/{year}/{report_type}")
|
|
||||||
async def export_project_report(
|
|
||||||
project_id: int,
|
|
||||||
year: int,
|
|
||||||
report_type: str,
|
|
||||||
sections: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
|
||||||
):
|
|
||||||
export_service = ExportService(db)
|
|
||||||
try:
|
|
||||||
stream, filename, media_type = await export_service.export_project_report_payload(
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
current_user=current_user,
|
|
||||||
sections=_parse_form3_sections_csv(sections),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
|
||||||
return StreamingResponse(
|
|
||||||
stream,
|
|
||||||
media_type=media_type,
|
|
||||||
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
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, "Этап не найден")
|
|
||||||
@ -1,391 +0,0 @@
|
|||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.services.org_unit_service import OrgUnitService
|
|
||||||
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, FormCreateSchema, 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, require_admin
|
|
||||||
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}")
|
|
||||||
async def get_form(
|
|
||||||
form_id: int,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
|
||||||
) -> BaseSingleResponse[BudgetFormResponse]:
|
|
||||||
bf_service = BudgetFormService(db)
|
|
||||||
form = await bf_service.get(
|
|
||||||
user=current_user,
|
|
||||||
budget_form_id=form_id,
|
|
||||||
load_org=True,
|
|
||||||
)
|
|
||||||
return BaseSingleResponse(
|
|
||||||
result = BudgetFormResponse.model_validate(form),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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,
|
|
||||||
all_sheets=form.form_type.sheet_list_with_directions,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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 not budget_line or 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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
async def add_form(
|
|
||||||
response: Response,
|
|
||||||
form: FormCreateSchema,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
) -> BaseListResponse[BudgetFormResponse]:
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
bf_service = BudgetFormService(db)
|
|
||||||
org_service = OrgUnitService(db)
|
|
||||||
|
|
||||||
org_units = await org_service.get_list(user=current_user, org_unit_id=form.org_unit_ids)
|
|
||||||
org_unit_ids = [o.id for o in org_units]
|
|
||||||
if len(org_units) != len(form.org_unit_ids):
|
|
||||||
not_found_ids = ", ".join(
|
|
||||||
[str(oid) for oid in set(form.org_unit_ids) - set(org_unit_ids)],
|
|
||||||
)
|
|
||||||
raise HTTPException(404, f"ССП {not_found_ids} не найдены")
|
|
||||||
|
|
||||||
new_forms = await bf_service.bulk_create(
|
|
||||||
form_type_code=form.form_type_code,
|
|
||||||
year=form.year,
|
|
||||||
org_unit_ids=org_unit_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
db_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
|
|
||||||
|
|
||||||
return BaseListResponse(
|
|
||||||
result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms],
|
|
||||||
count=len(new_forms),
|
|
||||||
)
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.core.errors import AccessDeniedException
|
|
||||||
from src.api.v1.deps import get_current_active_user, require_admin
|
|
||||||
from src.db.models.app_user import AppUser
|
|
||||||
from src.db.session import get_db
|
|
||||||
from src.domain.schemas import BaseListResponse, BaseSingleResponse, OrgUnitBaseSchema, OrgUnitListSchema, OrgUnitSchema, OrgUnitUpdateSchema, ResponseBase
|
|
||||||
from src.services.org_unit_service import OrgUnitService
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/org-unit", tags=["ssp"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
async def org_unit_list(
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
is_ssp: bool | None = None,
|
|
||||||
load_users_count: bool = False,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
) -> BaseListResponse[OrgUnitListSchema]:
|
|
||||||
org_service = OrgUnitService(db)
|
|
||||||
count, units = await org_service.get_list(
|
|
||||||
user=current_user,
|
|
||||||
with_count=True,
|
|
||||||
offset=offset,
|
|
||||||
limit=limit,
|
|
||||||
load_users=load_users_count,
|
|
||||||
is_active=is_active,
|
|
||||||
is_ssp=is_ssp,
|
|
||||||
|
|
||||||
)
|
|
||||||
return BaseListResponse(
|
|
||||||
result=[
|
|
||||||
OrgUnitListSchema.model_validate(unit) for unit in units
|
|
||||||
],
|
|
||||||
count=count,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{org_unit_id}")
|
|
||||||
async def org_unit_id(
|
|
||||||
org_unit_id: int,
|
|
||||||
load_users: bool = False,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
) -> BaseSingleResponse[OrgUnitSchema]:
|
|
||||||
org_service = OrgUnitService(db)
|
|
||||||
unit = await org_service.get(
|
|
||||||
user=current_user,
|
|
||||||
org_unit_id=org_unit_id,
|
|
||||||
load_users=load_users,
|
|
||||||
)
|
|
||||||
if not unit:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ССП не найден")
|
|
||||||
|
|
||||||
return BaseSingleResponse(
|
|
||||||
result = OrgUnitSchema.model_validate(unit)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
async def create_ssp(
|
|
||||||
org_data: OrgUnitBaseSchema,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
) -> BaseSingleResponse[OrgUnitSchema]:
|
|
||||||
"""Создание ССП/РФ."""
|
|
||||||
org_unit_service = OrgUnitService(db)
|
|
||||||
org_unit = await org_unit_service.create(
|
|
||||||
title=org_data.title,
|
|
||||||
is_ssp=org_data.is_ssp,
|
|
||||||
user=current_user,
|
|
||||||
)
|
|
||||||
return BaseSingleResponse(
|
|
||||||
message="ССП/РФ создана",
|
|
||||||
result=OrgUnitSchema.model_validate(org_unit),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{org_unit_id}")
|
|
||||||
async def update_ssp(
|
|
||||||
org_unit_id: int,
|
|
||||||
org_data: OrgUnitUpdateSchema,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
) -> BaseSingleResponse[OrgUnitSchema]:
|
|
||||||
"""Редактирование ССП/РФ."""
|
|
||||||
org_unit_service = OrgUnitService(db)
|
|
||||||
org_unit = await org_unit_service.update(
|
|
||||||
org_unit_id=org_unit_id,
|
|
||||||
user=current_user,
|
|
||||||
**org_data.model_dump(exclude_unset=True),
|
|
||||||
)
|
|
||||||
if not org_unit:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ССП не найден")
|
|
||||||
|
|
||||||
return BaseSingleResponse(
|
|
||||||
message="ССП/РФ обновлена",
|
|
||||||
result=OrgUnitSchema.model_validate(org_unit),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{org_unit_id}")
|
|
||||||
async def delete_ssp(
|
|
||||||
org_unit_id: int,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
) -> ResponseBase:
|
|
||||||
"""Удаление ССП/РФ."""
|
|
||||||
org_unit_service = OrgUnitService(db)
|
|
||||||
result = await org_unit_service.logical_delete(
|
|
||||||
org_unit_id=org_unit_id,
|
|
||||||
user=current_user,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ССП не найден")
|
|
||||||
|
|
||||||
return ResponseBase(success=True, message="ССП/РФ удалена")
|
|
||||||
@ -1,247 +0,0 @@
|
|||||||
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,22 +1,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from src.api.v1 import (
|
from src.api.v1 import auth, users
|
||||||
auth, users, admin, audit, forms, form_phases, projects, export, org_unit, vsp, expense_item
|
|
||||||
)
|
|
||||||
|
|
||||||
from src.api.v1 import websocket
|
|
||||||
|
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(admin.router)
|
|
||||||
api_router.include_router(audit.router)
|
|
||||||
api_router.include_router(forms.router)
|
|
||||||
api_router.include_router(projects.router)
|
|
||||||
api_router.include_router(form_phases.router)
|
|
||||||
api_router.include_router(export.router)
|
|
||||||
api_router.include_router(websocket.router)
|
|
||||||
api_router.include_router(org_unit.router)
|
|
||||||
api_router.include_router(vsp.router)
|
|
||||||
api_router.include_router(expense_item.router)
|
|
||||||
|
|||||||
@ -1,14 +1,10 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.api.v1.deps import (
|
from src.api.v1.deps import get_current_active_user, require_admin, require_executor_dfip
|
||||||
get_current_active_user,
|
|
||||||
require_admin,
|
|
||||||
require_executor_dfip,
|
|
||||||
)
|
|
||||||
from src.db.session import get_db
|
from src.db.session import get_db
|
||||||
from src.db.models.app_user import AppUser
|
from src.domain.models import Users
|
||||||
from src.domain.schemas import BaseListResponse, BaseSingleResponse, User as UserSchema, UserAdminListResponse
|
from src.domain.schemas import User as UserSchema
|
||||||
from src.domain.schemas import (
|
from src.domain.schemas import (
|
||||||
SSPIDList,
|
SSPIDList,
|
||||||
ResponseBase,
|
ResponseBase,
|
||||||
@ -24,48 +20,28 @@ from src.services.user_service import UserService
|
|||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserAdminListResponse)
|
@router.get("/me", response_model=UserSchema)
|
||||||
async def get_current_user_info(
|
async def get_current_user_info(current_user: Users = Depends(get_current_active_user)):
|
||||||
current_user: AppUser = Depends(get_current_active_user),
|
return current_user
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
load_orgs: bool = False,
|
|
||||||
):
|
|
||||||
if load_orgs:
|
|
||||||
user_service = UserService(db)
|
|
||||||
result = await user_service.get(user_id=current_user.id, load_orgs=True)
|
|
||||||
return UserAdminListResponse.model_validate(result)
|
|
||||||
else:
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/", response_model=UserListResponse)
|
||||||
async def get_users(
|
async def get_users(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
load_orgs: bool = False,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(require_admin),
|
current_user: Users = Depends(require_admin),
|
||||||
) -> BaseListResponse[UserAdminListResponse]:
|
):
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
count, users = await user_service.get_all(
|
users = await user_service.get_all(current_user, skip=skip, limit=limit)
|
||||||
current_user,
|
return UserListResponse(success=True, message="Список пользователей", result=users)
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
load_orgs=load_orgs,
|
|
||||||
with_count=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return BaseListResponse(
|
|
||||||
count=count,
|
|
||||||
result=[UserAdminListResponse.model_validate(user) for user in users]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dfip-many-ssp/", response_model=UserSSPLinkResponse)
|
@router.get("/dfip-many-ssp/", response_model=UserSSPLinkResponse)
|
||||||
async def get_many_ssp(
|
async def get_many_ssp(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(require_executor_dfip),
|
current_user: Users = Depends(require_executor_dfip),
|
||||||
):
|
):
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
ssp_list = await user_service.get_many_ssp(user_id, current_user)
|
ssp_list = await user_service.get_many_ssp(user_id, current_user)
|
||||||
@ -81,7 +57,7 @@ async def set_many_ssp(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
ssp_ids_data: SSPIDList,
|
ssp_ids_data: SSPIDList,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(require_admin),
|
current_user: Users = Depends(require_admin),
|
||||||
):
|
):
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user)
|
success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user)
|
||||||
@ -96,7 +72,7 @@ async def unset_many_ssp(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
ssp_ids_data: SSPIDList,
|
ssp_ids_data: SSPIDList,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(require_admin),
|
current_user: Users = Depends(require_admin),
|
||||||
):
|
):
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
success = await user_service.unset_many_ssp(
|
success = await user_service.unset_many_ssp(
|
||||||
@ -108,50 +84,39 @@ async def unset_many_ssp(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}")
|
@router.get("/{user_id}", response_model=UserSchema)
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
load_orgs: bool = False,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(require_admin),
|
current_user: Users = Depends(require_admin),
|
||||||
) -> UserAdminListResponse:
|
):
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
user = await user_service.get(
|
user = await user_service.get(user_id, current_user)
|
||||||
user_id=user_id,
|
|
||||||
current_user=current_user,
|
|
||||||
load_orgs=load_orgs,
|
|
||||||
)
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Пользователь не найден",
|
detail="Пользователь не найден",
|
||||||
)
|
)
|
||||||
return UserAdminListResponse.model_validate(user)
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/", response_model=UserResponse)
|
||||||
@router.post("/", response_model=BaseSingleResponse[UserAdminListResponse])
|
|
||||||
async def create_user(
|
async def create_user(
|
||||||
user_data: UserCreate,
|
user_data: UserCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(require_admin),
|
current_user: Users = Depends(require_admin),
|
||||||
):
|
):
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
user = await user_service.create_user(user_data=user_data, creator=current_user)
|
user = await user_service.create_user(user_data=user_data, creator=current_user)
|
||||||
|
return UserResponse(success=True, message="Пользователь создан", result=user)
|
||||||
result = UserAdminListResponse.model_validate(user)
|
|
||||||
if user_data.load_orgs:
|
|
||||||
result.org_units = []
|
|
||||||
|
|
||||||
return BaseSingleResponse(success=True, message="Пользователь создан", result=result)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{user_id}", response_model=BaseSingleResponse[UserAdminListResponse])
|
@router.patch("/{user_id}", response_model=UserResponse)
|
||||||
async def update_user(
|
async def update_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
user_data: UserUpdate,
|
user_data: UserUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(require_admin),
|
current_user: Users = Depends(require_admin),
|
||||||
):
|
):
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
user = await user_service.update_user(
|
user = await user_service.update_user(
|
||||||
@ -159,15 +124,14 @@ async def update_user(
|
|||||||
user_data=user_data,
|
user_data=user_data,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
result = UserAdminListResponse.model_validate(user)
|
return UserResponse(success=True, message="Пользователь обновлен", result=user)
|
||||||
return UserResponse(success=True, message="Пользователь обновлен", result=result)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", response_model=UserResponse)
|
@router.delete("/{user_id}", response_model=UserResponse)
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(require_admin),
|
current_user: Users = Depends(require_admin),
|
||||||
):
|
):
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
await user_service.delete_user(user_id=user_id, user=current_user)
|
await user_service.delete_user(user_id=user_id, user=current_user)
|
||||||
|
|||||||
@ -1,184 +0,0 @@
|
|||||||
from datetime import date
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.db.models.app_user import AppUser
|
|
||||||
from src.api.v1.deps import require_admin, require_executor
|
|
||||||
from src.db.session import get_db
|
|
||||||
from src.domain.schemas import (
|
|
||||||
BaseListResponse,
|
|
||||||
BaseSingleResponse,
|
|
||||||
VSPCreate,
|
|
||||||
VSPExportRequest,
|
|
||||||
VSPInDB,
|
|
||||||
VSPUpdate,
|
|
||||||
)
|
|
||||||
from src.services.vsp_service import VSPService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/dict/info", tags=["info"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=BaseListResponse[VSPInDB])
|
|
||||||
async def get_vsp(
|
|
||||||
registration_number: str | None = None,
|
|
||||||
address: str | None = None,
|
|
||||||
ssp_id: int | None = None,
|
|
||||||
open_date_start: date | None = None,
|
|
||||||
open_date_end: date | None = None,
|
|
||||||
location_form: str | None = None,
|
|
||||||
numbers_min: int | None = None,
|
|
||||||
numbers_max: int | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_executor),
|
|
||||||
):
|
|
||||||
"""Получение записей справочника INFO."""
|
|
||||||
service = VSPService(db)
|
|
||||||
result = await service.get_list(
|
|
||||||
user=current_user,
|
|
||||||
registration_number=registration_number,
|
|
||||||
address=address,
|
|
||||||
ssp_id=ssp_id,
|
|
||||||
open_date_start=open_date_start,
|
|
||||||
open_date_end=open_date_end,
|
|
||||||
location_form=location_form,
|
|
||||||
numbers_min=numbers_min,
|
|
||||||
numbers_max=numbers_max,
|
|
||||||
load_org_unit=True,
|
|
||||||
with_count=True,
|
|
||||||
)
|
|
||||||
return BaseListResponse(
|
|
||||||
success=True,
|
|
||||||
message="Список INFO",
|
|
||||||
result=[VSPInDB.model_validate(vsp) for vsp in result[1]],
|
|
||||||
count=result[0]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dropdown", response_model=BaseListResponse[VSPInDB])
|
|
||||||
async def get_vsp_dropdown(
|
|
||||||
ssp_id: int | None = None,
|
|
||||||
form_id: int | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_executor),
|
|
||||||
):
|
|
||||||
"""Получение записей для выпадающего списка INFO."""
|
|
||||||
service = VSPService(db)
|
|
||||||
result = await service.get_dropdown(user=current_user, ssp_id=ssp_id, form_id=form_id, with_count=True)
|
|
||||||
return BaseListResponse(
|
|
||||||
success=True,
|
|
||||||
message="Список INFO для выпадающего списка",
|
|
||||||
result=[VSPInDB.model_validate(vsp) for vsp in result[1]],
|
|
||||||
count=result[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/export")
|
|
||||||
async def export_info(
|
|
||||||
body: VSPExportRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_executor),
|
|
||||||
):
|
|
||||||
"""Экспорт справочника INFO."""
|
|
||||||
service = VSPService(db)
|
|
||||||
stream, filename = await service.export_xlsx(
|
|
||||||
user=current_user,
|
|
||||||
registration_number=body.registration_number,
|
|
||||||
address=body.address,
|
|
||||||
ssp_ids=body.ssp_ids,
|
|
||||||
open_date_start=body.open_date_start,
|
|
||||||
open_date_end=body.open_date_end,
|
|
||||||
location_form=body.location_form,
|
|
||||||
numbers_min=body.numbers_min,
|
|
||||||
numbers_max=body.numbers_max,
|
|
||||||
)
|
|
||||||
return StreamingResponse(
|
|
||||||
stream,
|
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=BaseSingleResponse[VSPInDB], status_code=201)
|
|
||||||
async def add_vsp(
|
|
||||||
body: VSPCreate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
):
|
|
||||||
"""Добавление строки в справочник INFO."""
|
|
||||||
service = VSPService(db)
|
|
||||||
result = await service.create(
|
|
||||||
body,
|
|
||||||
current_user,
|
|
||||||
load_org_unit=True,
|
|
||||||
)
|
|
||||||
return BaseSingleResponse(
|
|
||||||
success=True,
|
|
||||||
message="Запись INFO создана",
|
|
||||||
result=VSPInDB.model_validate(result),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{vsp_id}", response_model=BaseSingleResponse[VSPInDB])
|
|
||||||
async def get_vsp_by_id(
|
|
||||||
vsp_id: int,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_executor),
|
|
||||||
):
|
|
||||||
"""Получение записи справочника INFO по ID."""
|
|
||||||
service = VSPService(db)
|
|
||||||
result = await service.get(vsp_id, user=current_user, load_org_unit=True)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"ВСП не найден",
|
|
||||||
)
|
|
||||||
return BaseSingleResponse(
|
|
||||||
success=True,
|
|
||||||
message="Запись INFO",
|
|
||||||
result=VSPInDB.model_validate(result),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{vsp_id}", response_model=BaseSingleResponse[VSPInDB])
|
|
||||||
async def update_vsp(
|
|
||||||
vsp_id: int,
|
|
||||||
body: VSPUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_executor),
|
|
||||||
):
|
|
||||||
"""Редактирование строки справочника INFO."""
|
|
||||||
service = VSPService(db)
|
|
||||||
result = await service.update(
|
|
||||||
vsp_id,
|
|
||||||
body,
|
|
||||||
current_user,
|
|
||||||
load_org_unit=True,
|
|
||||||
)
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"ВСП не найден",
|
|
||||||
)
|
|
||||||
return BaseSingleResponse(
|
|
||||||
success=True,
|
|
||||||
message="Запись INFO обновлена",
|
|
||||||
result=VSPInDB.model_validate(result),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{vsp_id}", response_model=BaseSingleResponse[VSPInDB])
|
|
||||||
async def delete_vsp(
|
|
||||||
vsp_id: int,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: AppUser = Depends(require_admin),
|
|
||||||
):
|
|
||||||
"""Удаление строки справочника INFO."""
|
|
||||||
service = VSPService(db)
|
|
||||||
if await service.logical_delete(vsp_id, current_user):
|
|
||||||
return BaseSingleResponse(success=True, message="Запись INFO удалена")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"ВСП не найден",
|
|
||||||
)
|
|
||||||
@ -1,877 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from dataclasses import asdict
|
|
||||||
import dataclasses
|
|
||||||
import enum
|
|
||||||
from json import dumps, loads
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
#
|
|
||||||
from asyncpg import UniqueViolationError
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.repository.user_repository import UserRepository
|
|
||||||
from src.services.budget_line_service import BudgetLineService
|
|
||||||
from src.api.v1.deps import get_user_by_token
|
|
||||||
from src.db.models.app_user import AppUser
|
|
||||||
from src.db.models.form_type import FormTypeEnum
|
|
||||||
from src.services.budget_form_service import BudgetFormService
|
|
||||||
from src.services.project_service import ProjectService
|
|
||||||
from src.services.sheet_service import SheetService
|
|
||||||
from src.core.errors import BasicAppException, ValidationsError
|
|
||||||
from src.db.session import SessionLocal
|
|
||||||
|
|
||||||
from src.services.user_service import UserService
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def get_db_session(user_id: int | None = None):
|
|
||||||
"""Контекстный менеджер для получения сессии базы данных."""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
if user_id is not None:
|
|
||||||
user_repo = UserRepository(db)
|
|
||||||
await user_repo.set_app_user_id(user_id=user_id)
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ws", tags=["websocket"])
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class ConnectionInfo:
|
|
||||||
ws: WebSocket
|
|
||||||
user_id: int | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class FormConnectionInfo(ConnectionInfo):
|
|
||||||
form_id: int | None = None
|
|
||||||
sheet: str | None = None
|
|
||||||
direction: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class ProjectConnectionInfo(ConnectionInfo):
|
|
||||||
project_id: int | None = None
|
|
||||||
year: int | None = None
|
|
||||||
report_type: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionKeyEnum(str, enum.Enum):
|
|
||||||
FORM = "FORM"
|
|
||||||
PROJECT = "PROJECT"
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
|
||||||
con_info_mapping = {
|
|
||||||
ConnectionKeyEnum.FORM: FormConnectionInfo,
|
|
||||||
ConnectionKeyEnum.PROJECT: ProjectConnectionInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.connections: dict[int, dict[ConnectionKeyEnum, list[ConnectionInfo]]] = {
|
|
||||||
ConnectionKeyEnum.FORM: {},
|
|
||||||
ConnectionKeyEnum.PROJECT: {},
|
|
||||||
}
|
|
||||||
self.cell_locks: dict[Any, int] = {}
|
|
||||||
|
|
||||||
|
|
||||||
async def connect(
|
|
||||||
self,
|
|
||||||
websocket: WebSocket,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
**kwargs,
|
|
||||||
# form_id: int,
|
|
||||||
# sheet: str,
|
|
||||||
# direction: str | None = None,
|
|
||||||
):
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
key = frozenset(kwargs.items())
|
|
||||||
cls = self.con_info_mapping[con_key]
|
|
||||||
|
|
||||||
if key not in self.connections[con_key]:
|
|
||||||
self.connections[con_key][key] = [
|
|
||||||
cls(
|
|
||||||
ws=websocket,
|
|
||||||
user_id=None,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
self.connections[con_key][key].append(
|
|
||||||
cls(
|
|
||||||
ws=websocket,
|
|
||||||
user_id=None,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_user(
|
|
||||||
self,
|
|
||||||
websocket: WebSocket,
|
|
||||||
user_id: int,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
key = frozenset(kwargs.items())
|
|
||||||
|
|
||||||
if key not in self.connections[con_key]:
|
|
||||||
return
|
|
||||||
for con_info in self.connections[con_key][key]:
|
|
||||||
if con_info.ws == websocket:
|
|
||||||
con_info.user_id = user_id
|
|
||||||
|
|
||||||
async def disconnect(
|
|
||||||
self,
|
|
||||||
websocket: WebSocket,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
code: int = status.WS_1008_POLICY_VIOLATION,
|
|
||||||
reason: str = "Ошибка",
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
key = frozenset(kwargs.items())
|
|
||||||
|
|
||||||
if key not in self.connections[con_key]:
|
|
||||||
return
|
|
||||||
for con_info in self.connections[con_key][key]:
|
|
||||||
if con_info.ws == websocket:
|
|
||||||
self.connections[con_key][key].remove(con_info)
|
|
||||||
if not len(self.connections[con_key][key]):
|
|
||||||
del self.connections[con_key][key]
|
|
||||||
|
|
||||||
self.release_locks_for_user(con_key=con_key, user_id=con_info.user_id, form_key=kwargs)
|
|
||||||
await websocket.close(
|
|
||||||
code=code, reason=reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def broadcast_to_other(
|
|
||||||
self,
|
|
||||||
data: dict,
|
|
||||||
websocket: WebSocket,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
key = frozenset(kwargs.items())
|
|
||||||
|
|
||||||
if key not in self.connections[con_key]:
|
|
||||||
return
|
|
||||||
|
|
||||||
data = data.copy()
|
|
||||||
data["is_self"] = False
|
|
||||||
message = dumps(data, default=_convert_error, ensure_ascii=False)
|
|
||||||
|
|
||||||
for con_info in self.connections[con_key][key]:
|
|
||||||
if con_info.ws != websocket:
|
|
||||||
try:
|
|
||||||
await con_info.ws.send_text(message)
|
|
||||||
except:
|
|
||||||
pass # Игнорируем недоступные соединения
|
|
||||||
|
|
||||||
async def broadcast_to_all(
|
|
||||||
self,
|
|
||||||
data: dict,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
websocket: WebSocket | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
key = frozenset(kwargs.items())
|
|
||||||
if key not in self.connections[con_key]:
|
|
||||||
return
|
|
||||||
|
|
||||||
data = data.copy()
|
|
||||||
if websocket is None:
|
|
||||||
data["is_self"] = None
|
|
||||||
default_message = dumps(data, default=_convert_error, ensure_ascii=False)
|
|
||||||
self_message = None
|
|
||||||
else:
|
|
||||||
data["is_self"] = False
|
|
||||||
default_message = dumps(data, default=_convert_error, ensure_ascii=False)
|
|
||||||
data["is_self"] = True
|
|
||||||
self_message = dumps(data, default=_convert_error, ensure_ascii=False)
|
|
||||||
|
|
||||||
for con_info in self.connections[con_key][key]:
|
|
||||||
try:
|
|
||||||
if websocket is not None and con_info.ws == websocket:
|
|
||||||
await con_info.ws.send_text(self_message)
|
|
||||||
else:
|
|
||||||
await con_info.ws.send_text(default_message)
|
|
||||||
|
|
||||||
|
|
||||||
except:
|
|
||||||
pass # Игнорируем недоступные соединения
|
|
||||||
|
|
||||||
async def send_back(self, data: dict, websocket: WebSocket):
|
|
||||||
data = data.copy()
|
|
||||||
data["is_self"] = True
|
|
||||||
message = dumps(data, default=_convert_error, ensure_ascii=False)
|
|
||||||
await websocket.send_text(message)
|
|
||||||
|
|
||||||
async def get_data(self, websocket: WebSocket) -> dict:
|
|
||||||
return loads(await websocket.receive_text())
|
|
||||||
|
|
||||||
def acquire_cell_lock(
|
|
||||||
self,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
user_id: int,
|
|
||||||
cell_key: dict,
|
|
||||||
form_key: dict,
|
|
||||||
) -> bool:
|
|
||||||
if not cell_key:
|
|
||||||
return False
|
|
||||||
cell_key = frozenset(cell_key.items())
|
|
||||||
|
|
||||||
lock_key = (con_key, frozenset(form_key.items()), cell_key)
|
|
||||||
lock_owner_id = self.cell_locks.get(lock_key)
|
|
||||||
if lock_owner_id is not None and lock_owner_id != user_id:
|
|
||||||
return False
|
|
||||||
self.cell_locks[lock_key] = user_id
|
|
||||||
return True
|
|
||||||
|
|
||||||
def release_cell_lock(
|
|
||||||
self,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
user_id: int,
|
|
||||||
cell_key: dict,
|
|
||||||
form_key: dict,
|
|
||||||
):
|
|
||||||
if not cell_key:
|
|
||||||
return
|
|
||||||
cell_key = frozenset(cell_key.items())
|
|
||||||
|
|
||||||
lock_key = (con_key, frozenset(form_key.items()), cell_key)
|
|
||||||
lock_owner_id = self.cell_locks.get(lock_key)
|
|
||||||
if lock_owner_id == user_id:
|
|
||||||
self.cell_locks.pop(lock_key, None)
|
|
||||||
|
|
||||||
def release_locks_for_user(
|
|
||||||
self,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
user_id: int,
|
|
||||||
form_key: dict
|
|
||||||
):
|
|
||||||
filter_key = (con_key, frozenset(form_key.items()))
|
|
||||||
lock_keys = [
|
|
||||||
lock_key
|
|
||||||
for lock_key, lock_owner_id in self.cell_locks.items()
|
|
||||||
if lock_key[:-1] == filter_key and lock_owner_id == user_id
|
|
||||||
]
|
|
||||||
for lock_key in lock_keys:
|
|
||||||
self.cell_locks.pop(lock_key, None)
|
|
||||||
|
|
||||||
def is_locked_by_other(
|
|
||||||
self,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
user_id: int,
|
|
||||||
cell_key: dict,
|
|
||||||
form_key,
|
|
||||||
) -> bool:
|
|
||||||
if not cell_key:
|
|
||||||
return False
|
|
||||||
cell_key = frozenset(cell_key.items())
|
|
||||||
lock_owner_id = self.cell_locks.get((con_key, frozenset(form_key.items()), cell_key))
|
|
||||||
return lock_owner_id is not None and lock_owner_id != user_id
|
|
||||||
|
|
||||||
def is_row_locked_by_other(
|
|
||||||
self,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
user_id: int,
|
|
||||||
line_id: int,
|
|
||||||
) -> bool:
|
|
||||||
if line_id is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
for (dict_con_key, form_key, cell_key), lock_owner_id in self.cell_locks.items():
|
|
||||||
if dict_con_key != con_key:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if form_key != form_key or lock_owner_id == user_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for k, v in cell_key:
|
|
||||||
if k == "line_id" and v == line_id:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def release_locks_for_row(
|
|
||||||
self,
|
|
||||||
con_key: ConnectionKeyEnum,
|
|
||||||
user_id: int,
|
|
||||||
line_id: int,
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if line_id is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
lock_keys = []
|
|
||||||
for lock_key, lock_owner_id in self.cell_locks.items():
|
|
||||||
dict_con_key, form_key, cell_key = lock_key
|
|
||||||
if dict_con_key != con_key:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if form_key != form_key or lock_owner_id == user_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for k, v in cell_key.items():
|
|
||||||
if k == "line_id" and v == line_id:
|
|
||||||
lock_keys.append(lock_key)
|
|
||||||
|
|
||||||
for lock_key in lock_keys:
|
|
||||||
self.cell_locks.pop(lock_key, None)
|
|
||||||
|
|
||||||
|
|
||||||
class FormEventProcess:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.sheet_service: SheetService = SheetService(db)
|
|
||||||
self.bf_service: BudgetFormService = BudgetFormService(db)
|
|
||||||
self.bl_service: BudgetLineService = BudgetLineService(db)
|
|
||||||
self.user_service: UserService = UserService(db)
|
|
||||||
|
|
||||||
|
|
||||||
async def process(
|
|
||||||
self,
|
|
||||||
event_data: dict,
|
|
||||||
form_id: int,
|
|
||||||
user_id: int,
|
|
||||||
sheet: str,
|
|
||||||
direction: str | None = None,
|
|
||||||
) -> int | bool | dict:
|
|
||||||
curr_user: AppUser = await self.user_service.get(user_id)
|
|
||||||
|
|
||||||
match event_data["event"]:
|
|
||||||
case "cell_updated":
|
|
||||||
return await self.__update_cell(
|
|
||||||
event_data=event_data["data"],
|
|
||||||
form_id=form_id,
|
|
||||||
user=curr_user,
|
|
||||||
sheet=sheet,
|
|
||||||
direction=direction,
|
|
||||||
)
|
|
||||||
case "row_added":
|
|
||||||
return await self.__add_row(
|
|
||||||
event_data=event_data["data"],
|
|
||||||
form_id=form_id,
|
|
||||||
user=curr_user,
|
|
||||||
sheet=sheet,
|
|
||||||
direction=direction,
|
|
||||||
)
|
|
||||||
case "row_deleted":
|
|
||||||
return await self.__del_row(
|
|
||||||
event_data=event_data["data"],
|
|
||||||
form_id=form_id,
|
|
||||||
user=curr_user,
|
|
||||||
sheet=sheet,
|
|
||||||
direction=direction,
|
|
||||||
)
|
|
||||||
case _:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __update_cell(
|
|
||||||
self,
|
|
||||||
event_data: dict,
|
|
||||||
form_id: int,
|
|
||||||
sheet: str,
|
|
||||||
user: AppUser,
|
|
||||||
direction: str | None = None,
|
|
||||||
) -> list[tuple]:
|
|
||||||
"""
|
|
||||||
event_data: {
|
|
||||||
"line_id": int,
|
|
||||||
"column": str,
|
|
||||||
"value": any,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
form = await self.bf_service.get(budget_form_id=form_id, user=user)
|
|
||||||
if not form:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return await self.sheet_service.update_cell(
|
|
||||||
form_id=form_id,
|
|
||||||
sheet=sheet,
|
|
||||||
direction=direction,
|
|
||||||
sections=None,
|
|
||||||
line_id=event_data["line_id"],
|
|
||||||
column=event_data["column"],
|
|
||||||
value=event_data["value"],
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def __add_row(
|
|
||||||
self,
|
|
||||||
event_data: dict,
|
|
||||||
form_id: int,
|
|
||||||
sheet: str,
|
|
||||||
user: AppUser,
|
|
||||||
direction: str | None,
|
|
||||||
) -> list[tuple]:
|
|
||||||
"""
|
|
||||||
data = {
|
|
||||||
expense_item_id: Optional[int] = None
|
|
||||||
item_id: Optional[str] = None
|
|
||||||
section_code: Optional[str] = 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
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
form = await self.bf_service.get(budget_form_id=form_id, user=user)
|
|
||||||
if not form:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ids = await self.bl_service.get_ids(budget_form_id=form_id)
|
|
||||||
result = await self.sheet_service.add_line(
|
|
||||||
form_id=form_id,
|
|
||||||
sheet=sheet,
|
|
||||||
expense_item_id=event_data.get("expense_item_id"),
|
|
||||||
item_id=event_data.get("item_id"),
|
|
||||||
section_code=event_data.get("section_code"),
|
|
||||||
direction=direction,
|
|
||||||
name=event_data.get("name"),
|
|
||||||
internal_order=event_data.get("internal_order"),
|
|
||||||
vsp_id=event_data.get("vsp_id"),
|
|
||||||
project_id=event_data.get("project_id"),
|
|
||||||
justification=event_data.get("justification"),
|
|
||||||
contract_number=event_data.get("contract_number"),
|
|
||||||
contract_end_date=event_data.get("contract_end_date"),
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
final_result = {
|
|
||||||
"data": result,
|
|
||||||
"new_line_id": None,
|
|
||||||
}
|
|
||||||
for el in result:
|
|
||||||
if el[3]["line_id"] and el[3]["line_id"] not in ids:
|
|
||||||
final_result["new_line_id"] = el[3]["line_id"]
|
|
||||||
return final_result
|
|
||||||
return final_result
|
|
||||||
|
|
||||||
|
|
||||||
async def __del_row(
|
|
||||||
self,
|
|
||||||
event_data: dict,
|
|
||||||
form_id: int,
|
|
||||||
sheet: str,
|
|
||||||
user: AppUser,
|
|
||||||
direction: str | None,
|
|
||||||
) -> list[tuple]:
|
|
||||||
"""
|
|
||||||
data = {
|
|
||||||
"row_id": int,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
form = await self.bf_service.get(budget_form_id=form_id, user=user)
|
|
||||||
if not form:
|
|
||||||
return None
|
|
||||||
result = await self.sheet_service.delete_line(
|
|
||||||
form_id=form_id,
|
|
||||||
sheet=sheet,
|
|
||||||
row_id=event_data["row_id"],
|
|
||||||
direction=direction,
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectEventProcess:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.project_service: ProjectService = ProjectService(db)
|
|
||||||
self.bf_service: BudgetFormService = BudgetFormService(db)
|
|
||||||
self.user_service: UserService = UserService(db)
|
|
||||||
|
|
||||||
async def process(
|
|
||||||
self,
|
|
||||||
event_data: dict,
|
|
||||||
project_id: int,
|
|
||||||
user_id: int,
|
|
||||||
year: int,
|
|
||||||
report_type: str,
|
|
||||||
) -> int | bool | dict:
|
|
||||||
curr_user: AppUser = await self.user_service.get(user_id)
|
|
||||||
|
|
||||||
match event_data["event"]:
|
|
||||||
case "cell_updated":
|
|
||||||
return await self.__update_cell(
|
|
||||||
event_data=event_data["data"],
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
user=curr_user,
|
|
||||||
)
|
|
||||||
case "row_added":
|
|
||||||
return await self.__add_row(
|
|
||||||
event_data=event_data["data"],
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
user=curr_user,
|
|
||||||
)
|
|
||||||
case "row_deleted":
|
|
||||||
return await self.__del_row(
|
|
||||||
event_data=event_data["data"],
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
user=curr_user,
|
|
||||||
)
|
|
||||||
case _:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __update_cell(
|
|
||||||
self,
|
|
||||||
event_data: dict,
|
|
||||||
project_id: int,
|
|
||||||
year: int,
|
|
||||||
report_type: str,
|
|
||||||
user: AppUser,
|
|
||||||
) -> list[tuple]:
|
|
||||||
"""
|
|
||||||
event_data: {
|
|
||||||
"line_id": int,
|
|
||||||
"column": str,
|
|
||||||
"value": any,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
project = await self.project_service.get(project_id=project_id, user=user)
|
|
||||||
if not project:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return await self.project_service.upd_form3_cell(
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
line_id=event_data["line_id"],
|
|
||||||
column=event_data["column"],
|
|
||||||
value=event_data["value"],
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def __add_row(
|
|
||||||
self,
|
|
||||||
event_data: dict,
|
|
||||||
project_id: int,
|
|
||||||
year: int,
|
|
||||||
report_type: str,
|
|
||||||
user: AppUser,
|
|
||||||
) -> list[tuple]:
|
|
||||||
"""
|
|
||||||
data = {
|
|
||||||
expense_item_id: Optional[int] = None
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
project = await self.project_service.get(project_id=project_id, user=user)
|
|
||||||
if not project:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return await self.project_service.add_form3_line(
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
expense_item_id=event_data["expense_item_id"],
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def __del_row(
|
|
||||||
self,
|
|
||||||
event_data: dict,
|
|
||||||
project_id: int,
|
|
||||||
year: int,
|
|
||||||
report_type: str,
|
|
||||||
user: AppUser,
|
|
||||||
) -> list[tuple]:
|
|
||||||
"""
|
|
||||||
data = {
|
|
||||||
"line_id": int,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
project = await self.project_service.get(project_id=project_id, user=user)
|
|
||||||
if not project:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return await self.project_service.del_form3_line(
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
line_id=event_data["line_id"],
|
|
||||||
user=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
manager = ConnectionManager()
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_error(o):
|
|
||||||
try:
|
|
||||||
return asdict(o)
|
|
||||||
except TypeError:
|
|
||||||
return o
|
|
||||||
|
|
||||||
|
|
||||||
async def login(websocket: WebSocket, **kwargs) -> int:
|
|
||||||
user_data = await manager.get_data(websocket=websocket)
|
|
||||||
if user_data.get("event") != "user_login":
|
|
||||||
return None
|
|
||||||
|
|
||||||
async with get_db_session() as db:
|
|
||||||
user = await get_user_by_token(token=user_data["data"].get("token"), db=db)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
manager.set_user(
|
|
||||||
websocket=websocket,
|
|
||||||
user_id=user.id,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
return user.id
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_cell_key(event_data: dict) -> dict | None:
|
|
||||||
data = event_data.get("data")
|
|
||||||
|
|
||||||
if not data or "line_id" not in data or "column" not in data:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"line_id": data["line_id"],
|
|
||||||
"column": data["column"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def process_websocket(
|
|
||||||
websocket: WebSocket,
|
|
||||||
processor_cls,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
await manager.connect(
|
|
||||||
websocket=websocket,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
kwargs_process = kwargs.copy()
|
|
||||||
if "con_key" in kwargs_process:
|
|
||||||
kwargs_process.pop("con_key")
|
|
||||||
con_key = kwargs.get("con_key")
|
|
||||||
|
|
||||||
try:
|
|
||||||
user_id = await login(
|
|
||||||
websocket=websocket,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
if user_id is None:
|
|
||||||
|
|
||||||
await manager.disconnect(
|
|
||||||
websocket=websocket,
|
|
||||||
reason="Ошибка авторизации",
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
while True:
|
|
||||||
data = loads(await websocket.receive_text())
|
|
||||||
|
|
||||||
try:
|
|
||||||
event_name = data.get("event")
|
|
||||||
cell_key = resolve_cell_key(data)
|
|
||||||
|
|
||||||
match event_name:
|
|
||||||
case "cell_edit_start":
|
|
||||||
if not manager.acquire_cell_lock(
|
|
||||||
con_key=con_key,
|
|
||||||
user_id=user_id,
|
|
||||||
cell_key=cell_key,
|
|
||||||
form_key=kwargs_process,
|
|
||||||
):
|
|
||||||
data["error"] = "Ячейка уже редактируется другим пользователем"
|
|
||||||
await manager.send_back(
|
|
||||||
data,
|
|
||||||
websocket,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
await manager.send_back(
|
|
||||||
data,
|
|
||||||
websocket,
|
|
||||||
)
|
|
||||||
await manager.broadcast_to_other(
|
|
||||||
data=data,
|
|
||||||
# user_id=user_id,
|
|
||||||
websocket=websocket,
|
|
||||||
con_key=con_key,
|
|
||||||
**kwargs_process,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
case "cell_edit_end":
|
|
||||||
manager.release_cell_lock(
|
|
||||||
con_key=con_key,
|
|
||||||
user_id=user_id,
|
|
||||||
cell_key=cell_key,
|
|
||||||
form_key=kwargs_process,
|
|
||||||
)
|
|
||||||
await manager.send_back(
|
|
||||||
data,
|
|
||||||
websocket,
|
|
||||||
)
|
|
||||||
await manager.broadcast_to_other(
|
|
||||||
data=data,
|
|
||||||
# user_id=user_id,
|
|
||||||
websocket=websocket,
|
|
||||||
con_key=con_key,
|
|
||||||
**kwargs_process,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
case "cell_updated":
|
|
||||||
if manager.is_locked_by_other(
|
|
||||||
con_key=con_key,
|
|
||||||
user_id=user_id,
|
|
||||||
cell_key=cell_key,
|
|
||||||
form_key=kwargs_process,
|
|
||||||
):
|
|
||||||
data["error"] = "Ячейка уже редактируется другим пользователем"
|
|
||||||
await manager.send_back(
|
|
||||||
data,
|
|
||||||
websocket,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if cell_key and not manager.acquire_cell_lock(
|
|
||||||
con_key=con_key,
|
|
||||||
user_id=user_id,
|
|
||||||
cell_key=cell_key,
|
|
||||||
form_key=kwargs_process,
|
|
||||||
):
|
|
||||||
data["error"] = "Ячейка уже редактируется другим пользователем"
|
|
||||||
await manager.send_back(
|
|
||||||
data,
|
|
||||||
websocket,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
case "row_deleted":
|
|
||||||
if manager.is_row_locked_by_other(
|
|
||||||
con_key=con_key,
|
|
||||||
user_id=user_id,
|
|
||||||
line_id=data["data"]["row_id"]
|
|
||||||
):
|
|
||||||
data["error"] = (
|
|
||||||
"Нельзя удалить строку: в этой строке есть редактируемые ячейки"
|
|
||||||
)
|
|
||||||
await manager.send_back(
|
|
||||||
data,
|
|
||||||
websocket,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
case _:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async with get_db_session(user_id=user_id) as db:
|
|
||||||
processor = processor_cls(db)
|
|
||||||
data["result"] = await processor.process(
|
|
||||||
event_data=data,
|
|
||||||
user_id=user_id,
|
|
||||||
**kwargs_process
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
if data.get("event") == "row_deleted":
|
|
||||||
manager.release_locks_for_row(
|
|
||||||
con_key=con_key,
|
|
||||||
user_id=user_id,
|
|
||||||
line_id=data["data"]["row_id"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.get("event") in [
|
|
||||||
"cell_updated",
|
|
||||||
"row_added",
|
|
||||||
"row_deleted",
|
|
||||||
]:
|
|
||||||
await manager.broadcast_to_all(
|
|
||||||
data=data,
|
|
||||||
websocket=websocket,
|
|
||||||
# user_id=user_id,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await manager.broadcast_to_other(
|
|
||||||
data=data,
|
|
||||||
# user_id=user_id,
|
|
||||||
websocket=websocket,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
except BasicAppException as e:
|
|
||||||
data["error"] = e.description or "Неизвестная ошибка"
|
|
||||||
await manager.send_back(
|
|
||||||
data=data,
|
|
||||||
websocket=websocket,
|
|
||||||
)
|
|
||||||
except IntegrityError as e:
|
|
||||||
data["error"] = str(e)
|
|
||||||
await manager.send_back(
|
|
||||||
data=data,
|
|
||||||
websocket=websocket,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
tp = type(e)
|
|
||||||
handlers = websocket.app.exception_handlers
|
|
||||||
if tp in handlers:
|
|
||||||
data["error"] = loads((await handlers[tp](request=None, exc=e)).body)
|
|
||||||
await manager.send_back(
|
|
||||||
data=data,
|
|
||||||
websocket=websocket,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
await manager.disconnect(
|
|
||||||
websocket=websocket,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Логируем ошибку, но не бросаем HTTPException — это WebSocket
|
|
||||||
print(f"Error: {e}")
|
|
||||||
await manager.disconnect(
|
|
||||||
websocket=websocket,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/form/{form_id}/sheet/{sheet}")
|
|
||||||
async def websocket_form(
|
|
||||||
websocket: WebSocket,
|
|
||||||
form_id: int,
|
|
||||||
sheet: str,
|
|
||||||
direction: Optional[str] = None,
|
|
||||||
):
|
|
||||||
await process_websocket(
|
|
||||||
websocket=websocket,
|
|
||||||
form_id=form_id,
|
|
||||||
sheet=sheet,
|
|
||||||
direction=direction,
|
|
||||||
processor_cls=FormEventProcess,
|
|
||||||
con_key=ConnectionKeyEnum.FORM,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/projects/{project_id}/report/{year}/{report_type}")
|
|
||||||
async def websocket_project(
|
|
||||||
websocket: WebSocket,
|
|
||||||
project_id: int,
|
|
||||||
year: int,
|
|
||||||
report_type: str,
|
|
||||||
):
|
|
||||||
await process_websocket(
|
|
||||||
websocket=websocket,
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
processor_cls=ProjectEventProcess,
|
|
||||||
con_key=ConnectionKeyEnum.PROJECT,
|
|
||||||
)
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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:",
|
|
||||||
)
|
|
||||||
@ -4,7 +4,10 @@ from typing import List
|
|||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
prefix = "OPENBAO__SETTINGS"
|
if os.environ.get("APP_ENV", "dev") == "test":
|
||||||
|
prefix = "OPENBAO__SETTINGS_TEST"
|
||||||
|
else:
|
||||||
|
prefix = "OPENBAO__SETTINGS"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@ -102,11 +105,6 @@ class Settings(BaseSettings):
|
|||||||
description="Размер батча для очистки аудита",
|
description="Размер батча для очистки аудита",
|
||||||
alias="AUDIT_LOG_CLEANUP_BATCH_SIZE",
|
alias="AUDIT_LOG_CLEANUP_BATCH_SIZE",
|
||||||
)
|
)
|
||||||
AUDIT_LOG_CLEANUP_INTERVAL_SECONDS: int = Field(
|
|
||||||
default=86400,
|
|
||||||
description="Интервал автозапуска очистки аудита в секундах",
|
|
||||||
alias="AUDIT_LOG_CLEANUP_INTERVAL_SECONDS",
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(BaseModel):
|
class ValidationError(BaseModel):
|
||||||
field: str = Field(...)
|
field: str = Field(...)
|
||||||
description: str = Field(...)
|
description: str = Field(...)
|
||||||
@ -12,14 +10,6 @@ class BasicAppException(Exception):
|
|||||||
description: str | None = None
|
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):
|
class AccessDeniedException(BasicAppException):
|
||||||
description = "Доступ запрещен"
|
description = "Доступ запрещен"
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
from fastapi import FastAPI, HTTPException, status
|
from fastapi import FastAPI, status
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError
|
|
||||||
|
|
||||||
from src.core.errors import (
|
from src.core.errors import (
|
||||||
AccessDeniedException,
|
AccessDeniedException,
|
||||||
@ -9,176 +7,41 @@ from src.core.errors import (
|
|||||||
UserNotFoundException,
|
UserNotFoundException,
|
||||||
UsernameConflictException,
|
UsernameConflictException,
|
||||||
ValidationException,
|
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: int, message: str, field: str = "") -> dict:
|
|
||||||
"""Формирует структурированный payload ошибки по спеке."""
|
|
||||||
return {
|
|
||||||
"code": code,
|
|
||||||
"field": field,
|
|
||||||
"message": message,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_prefixed_validation_error(message: str) -> str:
|
|
||||||
for prefix in VALIDATION_PREFIXES:
|
|
||||||
if message.lower().startswith(prefix):
|
|
||||||
field = message.split(":", 1)[1].strip() if ":" in message else ""
|
|
||||||
return field or ""
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
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, int, str, str]:
|
|
||||||
"""Маппинг ошибок 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):
|
|
||||||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
||||||
field = _parse_prefixed_validation_error(message)
|
|
||||||
return status_code, status_code, message, field
|
|
||||||
if any(error in low for error in PG_404_ERRORS):
|
|
||||||
status_code = status.HTTP_404_NOT_FOUND
|
|
||||||
return status_code, status_code, message, ""
|
|
||||||
if any(error in low for error in PG_403_ERRORS):
|
|
||||||
status_code = status.HTTP_403_FORBIDDEN
|
|
||||||
return status_code, status_code, message, ""
|
|
||||||
|
|
||||||
if isinstance(exc, IntegrityError):
|
|
||||||
if any(error in low for error in PG_409_ERRORS):
|
|
||||||
status_code = status.HTTP_409_CONFLICT
|
|
||||||
return status_code, status_code, message, ""
|
|
||||||
if any(error in low for error in PG_400_ERRORS):
|
|
||||||
status_code = status.HTTP_400_BAD_REQUEST
|
|
||||||
return status_code, status_code, message, ""
|
|
||||||
status_code = status.HTTP_400_BAD_REQUEST
|
|
||||||
return status_code, status_code, message, ""
|
|
||||||
|
|
||||||
if isinstance(exc, OperationalError):
|
|
||||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
||||||
return status_code, status_code, "Database unavailable", ""
|
|
||||||
|
|
||||||
status_code = status.HTTP_400_BAD_REQUEST
|
|
||||||
return status_code, status_code, message, ""
|
|
||||||
|
|
||||||
|
|
||||||
def map_http_exception(exc: HTTPException) -> tuple[int, int, str, str]:
|
|
||||||
"""Маппинг 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")
|
|
||||||
field_str = str(field) if field is not None else ""
|
|
||||||
return status_code, status_code, message, field_str
|
|
||||||
|
|
||||||
message = str(detail) if detail else "HTTP error"
|
|
||||||
return status_code, status_code, message, ""
|
|
||||||
|
|
||||||
|
|
||||||
def map_request_validation_error(exc: RequestValidationError) -> tuple[int, int, str, str]:
|
|
||||||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
|
||||||
errors = exc.errors()
|
|
||||||
if not errors:
|
|
||||||
return status_code, status_code, "Validation error", ""
|
|
||||||
|
|
||||||
first_error = errors[0]
|
|
||||||
message = str(first_error.get("msg") or "Validation error")
|
|
||||||
loc = first_error.get("loc") or ()
|
|
||||||
field = ".".join(str(part) for part in loc if part not in ("body", "query", "path", "header", "cookie"))
|
|
||||||
return status_code, status_code, message, field
|
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI) -> None:
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
@app.exception_handler(RequestValidationError)
|
|
||||||
async def request_validation_exception_handler(request, exc: RequestValidationError):
|
|
||||||
status_code, code, message, field = map_request_validation_error(exc)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status_code,
|
|
||||||
content=_error_payload(code, message, field=field),
|
|
||||||
)
|
|
||||||
|
|
||||||
@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)
|
@app.exception_handler(AccessDeniedException)
|
||||||
async def access_denied_exception_handler(request, exc: AccessDeniedException):
|
async def access_denied_exception_handler(request, exc: AccessDeniedException):
|
||||||
status_code = status.HTTP_403_FORBIDDEN
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status_code,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
content=_error_payload(status_code, exc.description),
|
content={"detail": exc.description},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(ValidationException)
|
@app.exception_handler(ValidationException)
|
||||||
async def validation_exception_handler(request, exc: ValidationException):
|
async def validation_exception_handler(request, exc: ValidationException):
|
||||||
status_code = status.HTTP_400_BAD_REQUEST
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status_code,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
content=_error_payload(status_code, exc.description, field=exc.field or ""),
|
content={"detail": exc.description},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(UserNotFoundException)
|
@app.exception_handler(UserNotFoundException)
|
||||||
async def user_not_found_exception_handler(request, exc: UserNotFoundException):
|
async def user_not_found_exception_handler(request, exc: UserNotFoundException):
|
||||||
status_code = status.HTTP_404_NOT_FOUND
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status_code,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
content=_error_payload(status_code, exc.description),
|
content={"detail": exc.description},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(UsernameConflictException)
|
@app.exception_handler(UsernameConflictException)
|
||||||
async def username_conflict_exception_handler(request, exc: UsernameConflictException):
|
async def username_conflict_exception_handler(request, exc: UsernameConflictException):
|
||||||
status_code = status.HTTP_409_CONFLICT
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status_code,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
content=_error_payload(status_code, exc.description),
|
content={"detail": exc.description},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(BasicAppException)
|
@app.exception_handler(BasicAppException)
|
||||||
async def basic_app_exception_handler(request, exc: BasicAppException):
|
async def basic_app_exception_handler(request, exc: BasicAppException):
|
||||||
message = exc.description or "Application error"
|
|
||||||
status_code = status.HTTP_400_BAD_REQUEST
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status_code,
|
|
||||||
content=_error_payload(status_code, message),
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(SQLAlchemyError)
|
|
||||||
@app.exception_handler(DBAPIError)
|
|
||||||
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(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
content=exc.errors,
|
content={"detail": exc.description or "Application error"},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,15 +7,12 @@ connect_args = {}
|
|||||||
if settings.DATABASE_SCHEMA:
|
if settings.DATABASE_SCHEMA:
|
||||||
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
|
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
|
||||||
|
|
||||||
|
|
||||||
if "sqlite" in settings.DATABASE_URL:
|
if "sqlite" in settings.DATABASE_URL:
|
||||||
connect_args["check_same_thread"] = False
|
connect_args["check_same_thread"] = False
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
settings.DATABASE_URL,
|
settings.DATABASE_URL,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_size=5,
|
|
||||||
max_overflow=10,
|
|
||||||
connect_args=connect_args,
|
connect_args=connect_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
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.expense_item import ExpenseItem
|
|
||||||
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, UserRoleEnum
|
|
||||||
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
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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"
|
|
||||||
# )
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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,
|
|
||||||
default=func.now(),
|
|
||||||
onupdate=func.now(),
|
|
||||||
)
|
|
||||||
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"
|
|
||||||
# )
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from src.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ExpenseItem(Base):
|
|
||||||
__tablename__ = "expense_item"
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint("direction IN ('Support', 'Development')", name="expense_item_direction_check"),
|
|
||||||
CheckConstraint("sheet IN ('AHR', 'CAP', 'OPER')", name="expense_item_sheet_check"),
|
|
||||||
Index("ix_v3_expense_item_parent", "parent_id"),
|
|
||||||
Index("ix_v3_expense_item_sheet", "sheet"),
|
|
||||||
{"schema": "v3"},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
section_code: Mapped[str | None] = mapped_column(String)
|
|
||||||
item_id: Mapped[str | None] = mapped_column(String)
|
|
||||||
num_group_id: Mapped[str | None] = mapped_column(String)
|
|
||||||
name: Mapped[str | None] = mapped_column(String)
|
|
||||||
sheet: Mapped[str | None] = mapped_column(String)
|
|
||||||
direction: Mapped[str | None] = mapped_column(String)
|
|
||||||
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
|
|
||||||
depth: Mapped[int | None] = mapped_column(Integer, default=0)
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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))
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
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_3: ["LIMIT", "CURRENT_EXPENSES"],
|
|
||||||
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_3: ["q1", "q2", "q3", "q4", "year"],
|
|
||||||
FormTypeEnum.FORM_4: ["plan", "seq_dfip", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals", "contract_summary", "allocation", "reserve", "collegial", "ckk"],
|
|
||||||
}
|
|
||||||
|
|
||||||
_sheet_list_with_directions = {
|
|
||||||
FormTypeEnum.FORM_1: [
|
|
||||||
{
|
|
||||||
"sheet_name": sheet_name,
|
|
||||||
"direction": None,
|
|
||||||
} for sheet_name in ["OPER", "SMETA"]
|
|
||||||
] + [
|
|
||||||
{
|
|
||||||
"sheet_name": sheet_name,
|
|
||||||
"direction": direction
|
|
||||||
} for sheet_name in ["AHR", "CAP"] for direction in ["Support", "Development"]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sheet_list_with_directions(self) -> dict:
|
|
||||||
return self._sheet_list_with_directions.get(
|
|
||||||
self.code,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"sheet_name": sheet_name,
|
|
||||||
"direction": None,
|
|
||||||
} for sheet_name in self.sheet_list
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@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, [])
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
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)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def users_count(self) -> int | None:
|
|
||||||
return len(self.users) if self.users is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
users: Mapped[list["AppUser"]] = relationship("AppUser", secondary="v3.user_org", back_populates="org_units")
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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"
|
|
||||||
# )
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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"
|
|
||||||
# )
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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"
|
|
||||||
# )
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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")
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, CheckConstraint, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Vsp(Base):
|
|
||||||
__tablename__ = "vsp"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"reg_number", name="vsp_reg_number_unique",
|
|
||||||
),
|
|
||||||
CheckConstraint(
|
|
||||||
"closed_at IS NULL OR opened_at IS NULL OR closed_at >= opened_at",
|
|
||||||
name="chk_closed_after_opened",
|
|
||||||
),
|
|
||||||
|
|
||||||
{"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)
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
system_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
||||||
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
|
||||||
updated_by: Mapped[int | None] = mapped_column(
|
|
||||||
Integer, ForeignKey("v3.app_user.id"), nullable=True,
|
|
||||||
)
|
|
||||||
vsp_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
location_form: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
||||||
numbers: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
rent_contract_num: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
||||||
rent_end_date : Mapped[date | None]= mapped_column(Date, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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,8 +5,6 @@ from alembic import command
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
from src.db.base import SessionLocal
|
from src.db.base import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
@ -14,10 +12,6 @@ async def get_db() -> AsyncGenerator:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
await db.commit()
|
|
||||||
except Exception:
|
|
||||||
await db.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,8 @@
|
|||||||
import enum
|
import enum
|
||||||
|
|
||||||
|
|
||||||
#ToDo: вероятно это теперь AuditEvent. А еще он нигде не юзается больше, вероятно, выпилить
|
|
||||||
class AuditEventType(str, enum.Enum):
|
class AuditEventType(str, enum.Enum):
|
||||||
LOGIN = "LOGIN"
|
LOGIN = "LOGIN"
|
||||||
USER_CREATE = "USER_CREATE"
|
USER_CREATE = "USER_CREATE"
|
||||||
USER_UPDATE = "USER_UPDATE"
|
USER_UPDATE = "USER_UPDATE"
|
||||||
USER_DELETE = "USER_DELETE"
|
USER_DELETE = "USER_DELETE"
|
||||||
ROW_CREATE = "ROW_CREATE"
|
|
||||||
ROW_DELETE = "ROW_DELETE"
|
|
||||||
ACCESS_WINDOW_CHANGE = "ACCESS_WINDOW_CHANGE"
|
|
||||||
ACCESS_EXTEND = "ACCESS_EXTEND"
|
|
||||||
USER_ROLE_CHANGE = "USER_ROLE_CHANGE"
|
|
||||||
CREATE_TASK = "CREATE_TASK"
|
|
||||||
UPDATE_TASK = "UPDATE_TASK"
|
|
||||||
DELETE_TASK = "DELETE_TASK"
|
|
||||||
VSP_CREATE = "VSP_CREATE"
|
|
||||||
VSP_UPDATE = "VSP_UPDATE"
|
|
||||||
PROJECT_CREATE = "PROJECT_CREATE"
|
|
||||||
PROJECT_DELETE = "PROJECT_DELETE"
|
|
||||||
USER_ACCESS_GRANTED = "USER_ACCESS_GRANTED"
|
|
||||||
USER_ACCESS_REVOKED = "USER_ACCESS_REVOKED"
|
|
||||||
|
|||||||
70
api/src/domain/models.py
Normal file
70
api/src/domain/models.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
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,124 +1,41 @@
|
|||||||
from datetime import datetime, date
|
from datetime import datetime
|
||||||
import enum
|
from typing import Optional
|
||||||
from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
|
|
||||||
from sqlalchemy.exc import MissingGreenlet
|
|
||||||
|
|
||||||
FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"]
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
ProjectTypeLiteral = Literal[
|
|
||||||
"Открытие ВСП",
|
|
||||||
"Закрытие ВСП",
|
|
||||||
"Переезд ВСП",
|
|
||||||
"Реновация РФ",
|
|
||||||
"Открытие УРМ",
|
|
||||||
]
|
|
||||||
|
|
||||||
VspFormatLiteral = Literal[
|
|
||||||
"Флагманский",
|
|
||||||
"Типовой",
|
|
||||||
"Розничный",
|
|
||||||
"МСБ",
|
|
||||||
"Лёгкий",
|
|
||||||
"Мини",
|
|
||||||
"Розничный-киоск",
|
|
||||||
"МБО",
|
|
||||||
"Офис самообслуживания",
|
|
||||||
"Другое",
|
|
||||||
]
|
|
||||||
|
|
||||||
PlacementTypeLiteral = Literal[
|
|
||||||
"Собственность",
|
|
||||||
"Аренда",
|
|
||||||
"Субаренда",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# def serialize_missing_greenlet(cls, obj):
|
|
||||||
# result = {}
|
|
||||||
# for attr in cls.model_fields:
|
|
||||||
# try:
|
|
||||||
# result[attr] = getattr(obj, attr)
|
|
||||||
# except MissingGreenlet:
|
|
||||||
# result[attr] = None
|
|
||||||
# return result
|
|
||||||
|
|
||||||
|
|
||||||
class ResponseBase(BaseModel):
|
class ResponseBase(BaseModel):
|
||||||
success: Optional[bool] = Field(True)
|
success: Optional[bool] = Field(None)
|
||||||
message: Optional[str] = None
|
message: Optional[str] = Field(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):
|
class UserBase(BaseModel):
|
||||||
email: EmailStr = Field(..., examples=["user@example.com"])
|
email: EmailStr
|
||||||
username: str = Field(
|
username: str = Field(
|
||||||
...,
|
...,
|
||||||
min_length=3,
|
min_length=3,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
pattern=r"^[A-Za-z0-9_.-]+$",
|
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
|
role_id: int = 3
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str | None = Field(default=None, examples=["pass123"])
|
password: str | None = None
|
||||||
load_orgs: bool = Field(default=False)
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"email": "user@example.com",
|
|
||||||
"username": "user_1",
|
|
||||||
"full_name": "Иван Иванов",
|
|
||||||
"role_id": 3,
|
|
||||||
"password": "pass123",
|
|
||||||
"load_orgs": True,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"])
|
email: Optional[EmailStr] = None
|
||||||
username: Optional[str] = Field(
|
username: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
min_length=3,
|
min_length=3,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
pattern=r"^[A-Za-z0-9_.-]+$",
|
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
|
role_id: Optional[int] = None
|
||||||
is_active: Optional[bool] = True
|
is_active: Optional[bool] = True
|
||||||
load_orgs: bool = Field(default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class UserInDB(UserBase):
|
class UserInDB(UserBase):
|
||||||
@ -134,37 +51,6 @@ class User(UserInDB):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class UserAdminListResponse(UserInDB):
|
|
||||||
org_units: Optional[list["OrgUnitSchema"]] = None
|
|
||||||
|
|
||||||
|
|
||||||
@model_validator(mode='before')
|
|
||||||
@classmethod
|
|
||||||
def safe_orm_handler(cls, data: Any) -> Any:
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data
|
|
||||||
result = {
|
|
||||||
"org_units": None,
|
|
||||||
"id": data.id,
|
|
||||||
"created_at": data.created_at,
|
|
||||||
"updated_at": data.updated_at,
|
|
||||||
"is_active": data.is_active,
|
|
||||||
"email": data.email,
|
|
||||||
"username": data.username,
|
|
||||||
"full_name": data.full_name,
|
|
||||||
"role_id": data.role_id,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
result["org_units"] = [
|
|
||||||
ou for ou in data.org_units
|
|
||||||
if ou.is_active
|
|
||||||
]
|
|
||||||
except MissingGreenlet:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(ResponseBase):
|
class UserResponse(ResponseBase):
|
||||||
result: Optional[UserInDB] = None
|
result: Optional[UserInDB] = None
|
||||||
|
|
||||||
@ -211,424 +97,3 @@ class LoginRequest(BaseModel):
|
|||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
class RefreshRequest(BaseModel):
|
||||||
refresh_token: str
|
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 FormCreateSchema(BaseModel):
|
|
||||||
year: int
|
|
||||||
form_type_code: FormTypeSchemaEnum
|
|
||||||
org_unit_ids: list[int] = Field(..., min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class OrgUnitBaseSchema(BaseModel):
|
|
||||||
title: str
|
|
||||||
is_ssp: bool
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class OrgUnitUpdateSchema(OrgUnitBaseSchema):
|
|
||||||
title: Optional[str] = None
|
|
||||||
is_ssp: Optional[bool] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class OrgUnitResponseSchema(OrgUnitBaseSchema):
|
|
||||||
id: int
|
|
||||||
is_active: bool
|
|
||||||
users_count: int | None = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
@model_validator(mode='before')
|
|
||||||
@classmethod
|
|
||||||
def safe_orm_handler(cls, data: Any) -> Any:
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data
|
|
||||||
result = {
|
|
||||||
"users_count": None,
|
|
||||||
"id": data.id,
|
|
||||||
"title": data.title,
|
|
||||||
"is_ssp": data.is_ssp,
|
|
||||||
"is_ssp": data.is_ssp,
|
|
||||||
"is_active": data.is_active,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
result["users_count"] = data.users_count
|
|
||||||
except MissingGreenlet:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class OrgUnitListSchema(OrgUnitResponseSchema):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OrgUnitSchema(OrgUnitResponseSchema):
|
|
||||||
users: list[UserInDB] | None = None
|
|
||||||
|
|
||||||
@model_validator(mode='before')
|
|
||||||
@classmethod
|
|
||||||
def safe_orm_handler(cls, data: Any) -> Any:
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data
|
|
||||||
result = super().safe_orm_handler(data)
|
|
||||||
result["users"] = None
|
|
||||||
try:
|
|
||||||
result["users"] = data.users
|
|
||||||
except MissingGreenlet:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class FullSheetSchema(BaseModel):
|
|
||||||
sheet_name: str
|
|
||||||
direction: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SheetFormTypeResponse(BaseModel):
|
|
||||||
form_type: FormTypeSchemaEnum
|
|
||||||
sheets: list[str]
|
|
||||||
all_sheets: list[FullSheetSchema] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
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 ExportBulkRequest(BaseModel):
|
|
||||||
form_ids: list[int] = Field(min_length=1)
|
|
||||||
skip_failed: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogBase(BaseModel):
|
|
||||||
"""Базовая схема записи аудита (единый формат вывода как у auditlog)."""
|
|
||||||
|
|
||||||
entity: str = Field(..., description="Тип сущности")
|
|
||||||
entity_id: Optional[int] = Field(None, description="ID сущности")
|
|
||||||
action: str = Field(..., description="Действие")
|
|
||||||
payload_json: Optional[Dict[str, Any]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogInDB(AuditLogBase):
|
|
||||||
"""Схема записи аудита в базе данных."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: int = Field(..., description="ID записи")
|
|
||||||
user_id: Optional[int] = Field(None, description="ID пользователя")
|
|
||||||
at: datetime = Field(..., description="Дата/время события")
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(AuditLogInDB):
|
|
||||||
"""Схема записи аудита для ответа API."""
|
|
||||||
|
|
||||||
user: Optional[User] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogListResponse(ResponseBase):
|
|
||||||
"""Схема всех записей аудита для ответа API."""
|
|
||||||
|
|
||||||
result: List[AuditLog] = Field(..., description="Вывод записей аудита")
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogQueryParams(BaseModel):
|
|
||||||
"""Query-параметры для фильтрации журнала аудита."""
|
|
||||||
|
|
||||||
page: int = Field(1, ge=1, description="Номер страницы")
|
|
||||||
limit: int = Field(
|
|
||||||
20, ge=1, le=100, description="Количество записей на странице"
|
|
||||||
)
|
|
||||||
user_id: Optional[int] = Field(None, description="ID пользователя")
|
|
||||||
org_unit_id: Optional[int] = Field(None, description="ID ССП")
|
|
||||||
task_id: Optional[int] = Field(None, description="ID задачи")
|
|
||||||
form_id: Optional[int] = Field(None, description="ID формы")
|
|
||||||
event_type: Optional[str] = Field(None, description="Тип события")
|
|
||||||
event: Optional[str] = Field(None, description="Cобытиу")
|
|
||||||
date_from: Optional[date] = Field(None, description="Дата начала (ISO 8601)")
|
|
||||||
date_to: Optional[date] = Field(None, description="Дата окончания (ISO 8601)")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class VSPBase(BaseModel):
|
|
||||||
system_code: Optional[str] = Field(None, description="Системный код ВСП")
|
|
||||||
vsp_type: Optional[str] = Field(None, description="Вид ВСП")
|
|
||||||
registration_number: str = Field(
|
|
||||||
...,
|
|
||||||
description="Значение для выпадающего списка",
|
|
||||||
validation_alias='reg_number',
|
|
||||||
serialization_alias='registration_number',
|
|
||||||
)
|
|
||||||
address: str = Field(..., description="Автоподставляемый адрес")
|
|
||||||
open_date: Optional[date] = Field(
|
|
||||||
None,
|
|
||||||
description="Дата открытия",
|
|
||||||
validation_alias='opened_at',
|
|
||||||
serialization_alias='open_date',
|
|
||||||
)
|
|
||||||
close_date: Optional[date] = Field(
|
|
||||||
None,
|
|
||||||
description="Дата закрытия",
|
|
||||||
validation_alias='closed_at',
|
|
||||||
serialization_alias='close_date',
|
|
||||||
)
|
|
||||||
approved_format: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="Утвержденный формат",
|
|
||||||
validation_alias='format',
|
|
||||||
serialization_alias='approved_format',
|
|
||||||
)
|
|
||||||
notes: Optional[str] = Field(None, description="Примечания")
|
|
||||||
location_form: Optional[str] = Field(None, description="Форма расположения")
|
|
||||||
numbers: Optional[int] = Field(None, description="Штатная численность")
|
|
||||||
area: Optional[float] = Field(
|
|
||||||
None,
|
|
||||||
description="Арендная площадь",
|
|
||||||
validation_alias='total_area',
|
|
||||||
serialization_alias='area',
|
|
||||||
)
|
|
||||||
rent_contract_num: Optional[str] = Field(None, description="Номер договора аренды")
|
|
||||||
rent_end_date: Optional[date] = Field(
|
|
||||||
None, description="Срок окончания договора аренды"
|
|
||||||
)
|
|
||||||
ssp_id: int = Field(
|
|
||||||
...,
|
|
||||||
description="ID ССП",
|
|
||||||
validation_alias="branch_id",
|
|
||||||
serialization_alias="ssp_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VSPEditBase(BaseModel):
|
|
||||||
system_code: Optional[str] = Field(None, description="Системный код ВСП")
|
|
||||||
vsp_type: Optional[str] = Field(None, description="Вид ВСП")
|
|
||||||
reg_number: str = Field(
|
|
||||||
...,
|
|
||||||
description="Значение для выпадающего списка",
|
|
||||||
validation_alias='registration_number',
|
|
||||||
serialization_alias='reg_number',
|
|
||||||
)
|
|
||||||
address: str = Field(..., description="Автоподставляемый адрес")
|
|
||||||
opened_at: Optional[date] = Field(
|
|
||||||
None,
|
|
||||||
description="Дата открытия",
|
|
||||||
validation_alias='open_date',
|
|
||||||
serialization_alias='opened_at',
|
|
||||||
)
|
|
||||||
closed_at: Optional[date] = Field(
|
|
||||||
None,
|
|
||||||
description="Дата закрытия",
|
|
||||||
validation_alias='close_date',
|
|
||||||
serialization_alias='closed_at',
|
|
||||||
)
|
|
||||||
format: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="Утвержденный формат",
|
|
||||||
validation_alias='approved_format',
|
|
||||||
serialization_alias='format',
|
|
||||||
)
|
|
||||||
notes: Optional[str] = Field(None, description="Примечания")
|
|
||||||
location_form: Optional[str] = Field(None, description="Форма расположения")
|
|
||||||
numbers: Optional[int] = Field(None, description="Штатная численность")
|
|
||||||
total_area: Optional[float] = Field(
|
|
||||||
None,
|
|
||||||
description="Арендная площадь",
|
|
||||||
validation_alias='area',
|
|
||||||
serialization_alias='total_area',
|
|
||||||
)
|
|
||||||
rent_contract_num: Optional[str] = Field(None, description="Номер договора аренды")
|
|
||||||
rent_end_date: Optional[date] = Field(
|
|
||||||
None, description="Срок окончания договора аренды"
|
|
||||||
)
|
|
||||||
branch_id: int = Field(
|
|
||||||
...,
|
|
||||||
description="ID ССП",
|
|
||||||
validation_alias="ssp_id",
|
|
||||||
serialization_alias="branch_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VSPCreate(VSPEditBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class VSPUpdate(VSPEditBase):
|
|
||||||
reg_number: str = Field(
|
|
||||||
None,
|
|
||||||
description="Значение для выпадающего списка",
|
|
||||||
validation_alias='registration_number',
|
|
||||||
serialization_alias='reg_number',
|
|
||||||
)
|
|
||||||
address: str = Field(None, description="Автоподставляемый адрес")
|
|
||||||
branch_id: int = Field(
|
|
||||||
None,
|
|
||||||
description="ID ССП",
|
|
||||||
validation_alias="ssp_id",
|
|
||||||
serialization_alias="branch_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VSPInDB(VSPBase):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
id: int
|
|
||||||
is_active: bool
|
|
||||||
is_deleted: bool = False
|
|
||||||
created_by: int
|
|
||||||
updated_by: Optional[int] = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
regional_branch: Optional[str] = Field(None, description="Региональный филиал")
|
|
||||||
|
|
||||||
@model_validator(mode='before')
|
|
||||||
@classmethod
|
|
||||||
def get_regional_branch(cls, data: Any) -> Any:
|
|
||||||
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data
|
|
||||||
result = vars(data)
|
|
||||||
result["regional_branch"] = None
|
|
||||||
try:
|
|
||||||
result["regional_branch"] = data.org_unit.title
|
|
||||||
except MissingGreenlet:
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class VSPExportRequest(BaseModel):
|
|
||||||
registration_number: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
ssp_ids: Optional[List[int]] = None
|
|
||||||
open_date_start: Optional[date] = None
|
|
||||||
open_date_end: Optional[date] = None
|
|
||||||
location_form: Optional[str] = None
|
|
||||||
numbers_min: Optional[int] = None
|
|
||||||
numbers_max: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ExpenseItemBaseSchema(BaseModel):
|
|
||||||
section_code: Optional[str] = None
|
|
||||||
item_id: Optional[str] = None
|
|
||||||
num_group_id: Optional[str] = None
|
|
||||||
name: Optional[str] = None
|
|
||||||
sheet: Optional[str] = None
|
|
||||||
direction: Optional[str] = None
|
|
||||||
parent_id: Optional[int] = None
|
|
||||||
depth: Optional[int] = Field(default=0)
|
|
||||||
|
|
||||||
model_config = {
|
|
||||||
"from_attributes": True
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExpenseItemResponseSchema(ExpenseItemBaseSchema):
|
|
||||||
id: int
|
|
||||||
|
|||||||
243
api/src/main.py
243
api/src/main.py
@ -1,15 +1,9 @@
|
|||||||
import asyncio
|
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from contextlib import suppress
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Response
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.openapi.docs import get_swagger_ui_html
|
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||||
@ -20,100 +14,31 @@ from src.core.exception_handlers import register_exception_handlers
|
|||||||
from src.db.base import engine
|
from src.db.base import engine
|
||||||
from src.db.session import create_tables
|
from src.db.session import create_tables
|
||||||
|
|
||||||
# if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
# from raisa_fastapi_protected_api import (
|
from raisa_fastapi_protected_api import (
|
||||||
# AuthorizationMiddleware,
|
AuthorizationMiddleware,
|
||||||
# OpenEndpoint,
|
OpenEndpoint,
|
||||||
# ProtectedOAuthSettings,
|
ProtectedOAuthSettings,
|
||||||
# ProtectedRolesSettings,
|
ProtectedRolesSettings,
|
||||||
# ProtectedSettings,
|
ProtectedSettings,
|
||||||
# SearchType,
|
SearchType,
|
||||||
# )
|
)
|
||||||
|
|
||||||
|
|
||||||
_AUDIT_CLEANUP_LOCK_KEY = 21987431
|
|
||||||
|
|
||||||
|
|
||||||
async def _cleanup_audit_log_once() -> int:
|
|
||||||
if "postgresql" not in settings.DATABASE_URL:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
total_deleted = 0
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
lock_ok = (
|
|
||||||
await conn.execute(
|
|
||||||
text("SELECT pg_try_advisory_lock(:k)"),
|
|
||||||
{"k": _AUDIT_CLEANUP_LOCK_KEY},
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
if not lock_ok:
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
deleted = (
|
|
||||||
await conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
WITH doomed AS (
|
|
||||||
SELECT id
|
|
||||||
FROM v3.audit_log
|
|
||||||
WHERE event_dt < now() - make_interval(days => :retention_days)
|
|
||||||
ORDER BY id
|
|
||||||
LIMIT :batch_size
|
|
||||||
)
|
|
||||||
DELETE FROM v3.audit_log a
|
|
||||||
USING doomed d
|
|
||||||
WHERE a.id = d.id
|
|
||||||
RETURNING a.id
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"retention_days": settings.AUDIT_LOG_RETENTION_DAYS,
|
|
||||||
"batch_size": settings.AUDIT_LOG_CLEANUP_BATCH_SIZE,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).rowcount
|
|
||||||
if not deleted:
|
|
||||||
break
|
|
||||||
total_deleted += deleted
|
|
||||||
finally:
|
|
||||||
await conn.execute(
|
|
||||||
text("SELECT pg_advisory_unlock(:k)"),
|
|
||||||
{"k": _AUDIT_CLEANUP_LOCK_KEY},
|
|
||||||
)
|
|
||||||
return total_deleted
|
|
||||||
|
|
||||||
|
|
||||||
async def _audit_cleanup_loop() -> None:
|
|
||||||
# Первый прогон сразу после старта, дальше — по интервалу.
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await _cleanup_audit_log_once()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await asyncio.sleep(max(60, settings.AUDIT_LOG_CLEANUP_INTERVAL_SECONDS))
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
cleanup_task: asyncio.Task | None = None
|
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
await create_tables()
|
await create_tables()
|
||||||
# ProtectedSettings(
|
ProtectedSettings(
|
||||||
# ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
|
ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
|
||||||
# ProtectedRolesSettings(
|
ProtectedRolesSettings(
|
||||||
# APP_NAMESPACE=settings.APP_NAMESPACE,
|
APP_NAMESPACE=settings.APP_NAMESPACE,
|
||||||
# APP_NAME=settings.APP_NAME,
|
APP_NAME=settings.APP_NAME,
|
||||||
# ),
|
),
|
||||||
# )
|
)
|
||||||
cleanup_task = asyncio.create_task(_audit_cleanup_loop())
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
if cleanup_task:
|
|
||||||
cleanup_task.cancel()
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await cleanup_task
|
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@ -144,77 +69,32 @@ async def healthcheck2():
|
|||||||
"port": settings.PORT,
|
"port": settings.PORT,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/healthz", status_code=200)
|
|
||||||
async def healthz():
|
|
||||||
return {"status": "ok", "uptime": UP_TIME, "version": VERSION}
|
|
||||||
|
|
||||||
|
if not settings.DEBUG:
|
||||||
@app.get("/readyz")
|
app.add_middleware(
|
||||||
async def readyz(response: Response):
|
AuthorizationMiddleware,
|
||||||
result = {"status": "ok", "db_status": "ok", "mv_expense_item_tree": "ok"}
|
open_endpoints=[
|
||||||
try:
|
OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
|
||||||
async with engine.connect() as conn:
|
OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
|
||||||
await conn.execute(text("SELECT 1"))
|
OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
|
||||||
if "postgresql" in settings.DATABASE_URL:
|
OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
|
||||||
my_exists = (
|
OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
|
||||||
await conn.execute(
|
OpenEndpoint(
|
||||||
text(
|
path=settings.ROOT_PATH + "/healthcheck",
|
||||||
"""
|
type_search=SearchType.ABSOLUTE,
|
||||||
SELECT 1
|
),
|
||||||
FROM pg_matviews
|
OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
|
||||||
WHERE schemaname = 'v3'
|
OpenEndpoint(
|
||||||
AND matviewname = 'mv_expense_item_tree'
|
path=settings.ROOT_PATH + "/openapi.json",
|
||||||
"""
|
type_search=SearchType.START,
|
||||||
)
|
),
|
||||||
)
|
OpenEndpoint(path="/docs", type_search=SearchType.START),
|
||||||
).scalar_one_or_none()
|
OpenEndpoint(
|
||||||
if my_exists is None:
|
path=settings.ROOT_PATH + "/docs",
|
||||||
result["status"] = "fail"
|
type_search=SearchType.START,
|
||||||
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(
|
|
||||||
# AuthorizationMiddleware,
|
|
||||||
# open_endpoints=[
|
|
||||||
# OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
|
|
||||||
# OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
|
|
||||||
# OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
|
|
||||||
# OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
|
|
||||||
# OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
|
|
||||||
# OpenEndpoint(
|
|
||||||
# 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",
|
|
||||||
# type_search=SearchType.START,
|
|
||||||
# ),
|
|
||||||
# OpenEndpoint(path="/docs", type_search=SearchType.START),
|
|
||||||
# OpenEndpoint(
|
|
||||||
# path=settings.ROOT_PATH + "/docs",
|
|
||||||
# type_search=SearchType.START,
|
|
||||||
# ),
|
|
||||||
# ],
|
|
||||||
# )
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@ -228,43 +108,4 @@ app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
|||||||
|
|
||||||
os.makedirs("web", exist_ok=True)
|
os.makedirs("web", exist_ok=True)
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/docs-local", include_in_schema=False)
|
|
||||||
async def custom_swagger_ui_html():
|
|
||||||
return get_swagger_ui_html(
|
|
||||||
openapi_url=f"{settings.ROOT_PATH}{app.openapi_url}", # Путь к OpenAPI схеме (обычно /openapi.json)
|
|
||||||
title=app.title + " - Swagger UI",
|
|
||||||
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
|
||||||
# Указываем пути к локальным файлам
|
|
||||||
swagger_js_url=f"{settings.ROOT_PATH}/back-static/swagger-ui-bundle.js",
|
|
||||||
swagger_css_url=f"{settings.ROOT_PATH}/back-static/swagger-ui.css",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def sep_static(path, stat_dir):
|
|
||||||
sep_path = path.split("/")
|
|
||||||
index = sep_path.index(stat_dir)
|
|
||||||
return "/".join(sep_path[index:])
|
|
||||||
|
|
||||||
|
|
||||||
class SPAStaticFiles(StaticFiles):
|
|
||||||
async def get_response(self, path: str, scope):
|
|
||||||
try:
|
|
||||||
return await super().get_response(path, scope)
|
|
||||||
except (HTTPException, StarletteHTTPException) as ex:
|
|
||||||
if ex.status_code == 404:
|
|
||||||
if "images" in path:
|
|
||||||
return await super().get_response(sep_static(path, "images"), scope)
|
|
||||||
if "static" in path:
|
|
||||||
return await super().get_response(sep_static(path, "static"), scope)
|
|
||||||
return await super().get_response("index.html", scope)
|
|
||||||
else:
|
|
||||||
raise ex
|
|
||||||
|
|
||||||
|
|
||||||
app.mount("/back-static", StaticFiles(directory="back_static"), name="back-static")
|
|
||||||
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
|
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Iterable, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.sql.functions import func
|
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
# from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
# from app.domain.models import AuditLog
|
|
||||||
from src.db.models.audit_log import AuditLog
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogRepository:
|
|
||||||
"""Репозиторий для работы с журналом аудита."""
|
|
||||||
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
async def get(self, audit_log_id: int) -> Optional[AuditLog]:
|
|
||||||
"""Получение записи аудита по ID."""
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await self.db.execute(
|
|
||||||
select(AuditLog).where(AuditLog.id == audit_log_id).limit(1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalars()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_all(
|
|
||||||
self,
|
|
||||||
limit: int | None = None,
|
|
||||||
offset: int | None = None,
|
|
||||||
user_id: int | None = None,
|
|
||||||
org_unit_id: int | None = None,
|
|
||||||
task_id: int | None = None,
|
|
||||||
form_id: int | None = None,
|
|
||||||
event_type: str | None = None,
|
|
||||||
event: str | None = None,
|
|
||||||
date_from: datetime | None = None,
|
|
||||||
date_to: datetime | None = None,
|
|
||||||
) -> tuple[Iterable[AuditLog], int]:
|
|
||||||
"""Получение всех записей аудита с опциональной фильтрацией."""
|
|
||||||
query = select(AuditLog)
|
|
||||||
query_count = select(func.count(AuditLog.id))
|
|
||||||
|
|
||||||
if user_id is not None:
|
|
||||||
query = query.where(AuditLog.user_id == user_id)
|
|
||||||
query_count = query_count.where(AuditLog.user_id == user_id)
|
|
||||||
if org_unit_id is not None:
|
|
||||||
query = query.where(AuditLog.org_unit_id == org_unit_id)
|
|
||||||
query_count = query_count.where(AuditLog.org_unit_id == org_unit_id)
|
|
||||||
if task_id is not None:
|
|
||||||
query = query.where(AuditLog.task_id == task_id)
|
|
||||||
query_count = query_count.where(AuditLog.task_id == task_id)
|
|
||||||
if form_id is not None:
|
|
||||||
query = query.where(AuditLog.form_id == form_id)
|
|
||||||
query_count = query_count.where(AuditLog.form_id == form_id)
|
|
||||||
if event_type is not None:
|
|
||||||
query = query.where(AuditLog.event_type == event_type)
|
|
||||||
query_count = query_count.where(AuditLog.event_type == event_type)
|
|
||||||
if event is not None:
|
|
||||||
query = query.where(AuditLog.event == event)
|
|
||||||
query_count = query_count.where(AuditLog.event == event)
|
|
||||||
if date_from is not None:
|
|
||||||
query = query.where(AuditLog.event_dt >= date_from)
|
|
||||||
query_count = query_count.where(AuditLog.event_dt >= date_from)
|
|
||||||
if date_to is not None:
|
|
||||||
query = query.where(AuditLog.event_dt <= date_to)
|
|
||||||
query_count = query_count.where(AuditLog.event_dt <= date_to)
|
|
||||||
|
|
||||||
query = query.options(joinedload(AuditLog.user))
|
|
||||||
|
|
||||||
query = query.order_by(AuditLog.event_dt.desc())
|
|
||||||
|
|
||||||
if limit is not None:
|
|
||||||
query = query.limit(limit)
|
|
||||||
if offset is not None:
|
|
||||||
query = query.offset(offset)
|
|
||||||
|
|
||||||
return (await self.db.execute(query)).scalars().all(), (
|
|
||||||
await self.db.execute(query_count)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
from sqlalchemy import func, select, text
|
|
||||||
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:
|
|
||||||
count_query = select(func.count(BudgetForm.id))
|
|
||||||
if query.whereclause is not None:
|
|
||||||
count_query = count_query.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,
|
|
||||||
load_org: 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_org:
|
|
||||||
query = query.options(
|
|
||||||
joinedload(BudgetForm.org_unit)
|
|
||||||
)
|
|
||||||
|
|
||||||
if load_form_type:
|
|
||||||
query = query.options(
|
|
||||||
joinedload(BudgetForm.form_type)
|
|
||||||
)
|
|
||||||
return (await self.db.execute(query)).scalars().first()
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self,
|
|
||||||
form_type_code: str,
|
|
||||||
year: int,
|
|
||||||
org_unit_id: int,
|
|
||||||
) -> BudgetForm | None:
|
|
||||||
form_id = (
|
|
||||||
await self.db.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT *
|
|
||||||
FROM v3.add_budget_form(
|
|
||||||
:form_type,
|
|
||||||
:year,
|
|
||||||
:org_unit_id
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"form_type": form_type_code,
|
|
||||||
"year": year,
|
|
||||||
"org_unit_id": org_unit_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if form_id is None:
|
|
||||||
return None
|
|
||||||
return await self.get(
|
|
||||||
budget_form_id=form_id,
|
|
||||||
)
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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()
|
|
||||||
|
|
||||||
async def get_ids(
|
|
||||||
self,
|
|
||||||
budget_form_id: int,
|
|
||||||
) -> list[int]:
|
|
||||||
|
|
||||||
query = select(BudgetLine.id).where(BudgetLine.budget_form_id == budget_form_id)
|
|
||||||
return (await self.db.execute(query)).scalars().all()
|
|
||||||
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.db.models.expense_item import ExpenseItem
|
|
||||||
|
|
||||||
|
|
||||||
class ExpenseItemRepository:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
async def get(self, item_id: int) -> ExpenseItem | None:
|
|
||||||
query = select(ExpenseItem).where(ExpenseItem.id == item_id).limit(1)
|
|
||||||
return (await self.db.execute(query)).scalar_one_or_none()
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import select, text
|
|
||||||
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:
|
|
||||||
result = await self.db.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
(v3.add_form_phase(
|
|
||||||
:budget_form_id,
|
|
||||||
:sheet,
|
|
||||||
:phase_code,
|
|
||||||
:role,
|
|
||||||
:column_keys,
|
|
||||||
:opens_at,
|
|
||||||
:closes_at
|
|
||||||
)).budget_form_id
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if result.scalar_one_or_none() is None:
|
|
||||||
return None
|
|
||||||
return await self.get(budget_form_id, sheet, phase_code)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, budget_form_id: int, sheet: str, phase_code: str, data: dict
|
|
||||||
) -> FormPhase | None:
|
|
||||||
result = await self.db.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
(v3.upd_form_phase(
|
|
||||||
:budget_form_id,
|
|
||||||
:sheet,
|
|
||||||
:phase_code,
|
|
||||||
:role,
|
|
||||||
:column_keys,
|
|
||||||
:opens_at,
|
|
||||||
:closes_at
|
|
||||||
)).budget_form_id
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"budget_form_id": budget_form_id,
|
|
||||||
"sheet": sheet,
|
|
||||||
"phase_code": phase_code,
|
|
||||||
"role": data.get("role"),
|
|
||||||
"column_keys": data.get("column_keys"),
|
|
||||||
"opens_at": data.get("opens_at"),
|
|
||||||
"closes_at": data.get("closes_at"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if result.scalar_one_or_none() is None:
|
|
||||||
return None
|
|
||||||
return await self.get(budget_form_id, sheet, phase_code)
|
|
||||||
|
|
||||||
async def delete(
|
|
||||||
self, budget_form_id: int, sheet: str, phase_code: str
|
|
||||||
) -> bool:
|
|
||||||
result = await self.db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT v3.del_form_phase(:budget_form_id, :sheet, :phase_code)"
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"budget_form_id": budget_form_id,
|
|
||||||
"sheet": sheet,
|
|
||||||
"phase_code": phase_code,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
deleted = result.scalar_one_or_none()
|
|
||||||
return bool(deleted)
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.db.models.org_unit import OrgUnit
|
|
||||||
|
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class OrgUnitRepository:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
async def get_list(
|
|
||||||
self,
|
|
||||||
offset: int | None = None,
|
|
||||||
limit: int | None = None,
|
|
||||||
is_ssp: bool | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
org_unit_id: int | list[int] | None = None,
|
|
||||||
with_count: bool = False,
|
|
||||||
load_users: bool = False,
|
|
||||||
) -> list[OrgUnit] | tuple[int, list[OrgUnit]]:
|
|
||||||
if with_count:
|
|
||||||
query = select(func.count().over().label("total_count"), OrgUnit)
|
|
||||||
else:
|
|
||||||
query = select(OrgUnit)
|
|
||||||
where = []
|
|
||||||
if is_ssp is not None:
|
|
||||||
where.append(OrgUnit.is_ssp == is_ssp)
|
|
||||||
if is_active is not None:
|
|
||||||
where.append(OrgUnit.is_active == is_active)
|
|
||||||
if org_unit_id is not None:
|
|
||||||
if isinstance(org_unit_id, int):
|
|
||||||
where.append(OrgUnit.id == org_unit_id)
|
|
||||||
else:
|
|
||||||
where.append(OrgUnit.id.in_(org_unit_id))
|
|
||||||
|
|
||||||
query = query.where(*where)
|
|
||||||
if load_users:
|
|
||||||
query = query.options(
|
|
||||||
joinedload(OrgUnit.users)
|
|
||||||
)
|
|
||||||
|
|
||||||
query = query.order_by(OrgUnit.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)).unique().all()
|
|
||||||
if result:
|
|
||||||
return result[0][0], [res[1] for res in result]
|
|
||||||
else:
|
|
||||||
count_query = select(func.count(OrgUnit.id))
|
|
||||||
if query.whereclause is not None:
|
|
||||||
count_query = count_query.where(
|
|
||||||
query.whereclause
|
|
||||||
)
|
|
||||||
return (await self.db.execute(count_query)).scalar(), []
|
|
||||||
else:
|
|
||||||
return (await self.db.execute(query)).scalars().unique().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def get(
|
|
||||||
self,
|
|
||||||
org_unit_id: int,
|
|
||||||
load_users: bool = False
|
|
||||||
) -> OrgUnit | None:
|
|
||||||
query = select(OrgUnit).where(OrgUnit.id == org_unit_id)
|
|
||||||
if load_users:
|
|
||||||
query = query.options(
|
|
||||||
joinedload(OrgUnit.users)
|
|
||||||
)
|
|
||||||
|
|
||||||
return (await self.db.execute(query)).scalars().first()
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
is_ssp: bool,
|
|
||||||
) -> OrgUnit:
|
|
||||||
"""Создание SSP."""
|
|
||||||
ssp = OrgUnit(title=title, is_ssp=is_ssp)
|
|
||||||
self.db.add(ssp)
|
|
||||||
await self.db.flush()
|
|
||||||
# await self.db.refresh(ssp)
|
|
||||||
return ssp
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, org_unit: OrgUnit, **kwargs,
|
|
||||||
) -> OrgUnit | None:
|
|
||||||
if not org_unit:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for field, value in kwargs.items():
|
|
||||||
setattr(org_unit, field, value)
|
|
||||||
|
|
||||||
await self.db.flush()
|
|
||||||
# await self.db.refresh(ssp)
|
|
||||||
return org_unit
|
|
||||||
|
|
||||||
async def logical_delete(self, org_unit_id: int, is_active: bool | None = None) -> bool:
|
|
||||||
"""Логическое удаление SSP."""
|
|
||||||
|
|
||||||
query = update(OrgUnit).where(OrgUnit.id == org_unit_id)
|
|
||||||
if is_active is not None:
|
|
||||||
query = query.where(OrgUnit.is_active == is_active)
|
|
||||||
query = query.values(is_active=False)
|
|
||||||
res = await self.db.execute(query)
|
|
||||||
await self.db.flush()
|
|
||||||
|
|
||||||
return res.rowcount > 0
|
|
||||||
@ -1,354 +0,0 @@
|
|||||||
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()
|
|
||||||
return [tuple(r) for r in 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()
|
|
||||||
return [tuple(r) for r in 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()
|
|
||||||
return [tuple(r) for r in 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()
|
|
||||||
return [tuple(r) for r in 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()
|
|
||||||
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()
|
|
||||||
return int(row[0]), int(row[1]), int(row[2])
|
|
||||||
@ -1,245 +0,0 @@
|
|||||||
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 [
|
|
||||||
tuple(r) for r in (
|
|
||||||
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 [
|
|
||||||
tuple(r) for r in (
|
|
||||||
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 [
|
|
||||||
tuple(r) for r in (
|
|
||||||
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 [
|
|
||||||
tuple(r) for r in (
|
|
||||||
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,120 +1,96 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select, text, update
|
from sqlalchemy import delete, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload, 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.core.security import get_password_hash, verify_password
|
||||||
|
from src.domain.models import Roles, SSP, UserSSPLink, Users
|
||||||
|
|
||||||
|
|
||||||
class UserRepository:
|
class UserRepository:
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
async def get(self, user_id: int, load_orgs: bool = False) -> Optional[AppUser]:
|
async def get(self, user_id: int) -> Optional[Users]:
|
||||||
query = select(AppUser).where(AppUser.id == user_id)
|
|
||||||
if load_orgs:
|
|
||||||
query = query.options(
|
|
||||||
selectinload(AppUser.org_units),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
query.limit(1)
|
select(Users).where(Users.id == user_id).limit(1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_by_email(self, email: str) -> Optional[AppUser]:
|
async def get_by_email(self, email: str) -> Optional[Users]:
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(AppUser).where(AppUser.email == email).limit(1)
|
select(Users).where(Users.email == email).limit(1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_by_username(self, username: str) -> Optional[AppUser]:
|
async def get_by_username(self, username: str) -> Optional[Users]:
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(AppUser).where(AppUser.username == username).limit(1)
|
select(Users).where(Users.username == username).limit(1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
async def set_app_user_id(self, user_id: int) -> None:
|
async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]:
|
||||||
if isinstance(user_id, int):
|
query = (
|
||||||
await self.db.execute(
|
select(Users)
|
||||||
text(
|
.where(Users.is_active.is_(True))
|
||||||
f"SET LOCAL app.user_id = {user_id}"
|
.offset(skip)
|
||||||
),
|
.limit(limit)
|
||||||
)
|
.order_by(Users.id)
|
||||||
|
)
|
||||||
|
return (await self.db.execute(query)).scalars().all()
|
||||||
|
|
||||||
|
async def create(self, user_data: dict) -> Users:
|
||||||
|
hashed_password = (
|
||||||
|
get_password_hash(user_data["password"]) if user_data.get("password") else None
|
||||||
|
)
|
||||||
|
db_user = Users(
|
||||||
|
email=user_data["email"],
|
||||||
|
username=user_data["username"],
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
full_name=user_data.get("full_name"),
|
||||||
|
role_id=user_data.get("role_id"),
|
||||||
|
)
|
||||||
|
self.db.add(db_user)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
async def get_list(
|
async def update(self, user_id: int, user_data: dict) -> Optional[Users]:
|
||||||
self,
|
user = await self.get(user_id)
|
||||||
skip: int = 0,
|
if not user:
|
||||||
limit: int = 100,
|
return None
|
||||||
with_count: bool = False,
|
|
||||||
load_orgs: bool = False,
|
|
||||||
) -> list[AppUser] | tuple[int, list[AppUser]]:
|
|
||||||
|
|
||||||
where = [AppUser.is_active.is_(True)]
|
for field, value in user_data.items():
|
||||||
|
if field == "password" and value:
|
||||||
|
value = get_password_hash(value)
|
||||||
|
setattr(user, field, value)
|
||||||
|
|
||||||
if with_count:
|
await self.db.commit()
|
||||||
query = select(func.count().over().label("total_count"), AppUser)
|
await self.db.refresh(user)
|
||||||
else:
|
return user
|
||||||
query = select(AppUser)
|
|
||||||
|
|
||||||
|
|
||||||
query = query.where(*where)
|
|
||||||
if load_orgs:
|
|
||||||
query = query.options(
|
|
||||||
joinedload(AppUser.org_units)
|
|
||||||
)
|
|
||||||
|
|
||||||
query = query.order_by(AppUser.id)
|
|
||||||
|
|
||||||
if skip is not None:
|
|
||||||
query = query.offset(skip)
|
|
||||||
if limit is not None:
|
|
||||||
query = query.limit(limit)
|
|
||||||
|
|
||||||
if with_count:
|
|
||||||
result = (await self.db.execute(query)).unique().all()
|
|
||||||
if result:
|
|
||||||
return result[0][0], [res[1] for res in result]
|
|
||||||
else:
|
|
||||||
count_query = select(func.count(AppUser.id))
|
|
||||||
if query.whereclause is not None:
|
|
||||||
count_query = count_query.where(
|
|
||||||
query.whereclause
|
|
||||||
)
|
|
||||||
return (await self.db.execute(count_query)).scalar(), []
|
|
||||||
else:
|
|
||||||
return (await self.db.execute(query)).scalars().unique().all()
|
|
||||||
|
|
||||||
async def logical_delete(self, user_id: int) -> bool:
|
async def logical_delete(self, user_id: int) -> bool:
|
||||||
query = update(AppUser).where(AppUser.id == user_id).values(is_active=False)
|
query = update(Users).where(Users.id == user_id).values(is_active=False)
|
||||||
result = await self.db.execute(query)
|
result = await self.db.execute(query)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
async def authenticate(self, username: str, password: str) -> Optional[AppUser]:
|
async def authenticate(self, username: str, password: str) -> Optional[Users]:
|
||||||
user = await self.get_by_username(username)
|
user = await self.get_by_username(username)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
@ -122,122 +98,52 @@ class UserRepository:
|
|||||||
return None
|
return None
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def authenticate_via_email(self, email: str) -> Optional[AppUser]:
|
async def authenticate_via_email(self, email: str) -> Optional[Users]:
|
||||||
return await self.get_by_email(email)
|
return await self.get_by_email(email)
|
||||||
|
|
||||||
async def get_roles(self) -> list[Role]:
|
async def get_roles(self) -> list[Roles]:
|
||||||
return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
|
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all()
|
||||||
|
|
||||||
async def get_many_ssp(self, user_id: int) -> list[OrgUnit]:
|
async def get_many_ssp(self, user_id: int) -> list[SSP]:
|
||||||
query = select(OrgUnit).join(UserOrg, UserOrg.org_unit_id == OrgUnit.id).where(
|
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where(
|
||||||
UserOrg.user_id == user_id
|
UserSSPLink.user_id == user_id
|
||||||
)
|
)
|
||||||
return (await self.db.execute(query)).scalars().all()
|
return (await self.db.execute(query)).scalars().all()
|
||||||
|
|
||||||
async def get_many_ssp_ids(self, user_id: int) -> list[int]:
|
async def get_many_ssp_ids(self, user_id: int) -> list[int]:
|
||||||
query = select(UserOrg.org_unit_id).where(UserOrg.user_id == user_id)
|
query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id)
|
||||||
return (await self.db.execute(query)).scalars().all()
|
return (await self.db.execute(query)).scalars().all()
|
||||||
|
|
||||||
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
||||||
for ssp_id in ssp_ids:
|
try:
|
||||||
await self.db.execute(
|
for ssp_id in ssp_ids:
|
||||||
text(
|
link = UserSSPLink(user_id=user_id, ssp_id=ssp_id)
|
||||||
"SELECT v3.grant_user_org_access(:user_id, :org_unit_id)"
|
self.db.add(link)
|
||||||
),
|
await self.db.commit()
|
||||||
{"user_id": user_id, "org_unit_id": ssp_id},
|
return True
|
||||||
)
|
except Exception:
|
||||||
await self.db.commit()
|
await self.db.rollback()
|
||||||
return True
|
return False
|
||||||
|
|
||||||
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
||||||
for ssp_id in ssp_ids:
|
try:
|
||||||
await self.db.execute(
|
for ssp_id in ssp_ids:
|
||||||
text(
|
query = delete(UserSSPLink).where(
|
||||||
"SELECT v3.revoke_user_org_access(:user_id, :org_unit_id)"
|
(UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id)
|
||||||
),
|
)
|
||||||
{"user_id": user_id, "org_unit_id": ssp_id},
|
await self.db.execute(query)
|
||||||
)
|
await self.db.commit()
|
||||||
await self.db.commit()
|
return True
|
||||||
return True
|
except Exception:
|
||||||
|
await self.db.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
async def clear_many_ssp(self, user_id: int) -> None:
|
async def clear_many_ssp(self, user_id: int) -> bool:
|
||||||
org_unit_ids = await self.get_many_ssp_ids(user_id)
|
try:
|
||||||
await self.db.execute(
|
query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id)
|
||||||
text(
|
await self.db.execute(query)
|
||||||
"SELECT v3.revoke_many_user_org_access(:user_id, :org_unit_ids)"
|
await self.db.commit()
|
||||||
),
|
return True
|
||||||
{"user_id": user_id, "org_unit_ids": org_unit_ids},
|
except Exception:
|
||||||
)
|
await self.db.rollback()
|
||||||
await self.db.flush()
|
return False
|
||||||
|
|
||||||
async def create(self, user_data: dict) -> AppUser:
|
|
||||||
hashed_password = (
|
|
||||||
get_password_hash(user_data["password"]) if user_data.get("password") else None
|
|
||||||
)
|
|
||||||
user_id = (
|
|
||||||
await self.db.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT (v3.add_user(
|
|
||||||
:email,
|
|
||||||
:username,
|
|
||||||
:hashed_password,
|
|
||||||
:full_name,
|
|
||||||
:role_id
|
|
||||||
)).id
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"email": user_data["email"],
|
|
||||||
"username": user_data["username"],
|
|
||||||
"hashed_password": hashed_password,
|
|
||||||
"full_name": user_data.get("full_name"),
|
|
||||||
"role_id": user_data.get("role_id"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
await self.db.flush()
|
|
||||||
return await self.get(user_id)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self,
|
|
||||||
user_id: int,
|
|
||||||
user_data: dict,
|
|
||||||
load_orgs: bool = False,
|
|
||||||
) -> Optional[AppUser]:
|
|
||||||
user = await self.get(user_id)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
|
|
||||||
hashed_password = None
|
|
||||||
if "password" in user_data and user_data["password"]:
|
|
||||||
hashed_password = get_password_hash(user_data["password"])
|
|
||||||
|
|
||||||
await self.db.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT (v3.upd_user(
|
|
||||||
:user_id,
|
|
||||||
:email,
|
|
||||||
:username,
|
|
||||||
:hashed_password,
|
|
||||||
:full_name,
|
|
||||||
:role_id,
|
|
||||||
:is_active
|
|
||||||
)).id
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"user_id": user_id,
|
|
||||||
"email": user_data.get("email"),
|
|
||||||
"username": user_data.get("username"),
|
|
||||||
"hashed_password": hashed_password,
|
|
||||||
"full_name": user_data.get("full_name"),
|
|
||||||
"role_id": user_data.get("role_id"),
|
|
||||||
"is_active": user_data.get("is_active"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await self.db.flush()
|
|
||||||
self.db.expire(user)
|
|
||||||
result = await self.get(user_id, load_orgs=load_orgs)
|
|
||||||
return result
|
|
||||||
|
|||||||
@ -1,244 +0,0 @@
|
|||||||
from datetime import date
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import func, select, text, update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
|
|
||||||
from src.db.models import Vsp
|
|
||||||
from src.domain.schemas import VSPCreate
|
|
||||||
|
|
||||||
|
|
||||||
class VSPRepository:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
async def get(self, vsp_id: int, is_active: bool | None = None, load_org_unit: bool = False) -> Optional[Vsp]:
|
|
||||||
queries = [Vsp.id == vsp_id, Vsp.is_deleted.is_(False)]
|
|
||||||
if is_active is not None:
|
|
||||||
queries.append(Vsp.is_active == is_active)
|
|
||||||
if load_org_unit:
|
|
||||||
return (
|
|
||||||
(await self.db.execute(
|
|
||||||
select(Vsp).where(*queries).options(joinedload(Vsp.org_unit)).limit(1))
|
|
||||||
).scalars().first()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
(await self.db.execute(select(Vsp).where(*queries).limit(1))).scalars().first()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_list(
|
|
||||||
self,
|
|
||||||
registration_number: str | None = None,
|
|
||||||
address: str | None = None,
|
|
||||||
ssp_ids: list[int] | None = None,
|
|
||||||
open_date_start: date | None = None,
|
|
||||||
open_date_end: date | None = None,
|
|
||||||
location_form: str | None = None,
|
|
||||||
numbers_min: int | None = None,
|
|
||||||
numbers_max: int | None = None,
|
|
||||||
is_active: bool | None = True,
|
|
||||||
close_date_is_null: bool | None = None,
|
|
||||||
load_org_unit: bool = False,
|
|
||||||
with_count: bool = False,
|
|
||||||
) -> list[Vsp] | tuple[int, list[Vsp]]:
|
|
||||||
|
|
||||||
if with_count:
|
|
||||||
query = select(func.count().over().label("total_count"), Vsp)
|
|
||||||
else:
|
|
||||||
query = select(Vsp)
|
|
||||||
queries = [Vsp.is_deleted.is_(False)]
|
|
||||||
filters = [
|
|
||||||
("registration_number", registration_number),
|
|
||||||
("address", address),
|
|
||||||
("location_form", location_form),
|
|
||||||
("open_date_start", open_date_start),
|
|
||||||
("open_date_end", open_date_end),
|
|
||||||
("numbers_min", numbers_min),
|
|
||||||
("numbers_max", numbers_max),
|
|
||||||
("is_active", is_active),
|
|
||||||
]
|
|
||||||
for filter_name, filter_value in filters:
|
|
||||||
# print(filter_name, filter_value)
|
|
||||||
match filter_name, filter_value:
|
|
||||||
case ("registration_number", str(value)) if value:
|
|
||||||
queries.append(Vsp.reg_number.ilike(f"%{value}%"))
|
|
||||||
case ("address", str(value)) if value:
|
|
||||||
queries.append(Vsp.address.ilike(f"%{value}%"))
|
|
||||||
case ("location_form", str(value)) if value:
|
|
||||||
queries.append(Vsp.location_form.ilike(f"%{value}%"))
|
|
||||||
case ("open_date_start", value) if value is not None:
|
|
||||||
queries.append(Vsp.opened_at >= value)
|
|
||||||
case ("open_date_end", value) if value is not None:
|
|
||||||
queries.append(Vsp.opened_at <= value)
|
|
||||||
case ("numbers_min", value) if value is not None:
|
|
||||||
queries.append(Vsp.numbers >= value)
|
|
||||||
case ("numbers_max", value) if value is not None:
|
|
||||||
queries.append(Vsp.numbers <= value)
|
|
||||||
case ("is_active", value) if value is not None:
|
|
||||||
queries.append(Vsp.is_active == value)
|
|
||||||
case _:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if close_date_is_null is True:
|
|
||||||
queries.append(Vsp.closed_at.is_(None))
|
|
||||||
elif close_date_is_null is False:
|
|
||||||
queries.append(Vsp.closed_at.is_not(None))
|
|
||||||
|
|
||||||
if ssp_ids is not None:
|
|
||||||
if not ssp_ids:
|
|
||||||
if with_count:
|
|
||||||
return 0, []
|
|
||||||
return []
|
|
||||||
queries.append(Vsp.branch_id.in_(ssp_ids))
|
|
||||||
|
|
||||||
query = query.where(*queries)
|
|
||||||
if load_org_unit:
|
|
||||||
query = query.options(joinedload(Vsp.org_unit))
|
|
||||||
query = query.order_by(Vsp.id)
|
|
||||||
|
|
||||||
if with_count:
|
|
||||||
result = (await self.db.execute(query)).all()
|
|
||||||
if result:
|
|
||||||
return result[0][0], [res[1] for res in result]
|
|
||||||
else:
|
|
||||||
count_query = select(func.count(Vsp.id))
|
|
||||||
if query.whereclause is not None:
|
|
||||||
count_query = count_query.where(
|
|
||||||
query.whereclause
|
|
||||||
)
|
|
||||||
return (await self.db.execute(count_query)).scalar(), []
|
|
||||||
else:
|
|
||||||
return (await self.db.execute(query)).scalars().all()
|
|
||||||
return (await self.db.execute(query.order_by(Vsp.id))).scalars().all()
|
|
||||||
|
|
||||||
async def create(self, payload: VSPCreate, created_by: int, load_org_unit: bool = False) -> Vsp:
|
|
||||||
data = payload.model_dump()
|
|
||||||
vsp_id = (
|
|
||||||
await self.db.execute(
|
|
||||||
text("""
|
|
||||||
SELECT v3.add_vsp(
|
|
||||||
:p_branch_id, :p_reg_number, :p_address, :p_format,
|
|
||||||
:p_opened_at, :p_placement_type, :p_staff_count,
|
|
||||||
:p_total_area, :p_closed_at, :p_is_active, :p_is_deleted,
|
|
||||||
:p_system_code, :p_created_by, :p_vsp_type, :p_notes,
|
|
||||||
:p_location_form, :p_numbers, :p_rent_contract_num,
|
|
||||||
:p_rent_end_date
|
|
||||||
)
|
|
||||||
"""),
|
|
||||||
{
|
|
||||||
"p_branch_id": data.get("branch_id"),
|
|
||||||
"p_reg_number": data.get("reg_number"),
|
|
||||||
"p_address": data.get("address"),
|
|
||||||
"p_format": data.get("format"),
|
|
||||||
"p_opened_at": data.get("opened_at"),
|
|
||||||
"p_placement_type": data.get("placement_type"),
|
|
||||||
"p_staff_count": data.get("staff_count"),
|
|
||||||
"p_total_area": data.get("total_area"),
|
|
||||||
"p_closed_at": data.get("closed_at"),
|
|
||||||
"p_is_active": data.get("is_active", True),
|
|
||||||
"p_is_deleted": data.get("is_deleted", False),
|
|
||||||
"p_system_code": data.get("system_code"),
|
|
||||||
"p_created_by": created_by,
|
|
||||||
"p_vsp_type": data.get("vsp_type"),
|
|
||||||
"p_notes": data.get("notes"),
|
|
||||||
"p_location_form": data.get("location_form"),
|
|
||||||
"p_numbers": data.get("numbers"),
|
|
||||||
"p_rent_contract_num": data.get("rent_contract_num"),
|
|
||||||
"p_rent_end_date": data.get("rent_end_date"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).scalar()
|
|
||||||
|
|
||||||
await self.db.flush()
|
|
||||||
|
|
||||||
if load_org_unit:
|
|
||||||
return (
|
|
||||||
await self.db.execute(
|
|
||||||
select(Vsp).where(Vsp.id == vsp_id).options(joinedload(Vsp.org_unit)).limit(1)
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
await self.db.execute(
|
|
||||||
select(Vsp).where(Vsp.id == vsp_id).limit(1)
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
|
|
||||||
async def update(self, vsp: Vsp, data: dict, updated_by: int, load_org_unit: bool = False) -> Vsp:
|
|
||||||
FIELD_TO_PARAM = {
|
|
||||||
"system_code": "p_system_code",
|
|
||||||
"vsp_type": "p_vsp_type",
|
|
||||||
"reg_number": "p_reg_number",
|
|
||||||
"address": "p_address",
|
|
||||||
"opened_at": "p_opened_at",
|
|
||||||
"closed_at": "p_closed_at",
|
|
||||||
"format": "p_format",
|
|
||||||
"notes": "p_notes",
|
|
||||||
"location_form": "p_location_form",
|
|
||||||
"numbers": "p_numbers",
|
|
||||||
"total_area": "p_total_area",
|
|
||||||
"rent_contract_num": "p_rent_contract_num",
|
|
||||||
"rent_end_date": "p_rent_end_date",
|
|
||||||
"branch_id": "p_branch_id",
|
|
||||||
"is_active": "p_is_active",
|
|
||||||
"is_deleted": "p_is_deleted",
|
|
||||||
}
|
|
||||||
|
|
||||||
params = {"p_vsp_id": vsp.id, "p_updated_by": updated_by}
|
|
||||||
for field, param in FIELD_TO_PARAM.items():
|
|
||||||
params[param] = data.get(field)
|
|
||||||
params.setdefault("p_placement_type", data.get("placement_type"))
|
|
||||||
params.setdefault("p_staff_count", data.get("staff_count"))
|
|
||||||
await self.db.execute(
|
|
||||||
text("""
|
|
||||||
SELECT v3.upd_vsp(
|
|
||||||
:p_vsp_id, :p_branch_id, :p_reg_number, :p_address,
|
|
||||||
:p_format, :p_opened_at, :p_placement_type, :p_staff_count,
|
|
||||||
:p_total_area, :p_closed_at, :p_is_active, :p_is_deleted,
|
|
||||||
:p_system_code, :p_updated_by, :p_vsp_type, :p_notes,
|
|
||||||
:p_location_form, :p_numbers, :p_rent_contract_num,
|
|
||||||
:p_rent_end_date
|
|
||||||
)
|
|
||||||
"""),
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.db.flush()
|
|
||||||
await self.db.refresh(vsp)
|
|
||||||
|
|
||||||
if load_org_unit:
|
|
||||||
return (
|
|
||||||
await self.db.execute(
|
|
||||||
select(Vsp).where(Vsp.id == vsp.id).options(joinedload(Vsp.org_unit)).limit(1)
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
else:
|
|
||||||
return vsp
|
|
||||||
|
|
||||||
async def deactivate_by_close_date(self, as_of: date) -> int:
|
|
||||||
result = await self.db.execute(
|
|
||||||
update(Vsp)
|
|
||||||
.where(
|
|
||||||
Vsp.is_active.is_(True),
|
|
||||||
Vsp.closed_at.is_not(None),
|
|
||||||
Vsp.closed_at <= as_of,
|
|
||||||
)
|
|
||||||
.values(is_active=False, updated_at=func.now())
|
|
||||||
)
|
|
||||||
await self.db.commit()
|
|
||||||
return result.rowcount or 0
|
|
||||||
|
|
||||||
async def logical_delete(self, vsp_id: int, updated_by: int) -> bool:
|
|
||||||
result = (
|
|
||||||
await self.db.execute(
|
|
||||||
text("SELECT v3.del_vsp(:p_vsp_id, :p_deleted_by)"),
|
|
||||||
{"p_vsp_id": vsp_id, "p_deleted_by": updated_by},
|
|
||||||
)
|
|
||||||
).scalar()
|
|
||||||
await self.db.flush()
|
|
||||||
return result is not None
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict, Iterable, Optional
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.core.errors import AccessDeniedException
|
|
||||||
|
|
||||||
# from src.domain.models import AuditLog, UserRole, Users
|
|
||||||
from src.db.models.audit_log import AuditLog
|
|
||||||
from src.db.models.role import UserRoleEnum
|
|
||||||
from src.db.models.app_user import AppUser
|
|
||||||
|
|
||||||
from src.repository.auditlog_repository import AuditLogRepository
|
|
||||||
|
|
||||||
from src.domain.schemas import AuditLog as AuditLogSchema
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogService:
|
|
||||||
"""Сервис для работы с журналом аудита."""
|
|
||||||
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
self.audit_repo = AuditLogRepository(db)
|
|
||||||
|
|
||||||
async def get(self, audit_log_id: int, user: AppUser) -> Optional[AuditLog]:
|
|
||||||
"""Получение записи аудита по ID."""
|
|
||||||
if not self._can_view_audit_logs(user):
|
|
||||||
raise AccessDeniedException(
|
|
||||||
"Недостаточно прав для просмотра журнала аудита"
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self.audit_repo.get(audit_log_id)
|
|
||||||
|
|
||||||
async def get_all(
|
|
||||||
self,
|
|
||||||
user: AppUser,
|
|
||||||
limit: int | None = None,
|
|
||||||
offset: int | None = None,
|
|
||||||
user_id: int | None = None,
|
|
||||||
org_unit_id: int | None = None,
|
|
||||||
task_id: int | None = None,
|
|
||||||
form_id: int | None = None,
|
|
||||||
event_type: str | None = None,
|
|
||||||
event: str | None = None,
|
|
||||||
date_from: datetime | None = None,
|
|
||||||
date_to: datetime | None = None,
|
|
||||||
) -> tuple[Iterable[AuditLog], int]:
|
|
||||||
"""Получение всех записей аудита с опциональной фильтрацией."""
|
|
||||||
if not self._can_view_audit_logs(user):
|
|
||||||
raise AccessDeniedException(
|
|
||||||
"Недостаточно прав для просмотра журнала аудита"
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self.audit_repo.get_all(
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
user_id=user_id,
|
|
||||||
org_unit_id=org_unit_id,
|
|
||||||
task_id=task_id,
|
|
||||||
form_id=form_id,
|
|
||||||
event_type=event_type,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
event=event,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _can_view_audit_logs(self, user: AppUser) -> bool:
|
|
||||||
"""Проверяет, может ли пользователь просматривать журнал аудита."""
|
|
||||||
return user.role_id == UserRoleEnum.ADMIN.value
|
|
||||||
|
|
||||||
def orm_log_to_response(self, log: AuditLog) -> AuditLogSchema:
|
|
||||||
"""Маппинг записи ORM audit_log в формат ответа API (entity, entity_id, action, at, payload_json).
|
|
||||||
model_validate(orm) не подходит: в БД поля event_dt/event/event_type/event_data, в API — at/action/entity/entity_id; entity и entity_id из event_data JSON.
|
|
||||||
"""
|
|
||||||
event_data: Optional[Dict[str, Any]] = getattr(log, "event_data", None) or {}
|
|
||||||
entity = (
|
|
||||||
event_data.get("entity_type") if isinstance(event_data, dict) else None
|
|
||||||
) or getattr(log, "event_type", "unknown")
|
|
||||||
entity_id = (
|
|
||||||
event_data.get("entity_id") if isinstance(event_data, dict) else None
|
|
||||||
)
|
|
||||||
if entity_id is not None and not isinstance(entity_id, int):
|
|
||||||
try:
|
|
||||||
entity_id = int(entity_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
entity_id = None
|
|
||||||
action = getattr(log, "event", "")
|
|
||||||
at = getattr(log, "event_dt", None)
|
|
||||||
return AuditLogSchema(
|
|
||||||
entity=entity,
|
|
||||||
entity_id=entity_id,
|
|
||||||
action=action,
|
|
||||||
payload_json=event_data if isinstance(event_data, dict) else None,
|
|
||||||
id=log.id,
|
|
||||||
user_id=getattr(log, "user_id", None),
|
|
||||||
at=at,
|
|
||||||
user=getattr(log, "user", None),
|
|
||||||
)
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
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,
|
|
||||||
load_org: bool = False,
|
|
||||||
) -> BudgetForm | None:
|
|
||||||
if user.role_id == UserRoleEnum.ADMIN:
|
|
||||||
return await self.bf_repo.get(
|
|
||||||
budget_form_id=budget_form_id,
|
|
||||||
load_form_type=load_form_type,
|
|
||||||
load_org=load_org,
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
load_org=load_org,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self,
|
|
||||||
form_type_code: str,
|
|
||||||
year: int,
|
|
||||||
org_unit_id: int,
|
|
||||||
) -> BudgetForm | None:
|
|
||||||
return await self.bf_repo.create(
|
|
||||||
form_type_code=form_type_code,
|
|
||||||
year=year,
|
|
||||||
org_unit_id=org_unit_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def bulk_create(
|
|
||||||
self,
|
|
||||||
form_type_code: str,
|
|
||||||
year: int,
|
|
||||||
org_unit_ids: list[int],
|
|
||||||
) -> BudgetForm | None:
|
|
||||||
return await asyncio.gather(
|
|
||||||
*[
|
|
||||||
self.create(
|
|
||||||
form_type_code=form_type_code,
|
|
||||||
year=year,
|
|
||||||
org_unit_id=org_unit_id,
|
|
||||||
)
|
|
||||||
for org_unit_id in org_unit_ids
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
async def get_ids(
|
|
||||||
self,
|
|
||||||
budget_form_id: int,
|
|
||||||
) -> list[int]:
|
|
||||||
return await self.bl_repo.get_ids(budget_form_id=budget_form_id)
|
|
||||||
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.db.models.role import UserRoleEnum
|
|
||||||
from src.db.models.app_user import AppUser
|
|
||||||
from src.db.models.expense_item import ExpenseItem
|
|
||||||
from src.repository.expense_item_repository import ExpenseItemRepository
|
|
||||||
|
|
||||||
|
|
||||||
class ExpenseItemService:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.repo = ExpenseItemRepository(db)
|
|
||||||
|
|
||||||
async def get(self, item_id: int, user: AppUser | None = None) -> ExpenseItem | None:
|
|
||||||
if not user or user.role_id == UserRoleEnum.ADMIN:
|
|
||||||
return await self.repo.get(item_id=item_id)
|
|
||||||
return None
|
|
||||||
@ -1 +0,0 @@
|
|||||||
from .export_service import ExportService
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,283 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
SMETA_VALUE_PATHS: list[tuple[str, tuple[str, ...]]] = [
|
|
||||||
("section_code", ("section_code",)),
|
|
||||||
("name", ("name",)),
|
|
||||||
("plan_support_q1", ("plan", "support", "q1")),
|
|
||||||
("plan_support_q2", ("plan", "support", "q2")),
|
|
||||||
("plan_support_q3", ("plan", "support", "q3")),
|
|
||||||
("plan_support_q4", ("plan", "support", "q4")),
|
|
||||||
("plan_support_year", ("plan", "support", "year")),
|
|
||||||
("plan_development_q1", ("plan", "development", "q1")),
|
|
||||||
("plan_development_q2", ("plan", "development", "q2")),
|
|
||||||
("plan_development_q3", ("plan", "development", "q3")),
|
|
||||||
("plan_development_q4", ("plan", "development", "q4")),
|
|
||||||
("plan_development_year", ("plan", "development", "year")),
|
|
||||||
("plan_total_year", ("plan", "total_year")),
|
|
||||||
("approved_support_q1", ("approved", "support", "q1")),
|
|
||||||
("approved_support_q2", ("approved", "support", "q2")),
|
|
||||||
("approved_support_q3", ("approved", "support", "q3")),
|
|
||||||
("approved_support_q4", ("approved", "support", "q4")),
|
|
||||||
("approved_support_year", ("approved", "support", "year")),
|
|
||||||
("approved_development_q1", ("approved", "development", "q1")),
|
|
||||||
("approved_development_q2", ("approved", "development", "q2")),
|
|
||||||
("approved_development_q3", ("approved", "development", "q3")),
|
|
||||||
("approved_development_q4", ("approved", "development", "q4")),
|
|
||||||
("approved_development_year", ("approved", "development", "year")),
|
|
||||||
("approved_total_year", ("approved", "total_year")),
|
|
||||||
("fact_support_q1", ("fact", "support", "q1")),
|
|
||||||
("fact_support_q2", ("fact", "support", "q2")),
|
|
||||||
("fact_support_q3", ("fact", "support", "q3")),
|
|
||||||
("fact_support_q4", ("fact", "support", "q4")),
|
|
||||||
("fact_support_year", ("fact", "support", "year")),
|
|
||||||
("fact_development_q1", ("fact", "development", "q1")),
|
|
||||||
("fact_development_q2", ("fact", "development", "q2")),
|
|
||||||
("fact_development_q3", ("fact", "development", "q3")),
|
|
||||||
("fact_development_q4", ("fact", "development", "q4")),
|
|
||||||
("fact_development_year", ("fact", "development", "year")),
|
|
||||||
("fact_total_year", ("fact", "total_year")),
|
|
||||||
("corrected_support_q2", ("corrected", "support", "q2")),
|
|
||||||
("corrected_support_q3", ("corrected", "support", "q3")),
|
|
||||||
("corrected_support_q4", ("corrected", "support", "q4")),
|
|
||||||
("corrected_development_q2", ("corrected", "development", "q2")),
|
|
||||||
("corrected_development_q3", ("corrected", "development", "q3")),
|
|
||||||
("corrected_development_q4", ("corrected", "development", "q4")),
|
|
||||||
]
|
|
||||||
QUARTER_KEYS = ("q1", "q2", "q3", "q4", "totals")
|
|
||||||
FORM1_SECTION_BLOCK_KEYS = (
|
|
||||||
"sequestration",
|
|
||||||
"allocation",
|
|
||||||
"reserve",
|
|
||||||
"approved",
|
|
||||||
"collegial",
|
|
||||||
"ckk",
|
|
||||||
"contract",
|
|
||||||
"plan",
|
|
||||||
)
|
|
||||||
FORM1_METRIC_BLOCK_KEYS = (
|
|
||||||
"plan",
|
|
||||||
"reserve",
|
|
||||||
"approved",
|
|
||||||
"collegial",
|
|
||||||
"allocation",
|
|
||||||
"sequestration",
|
|
||||||
"contract",
|
|
||||||
"ckk",
|
|
||||||
"contract_summary",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExcelValueSerializer:
|
|
||||||
def to_excel_value(self, value: Any) -> Any:
|
|
||||||
if value is None or isinstance(value, (str, int, float, bool)):
|
|
||||||
return value
|
|
||||||
if isinstance(value, dict):
|
|
||||||
for preferred_key in ("name", "title", "label", "value"):
|
|
||||||
preferred_value = value.get(preferred_key)
|
|
||||||
if isinstance(preferred_value, (str, int, float, bool)) and preferred_value is not None:
|
|
||||||
return preferred_value
|
|
||||||
if len(value) == 1:
|
|
||||||
only_value = next(iter(value.values()))
|
|
||||||
if isinstance(only_value, (str, int, float, bool)) or only_value is None:
|
|
||||||
return only_value
|
|
||||||
return self._dict_to_readable_text(value)
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
if all(isinstance(item, (str, int, float, bool)) or item is None for item in value):
|
|
||||||
return ", ".join("" if item is None else str(item) for item in value)
|
|
||||||
return json.dumps(value, ensure_ascii=False)
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
def _dict_to_readable_text(self, value: dict[str, Any]) -> Any:
|
|
||||||
parts: list[str] = []
|
|
||||||
for key, raw in value.items():
|
|
||||||
normalized = self.to_excel_value(raw)
|
|
||||||
if self._is_empty_excel_value(normalized):
|
|
||||||
continue
|
|
||||||
parts.append(f"{key}={normalized}")
|
|
||||||
if not parts:
|
|
||||||
return None
|
|
||||||
return "; ".join(parts)
|
|
||||||
|
|
||||||
def dict_to_readable_text(self, value: dict[str, Any]) -> Any:
|
|
||||||
return self._dict_to_readable_text(value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_empty_excel_value(value: Any) -> bool:
|
|
||||||
if value is None:
|
|
||||||
return True
|
|
||||||
if isinstance(value, str) and value.strip() == "":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class Form1RowNormalizer:
|
|
||||||
def __init__(self, serializer: ExcelValueSerializer):
|
|
||||||
self.serializer = serializer
|
|
||||||
|
|
||||||
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
normalized = dict(data)
|
|
||||||
self._merge_header_fields_into_row(normalized=normalized, original=data)
|
|
||||||
self._promote_quarter_values_from_section_blocks(normalized=normalized)
|
|
||||||
self._collapse_metric_blocks_to_scalar(normalized=normalized)
|
|
||||||
self._fill_contract_fields(normalized=normalized)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
def _merge_header_fields_into_row(self, normalized: dict[str, Any], original: dict[str, Any]) -> None:
|
|
||||||
header = original.get("header")
|
|
||||||
if isinstance(header, dict):
|
|
||||||
if "section" in header and "section" not in normalized:
|
|
||||||
normalized["section"] = header.get("section")
|
|
||||||
if "item_id" in header and "item_id" not in normalized:
|
|
||||||
normalized["item_id"] = header.get("item_id")
|
|
||||||
if "name" in header and "name" not in normalized:
|
|
||||||
normalized["name"] = header.get("name")
|
|
||||||
normalized.pop("header", None)
|
|
||||||
normalized.pop("line_id", None)
|
|
||||||
|
|
||||||
def _promote_quarter_values_from_section_blocks(self, normalized: dict[str, Any]) -> None:
|
|
||||||
for section_key in FORM1_SECTION_BLOCK_KEYS:
|
|
||||||
section_payload = normalized.get(section_key)
|
|
||||||
if not isinstance(section_payload, dict):
|
|
||||||
continue
|
|
||||||
for quarter_key in QUARTER_KEYS:
|
|
||||||
if normalized.get(quarter_key) is None and section_payload.get(quarter_key) is not None:
|
|
||||||
normalized[quarter_key] = section_payload.get(quarter_key)
|
|
||||||
|
|
||||||
def _collapse_metric_blocks_to_scalar(self, normalized: dict[str, Any]) -> None:
|
|
||||||
for block_key in FORM1_METRIC_BLOCK_KEYS:
|
|
||||||
block_value = normalized.get(block_key)
|
|
||||||
if isinstance(block_value, dict):
|
|
||||||
normalized[block_key] = self._extract_preferred_metric(block_value)
|
|
||||||
|
|
||||||
def _fill_contract_fields(self, normalized: dict[str, Any]) -> None:
|
|
||||||
contract_summary = normalized.get("contract_summary")
|
|
||||||
contract_detail = normalized.get("contract_detail")
|
|
||||||
if normalized.get("contract") is None:
|
|
||||||
normalized["contract"] = self._extract_contract_label(contract_detail, contract_summary)
|
|
||||||
if isinstance(contract_detail, dict):
|
|
||||||
normalized["contract_sum"] = self._extract_preferred_metric(contract_detail)
|
|
||||||
|
|
||||||
def _extract_preferred_metric(self, payload: dict[str, Any]) -> Any:
|
|
||||||
priority_keys = (
|
|
||||||
"total_year",
|
|
||||||
"year",
|
|
||||||
"totals",
|
|
||||||
"total",
|
|
||||||
"sum",
|
|
||||||
"amount",
|
|
||||||
"value",
|
|
||||||
"approved_amount",
|
|
||||||
"reserved_amount",
|
|
||||||
"allocated_amount",
|
|
||||||
"booked_amount",
|
|
||||||
)
|
|
||||||
for key in priority_keys:
|
|
||||||
if payload.get(key) is not None:
|
|
||||||
return payload.get(key)
|
|
||||||
for quarter_key in ("q1", "q2", "q3", "q4"):
|
|
||||||
if payload.get(quarter_key) is not None:
|
|
||||||
return payload.get(quarter_key)
|
|
||||||
return self.serializer.dict_to_readable_text(payload)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_contract_label(contract_detail: Any, contract_summary: Any) -> Any:
|
|
||||||
for payload in (contract_detail, contract_summary):
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
continue
|
|
||||||
for key in ("reference", "contract_ref", "counterparty", "subject"):
|
|
||||||
value = payload.get(key)
|
|
||||||
if value not in (None, ""):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Form1DepthRowNormalizer:
|
|
||||||
def __init__(self, serializer: ExcelValueSerializer):
|
|
||||||
self.serializer = serializer
|
|
||||||
|
|
||||||
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
normalized = dict()
|
|
||||||
self._merge_header_fields_into_row(normalized=normalized, original=data)
|
|
||||||
self._normilize_depth(normalized=normalized, original=data)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
def _merge_header_fields_into_row(self, normalized: dict[str, Any], original: dict[str, Any]) -> None:
|
|
||||||
header = original.get("header")
|
|
||||||
if isinstance(header, dict):
|
|
||||||
if "section" in header and "section" not in normalized:
|
|
||||||
normalized["section"] = header.get("section")
|
|
||||||
if "item_id" in header and "item_id" not in normalized:
|
|
||||||
normalized["item_id"] = header.get("item_id")
|
|
||||||
if "name" in header and "name" not in normalized:
|
|
||||||
normalized["name"] = header.get("name")
|
|
||||||
if "num_group" in header and "num_group" not in normalized:
|
|
||||||
normalized["num_group"] = header.get("num_group")
|
|
||||||
if "internal_order" in header and "internal_order" not in normalized:
|
|
||||||
normalized["internal_order"] = header.get("internal_order")
|
|
||||||
|
|
||||||
normalized.pop("header", None)
|
|
||||||
normalized.pop("line_id", None)
|
|
||||||
|
|
||||||
def _normilize_depth(self, normalized: dict, original: dict, prefix: str = "") -> None:
|
|
||||||
for key, value in original.items():
|
|
||||||
if isinstance(value, dict):
|
|
||||||
self._normilize_depth(normalized=normalized, original=value, prefix=f"{prefix}{key}.")
|
|
||||||
else:
|
|
||||||
normalized[f"{prefix}{key}"] = value
|
|
||||||
|
|
||||||
|
|
||||||
class SmetaRowNormalizer:
|
|
||||||
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
field: self._pick_nested_value(data, *path)
|
|
||||||
for field, path in SMETA_VALUE_PATHS
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _pick_nested_value(container: Any, *path: str) -> Any:
|
|
||||||
cur = container
|
|
||||||
for key in path:
|
|
||||||
if not isinstance(cur, dict):
|
|
||||||
return None
|
|
||||||
cur = cur.get(key)
|
|
||||||
return cur
|
|
||||||
|
|
||||||
|
|
||||||
class Form3ReportRowNormalizer:
|
|
||||||
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
normalized = dict(data)
|
|
||||||
header = data.get("header")
|
|
||||||
if isinstance(header, dict):
|
|
||||||
if "section_code" in header and "section" not in normalized:
|
|
||||||
normalized["section"] = header.get("section_code")
|
|
||||||
if "item_id" in header and "item_id" not in normalized:
|
|
||||||
normalized["item_id"] = header.get("item_id")
|
|
||||||
if "name" in header and "name" not in normalized:
|
|
||||||
normalized["name"] = header.get("name")
|
|
||||||
for quarter_key in ("q1", "q2", "q3", "q4"):
|
|
||||||
normalized[quarter_key] = self._extract_quarter_metric(normalized.get(quarter_key))
|
|
||||||
normalized["totals"] = self._extract_totals_metric(normalized.get("totals"))
|
|
||||||
normalized.pop("header", None)
|
|
||||||
normalized.pop("line_id", None)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_quarter_metric(value: Any) -> Any:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return value
|
|
||||||
for key in ("quarter_actual", "total_corr", "economy"):
|
|
||||||
if value.get(key) is not None:
|
|
||||||
return value.get(key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_totals_metric(value: Any) -> Any:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return value
|
|
||||||
for key in ("total_actual", "total_corr", "economy"):
|
|
||||||
if value.get(key) is not None:
|
|
||||||
return value.get(key)
|
|
||||||
return None
|
|
||||||
@ -1,449 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import zipfile
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from openpyxl import Workbook
|
|
||||||
from openpyxl.worksheet.worksheet import Worksheet
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.db.models.app_user import AppUser
|
|
||||||
from src.db.models.form_type import FormTypeEnum
|
|
||||||
from src.services.budget_form_service import BudgetFormService
|
|
||||||
from src.services.export_service.export_normalizers import (
|
|
||||||
ExcelValueSerializer,
|
|
||||||
Form1DepthRowNormalizer,
|
|
||||||
Form3ReportRowNormalizer,
|
|
||||||
SmetaRowNormalizer,
|
|
||||||
)
|
|
||||||
from src.services.export_service.export_mappings import (
|
|
||||||
FORM1_COLORS,
|
|
||||||
FORM1_DEPTH_COLUMNS,
|
|
||||||
FORM3_REPORT_COLUMNS,
|
|
||||||
SMETA_COLUMNS,
|
|
||||||
)
|
|
||||||
from src.services.export_service.export_writers import (
|
|
||||||
DepthSheetWriter,
|
|
||||||
TabularSheetWriter,
|
|
||||||
)
|
|
||||||
from src.services.project_service import ProjectService
|
|
||||||
from src.services.sheet_service import SheetService
|
|
||||||
|
|
||||||
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
||||||
ZIP_MEDIA_TYPE = "application/zip"
|
|
||||||
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
|
|
||||||
SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ExportSheetPlan:
|
|
||||||
sheet_name: str
|
|
||||||
effective_direction: str | None
|
|
||||||
effective_sections: list[str] | None
|
|
||||||
validation_direction: str | None
|
|
||||||
validation_sections: list[str] | None
|
|
||||||
|
|
||||||
|
|
||||||
class ExportService:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.budget_form_service = BudgetFormService(db)
|
|
||||||
self.sheet_service = SheetService(db)
|
|
||||||
self.project_service = ProjectService(db)
|
|
||||||
self.value_serializer = ExcelValueSerializer()
|
|
||||||
self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer)
|
|
||||||
self.form3_report_row_normalizer = Form3ReportRowNormalizer()
|
|
||||||
self.smeta_row_normalizer = SmetaRowNormalizer()
|
|
||||||
self.sheet_writers: dict[str, TabularSheetWriter] = {
|
|
||||||
"__default__": DepthSheetWriter(
|
|
||||||
columns_dict=FORM1_DEPTH_COLUMNS,
|
|
||||||
row_normalizer=self.form1_row_normalizer.normalize,
|
|
||||||
value_serializer=self.value_serializer.to_excel_value,
|
|
||||||
),
|
|
||||||
"SMETA": TabularSheetWriter(
|
|
||||||
columns=SMETA_COLUMNS,
|
|
||||||
row_normalizer=self.smeta_row_normalizer.normalize,
|
|
||||||
value_serializer=self.value_serializer.to_excel_value,
|
|
||||||
),
|
|
||||||
"OPER": DepthSheetWriter(
|
|
||||||
columns_dict=FORM1_DEPTH_COLUMNS,
|
|
||||||
row_normalizer=self.form1_row_normalizer.normalize,
|
|
||||||
value_serializer=self.value_serializer.to_excel_value,
|
|
||||||
colors_config=FORM1_COLORS["OPER"],
|
|
||||||
),
|
|
||||||
"AHR": DepthSheetWriter(
|
|
||||||
columns_dict=FORM1_DEPTH_COLUMNS,
|
|
||||||
row_normalizer=self.form1_row_normalizer.normalize,
|
|
||||||
value_serializer=self.value_serializer.to_excel_value,
|
|
||||||
colors_config=FORM1_COLORS["AHR"],
|
|
||||||
),
|
|
||||||
"CAP": DepthSheetWriter(
|
|
||||||
columns_dict=FORM1_DEPTH_COLUMNS,
|
|
||||||
row_normalizer=self.form1_row_normalizer.normalize,
|
|
||||||
value_serializer=self.value_serializer.to_excel_value,
|
|
||||||
colors_config=FORM1_COLORS["CAP"],
|
|
||||||
),
|
|
||||||
}
|
|
||||||
self.form3_report_writer = TabularSheetWriter(
|
|
||||||
columns=FORM3_REPORT_COLUMNS,
|
|
||||||
row_normalizer=self.form3_report_row_normalizer.normalize,
|
|
||||||
value_serializer=self.value_serializer.to_excel_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def export_form_payload(
|
|
||||||
self,
|
|
||||||
form_id: int,
|
|
||||||
current_user: AppUser,
|
|
||||||
sheet: str | None = None,
|
|
||||||
direction: str | None = None,
|
|
||||||
sections: list[str] | None = None,
|
|
||||||
ignore_non_applicable_query_params: bool = False,
|
|
||||||
) -> tuple[io.BytesIO, str, str]:
|
|
||||||
stream, filename = await self.export_form_to_xlsx(
|
|
||||||
form_id=form_id,
|
|
||||||
current_user=current_user,
|
|
||||||
sheet=sheet,
|
|
||||||
direction=direction,
|
|
||||||
sections=sections,
|
|
||||||
ignore_non_applicable_query_params=ignore_non_applicable_query_params,
|
|
||||||
)
|
|
||||||
return stream, filename, XLSX_MEDIA_TYPE
|
|
||||||
|
|
||||||
async def export_project_report_payload(
|
|
||||||
self,
|
|
||||||
project_id: int,
|
|
||||||
year: int,
|
|
||||||
report_type: str,
|
|
||||||
current_user: AppUser,
|
|
||||||
sections: list[str] | None = None,
|
|
||||||
) -> tuple[io.BytesIO, str, str]:
|
|
||||||
stream, filename = await self.export_project_report_to_xlsx(
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
current_user=current_user,
|
|
||||||
sections=sections,
|
|
||||||
)
|
|
||||||
return stream, filename, XLSX_MEDIA_TYPE
|
|
||||||
|
|
||||||
async def export_bulk_payload(
|
|
||||||
self,
|
|
||||||
form_ids: list[int],
|
|
||||||
current_user: AppUser,
|
|
||||||
skip_failed: bool = True,
|
|
||||||
direction: str | None = None,
|
|
||||||
sections: list[str] | None = None,
|
|
||||||
ignore_non_applicable_query_params: bool = False,
|
|
||||||
) -> tuple[io.BytesIO, str, str]:
|
|
||||||
archive_stream = io.BytesIO()
|
|
||||||
exported_count = 0
|
|
||||||
errors: list[dict[str, Any]] = []
|
|
||||||
used_names: set[str] = set()
|
|
||||||
|
|
||||||
with zipfile.ZipFile(
|
|
||||||
archive_stream,
|
|
||||||
mode="w",
|
|
||||||
compression=zipfile.ZIP_DEFLATED,
|
|
||||||
) as archive:
|
|
||||||
for form_id in self._deduplicate_ids(form_ids):
|
|
||||||
try:
|
|
||||||
form_stream, filename = await self.export_form_to_xlsx(
|
|
||||||
form_id=form_id,
|
|
||||||
current_user=current_user,
|
|
||||||
direction=direction,
|
|
||||||
sections=sections,
|
|
||||||
ignore_non_applicable_query_params=ignore_non_applicable_query_params,
|
|
||||||
)
|
|
||||||
archive_name = self._make_unique_filename(filename, used_names)
|
|
||||||
archive.writestr(archive_name, form_stream.getvalue())
|
|
||||||
exported_count += 1
|
|
||||||
except ValueError as exc:
|
|
||||||
if not skip_failed:
|
|
||||||
raise
|
|
||||||
errors.append({"form_id": form_id, "error": str(exc)})
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
archive.writestr(
|
|
||||||
"errors.json",
|
|
||||||
json.dumps({"errors": errors}, ensure_ascii=False, indent=2).encode("utf-8"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if exported_count == 0:
|
|
||||||
raise ValueError("Не удалось экспортировать ни одной формы.")
|
|
||||||
|
|
||||||
archive_stream.seek(0)
|
|
||||||
filename = f"export_forms_{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.zip"
|
|
||||||
return archive_stream, filename, ZIP_MEDIA_TYPE
|
|
||||||
|
|
||||||
async def export_form_to_xlsx(
|
|
||||||
self,
|
|
||||||
form_id: int,
|
|
||||||
current_user: AppUser,
|
|
||||||
sheet: str | None = None,
|
|
||||||
direction: str | None = None,
|
|
||||||
sections: list[str] | None = None,
|
|
||||||
ignore_non_applicable_query_params: bool = False,
|
|
||||||
) -> tuple[io.BytesIO, str]:
|
|
||||||
form = await self._get_form_for_export(form_id=form_id, current_user=current_user)
|
|
||||||
sheets = self._resolve_requested_sheets(form=form, requested_sheet=sheet)
|
|
||||||
self._validate_sections_for_form(form=form, sections=sections)
|
|
||||||
|
|
||||||
if direction:
|
|
||||||
plans = self._build_sheet_plans(
|
|
||||||
form=form,
|
|
||||||
sheets=sheets,
|
|
||||||
direction=direction,
|
|
||||||
sections=sections,
|
|
||||||
ignore_non_applicable_query_params=ignore_non_applicable_query_params,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
sheets_by_directions = self._resolve_sheets_by_directions(form=form, requested_sheet=sheet)
|
|
||||||
plans = []
|
|
||||||
for dr, shts in sheets_by_directions.items():
|
|
||||||
plans += self._build_sheet_plans(
|
|
||||||
form=form,
|
|
||||||
sheets=shts,
|
|
||||||
direction=dr,
|
|
||||||
sections=sections,
|
|
||||||
ignore_non_applicable_query_params=ignore_non_applicable_query_params,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
workbook = self._create_workbook_with_metadata(form=form)
|
|
||||||
exported_sheets = 0
|
|
||||||
for plan in plans:
|
|
||||||
self._validate_sheet_query_params(
|
|
||||||
form_type_code=form.form_type.code,
|
|
||||||
sheet=plan.sheet_name,
|
|
||||||
direction=plan.validation_direction,
|
|
||||||
sections=plan.validation_sections,
|
|
||||||
)
|
|
||||||
rows = await self.sheet_service.get(
|
|
||||||
form_id=form_id,
|
|
||||||
sheet=plan.sheet_name,
|
|
||||||
direction=plan.effective_direction,
|
|
||||||
sections=plan.effective_sections,
|
|
||||||
)
|
|
||||||
worksheet = workbook.create_sheet(title=self._safe_sheet_name(plan.sheet_name))
|
|
||||||
self._write_sheet_rows(
|
|
||||||
worksheet=worksheet,
|
|
||||||
rows=rows,
|
|
||||||
sheet_name=plan.sheet_name,
|
|
||||||
)
|
|
||||||
exported_sheets += 1
|
|
||||||
|
|
||||||
if exported_sheets == 0:
|
|
||||||
raise ValueError("Не удалось экспортировать листы формы.")
|
|
||||||
|
|
||||||
stream = io.BytesIO()
|
|
||||||
workbook.save(stream)
|
|
||||||
workbook.close()
|
|
||||||
stream.seek(0)
|
|
||||||
filename = self._build_filename(form_id=form.id, form_type_code=form.form_type.code, year=form.year, sheet=sheet)
|
|
||||||
return stream, filename
|
|
||||||
|
|
||||||
async def export_project_report_to_xlsx(
|
|
||||||
self,
|
|
||||||
project_id: int,
|
|
||||||
year: int,
|
|
||||||
report_type: str,
|
|
||||||
current_user: AppUser,
|
|
||||||
sections: list[str] | None = None,
|
|
||||||
) -> tuple[io.BytesIO, str]:
|
|
||||||
rows = await self.project_service.get_report_rows(
|
|
||||||
project_id=project_id,
|
|
||||||
year=year,
|
|
||||||
report_type=report_type,
|
|
||||||
sections=sections,
|
|
||||||
user=current_user,
|
|
||||||
)
|
|
||||||
report_type_upper = report_type.upper()
|
|
||||||
workbook = Workbook()
|
|
||||||
meta_sheet = workbook.active
|
|
||||||
meta_sheet.title = "metadata"
|
|
||||||
metadata_rows = [
|
|
||||||
("Тип формы", "FORM_3"),
|
|
||||||
("Project ID", project_id),
|
|
||||||
("Year", year),
|
|
||||||
("Report Type", report_type_upper),
|
|
||||||
("Sections", ", ".join(sections) if sections else None),
|
|
||||||
("Экспортировано", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")),
|
|
||||||
]
|
|
||||||
for row_idx, (key, value) in enumerate(metadata_rows, start=1):
|
|
||||||
meta_sheet.cell(row=row_idx, column=1, value=key)
|
|
||||||
meta_sheet.cell(row=row_idx, column=2, value=value)
|
|
||||||
worksheet = workbook.create_sheet(title=self._safe_sheet_name(report_type_upper))
|
|
||||||
self.form3_report_writer.write(worksheet, rows)
|
|
||||||
stream = io.BytesIO()
|
|
||||||
workbook.save(stream)
|
|
||||||
workbook.close()
|
|
||||||
stream.seek(0)
|
|
||||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
filename = f"{year}_FORM_3_{project_id}_{report_type_upper}_{date_str}.xlsx"
|
|
||||||
return stream, filename
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def build_content_disposition(filename: str) -> str:
|
|
||||||
encoded = quote(filename)
|
|
||||||
return f"attachment; filename*=UTF-8''{encoded}"
|
|
||||||
|
|
||||||
def _fill_metadata(self, sheet: Worksheet, form: Any) -> None:
|
|
||||||
metadata_rows = [
|
|
||||||
("Форма ID", form.id),
|
|
||||||
("Тип формы", form.form_type_code),
|
|
||||||
("Год", form.year),
|
|
||||||
("Org Unit ID", form.org_unit_id),
|
|
||||||
("Org Unit", form.org_unit.title if form.org_unit else None),
|
|
||||||
("Экспортировано", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")),
|
|
||||||
]
|
|
||||||
for row_idx, (key, value) in enumerate(metadata_rows, start=1):
|
|
||||||
sheet.cell(row=row_idx, column=1, value=key)
|
|
||||||
sheet.cell(row=row_idx, column=2, value=value)
|
|
||||||
|
|
||||||
def _write_sheet_rows(
|
|
||||||
self,
|
|
||||||
worksheet: Worksheet,
|
|
||||||
rows: list[tuple],
|
|
||||||
sheet_name: str,
|
|
||||||
) -> None:
|
|
||||||
writer = self.sheet_writers.get(sheet_name, self.sheet_writers["__default__"])
|
|
||||||
writer.write(worksheet, rows)
|
|
||||||
|
|
||||||
async def _get_form_for_export(self, form_id: int, current_user: AppUser) -> Any:
|
|
||||||
form = await self.budget_form_service.get(
|
|
||||||
budget_form_id=form_id,
|
|
||||||
user=current_user,
|
|
||||||
load_form_type=True,
|
|
||||||
load_org=True,
|
|
||||||
)
|
|
||||||
if not form:
|
|
||||||
raise ValueError(f"Форма {form_id} не найдена")
|
|
||||||
return form
|
|
||||||
|
|
||||||
def _resolve_requested_sheets(self, form: Any, requested_sheet: str | None) -> list[str]:
|
|
||||||
if requested_sheet and requested_sheet not in form.form_type.sheet_list:
|
|
||||||
raise ValueError(f"Лист {requested_sheet} формы {form.id} не найден")
|
|
||||||
if requested_sheet:
|
|
||||||
return [requested_sheet]
|
|
||||||
return list(form.form_type.sheet_list)
|
|
||||||
|
|
||||||
def _resolve_sheets_by_directions(self, form: Any, requested_sheet: str | None) -> dict[str | None, str]:
|
|
||||||
result = {}
|
|
||||||
for sheet in form.form_type.sheet_list_with_directions:
|
|
||||||
if requested_sheet and sheet["sheet_name"] != requested_sheet:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if sheet["direction"] in result:
|
|
||||||
result[sheet["direction"]].append(sheet["sheet_name"])
|
|
||||||
else:
|
|
||||||
result[sheet["direction"]] = [sheet["sheet_name"]]
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_sections_for_form(form: Any, sections: list[str] | None) -> None:
|
|
||||||
if not sections:
|
|
||||||
return
|
|
||||||
invalid = sorted(set(sections) - set(form.form_type.section_list))
|
|
||||||
if invalid:
|
|
||||||
raise ValueError(f"Недопустимые sections: {', '.join(invalid)}")
|
|
||||||
|
|
||||||
def _create_workbook_with_metadata(self, form: Any) -> Workbook:
|
|
||||||
workbook = Workbook()
|
|
||||||
meta_sheet = workbook.active
|
|
||||||
meta_sheet.title = "metadata"
|
|
||||||
self._fill_metadata(meta_sheet, form)
|
|
||||||
return workbook
|
|
||||||
|
|
||||||
def _build_sheet_plans(
|
|
||||||
self,
|
|
||||||
form: Any,
|
|
||||||
sheets: list[str],
|
|
||||||
direction: str | None,
|
|
||||||
sections: list[str] | None,
|
|
||||||
ignore_non_applicable_query_params: bool,
|
|
||||||
) -> list[ExportSheetPlan]:
|
|
||||||
plans: list[ExportSheetPlan] = []
|
|
||||||
for sheet_name in sheets:
|
|
||||||
effective_direction = direction if self._requires_direction(form.form_type.code, sheet_name) else None
|
|
||||||
effective_sections = sections if sheet_name in SHEETS_WITH_SECTIONS else None
|
|
||||||
validation_direction = effective_direction if ignore_non_applicable_query_params else direction
|
|
||||||
validation_sections = effective_sections if ignore_non_applicable_query_params else sections
|
|
||||||
plans.append(
|
|
||||||
ExportSheetPlan(
|
|
||||||
sheet_name=sheet_name,
|
|
||||||
effective_direction=effective_direction,
|
|
||||||
effective_sections=effective_sections,
|
|
||||||
validation_direction=validation_direction,
|
|
||||||
validation_sections=validation_sections,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return plans
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_sheet_query_params(
|
|
||||||
self,
|
|
||||||
form_type_code: FormTypeEnum,
|
|
||||||
sheet: str,
|
|
||||||
direction: str | None,
|
|
||||||
sections: list[str] | None,
|
|
||||||
) -> None:
|
|
||||||
if form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS and not direction:
|
|
||||||
raise ValueError("direction обязателен для FORM_1 листов AHR/CAP")
|
|
||||||
if direction and not self._requires_direction(form_type_code, sheet):
|
|
||||||
raise ValueError("direction допустим только для FORM_1 листов AHR/CAP")
|
|
||||||
if sections and sheet not in SHEETS_WITH_SECTIONS:
|
|
||||||
raise ValueError(f"sections не поддерживается для листа {sheet}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _requires_direction(form_type_code: FormTypeEnum, sheet: str) -> bool:
|
|
||||||
return form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _safe_sheet_name(value: str) -> str:
|
|
||||||
safe = value
|
|
||||||
for char in ("[", "]", ":", "*", "?", "/", "\\"):
|
|
||||||
safe = safe.replace(char, "_")
|
|
||||||
safe = safe.strip()
|
|
||||||
return (safe or "Sheet")[:31]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_filename(form_id: int, form_type_code: str, year: int, sheet: str | None = None) -> str:
|
|
||||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
if sheet:
|
|
||||||
return f"{year}_{form_type_code}_{form_id}_{sheet}_{date_str}.xlsx"
|
|
||||||
return f"{year}_{form_type_code}_{form_id}_{date_str}.xlsx"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _deduplicate_ids(form_ids: list[int]) -> list[int]:
|
|
||||||
seen: set[int] = set()
|
|
||||||
result: list[int] = []
|
|
||||||
for form_id in form_ids:
|
|
||||||
if form_id not in seen:
|
|
||||||
seen.add(form_id)
|
|
||||||
result.append(form_id)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _make_unique_filename(filename: str, used_names: set[str]) -> str:
|
|
||||||
if filename not in used_names:
|
|
||||||
used_names.add(filename)
|
|
||||||
return filename
|
|
||||||
|
|
||||||
if "." in filename:
|
|
||||||
base, ext = filename.rsplit(".", 1)
|
|
||||||
ext = f".{ext}"
|
|
||||||
else:
|
|
||||||
base, ext = filename, ""
|
|
||||||
|
|
||||||
suffix = 2
|
|
||||||
while True:
|
|
||||||
candidate = f"{base}_{suffix}{ext}"
|
|
||||||
if candidate not in used_names:
|
|
||||||
used_names.add(candidate)
|
|
||||||
return candidate
|
|
||||||
suffix += 1
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from openpyxl.styles import Alignment, Border, Color, Font, PatternFill, Side
|
|
||||||
from openpyxl.worksheet.worksheet import Worksheet
|
|
||||||
from openpyxl.utils import get_column_letter
|
|
||||||
|
|
||||||
from src.services.export_service.export_mappings import PALETTE
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
RowNormalizer = Callable[[dict[str, Any]], dict[str, Any]]
|
|
||||||
ValueSerializer = Callable[[Any], Any]
|
|
||||||
|
|
||||||
|
|
||||||
class TabularSheetWriter:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
columns: list[tuple[str, str]],
|
|
||||||
row_normalizer: RowNormalizer,
|
|
||||||
value_serializer: ValueSerializer,
|
|
||||||
):
|
|
||||||
self.columns = columns
|
|
||||||
self.row_normalizer = row_normalizer
|
|
||||||
self.value_serializer = value_serializer
|
|
||||||
|
|
||||||
def write(self, worksheet: Worksheet, rows: list[tuple]) -> None:
|
|
||||||
worksheet.append([title for _, title in self.columns])
|
|
||||||
for row in rows:
|
|
||||||
raw_data = row[3] if len(row) > 3 and isinstance(row[3], dict) else {}
|
|
||||||
normalized = self.row_normalizer(raw_data)
|
|
||||||
worksheet.append(
|
|
||||||
[self.value_serializer(normalized.get(key)) for key, _ in self.columns]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DepthSheetWriter:
|
|
||||||
alignment = Alignment(horizontal='center', vertical='center')
|
|
||||||
thin_border = Border(
|
|
||||||
left=Side(style='thin'),
|
|
||||||
right=Side(style='thin'),
|
|
||||||
top=Side(style='thin'),
|
|
||||||
bottom=Side(style='thin')
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
columns_dict: dict,
|
|
||||||
row_normalizer: RowNormalizer,
|
|
||||||
value_serializer: ValueSerializer,
|
|
||||||
colors_config: dict | None = None,
|
|
||||||
):
|
|
||||||
self.columns_dict = columns_dict
|
|
||||||
self._flat_columns_to_depth = {}
|
|
||||||
self.columns = self._columns_dict_to_flat(columns_dict)
|
|
||||||
self.row_normalizer = row_normalizer
|
|
||||||
self.value_serializer = value_serializer
|
|
||||||
self.colors_config = colors_config
|
|
||||||
self.palette = None
|
|
||||||
if colors_config:
|
|
||||||
self._set_palette()
|
|
||||||
|
|
||||||
def _columns_dict_to_flat(
|
|
||||||
self,
|
|
||||||
columns_dict: dict | None = None,
|
|
||||||
prefix: str = "",
|
|
||||||
depth_prefix: str = "",
|
|
||||||
) -> None:
|
|
||||||
if columns_dict is None:
|
|
||||||
columns_dict = self.columns_dict
|
|
||||||
result = []
|
|
||||||
for key, sub_dict in columns_dict.items():
|
|
||||||
if "key" in sub_dict:
|
|
||||||
new_key = sub_dict['key']
|
|
||||||
else:
|
|
||||||
new_key = key
|
|
||||||
if "children" not in sub_dict:
|
|
||||||
if new_key:
|
|
||||||
add_key = f"{prefix}{new_key}"
|
|
||||||
result.append(add_key)
|
|
||||||
self._flat_columns_to_depth[add_key] = f"{depth_prefix}{key}"
|
|
||||||
else:
|
|
||||||
result += self._columns_dict_to_flat(
|
|
||||||
sub_dict["children"],
|
|
||||||
prefix=f"{prefix}{new_key}." if new_key else prefix,
|
|
||||||
depth_prefix=f"{depth_prefix}{key}.",
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _set_palette(self) -> None:
|
|
||||||
self.palette = {}
|
|
||||||
for key in self.columns:
|
|
||||||
full_key = None
|
|
||||||
for sub_key in self._flat_columns_to_depth[key].split("."):
|
|
||||||
if full_key is None:
|
|
||||||
full_key = sub_key
|
|
||||||
else:
|
|
||||||
full_key = f"{full_key}.{sub_key}"
|
|
||||||
if full_key in self.colors_config and "palette" in self.colors_config[full_key]:
|
|
||||||
self.palette[key] = self.colors_config[full_key]["palette"]
|
|
||||||
|
|
||||||
def _get_header_max_row(self, column_dict: dict) -> int:
|
|
||||||
max_depth = 0
|
|
||||||
for sub_dict in column_dict.values():
|
|
||||||
if "children" in sub_dict:
|
|
||||||
max_depth = max(max_depth, self._get_header_max_row(sub_dict["children"]))
|
|
||||||
return max_depth + 1
|
|
||||||
|
|
||||||
def _write_header(
|
|
||||||
self,
|
|
||||||
worksheet: Worksheet,
|
|
||||||
columns_dict: dict | None = None,
|
|
||||||
current_column: int = 1,
|
|
||||||
current_row: int = 1,
|
|
||||||
max_row: int | None = None,
|
|
||||||
full_key: str | None = None
|
|
||||||
) -> int:
|
|
||||||
if columns_dict is None:
|
|
||||||
columns_dict = self.columns_dict
|
|
||||||
max_row = self._get_header_max_row(self.columns_dict)
|
|
||||||
for key, sub_dict in columns_dict.items():
|
|
||||||
if full_key:
|
|
||||||
key = f"{full_key}.{key}"
|
|
||||||
|
|
||||||
if "children" not in sub_dict:
|
|
||||||
cell = worksheet.cell(row=current_row, column=current_column, value=sub_dict["name"])
|
|
||||||
cell.border = self.thin_border
|
|
||||||
if max_row != current_row:
|
|
||||||
worksheet.merge_cells(
|
|
||||||
start_row=current_row,
|
|
||||||
start_column=current_column,
|
|
||||||
end_row=max_row,
|
|
||||||
end_column=current_column,
|
|
||||||
)
|
|
||||||
current_column += 1
|
|
||||||
else:
|
|
||||||
new_column = self._write_header(
|
|
||||||
worksheet=worksheet,
|
|
||||||
columns_dict=sub_dict["children"],
|
|
||||||
current_column=current_column,
|
|
||||||
current_row=current_row+1,
|
|
||||||
max_row=max_row,
|
|
||||||
full_key=key,
|
|
||||||
)
|
|
||||||
cell = worksheet.cell(row=current_row, column=current_column, value=sub_dict["name"])
|
|
||||||
if new_column - current_column > 1:
|
|
||||||
worksheet.merge_cells(
|
|
||||||
start_row=current_row,
|
|
||||||
start_column=current_column,
|
|
||||||
end_row=current_row,
|
|
||||||
end_column=new_column - 1,
|
|
||||||
)
|
|
||||||
cell.border = self.thin_border
|
|
||||||
current_column = new_column
|
|
||||||
cell.alignment = self.alignment
|
|
||||||
|
|
||||||
if self.colors_config and (color := self.colors_config.get(key)):
|
|
||||||
if "background" in color:
|
|
||||||
cell.fill = PatternFill(
|
|
||||||
start_color=Color(rgb=color["background"]),
|
|
||||||
fill_type="solid",
|
|
||||||
)
|
|
||||||
if "color" in color:
|
|
||||||
cell.font = Font(color=color["color"])
|
|
||||||
return current_column
|
|
||||||
|
|
||||||
def _set_widths(self, worksheet: Worksheet):
|
|
||||||
for col in worksheet.columns:
|
|
||||||
max_len = 0
|
|
||||||
col_letter = get_column_letter(col[0].column) # Get alphabetical column name (e.g., 'A')
|
|
||||||
|
|
||||||
for cell in col:
|
|
||||||
if cell.value is not None:
|
|
||||||
cell_len = len(str(cell.value))
|
|
||||||
if cell_len > max_len:
|
|
||||||
max_len = cell_len
|
|
||||||
|
|
||||||
adjusted_width = max(max_len + 3, 10)
|
|
||||||
worksheet.column_dimensions[col_letter].width = adjusted_width
|
|
||||||
|
|
||||||
def _set_color_to_row(self, ws: Worksheet, item_type: str) -> None:
|
|
||||||
if not self.palette or item_type in ("SUB_ITEM", "INPUT"):
|
|
||||||
return
|
|
||||||
|
|
||||||
row = ws.max_row
|
|
||||||
|
|
||||||
for i, key in enumerate(self.columns):
|
|
||||||
if key not in self.palette:
|
|
||||||
continue
|
|
||||||
cell = ws.cell(column=i+1, row=row)
|
|
||||||
if (palette := self.palette.get(key)):
|
|
||||||
cell.fill = PatternFill(
|
|
||||||
start_color=Color(rgb=PALETTE[palette][item_type]),
|
|
||||||
fill_type="solid",
|
|
||||||
)
|
|
||||||
|
|
||||||
def write(self, worksheet: Worksheet, rows: list[tuple]) -> None:
|
|
||||||
self._write_header(worksheet)
|
|
||||||
for row in rows:
|
|
||||||
raw_data = row[3] if len(row) > 3 and isinstance(row[3], dict) else {}
|
|
||||||
normalized = self.row_normalizer(raw_data)
|
|
||||||
worksheet.append(
|
|
||||||
[self.value_serializer(normalized.get(key)) for key in self.columns]
|
|
||||||
)
|
|
||||||
if len(row) > 3:
|
|
||||||
self._set_color_to_row(ws=worksheet, item_type=row[0])
|
|
||||||
self._set_widths(worksheet)
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.core.errors import AccessDeniedException
|
|
||||||
from src.db.models.app_user import AppUser
|
|
||||||
from src.db.models.org_unit import OrgUnit
|
|
||||||
from src.db.models.role import UserRoleEnum
|
|
||||||
from src.repository.org_unit_repository import OrgUnitRepository
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class OrgUnitService:
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
self.org_repo = OrgUnitRepository(db)
|
|
||||||
|
|
||||||
async def get_list(
|
|
||||||
self,
|
|
||||||
user: AppUser,
|
|
||||||
offset: int | None = None,
|
|
||||||
limit: int | None = None,
|
|
||||||
is_ssp: bool | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
org_unit_id: int | list[int] | None = None,
|
|
||||||
with_count: bool = False,
|
|
||||||
load_users: bool = False,
|
|
||||||
) -> list[OrgUnit] | tuple[int, list[OrgUnit]]:
|
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
|
||||||
return (0, []) if with_count else []
|
|
||||||
|
|
||||||
return await self.org_repo.get_list(
|
|
||||||
offset=offset,
|
|
||||||
limit=limit,
|
|
||||||
with_count=with_count,
|
|
||||||
is_ssp=is_ssp,
|
|
||||||
is_active=is_active,
|
|
||||||
load_users=load_users,
|
|
||||||
org_unit_id=org_unit_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get(
|
|
||||||
self,
|
|
||||||
org_unit_id: int,
|
|
||||||
user: AppUser,
|
|
||||||
load_users: bool = False,
|
|
||||||
) -> OrgUnit | None:
|
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
|
||||||
return None
|
|
||||||
return await self.org_repo.get(
|
|
||||||
org_unit_id=org_unit_id,
|
|
||||||
load_users=load_users,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
is_ssp: bool,
|
|
||||||
user: AppUser,
|
|
||||||
) -> OrgUnit:
|
|
||||||
"""Создание нового ССП/РФ."""
|
|
||||||
if not self._can_create_ssp(user):
|
|
||||||
raise AccessDeniedException()
|
|
||||||
|
|
||||||
return await self.org_repo.create(
|
|
||||||
title=title,
|
|
||||||
is_ssp=is_ssp,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, org_unit_id: int, user: AppUser, **kwargs
|
|
||||||
) -> OrgUnit | None:
|
|
||||||
"""Редактирование ССП/РФ"""
|
|
||||||
#ToDo: писать логи
|
|
||||||
if not self._can_edit_ssp(user):
|
|
||||||
raise AccessDeniedException()
|
|
||||||
|
|
||||||
org_unit = await self.org_repo.get(org_unit_id=org_unit_id)
|
|
||||||
|
|
||||||
if not org_unit:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return await self.org_repo.update(
|
|
||||||
org_unit=org_unit, **kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def logical_delete(
|
|
||||||
self,
|
|
||||||
org_unit_id: int,
|
|
||||||
user: AppUser,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Логическое удаление ССП/РФ"""
|
|
||||||
if not self._can_delete_ssp(user):
|
|
||||||
raise AccessDeniedException()
|
|
||||||
|
|
||||||
return bool(await self.org_repo.logical_delete(org_unit_id=org_unit_id, is_active=is_active))
|
|
||||||
|
|
||||||
def _can_create_ssp(self, user: AppUser) -> bool:
|
|
||||||
return user.role_id == UserRoleEnum.ADMIN
|
|
||||||
|
|
||||||
def _can_edit_ssp(self, user: AppUser) -> bool:
|
|
||||||
return user.role_id == UserRoleEnum.ADMIN
|
|
||||||
|
|
||||||
def _can_delete_ssp(self, user: AppUser) -> bool:
|
|
||||||
return user.role_id == UserRoleEnum.ADMIN
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
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]
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
|
|
||||||
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,13 +1,12 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 (
|
from src.core.errors import (
|
||||||
AccessDeniedException,
|
AccessDeniedException,
|
||||||
UserNotFoundException,
|
UserNotFoundException,
|
||||||
ValidationException,
|
ValidationException,
|
||||||
UsernameConflictException,
|
UsernameConflictException,
|
||||||
)
|
)
|
||||||
|
from src.domain.models import UserRole, Users
|
||||||
from src.domain.schemas import UserCreate, UserUpdate
|
from src.domain.schemas import UserCreate, UserUpdate
|
||||||
from src.repository.user_repository import UserRepository
|
from src.repository.user_repository import UserRepository
|
||||||
|
|
||||||
@ -17,40 +16,23 @@ class UserService:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.user_repo = UserRepository(db)
|
self.user_repo = UserRepository(db)
|
||||||
|
|
||||||
async def get_all(
|
async def get_all(self, current_user: Users, skip: int = 0, limit: int = 100):
|
||||||
self,
|
if current_user.role_id != UserRole.ADMIN:
|
||||||
current_user: AppUser,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
with_count: bool = False,
|
|
||||||
load_orgs: bool = False,
|
|
||||||
) -> list[AppUser] | tuple[int, list[AppUser]]:
|
|
||||||
if current_user.role_id != UserRoleEnum.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
return await self.user_repo.get_list(
|
return await self.user_repo.get_list(skip=skip, limit=limit)
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
with_count=with_count,
|
|
||||||
load_orgs=load_orgs,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get(
|
async def get(self, user_id: int, current_user: Users):
|
||||||
self,
|
if current_user.role_id != UserRole.ADMIN:
|
||||||
user_id: int,
|
|
||||||
current_user: AppUser | None = None,
|
|
||||||
load_orgs: bool = False,
|
|
||||||
):
|
|
||||||
if current_user is not None and current_user.role_id != UserRoleEnum.ADMIN:
|
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
return await self.user_repo.get(user_id=user_id, load_orgs=load_orgs)
|
return await self.user_repo.get(user_id)
|
||||||
|
|
||||||
async def create_user(self, user_data: UserCreate, creator: AppUser):
|
async def create_user(self, user_data: UserCreate, creator: Users):
|
||||||
if creator.role_id != UserRoleEnum.ADMIN:
|
if creator.role_id != UserRole.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
|
|
||||||
role_id = user_data.role_id or UserRoleEnum.EXECUTOR_RF.value
|
role_id = user_data.role_id or UserRole.EXECUTOR_RF.value
|
||||||
try:
|
try:
|
||||||
role = UserRoleEnum(role_id)
|
role = UserRole(role_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationException("Роль не найдена", field="role_id")
|
raise ValidationException("Роль не найдена", field="role_id")
|
||||||
user_data.role_id = role.value
|
user_data.role_id = role.value
|
||||||
@ -61,8 +43,8 @@ class UserService:
|
|||||||
payload = user_data.model_dump(mode="json")
|
payload = user_data.model_dump(mode="json")
|
||||||
return await self.user_repo.create(payload)
|
return await self.user_repo.create(payload)
|
||||||
|
|
||||||
async def update_user(self, user_id: int, user_data: UserUpdate, user: AppUser):
|
async def update_user(self, user_id: int, user_data: UserUpdate, user: Users):
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
if user.role_id != UserRole.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
|
|
||||||
target = await self.user_repo.get(user_id)
|
target = await self.user_repo.get(user_id)
|
||||||
@ -75,7 +57,7 @@ class UserService:
|
|||||||
role = None
|
role = None
|
||||||
if role_id is not None:
|
if role_id is not None:
|
||||||
try:
|
try:
|
||||||
role = UserRoleEnum(role_id)
|
role = UserRole(role_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationException("Роль не найдена", field="role_id")
|
raise ValidationException("Роль не найдена", field="role_id")
|
||||||
|
|
||||||
@ -90,56 +72,47 @@ class UserService:
|
|||||||
if conflict:
|
if conflict:
|
||||||
raise UsernameConflictException()
|
raise UsernameConflictException()
|
||||||
|
|
||||||
updated = await self.user_repo.update(
|
updated = await self.user_repo.update(user_id, update_payload)
|
||||||
user_id=user_id,
|
|
||||||
user_data=update_payload,
|
|
||||||
load_orgs=user_data.load_orgs,
|
|
||||||
)
|
|
||||||
|
|
||||||
if role_id is not None and role is not None:
|
if role_id is not None and role is not None:
|
||||||
had_many_ssp_role = previous_role_id in (
|
had_many_ssp_role = previous_role_id in (
|
||||||
UserRoleEnum.ADMIN.value,
|
UserRole.ADMIN.value,
|
||||||
UserRoleEnum.EXECUTOR_DFIP.value,
|
UserRole.EXECUTOR_DFIP.value,
|
||||||
)
|
)
|
||||||
has_many_ssp_role_now = role.value in (
|
has_many_ssp_role_now = role.value in (
|
||||||
UserRoleEnum.ADMIN.value,
|
UserRole.ADMIN.value,
|
||||||
UserRoleEnum.EXECUTOR_DFIP.value,
|
UserRole.EXECUTOR_DFIP.value,
|
||||||
)
|
)
|
||||||
is_executor_to_dfip_transition = (
|
is_executor_to_dfip_transition = (
|
||||||
previous_role_id == UserRoleEnum.EXECUTOR_RF.value
|
previous_role_id == UserRole.EXECUTOR_RF.value
|
||||||
and role.value == UserRoleEnum.EXECUTOR_DFIP.value
|
and role.value == UserRole.EXECUTOR_DFIP.value
|
||||||
)
|
)
|
||||||
if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition:
|
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)
|
await self.user_repo.clear_many_ssp(user_id)
|
||||||
if user_data.load_orgs:
|
|
||||||
self.db.expire(updated)
|
|
||||||
updated = await self.user_repo.get(
|
|
||||||
user_id=user_id,
|
|
||||||
load_orgs=user_data.load_orgs,
|
|
||||||
)
|
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
async def delete_user(self, user_id: int, user: AppUser):
|
async def delete_user(self, user_id: int, user: Users):
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
if user.role_id != UserRole.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
deleted = await self.user_repo.logical_delete(user_id)
|
deleted = await self.user_repo.logical_delete(user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise UserNotFoundException()
|
raise UserNotFoundException()
|
||||||
|
|
||||||
async def get_roles(self, current_user: AppUser):
|
async def get_roles(self, current_user: Users):
|
||||||
if current_user.role_id != UserRoleEnum.ADMIN:
|
if current_user.role_id != UserRole.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
return await self.user_repo.get_roles()
|
return await self.user_repo.get_roles()
|
||||||
|
|
||||||
async def get_many_ssp(self, user_id: int, current_user: AppUser):
|
async def get_many_ssp(self, user_id: int, current_user: Users):
|
||||||
if current_user.role_id == UserRoleEnum.ADMIN:
|
if current_user.role_id == UserRole.ADMIN:
|
||||||
return await self.user_repo.get_many_ssp(user_id)
|
return await self.user_repo.get_many_ssp(user_id)
|
||||||
if current_user.id != user_id:
|
if current_user.id != user_id:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
return await self.user_repo.get_many_ssp(user_id)
|
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: AppUser):
|
async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users):
|
||||||
if current_user.role_id != UserRoleEnum.ADMIN:
|
if current_user.role_id != UserRole.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
|
|
||||||
user = await self.user_repo.get(user_id)
|
user = await self.user_repo.get(user_id)
|
||||||
@ -148,9 +121,9 @@ class UserService:
|
|||||||
|
|
||||||
target_role_id = self._normalize_role_id(user.role_id)
|
target_role_id = self._normalize_role_id(user.role_id)
|
||||||
if target_role_id not in (
|
if target_role_id not in (
|
||||||
UserRoleEnum.ADMIN.value,
|
UserRole.ADMIN.value,
|
||||||
UserRoleEnum.EXECUTOR_DFIP.value,
|
UserRole.EXECUTOR_DFIP.value,
|
||||||
UserRoleEnum.EXECUTOR_RF.value,
|
UserRole.EXECUTOR_RF.value,
|
||||||
):
|
):
|
||||||
err = AccessDeniedException()
|
err = AccessDeniedException()
|
||||||
err.description = (
|
err.description = (
|
||||||
@ -159,7 +132,7 @@ class UserService:
|
|||||||
raise err
|
raise err
|
||||||
|
|
||||||
curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id)
|
curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id)
|
||||||
if target_role_id == UserRoleEnum.EXECUTOR_RF.value:
|
if target_role_id == UserRole.EXECUTOR_RF.value:
|
||||||
merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids)
|
merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids)
|
||||||
if len(merged_ssp_ids) > 1:
|
if len(merged_ssp_ids) > 1:
|
||||||
raise ValidationException(
|
raise ValidationException(
|
||||||
@ -168,8 +141,8 @@ class UserService:
|
|||||||
new_ssp_ids = [ssp_id for ssp_id in ssp_ids if ssp_id not in curr_ssp_ids]
|
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)
|
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: AppUser):
|
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users):
|
||||||
if current_user.role_id != UserRoleEnum.ADMIN:
|
if current_user.role_id != UserRole.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
|
|
||||||
user = await self.user_repo.get(user_id)
|
user = await self.user_repo.get(user_id)
|
||||||
@ -178,9 +151,9 @@ class UserService:
|
|||||||
|
|
||||||
target_role_id = self._normalize_role_id(user.role_id)
|
target_role_id = self._normalize_role_id(user.role_id)
|
||||||
if target_role_id not in (
|
if target_role_id not in (
|
||||||
UserRoleEnum.ADMIN.value,
|
UserRole.ADMIN.value,
|
||||||
UserRoleEnum.EXECUTOR_DFIP.value,
|
UserRole.EXECUTOR_DFIP.value,
|
||||||
UserRoleEnum.EXECUTOR_RF.value,
|
UserRole.EXECUTOR_RF.value,
|
||||||
):
|
):
|
||||||
err = AccessDeniedException()
|
err = AccessDeniedException()
|
||||||
err.description = (
|
err.description = (
|
||||||
@ -190,5 +163,5 @@ class UserService:
|
|||||||
return await self.user_repo.unset_many_ssp(user_id, ssp_ids)
|
return await self.user_repo.unset_many_ssp(user_id, ssp_ids)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_role_id(role_id: int | UserRoleEnum) -> int:
|
def _normalize_role_id(role_id: int | UserRole) -> int:
|
||||||
return role_id.value if isinstance(role_id, UserRoleEnum) else int(role_id)
|
return role_id.value if isinstance(role_id, UserRole) else int(role_id)
|
||||||
|
|||||||
@ -1,293 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import io
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from openpyxl import Workbook
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.repository.budget_form_repository import BudgetFormRepository
|
|
||||||
from src.core.errors import AccessDeniedException, ValidationException
|
|
||||||
from src.db.models import AppUser, UserRoleEnum, Vsp
|
|
||||||
from src.domain.schemas import VSPCreate, VSPUpdate
|
|
||||||
from src.repository.user_repository import UserRepository
|
|
||||||
from src.repository.vsp_repository import VSPRepository
|
|
||||||
|
|
||||||
_last_info_close_sync_run: Optional[date] = None
|
|
||||||
_info_close_sync_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
class VSPService:
|
|
||||||
EXECUTOR_EDITABLE_FIELDS = {
|
|
||||||
"location_form",
|
|
||||||
"numbers",
|
|
||||||
"total_area",
|
|
||||||
"rent_contract_num",
|
|
||||||
"rent_end_date",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, db: AsyncSession):
|
|
||||||
self.db = db
|
|
||||||
self.vsp_repo = VSPRepository(db)
|
|
||||||
self.form_repo = BudgetFormRepository(db)
|
|
||||||
self.user_repo = UserRepository(db)
|
|
||||||
|
|
||||||
async def get(self, vsp_id: int, user: AppUser, load_org_unit: bool = False) -> Optional[Vsp]:
|
|
||||||
vsp = await self.vsp_repo.get(vsp_id=vsp_id, load_org_unit=load_org_unit)
|
|
||||||
if not vsp:
|
|
||||||
return None
|
|
||||||
if user.role_id == UserRoleEnum.ADMIN:
|
|
||||||
return vsp
|
|
||||||
|
|
||||||
ssp_ids = await self._get_scoped_ssp_ids(user=user)
|
|
||||||
if not ssp_ids or vsp.branch_id not in set(ssp_ids):
|
|
||||||
raise AccessDeniedException()
|
|
||||||
return vsp
|
|
||||||
|
|
||||||
async def get_list(
|
|
||||||
self,
|
|
||||||
user: AppUser,
|
|
||||||
registration_number: str | None = None,
|
|
||||||
address: str | None = None,
|
|
||||||
ssp_id: int | None = None,
|
|
||||||
ssp_ids: list[int] | None = None,
|
|
||||||
open_date_start: date | None = None,
|
|
||||||
open_date_end: date | None = None,
|
|
||||||
location_form: str | None = None,
|
|
||||||
numbers_min: int | None = None,
|
|
||||||
numbers_max: int | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
load_org_unit: bool = False,
|
|
||||||
with_count: bool = False,
|
|
||||||
) -> list[Vsp] | tuple[int, list[Vsp]]:
|
|
||||||
await self._sync_closed_records()
|
|
||||||
scoped_ssp_ids = await self._get_scoped_ssp_ids(user=user)
|
|
||||||
requested_ssp_ids = list(dict.fromkeys(ssp_ids or []))
|
|
||||||
if ssp_id is not None and ssp_id not in requested_ssp_ids:
|
|
||||||
requested_ssp_ids.append(ssp_id)
|
|
||||||
|
|
||||||
effective_ssp_ids = scoped_ssp_ids
|
|
||||||
if requested_ssp_ids:
|
|
||||||
if scoped_ssp_ids is None:
|
|
||||||
effective_ssp_ids = requested_ssp_ids
|
|
||||||
else:
|
|
||||||
scoped_set = set(scoped_ssp_ids)
|
|
||||||
effective_ssp_ids = [item for item in requested_ssp_ids if item in scoped_set]
|
|
||||||
if not effective_ssp_ids:
|
|
||||||
if with_count:
|
|
||||||
return 0, []
|
|
||||||
return []
|
|
||||||
return await self.vsp_repo.get_list(
|
|
||||||
registration_number=registration_number,
|
|
||||||
address=address,
|
|
||||||
ssp_ids=effective_ssp_ids,
|
|
||||||
open_date_start=open_date_start,
|
|
||||||
open_date_end=open_date_end,
|
|
||||||
location_form=location_form,
|
|
||||||
numbers_min=numbers_min,
|
|
||||||
numbers_max=numbers_max,
|
|
||||||
is_active=is_active,
|
|
||||||
load_org_unit=load_org_unit,
|
|
||||||
with_count=with_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_dropdown(
|
|
||||||
self,
|
|
||||||
user: AppUser,
|
|
||||||
ssp_id: int | None = None,
|
|
||||||
form_id: int | None = None,
|
|
||||||
with_count: bool = False,
|
|
||||||
) -> list[Vsp]:
|
|
||||||
ssp_ids = await self._get_scoped_ssp_ids(user=user)
|
|
||||||
|
|
||||||
checking_ssps = set()
|
|
||||||
if ssp_id is not None:
|
|
||||||
checking_ssps.add(ssp_id)
|
|
||||||
if form_id is not None:
|
|
||||||
form = await self.form_repo.get(budget_form_id=form_id)
|
|
||||||
if form is not None:
|
|
||||||
checking_ssps.add(form.org_unit_id)
|
|
||||||
if checking_ssps:
|
|
||||||
if ssp_ids is None:
|
|
||||||
ssp_ids = list(checking_ssps)
|
|
||||||
elif not checking_ssps & set(ssp_ids):
|
|
||||||
if with_count:
|
|
||||||
return 0, []
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
ssp_ids = list(checking_ssps & set(ssp_ids))
|
|
||||||
|
|
||||||
return await self.vsp_repo.get_list(
|
|
||||||
ssp_ids=ssp_ids,
|
|
||||||
is_active=True,
|
|
||||||
close_date_is_null=True,
|
|
||||||
with_count=with_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def create(self, payload: VSPCreate, user: AppUser, load_org_unit: bool = False) -> Vsp:
|
|
||||||
if not self._can_modify(user):
|
|
||||||
raise AccessDeniedException()
|
|
||||||
try:
|
|
||||||
created = await self.vsp_repo.create(payload=payload, created_by=user.id, load_org_unit=load_org_unit)
|
|
||||||
if created.closed_at is not None and created.closed_at <= date.today():
|
|
||||||
created = await self.vsp_repo.update(
|
|
||||||
vsp=created,
|
|
||||||
data={"is_active": False},
|
|
||||||
updated_by=user.id,
|
|
||||||
load_org_unit=load_org_unit,
|
|
||||||
)
|
|
||||||
return created
|
|
||||||
except IntegrityError as exc:
|
|
||||||
message = str(getattr(exc, "orig", exc) or exc).strip()
|
|
||||||
if "vsp_reg_number_unique" in message:
|
|
||||||
raise ValidationException(description="Указанный номер уже существует")
|
|
||||||
if "chk_closed_after_opened" in message:
|
|
||||||
raise ValidationException(description="Дата закрытия не может быть меньше даты открытия")
|
|
||||||
raise ValidationException(description="Некорректные данные для записи ВСП") from exc
|
|
||||||
|
|
||||||
async def export_xlsx(
|
|
||||||
self,
|
|
||||||
user: AppUser,
|
|
||||||
registration_number: str | None = None,
|
|
||||||
address: str | None = None,
|
|
||||||
ssp_ids: list[int] | None = None,
|
|
||||||
open_date_start: date | None = None,
|
|
||||||
open_date_end: date | None = None,
|
|
||||||
location_form: str | None = None,
|
|
||||||
numbers_min: int | None = None,
|
|
||||||
numbers_max: int | None = None,
|
|
||||||
) -> tuple[io.BytesIO, str]:
|
|
||||||
data = await self.get_list(
|
|
||||||
user=user,
|
|
||||||
registration_number=registration_number,
|
|
||||||
address=address,
|
|
||||||
ssp_ids=ssp_ids,
|
|
||||||
open_date_start=open_date_start,
|
|
||||||
open_date_end=open_date_end,
|
|
||||||
location_form=location_form,
|
|
||||||
numbers_min=numbers_min,
|
|
||||||
numbers_max=numbers_max,
|
|
||||||
is_active=None,
|
|
||||||
load_org_unit=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
workbook = Workbook()
|
|
||||||
sheet = workbook.active
|
|
||||||
sheet.title = "INFO"
|
|
||||||
sheet.append(
|
|
||||||
[
|
|
||||||
"id",
|
|
||||||
"ssp_id",
|
|
||||||
"system_code",
|
|
||||||
"regional_branch",
|
|
||||||
"vsp_type",
|
|
||||||
"registration_number",
|
|
||||||
"address",
|
|
||||||
"approved_format",
|
|
||||||
"open_date",
|
|
||||||
"close_date",
|
|
||||||
"notes",
|
|
||||||
"location_form",
|
|
||||||
"numbers",
|
|
||||||
"area",
|
|
||||||
"rent_contract_num",
|
|
||||||
"rent_end_date",
|
|
||||||
"is_active",
|
|
||||||
"is_deleted",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"created_by",
|
|
||||||
"updated_by",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
for item in data:
|
|
||||||
sheet.append(
|
|
||||||
[
|
|
||||||
item.id,
|
|
||||||
item.branch_id,
|
|
||||||
item.system_code or None,
|
|
||||||
item.org_unit.title or None,
|
|
||||||
item.vsp_type or None,
|
|
||||||
item.reg_number,
|
|
||||||
item.address,
|
|
||||||
item.format or None,
|
|
||||||
item.opened_at.isoformat() if item.opened_at else None,
|
|
||||||
item.closed_at.isoformat() if item.closed_at else None,
|
|
||||||
item.notes or None,
|
|
||||||
item.location_form or None,
|
|
||||||
item.numbers if item.numbers is not None else None,
|
|
||||||
float(item.total_area) if item.total_area is not None else None,
|
|
||||||
item.rent_contract_num or None,
|
|
||||||
item.rent_end_date.isoformat() if item.rent_end_date else None,
|
|
||||||
item.is_active,
|
|
||||||
item.is_deleted,
|
|
||||||
item.created_at.isoformat() if item.created_at else None,
|
|
||||||
item.updated_at.isoformat() if item.updated_at else None,
|
|
||||||
item.created_by,
|
|
||||||
item.updated_by if item.updated_by is not None else None,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
stream = io.BytesIO()
|
|
||||||
workbook.save(stream)
|
|
||||||
workbook.close()
|
|
||||||
stream.seek(0)
|
|
||||||
file_name = f"info_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
|
||||||
return stream, file_name
|
|
||||||
|
|
||||||
async def update(self, vsp_id: int, payload: VSPUpdate, user: AppUser, load_org_unit: bool = False) -> Vsp | None:
|
|
||||||
vsp = await self.vsp_repo.get(vsp_id=vsp_id, is_active=None)
|
|
||||||
if not vsp:
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = payload.model_dump(exclude_unset=True)
|
|
||||||
if not data:
|
|
||||||
return vsp
|
|
||||||
|
|
||||||
if user.role_id != UserRoleEnum.ADMIN:
|
|
||||||
ssp_ids = await self._get_scoped_ssp_ids(user=user)
|
|
||||||
if not ssp_ids or vsp.branch_id not in set(ssp_ids):
|
|
||||||
raise AccessDeniedException()
|
|
||||||
if not set(data.keys()).issubset(self.EXECUTOR_EDITABLE_FIELDS):
|
|
||||||
raise AccessDeniedException()
|
|
||||||
|
|
||||||
effective_close_date = data.get("close_date", vsp.closed_at)
|
|
||||||
if effective_close_date is not None and effective_close_date <= date.today():
|
|
||||||
data["is_active"] = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await self.vsp_repo.update(
|
|
||||||
vsp=vsp, data=data, updated_by=user.id, load_org_unit=load_org_unit,
|
|
||||||
)
|
|
||||||
except IntegrityError as exc:
|
|
||||||
message = str(getattr(exc, "orig", exc) or exc).strip()
|
|
||||||
if "vsp_reg_number_unique" in message:
|
|
||||||
raise ValidationException(description="Указанный номер уже существует")
|
|
||||||
if "chk_closed_after_opened" in message:
|
|
||||||
raise ValidationException(description="Дата закрытия не может быть меньше даты открытия")
|
|
||||||
raise ValidationException(description="Некорректные данные для записи ВСП") from exc
|
|
||||||
|
|
||||||
async def logical_delete(self, vsp_id: int, user: AppUser) -> bool:
|
|
||||||
if not self._can_modify(user):
|
|
||||||
raise AccessDeniedException()
|
|
||||||
return await self.vsp_repo.logical_delete(vsp_id=vsp_id, updated_by=user.id)
|
|
||||||
|
|
||||||
def _can_modify(self, user: AppUser) -> bool:
|
|
||||||
return user.role_id == UserRoleEnum.ADMIN
|
|
||||||
|
|
||||||
async def _get_scoped_ssp_ids(self, user: AppUser) -> list[int] | None:
|
|
||||||
if user.role_id == UserRoleEnum.ADMIN:
|
|
||||||
return None
|
|
||||||
return list(set(await self.user_repo.get_many_ssp_ids(user_id=user.id)))
|
|
||||||
|
|
||||||
async def _sync_closed_records(self) -> None:
|
|
||||||
global _last_info_close_sync_run
|
|
||||||
today = date.today()
|
|
||||||
if _last_info_close_sync_run is not None and _last_info_close_sync_run >= today:
|
|
||||||
return
|
|
||||||
async with _info_close_sync_lock:
|
|
||||||
if _last_info_close_sync_run is not None and _last_info_close_sync_run >= today:
|
|
||||||
return
|
|
||||||
await self.vsp_repo.deactivate_by_close_date(as_of=today)
|
|
||||||
_last_info_close_sync_run = today
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
|
||||||
import pytest
|
|
||||||
import asyncio
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
from sqlalchemy import text
|
|
||||||
from alembic.config import Config
|
|
||||||
from alembic import command
|
|
||||||
|
|
||||||
from src.core.config import settings
|
|
||||||
|
|
||||||
hasher = PasswordHasher()
|
|
||||||
|
|
||||||
ADMIN_PASSWORD = hasher.hash(os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123"))
|
|
||||||
ISP_PASSWORD = hasher.hash(os.getenv("OPENBAO__TEST_EXECUTOR_PASSWORD", "admin123"))
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
|
||||||
parser.addoption(
|
|
||||||
"--init_db",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Initialize database with migrations and seed data"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def init_db_flag(request):
|
|
||||||
return request.config.getoption("--init_db")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def setup_database(init_db_flag):
|
|
||||||
if not init_db_flag:
|
|
||||||
return
|
|
||||||
|
|
||||||
asyncio.run(run_migrations_async())
|
|
||||||
asyncio.run(
|
|
||||||
run_seed_script_async(
|
|
||||||
admin_password=ADMIN_PASSWORD,
|
|
||||||
isp_password=ISP_PASSWORD,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_migrations_async():
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
await loop.run_in_executor(None, run_migrations_sync)
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_sync():
|
|
||||||
alembic_cfg = Config("alembic.ini")
|
|
||||||
command.upgrade(alembic_cfg, "head")
|
|
||||||
|
|
||||||
|
|
||||||
async def run_seed_script_async(admin_password, isp_password):
|
|
||||||
sql_script_path = os.path.join(os.path.dirname(__file__), "sqls", "fixture.sql")
|
|
||||||
|
|
||||||
with open(sql_script_path, "r", encoding="utf-8") as f:
|
|
||||||
sql_script = f.read()
|
|
||||||
|
|
||||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
for statement in sql_script.split(";"):
|
|
||||||
statement = statement.strip().format(
|
|
||||||
admin_password=admin_password,
|
|
||||||
isp_password=isp_password,
|
|
||||||
)
|
|
||||||
if statement:
|
|
||||||
await conn.execute(text(statement))
|
|
||||||
await conn.commit()
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
@ -1,81 +1,49 @@
|
|||||||
import asyncio
|
|
||||||
from contextlib import contextmanager
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
from fastapi.testclient import TestClient
|
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
|
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
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
db_ref = SessionLocal()
|
|
||||||
loop_ref = None
|
|
||||||
|
|
||||||
async def _get_db():
|
|
||||||
nonlocal loop_ref
|
|
||||||
loop_ref = asyncio.get_running_loop()
|
|
||||||
try:
|
|
||||||
yield db_ref
|
|
||||||
await db_ref.flush()
|
|
||||||
except:
|
|
||||||
await db_ref.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = _get_db
|
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
try:
|
yield test_client
|
||||||
yield test_client
|
|
||||||
finally:
|
|
||||||
if db_ref is not None and loop_ref:
|
|
||||||
async def cleanup():
|
|
||||||
await db_ref.rollback()
|
|
||||||
await db_ref.close()
|
|
||||||
|
|
||||||
future = asyncio.run_coroutine_threadsafe(cleanup(), loop_ref)
|
|
||||||
future.result()
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_password() -> str:
|
def admin_tokens(client):
|
||||||
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
|
|
||||||
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(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": "admin", "password": admin_password},
|
json={"username": "admin", "password": "admin"},
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def auth_headers():
|
|
||||||
def _build(tokens: dict) -> dict:
|
|
||||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
||||||
|
|
||||||
return _build
|
|
||||||
|
|
||||||
@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
|
assert response.status_code == 200
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
# import uuid
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# def _create_executor_and_tokens(client, admin_tokens, auth_headers) -> tuple[int, dict]:
|
|
||||||
# suffix = uuid.uuid4().hex[:8]
|
|
||||||
# username = f"audit_exec_{suffix}"
|
|
||||||
# password = "pass123"
|
|
||||||
# created = client.put(
|
|
||||||
# "/api/v1/users/",
|
|
||||||
# json={
|
|
||||||
# "email": f"{username}@example.com",
|
|
||||||
# "username": username,
|
|
||||||
# "password": password,
|
|
||||||
# "full_name": "Audit Executor",
|
|
||||||
# "role_id": 2,
|
|
||||||
# },
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert created.status_code == 200
|
|
||||||
# user_id = created.json()["result"]["id"]
|
|
||||||
#
|
|
||||||
# login = client.post(
|
|
||||||
# "/api/v1/auth/login",
|
|
||||||
# json={"username": username, "password": password},
|
|
||||||
# )
|
|
||||||
# assert login.status_code == 200
|
|
||||||
# return user_id, login.json()
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_logs_admin_smoke(client, admin_tokens, auth_headers):
|
|
||||||
response = client.get("/api/v1/audit-logs", headers=auth_headers(admin_tokens))
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert "result" in payload
|
|
||||||
assert isinstance(payload["result"], list)
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_logs_requires_auth(client):
|
|
||||||
response = client.get("/api/v1/audit-logs")
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
# Тест временно отключён: внутри создаётся пользователь.
|
|
||||||
# def test_audit_logs_forbidden_for_non_admin(client, admin_tokens, auth_headers):
|
|
||||||
# user_id, executor_tokens = _create_executor_and_tokens(
|
|
||||||
# client, admin_tokens, auth_headers
|
|
||||||
# )
|
|
||||||
# try:
|
|
||||||
# response = client.get(
|
|
||||||
# "/api/v1/audit-logs",
|
|
||||||
# headers=auth_headers(executor_tokens),
|
|
||||||
# )
|
|
||||||
# assert response.status_code == 403
|
|
||||||
# finally:
|
|
||||||
# client.delete(
|
|
||||||
# f"/api/v1/users/{user_id}",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_logs_query_validation(client, admin_tokens, auth_headers):
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1/audit-logs?limit=101",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
@ -1,276 +0,0 @@
|
|||||||
|
|
||||||
# # закоментированы для избежания переполнения бд
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# from datetime import datetime, timedelta
|
|
||||||
# import uuid
|
|
||||||
|
|
||||||
# import pytest
|
|
||||||
|
|
||||||
|
|
||||||
# def _find_event(
|
|
||||||
# client,
|
|
||||||
# admin_tokens: dict,
|
|
||||||
# auth_headers,
|
|
||||||
# event_type: str,
|
|
||||||
# predicate,
|
|
||||||
# limit: int = 100,
|
|
||||||
# ):
|
|
||||||
# response = client.get(
|
|
||||||
# f"/api/v1/audit-logs?event_type={event_type}&limit={limit}",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert response.status_code == 200
|
|
||||||
# result = response.json().get("result", [])
|
|
||||||
# for item in result:
|
|
||||||
# payload = item.get("payload_json") or {}
|
|
||||||
# if predicate(payload):
|
|
||||||
# return item
|
|
||||||
# return None
|
|
||||||
|
|
||||||
|
|
||||||
# def _event_count(client, admin_tokens: dict, auth_headers, event_type: str) -> int:
|
|
||||||
# response = client.get(
|
|
||||||
# f"/api/v1/audit-logs?event_type={event_type}&limit=1",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert response.status_code == 200
|
|
||||||
# return int(response.json().get("count") or 0)
|
|
||||||
|
|
||||||
|
|
||||||
# def _pick_target_user_id(client, admin_tokens: dict, auth_headers) -> int:
|
|
||||||
# me_resp = client.get(
|
|
||||||
# "/api/v1/users/me",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert me_resp.status_code == 200
|
|
||||||
# me = me_resp.json() or {}
|
|
||||||
# assert me.get("id") is not None
|
|
||||||
# return int(me["id"])
|
|
||||||
|
|
||||||
|
|
||||||
# def _pick_org_unit_id(client, admin_tokens: dict, auth_headers, user_id: int) -> int:
|
|
||||||
# forms_resp = client.get(
|
|
||||||
# "/api/v1/form/?limit=100",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert forms_resp.status_code == 200
|
|
||||||
# forms = forms_resp.json().get("result") or []
|
|
||||||
# candidate_ids = [f.get("org_unit_id") for f in forms if f.get("org_unit_id") is not None]
|
|
||||||
# assert candidate_ids
|
|
||||||
|
|
||||||
# assigned_resp = client.get(
|
|
||||||
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert assigned_resp.status_code == 200
|
|
||||||
# assigned = {
|
|
||||||
# int(item["id"])
|
|
||||||
# for item in (assigned_resp.json().get("result") or {}).get("ssp", [])
|
|
||||||
# if item.get("id") is not None
|
|
||||||
# }
|
|
||||||
# for org_unit_id in candidate_ids:
|
|
||||||
# if int(org_unit_id) not in assigned:
|
|
||||||
# return int(org_unit_id)
|
|
||||||
# raise AssertionError("Не найден org_unit_id, который можно прикрепить пользователю")
|
|
||||||
|
|
||||||
|
|
||||||
# def _create_temp_user(client, admin_tokens: dict, auth_headers, role_id: int = 2) -> int:
|
|
||||||
# suffix = uuid.uuid4().hex[:8]
|
|
||||||
# username = f"audit_u_{suffix}"
|
|
||||||
# response = client.put(
|
|
||||||
# "/api/v1/users/",
|
|
||||||
# json={
|
|
||||||
# "email": f"{username}@example.com",
|
|
||||||
# "username": username,
|
|
||||||
# "password": "pass123",
|
|
||||||
# "full_name": "Audit User",
|
|
||||||
# "role_id": role_id,
|
|
||||||
# },
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert response.status_code == 200, response.text
|
|
||||||
# result = response.json().get("result") or {}
|
|
||||||
# return int(result["id"])
|
|
||||||
|
|
||||||
|
|
||||||
# def _create_project(client, admin_tokens: dict, auth_headers) -> int:
|
|
||||||
# response = client.post(
|
|
||||||
# "/api/v1/projects",
|
|
||||||
# json={"name": f"PT_{uuid.uuid4().hex[:10]}", "year": 2026, "branch_id": 1},
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert response.status_code == 200, response.text
|
|
||||||
# return int(response.json()["project_id"])
|
|
||||||
|
|
||||||
|
|
||||||
# def _add_project_line(client, admin_tokens: dict, auth_headers, 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"])
|
|
||||||
# raise AssertionError("Не удалось добавить строку в проектный отчёт для ROW_* тестов")
|
|
||||||
|
|
||||||
|
|
||||||
# def _create_stage(client, admin_tokens, auth_headers, form_id: int, sheet: str) -> dict:
|
|
||||||
# phase_code = f"autotest_{uuid.uuid4().hex[:8]}"
|
|
||||||
# opens_at = datetime.utcnow().replace(microsecond=0)
|
|
||||||
# closes_at = opens_at + timedelta(days=2)
|
|
||||||
# response = client.post(
|
|
||||||
# f"/api/v1/stages/form/{form_id}",
|
|
||||||
# json={
|
|
||||||
# "sheet": sheet,
|
|
||||||
# "phase_code": phase_code,
|
|
||||||
# "role": "DFIP",
|
|
||||||
# "column_keys": ["plan.q1"],
|
|
||||||
# "opens_at": opens_at.isoformat() + "Z",
|
|
||||||
# "closes_at": closes_at.isoformat() + "Z",
|
|
||||||
# },
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert response.status_code == 200, response.text
|
|
||||||
# result = response.json().get("result") or {}
|
|
||||||
# assert result.get("phase_code") == phase_code
|
|
||||||
# return result
|
|
||||||
|
|
||||||
|
|
||||||
# def test_audit_user_access_granted_event(client, admin_tokens, auth_headers):
|
|
||||||
# user_id = _pick_target_user_id(client, admin_tokens, auth_headers)
|
|
||||||
# org_unit_id = _pick_org_unit_id(client, admin_tokens, auth_headers, user_id)
|
|
||||||
|
|
||||||
# response = client.put(
|
|
||||||
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
|
|
||||||
# json={"ssp_ids": [org_unit_id]},
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert response.status_code == 200
|
|
||||||
# assert response.json().get("success") is True
|
|
||||||
|
|
||||||
# event = _find_event(
|
|
||||||
# client,
|
|
||||||
# admin_tokens,
|
|
||||||
# auth_headers,
|
|
||||||
# "USER_ACCESS_GRANTED",
|
|
||||||
# lambda payload: int(payload.get("user_id", -1)) == user_id
|
|
||||||
# and int(payload.get("org_unit_id", -1)) == org_unit_id,
|
|
||||||
# )
|
|
||||||
# assert event is not None
|
|
||||||
|
|
||||||
|
|
||||||
# def test_audit_user_access_revoked_event(client, admin_tokens, auth_headers):
|
|
||||||
# user_id = _pick_target_user_id(client, admin_tokens, auth_headers)
|
|
||||||
# assigned_resp = client.get(
|
|
||||||
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert assigned_resp.status_code == 200
|
|
||||||
# assigned = (assigned_resp.json().get("result") or {}).get("ssp", [])
|
|
||||||
|
|
||||||
# if assigned:
|
|
||||||
# org_unit_id = int(assigned[0]["id"])
|
|
||||||
# else:
|
|
||||||
# org_unit_id = _pick_org_unit_id(client, admin_tokens, auth_headers, user_id)
|
|
||||||
# attach_resp = client.put(
|
|
||||||
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
|
|
||||||
# json={"ssp_ids": [org_unit_id]},
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert attach_resp.status_code == 200
|
|
||||||
# assert attach_resp.json().get("success") is True
|
|
||||||
|
|
||||||
# response = client.request(
|
|
||||||
# "DELETE",
|
|
||||||
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
|
|
||||||
# json={"ssp_ids": [org_unit_id]},
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert response.status_code == 200
|
|
||||||
# assert response.json().get("success") is True
|
|
||||||
|
|
||||||
# event = _find_event(
|
|
||||||
# client,
|
|
||||||
# admin_tokens,
|
|
||||||
# auth_headers,
|
|
||||||
# "USER_ACCESS_REVOKED",
|
|
||||||
# lambda payload: int(payload.get("user_id", -1)) == user_id
|
|
||||||
# and int(payload.get("org_unit_id", -1)) == org_unit_id,
|
|
||||||
# )
|
|
||||||
# assert event is not None
|
|
||||||
|
|
||||||
|
|
||||||
# def test_audit_user_create_event(client, admin_tokens, auth_headers):
|
|
||||||
# before = _event_count(client, admin_tokens, auth_headers, "USER_CREATE")
|
|
||||||
# user_id = _create_temp_user(client, admin_tokens, auth_headers, role_id=2)
|
|
||||||
# after = _event_count(client, admin_tokens, auth_headers, "USER_CREATE")
|
|
||||||
# assert after > before
|
|
||||||
|
|
||||||
# delete_resp = client.delete(
|
|
||||||
# f"/api/v1/users/{user_id}",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert delete_resp.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
# def test_audit_user_role_change_event(client, admin_tokens, auth_headers):
|
|
||||||
# user_id = _create_temp_user(client, admin_tokens, auth_headers, role_id=2)
|
|
||||||
# before = _event_count(client, admin_tokens, auth_headers, "USER_ROLE_CHANGE")
|
|
||||||
|
|
||||||
# update_resp = client.patch(
|
|
||||||
# f"/api/v1/users/{user_id}",
|
|
||||||
# json={"role_id": 3},
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert update_resp.status_code == 200, update_resp.text
|
|
||||||
# after = _event_count(client, admin_tokens, auth_headers, "USER_ROLE_CHANGE")
|
|
||||||
# assert after > before
|
|
||||||
|
|
||||||
# delete_resp = client.delete(
|
|
||||||
# f"/api/v1/users/{user_id}",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert delete_resp.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
# def test_audit_project_create_event(client, admin_tokens, auth_headers):
|
|
||||||
# before = _event_count(client, admin_tokens, auth_headers, "PROJECT_CREATE")
|
|
||||||
# _create_project(client, admin_tokens, auth_headers)
|
|
||||||
# after = _event_count(client, admin_tokens, auth_headers, "PROJECT_CREATE")
|
|
||||||
# assert after > before
|
|
||||||
|
|
||||||
|
|
||||||
# def test_audit_row_create_event(client, admin_tokens, auth_headers):
|
|
||||||
# project_id = _create_project(client, admin_tokens, auth_headers)
|
|
||||||
# before = _event_count(client, admin_tokens, auth_headers, "ROW_CREATE")
|
|
||||||
# _add_project_line(client, admin_tokens, auth_headers, project_id)
|
|
||||||
# after = _event_count(client, admin_tokens, auth_headers, "ROW_CREATE")
|
|
||||||
# assert after > before
|
|
||||||
|
|
||||||
|
|
||||||
# def test_audit_row_delete_event(client, admin_tokens, auth_headers):
|
|
||||||
# project_id = _create_project(client, admin_tokens, auth_headers)
|
|
||||||
# line_id = _add_project_line(client, admin_tokens, auth_headers, project_id)
|
|
||||||
# before = _event_count(client, admin_tokens, auth_headers, "ROW_DELETE")
|
|
||||||
|
|
||||||
# delete_resp = client.delete(
|
|
||||||
# f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert delete_resp.status_code == 200, delete_resp.text
|
|
||||||
# after = _event_count(client, admin_tokens, auth_headers, "ROW_DELETE")
|
|
||||||
# assert after > before
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
def test_login_success(client, admin_password):
|
def test_login_success(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": "admin", "password": admin_password},
|
json={"username": "admin", "password": "admin"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@ -9,10 +9,10 @@ def test_login_success(client, admin_password):
|
|||||||
assert "refresh_token" in data
|
assert "refresh_token" in data
|
||||||
|
|
||||||
|
|
||||||
def test_login_invalid_password(client, admin_password):
|
def test_login_invalid_password(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": "admin", "password": f"{admin_password}_wrong"},
|
json={"username": "admin", "password": "wrong"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|||||||
@ -1,332 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_form_and_sheet(client, admin_tokens, auth_headers) -> tuple[int, str, str | None]:
|
|
||||||
response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
|
|
||||||
assert response.status_code == 200
|
|
||||||
forms = response.json().get("result") or []
|
|
||||||
|
|
||||||
for form in forms:
|
|
||||||
form_id = form.get("id")
|
|
||||||
form_type = form.get("form_type_code")
|
|
||||||
if not form_id or not form_type:
|
|
||||||
continue
|
|
||||||
|
|
||||||
sheets_response = client.get(
|
|
||||||
f"/api/v1/form/{form_id}/sheets",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
if sheets_response.status_code != 200:
|
|
||||||
continue
|
|
||||||
|
|
||||||
sheets = (sheets_response.json().get("result") or {}).get("sheets") or []
|
|
||||||
if "AHR" not in sheets:
|
|
||||||
continue
|
|
||||||
|
|
||||||
direction = "Support" if form_type == "FORM_1" else None
|
|
||||||
return int(form_id), "AHR", direction
|
|
||||||
|
|
||||||
pytest.skip("В тестовой БД не найдена форма с листом AHR")
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_form_with_sheet(
|
|
||||||
client, admin_tokens, auth_headers, target_sheet: str, form_type_code: str | None = None
|
|
||||||
) -> tuple[int, str]:
|
|
||||||
response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
|
|
||||||
assert response.status_code == 200
|
|
||||||
forms = response.json().get("result") or []
|
|
||||||
for form in forms:
|
|
||||||
form_id = form.get("id")
|
|
||||||
if not form_id:
|
|
||||||
continue
|
|
||||||
if form_type_code and form.get("form_type_code") != form_type_code:
|
|
||||||
continue
|
|
||||||
sheets_response = client.get(
|
|
||||||
f"/api/v1/form/{form_id}/sheets",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
if sheets_response.status_code != 200:
|
|
||||||
continue
|
|
||||||
sheets = (sheets_response.json().get("result") or {}).get("sheets") or []
|
|
||||||
if target_sheet in sheets:
|
|
||||||
return int(form_id), target_sheet
|
|
||||||
pytest.skip(f"Не найдена форма с листом {target_sheet}")
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_input_line_id(
|
|
||||||
client, admin_tokens, auth_headers, form_id: int, sheet: str, direction: str | None
|
|
||||||
) -> int:
|
|
||||||
url = f"/api/v1/form/{form_id}/sheet/{sheet}"
|
|
||||||
params = []
|
|
||||||
if direction:
|
|
||||||
params.append(f"direction={direction}")
|
|
||||||
if params:
|
|
||||||
url = f"{url}?{'&'.join(params)}"
|
|
||||||
|
|
||||||
response = client.get(url, headers=auth_headers(admin_tokens))
|
|
||||||
assert response.status_code == 200
|
|
||||||
rows = response.json().get("result") or []
|
|
||||||
for row in rows:
|
|
||||||
data = row.get("data") or {}
|
|
||||||
if row.get("row_type") == "INPUT" and data.get("line_id"):
|
|
||||||
return int(data["line_id"])
|
|
||||||
|
|
||||||
pytest.skip("Не найден INPUT line_id для проверки upd_form_cell")
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens, auth_headers):
|
|
||||||
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
|
|
||||||
url = f"/api/v1/form/{form_id}/sheet/{sheet}"
|
|
||||||
if direction:
|
|
||||||
url = f"{url}?direction={direction}"
|
|
||||||
|
|
||||||
response = client.get(url, headers=auth_headers(admin_tokens))
|
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert isinstance(payload.get("result"), list)
|
|
||||||
if payload["result"]:
|
|
||||||
first_row = payload["result"][0]
|
|
||||||
assert "row_type" in first_row
|
|
||||||
assert "data" in first_row
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(
|
|
||||||
client, admin_tokens, auth_headers
|
|
||||||
):
|
|
||||||
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
|
|
||||||
line_id = _pick_input_line_id(
|
|
||||||
client, admin_tokens, auth_headers, form_id, sheet, direction
|
|
||||||
)
|
|
||||||
|
|
||||||
url = f"/api/v1/form/{form_id}/sheet/{sheet}/cell"
|
|
||||||
if direction:
|
|
||||||
url = f"{url}?direction={direction}"
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
url,
|
|
||||||
json={"line_id": line_id, "column": "bad.scope", "value": 1},
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
|
|
||||||
# SQL-функция отдает prefixed validation error.
|
|
||||||
assert response.status_code in (400, 422)
|
|
||||||
payload = response.json()
|
|
||||||
assert isinstance(payload, dict)
|
|
||||||
assert "message" in payload
|
|
||||||
assert isinstance(payload["message"], str)
|
|
||||||
|
|
||||||
|
|
||||||
def test_backend_calls_form3_add_project_and_add_line_functions(
|
|
||||||
client, admin_tokens, auth_headers
|
|
||||||
):
|
|
||||||
create = client.post(
|
|
||||||
"/api/v1/projects",
|
|
||||||
json={"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert create.status_code == 200
|
|
||||||
project_payload = create.json()
|
|
||||||
assert "project_id" in project_payload
|
|
||||||
assert "limit_report_id" in project_payload
|
|
||||||
assert "current_expenses_report_id" in project_payload
|
|
||||||
|
|
||||||
project_id = int(project_payload["project_id"])
|
|
||||||
add_line = client.post(
|
|
||||||
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
|
|
||||||
json={"expense_item_id": 1},
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert add_line.status_code == 200
|
|
||||||
rows = add_line.json()
|
|
||||||
assert isinstance(rows, list)
|
|
||||||
|
|
||||||
|
|
||||||
def test_forms_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
|
|
||||||
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, auth_headers, "AHR")
|
|
||||||
response = client.get(
|
|
||||||
f"/api/v1/form/{form_id}/sheet/{sheet}?sections=bad_section",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
payload = response.json()
|
|
||||||
assert isinstance(payload, dict)
|
|
||||||
assert "message" in payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_forms_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers):
|
|
||||||
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
|
|
||||||
url = f"/api/v1/form/{form_id}/sheet/{sheet}?sections= , , "
|
|
||||||
if direction:
|
|
||||||
url += f"&direction={direction}"
|
|
||||||
response = client.get(url, headers=auth_headers(admin_tokens))
|
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert isinstance(payload.get("result"), list)
|
|
||||||
|
|
||||||
|
|
||||||
def test_forms_validation_direction_required_for_form1_ahr(
|
|
||||||
client, admin_tokens, auth_headers
|
|
||||||
):
|
|
||||||
form_id, sheet = _pick_form_with_sheet(
|
|
||||||
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_1"
|
|
||||||
)
|
|
||||||
response = client.get(
|
|
||||||
f"/api/v1/form/{form_id}/sheet/{sheet}",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
payload = response.json()
|
|
||||||
assert isinstance(payload, dict)
|
|
||||||
assert "message" in payload
|
|
||||||
assert "direction" in payload["message"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_forms_validation_direction_case_sensitive(client, admin_tokens, auth_headers):
|
|
||||||
form_id, sheet = _pick_form_with_sheet(
|
|
||||||
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_1"
|
|
||||||
)
|
|
||||||
response = client.get(
|
|
||||||
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=support",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_forms_validation_direction_forbidden_on_non_form1(
|
|
||||||
client, admin_tokens, auth_headers
|
|
||||||
):
|
|
||||||
form_id, sheet = _pick_form_with_sheet(
|
|
||||||
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_2"
|
|
||||||
)
|
|
||||||
response = client.get(
|
|
||||||
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=Support",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
payload = response.json()
|
|
||||||
assert isinstance(payload, dict)
|
|
||||||
assert "message" in payload
|
|
||||||
assert "direction" in payload["message"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_form3_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
|
|
||||||
create = client.post(
|
|
||||||
"/api/v1/projects",
|
|
||||||
json={"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert create.status_code == 200
|
|
||||||
project_id = int(create.json()["project_id"])
|
|
||||||
response = client.get(
|
|
||||||
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q5",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
payload = response.json()
|
|
||||||
assert isinstance(payload, dict)
|
|
||||||
assert "message" in payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers):
|
|
||||||
create = client.post(
|
|
||||||
"/api/v1/projects",
|
|
||||||
json={"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert create.status_code == 200
|
|
||||||
project_id = int(create.json()["project_id"])
|
|
||||||
response = client.get(
|
|
||||||
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections= , , ",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert isinstance(response.json().get("result"), list)
|
|
||||||
|
|
||||||
|
|
||||||
def test_form3_validation_duplicate_sections_is_allowed(client, admin_tokens, auth_headers):
|
|
||||||
create = client.post(
|
|
||||||
"/api/v1/projects",
|
|
||||||
json={"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert create.status_code == 200
|
|
||||||
project_id = int(create.json()["project_id"])
|
|
||||||
response = client.get(
|
|
||||||
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q1",
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert isinstance(response.json().get("result"), list)
|
|
||||||
|
|
||||||
|
|
||||||
def test_form3_add_project_invalid_name_returns_422(client, admin_tokens, auth_headers):
|
|
||||||
# Пробел запрещён regex-правилом AddProjectBody.
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1/projects",
|
|
||||||
json={"name": "INVALID NAME", "year": 2026, "branch_id": 1},
|
|
||||||
headers=auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_and_ready_endpoints(client):
|
|
||||||
healthz = client.get("/healthz")
|
|
||||||
assert healthz.status_code == 200
|
|
||||||
health_payload = healthz.json()
|
|
||||||
assert health_payload.get("status") == "ok"
|
|
||||||
|
|
||||||
readyz = client.get("/readyz")
|
|
||||||
assert readyz.status_code in (200, 503)
|
|
||||||
ready_payload = readyz.json()
|
|
||||||
assert "status" in ready_payload
|
|
||||||
assert "db_status" in ready_payload
|
|
||||||
assert "mv_expense_item_tree" in ready_payload
|
|
||||||
|
|
||||||
|
|
||||||
# Тест временно отключён: внутри создаётся пользователь.
|
|
||||||
# def test_admin_refresh_tree_requires_admin_role(client, admin_tokens, auth_headers):
|
|
||||||
# admin_resp = client.post(
|
|
||||||
# "/api/v1/admin/refresh-tree",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert admin_resp.status_code in (200, 503)
|
|
||||||
#
|
|
||||||
# uname = f"itest_non_admin_{uuid.uuid4().hex[:6]}"
|
|
||||||
# create_user = client.put(
|
|
||||||
# "/api/v1/users/",
|
|
||||||
# json={
|
|
||||||
# "email": f"{uname}@example.com",
|
|
||||||
# "username": uname,
|
|
||||||
# "password": "pass123",
|
|
||||||
# "full_name": "Integration User",
|
|
||||||
# "role_id": 2,
|
|
||||||
# },
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# if create_user.status_code != 200:
|
|
||||||
# pytest.skip("Не удалось создать non-admin пользователя в текущей БД")
|
|
||||||
#
|
|
||||||
# login_resp = client.post("/api/v1/auth/login", json={"username": uname, "password": "pass123"})
|
|
||||||
# if login_resp.status_code != 200:
|
|
||||||
# pytest.skip("Не удалось залогинить non-admin пользователя в текущей БД")
|
|
||||||
# user_tokens = login_resp.json()
|
|
||||||
#
|
|
||||||
# non_admin_resp = client.post(
|
|
||||||
# "/api/v1/admin/refresh-tree",
|
|
||||||
# headers=auth_headers(user_tokens),
|
|
||||||
# )
|
|
||||||
# assert non_admin_resp.status_code == 403
|
|
||||||
#
|
|
||||||
# # Что бы не было переполнения бд
|
|
||||||
# deleted = client.delete(
|
|
||||||
# f"/api/v1/users/{create_user.json()['result']['id']}",
|
|
||||||
# headers=auth_headers(admin_tokens),
|
|
||||||
# )
|
|
||||||
# assert deleted.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_refresh_tree_requires_auth(client):
|
|
||||||
response = client.post("/api/v1/admin/refresh-tree")
|
|
||||||
assert response.status_code in (401, 403)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user