Compare commits
131 Commits
main
...
fix-date-p
| Author | SHA1 | Date | |
|---|---|---|---|
| d292b2fbb0 | |||
| 56cc1d6f87 | |||
|
|
5210f1d6f7 | ||
| c247536278 | |||
|
|
2f5cb21dc0 | ||
| 4f7b4aaae5 | |||
| f2016e3eb6 | |||
| f079423c0f | |||
| 0d16a002b9 | |||
| 27cefec47b | |||
| d3d1babe76 | |||
| 8ef3b1cca1 | |||
| b328afb52d | |||
| b5a6114389 | |||
| 95d48bb2a0 | |||
| 96477df9e3 | |||
| 75f810da07 | |||
| ef335cf107 | |||
| d43b6faa2a | |||
| 098efcb497 | |||
| c3a4c9436a | |||
| 4308e11bc9 | |||
| 2073569765 | |||
| e7e8bc0df2 | |||
| 32a7c001eb | |||
| a206bb7191 | |||
| 3970fe5c35 | |||
| ee6fafb84f | |||
| e2bd55e5b1 | |||
| b27b8f3143 | |||
| 61a80a9e7d | |||
| d6a8f5ae58 | |||
| e20461a0ff | |||
| a47d2e58d0 | |||
| 67ce43e524 | |||
| 3bf44c50d4 | |||
| 71deb55485 | |||
| 9fc054acd4 | |||
| ed31f6b519 | |||
| 7d34376abe | |||
| 6419b5ad1d | |||
| e47200733e | |||
| 758b4d1315 | |||
| 14d2271eb9 | |||
| 8966829ad9 | |||
| b6a7ae509e | |||
| 8e07135a16 | |||
| bf3896f2f0 | |||
| 1db445d63d | |||
| 1a533f46f1 | |||
| f3eb75cf51 | |||
| 43703866aa | |||
| e0dc613c3c | |||
| 35ab5e7b5f | |||
| 7f837c34a7 | |||
| d95465abe4 | |||
|
|
75f86e6ef7 | ||
|
|
56833f454b | ||
| 30e3426408 | |||
| 38b0033c4a | |||
|
|
fcece07c64 | ||
|
|
f6be77d889 | ||
|
|
f93b4811a5 | ||
|
|
99e7a445cf | ||
| 023f2974df | |||
| 5f5ed32cd8 | |||
|
|
52de655149 | ||
|
|
079e719f71 | ||
|
|
8b16901f1b | ||
|
|
93881c7194 | ||
| 3f6d59ba06 | |||
| b7557f7b3d | |||
|
|
bc05bc8154 | ||
|
|
58d67d219f | ||
| aca03d59b7 | |||
|
|
fc55a7b042 | ||
| 47bb32da56 | |||
|
|
ad6eae9c0b | ||
|
|
d14d39cd27 | ||
|
|
a92df7ae14 | ||
| e37eb09e0a | |||
|
|
d8e260cc5b | ||
|
|
03cd022dac | ||
|
|
ed4c02589b | ||
|
|
76ce3702f5 | ||
|
|
499664511a | ||
| 2191e7d998 | |||
| 73c61918f7 | |||
|
|
e129f562dc | ||
|
|
b580e38117 | ||
|
|
b8a1427cf6 | ||
|
|
411773b6e9 | ||
| d2559d6bb7 | |||
| c247d13829 | |||
| 33055d6695 | |||
| 772afae276 | |||
| 06ad5e0e93 | |||
|
|
831f220dcb | ||
|
|
8e43920deb | ||
|
|
4a7ac50ca7 | ||
|
|
816f0e06bb | ||
|
|
bea8eb6240 | ||
|
|
188189534b | ||
|
|
ff2ae30623 | ||
| 2dff2a1f21 | |||
|
|
24a59fb853 | ||
|
|
5cd7232eb2 | ||
| 7e9fc08056 | |||
|
|
a718827c12 | ||
| 21d474464d | |||
| 8299b35d07 | |||
| 47fac8acbc | |||
|
|
f1f8f5c7b6 | ||
|
|
3d83a55ad8 | ||
|
|
2a3827ff91 | ||
| 05d1f2afb0 | |||
|
|
13db21c4af | ||
| 748d81ef06 | |||
|
|
312c8db04f | ||
|
|
0f46157cb5 | ||
|
|
de000905b3 | ||
| bdea6f9f4b | |||
| a3540e66c0 | |||
| 0e639ac433 | |||
| 79ad4a444c | |||
|
|
7b78a817cd | ||
|
|
539bd8c0f9 | ||
|
|
280dabfd95 | ||
|
|
52c0d1806a | ||
|
|
3d134fb7f9 | ||
| c67c94ba1f |
141
api/alembic.ini
Normal file
141
api/alembic.ini
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
api/alembic/README
Normal file
1
api/alembic/README
Normal file
@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
78
api/alembic/env.py
Normal file
78
api/alembic/env.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = None
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
api/alembic/script.py.mako
Normal file
28
api/alembic/script.py.mako
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@ -15,3 +15,4 @@ asyncpg==0.30.0
|
|||||||
pytest==8.3.2
|
pytest==8.3.2
|
||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==0.24.0
|
||||||
raisa-fastapi-protected-api==1.0.0
|
raisa-fastapi-protected-api==1.0.0
|
||||||
|
openpyxl
|
||||||
|
|||||||
25
api/src/api/v1/admin.py
Normal file
25
api/src/api/v1/admin.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from src.api.v1.deps import require_admin
|
||||||
|
from src.db.base import engine
|
||||||
|
from src.db.models.app_user import AppUser
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh-tree")
|
||||||
|
async def refresh_tree(current_user: AppUser = Depends(require_admin)):
|
||||||
|
"""Обновляет MV дерева статей расходов."""
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
# CONCURRENTLY требует autocommit-режим.
|
||||||
|
conn = await conn.execution_options(isolation_level="AUTOCOMMIT")
|
||||||
|
await conn.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree"))
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Не удалось обновить mv_expense_item_tree: {exc}",
|
||||||
|
) from exc
|
||||||
|
return {"status": "ok", "message": "mv_expense_item_tree refreshed"}
|
||||||
42
api/src/api/v1/audit.py
Normal file
42
api/src/api/v1/audit.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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,
|
||||||
|
date_from=params.date_from,
|
||||||
|
date_to=params.date_to,
|
||||||
|
)
|
||||||
|
result = [audit_service.orm_log_to_response(log) for log in logs]
|
||||||
|
return BaseListResponse(result=result, count=count)
|
||||||
@ -5,7 +5,8 @@ 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.domain.models import UserRole, Users
|
from src.db.models.app_user import AppUser
|
||||||
|
from src.db.models.role import UserRoleEnum
|
||||||
from src.repository.user_repository import UserRepository
|
from src.repository.user_repository import UserRepository
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
@ -13,11 +14,10 @@ security = HTTPBearer()
|
|||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_user_by_token(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
token: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession,
|
||||||
) -> Users:
|
) -> AppUser:
|
||||||
token = credentials.credentials
|
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -46,13 +46,19 @@ if settings.DEBUG:
|
|||||||
)
|
)
|
||||||
return user
|
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:
|
else:
|
||||||
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
user: UserInfo = Depends(get_user_dependency),
|
user: UserInfo = Depends(get_user_dependency),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Users:
|
) -> AppUser:
|
||||||
user_repo = UserRepository(db)
|
user_repo = UserRepository(db)
|
||||||
db_user = await user_repo.get_by_email(user.email)
|
db_user = await user_repo.get_by_email(user.email)
|
||||||
if db_user is None:
|
if db_user is None:
|
||||||
@ -64,12 +70,24 @@ else:
|
|||||||
return db_user
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users:
|
async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users:
|
async def get_current_active_user_with_set_db(
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
current_user: AppUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AppUser:
|
||||||
|
|
||||||
|
user_repo = UserRepository(db)
|
||||||
|
await user_repo.set_app_user_id(current_user.id)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> 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="Недостаточно прав",
|
||||||
@ -77,11 +95,13 @@ def require_admin(current_user: Users = Depends(get_current_active_user)) -> Use
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users:
|
def require_executor(
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> AppUser:
|
||||||
if current_user.role_id not in [
|
if current_user.role_id not in [
|
||||||
UserRole.ADMIN,
|
UserRoleEnum.ADMIN,
|
||||||
UserRole.EXECUTOR_DFIP,
|
UserRoleEnum.EXECUTOR_DFIP,
|
||||||
UserRole.EXECUTOR_RF,
|
UserRoleEnum.EXECUTOR_RF,
|
||||||
]:
|
]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
@ -90,8 +110,10 @@ def require_executor(current_user: Users = Depends(get_current_active_user)) ->
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users:
|
def require_executor_dfip(
|
||||||
if current_user.role_id not in [UserRole.ADMIN, UserRole.EXECUTOR_DFIP]:
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> 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="Недостаточно прав",
|
||||||
|
|||||||
155
api/src/api/v1/export.py
Normal file
155
api/src/api/v1/export.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
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)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
113
api/src/api/v1/form_phases.py
Normal file
113
api/src/api/v1/form_phases.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
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, "Этап не найден")
|
||||||
391
api/src/api/v1/forms.py
Normal file
391
api/src/api/v1/forms.py
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
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 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),
|
||||||
|
)
|
||||||
125
api/src/api/v1/org_unit.py
Normal file
125
api/src/api/v1/org_unit.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
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="ССП/РФ удалена")
|
||||||
247
api/src/api/v1/projects.py
Normal file
247
api/src/api/v1/projects.py
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.deps import get_current_active_user_with_set_db
|
||||||
|
from src.db.models.app_user import AppUser
|
||||||
|
from src.db.session import get_db
|
||||||
|
from src.domain.schemas import (
|
||||||
|
AddForm3LineBody,
|
||||||
|
AddProjectBody,
|
||||||
|
BaseListResponse,
|
||||||
|
BaseSingleResponse,
|
||||||
|
CellPatch,
|
||||||
|
CellsPatch,
|
||||||
|
SheetResponse,
|
||||||
|
UpdProjectBody,
|
||||||
|
)
|
||||||
|
from src.services.project_service import ProjectService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["forms"])
|
||||||
|
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sections(sections: Optional[str]) -> Optional[list[str]]:
|
||||||
|
if not sections:
|
||||||
|
return None
|
||||||
|
parsed = [s.strip() for s in sections.split(",") if s.strip()]
|
||||||
|
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(400, f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_to_payload(rows: list[tuple]) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"row_type": row[0], "depth": row[1], "sort_order": row[2], "data": row[3]}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects")
|
||||||
|
async def get_projects(
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
year: Optional[int] = None,
|
||||||
|
branch_id: Optional[int] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> BaseListResponse[dict]:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
count, projects = await project_service.get_list(
|
||||||
|
user=current_user,
|
||||||
|
with_count=True,
|
||||||
|
year=year,
|
||||||
|
branch_id=branch_id,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return BaseListResponse(result=projects, count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}")
|
||||||
|
async def get_project(
|
||||||
|
project_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> BaseSingleResponse[dict]:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
project = await project_service.get(project_id=project_id, user=current_user)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Project not found")
|
||||||
|
return BaseSingleResponse(result=project)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/report/{year}/{report_type}")
|
||||||
|
async def get_project_report(
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
response: Response,
|
||||||
|
sections: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> BaseListResponse[SheetResponse]:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
rows = await project_service.get_report_rows(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
report_type=report_type,
|
||||||
|
sections=_parse_sections(sections),
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
db_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
|
||||||
|
return BaseListResponse(count=len(rows), result=_rows_to_payload(rows))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rf-rollup/{branch_id}/{year}")
|
||||||
|
async def get_rf_rollup(
|
||||||
|
branch_id: int,
|
||||||
|
year: int,
|
||||||
|
response: Response,
|
||||||
|
sections: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> BaseListResponse[SheetResponse]:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
rows = await project_service.get_rf_rollup_rows(
|
||||||
|
branch_id=branch_id,
|
||||||
|
year=year,
|
||||||
|
sections=_parse_sections(sections),
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
db_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
|
||||||
|
return BaseListResponse(count=len(rows), result=_rows_to_payload(rows))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/projects/{project_id}/report/{year}/{report_type}/cell")
|
||||||
|
async def upd_form3_cell(
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
body: CellPatch,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> list[SheetResponse]:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
rows = await project_service.upd_form3_cell(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
report_type=report_type,
|
||||||
|
line_id=body.line_id,
|
||||||
|
column=body.column,
|
||||||
|
value=body.value,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
return _rows_to_payload(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/projects/{project_id}/report/{year}/{report_type}/cells")
|
||||||
|
async def upd_form3_cells(
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
body: CellsPatch,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> list[SheetResponse]:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
rows = await project_service.upd_form3_cells(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
report_type=report_type,
|
||||||
|
changes=[change.model_dump() for change in body.changes],
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
return _rows_to_payload(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/report/{year}/{report_type}/line")
|
||||||
|
async def add_form3_line(
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
body: AddForm3LineBody,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> list[SheetResponse]:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
rows = await project_service.add_form3_line(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
report_type=report_type,
|
||||||
|
expense_item_id=body.expense_item_id,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
return _rows_to_payload(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{project_id}/report/{year}/{report_type}/line/{line_id}")
|
||||||
|
async def del_form3_line(
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
line_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> list[SheetResponse]:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
rows = await project_service.del_form3_line(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
report_type=report_type,
|
||||||
|
line_id=line_id,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
return _rows_to_payload(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/project/{project_id}")
|
||||||
|
async def upd_project(
|
||||||
|
project_id: int,
|
||||||
|
body: UpdProjectBody,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> dict:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
result = await project_service.upd_project(
|
||||||
|
project_id=project_id,
|
||||||
|
column=body.column,
|
||||||
|
value=body.value,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects")
|
||||||
|
async def add_project(
|
||||||
|
body: AddProjectBody,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
) -> dict:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
project_id, limit_report_id, current_expenses_report_id = await project_service.add_project(
|
||||||
|
name=body.name,
|
||||||
|
year=body.year,
|
||||||
|
org_unit_id=body.branch_id,
|
||||||
|
level=body.level,
|
||||||
|
parent_id=body.parent_id,
|
||||||
|
project_type=body.project_type,
|
||||||
|
vsp_format=body.vsp_format,
|
||||||
|
placement_type=body.placement_type,
|
||||||
|
object_address=body.object_address,
|
||||||
|
staff_count=body.staff_count,
|
||||||
|
total_area=body.total_area,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"limit_report_id": limit_report_id,
|
||||||
|
"current_expenses_report_id": current_expenses_report_id,
|
||||||
|
}
|
||||||
@ -1,7 +1,21 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from src.api.v1 import auth, users
|
from src.api.v1 import (
|
||||||
|
auth, users, admin, audit, forms, form_phases, projects, export, org_unit, vsp,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
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 get_current_active_user, require_admin, require_executor_dfip
|
from src.api.v1.deps import (
|
||||||
|
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.domain.models import Users
|
from src.db.models.app_user import AppUser
|
||||||
from src.domain.schemas import User as UserSchema
|
from src.domain.schemas import BaseListResponse, BaseSingleResponse, User as UserSchema, UserAdminListResponse
|
||||||
from src.domain.schemas import (
|
from src.domain.schemas import (
|
||||||
SSPIDList,
|
SSPIDList,
|
||||||
ResponseBase,
|
ResponseBase,
|
||||||
@ -20,28 +24,48 @@ from src.services.user_service import UserService
|
|||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserSchema)
|
@router.get("/me", response_model=UserAdminListResponse)
|
||||||
async def get_current_user_info(current_user: Users = Depends(get_current_active_user)):
|
async def get_current_user_info(
|
||||||
return current_user
|
current_user: AppUser = Depends(get_current_active_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("/", response_model=UserListResponse)
|
@router.get("/")
|
||||||
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: Users = Depends(require_admin),
|
current_user: AppUser = Depends(require_admin),
|
||||||
):
|
) -> BaseListResponse[UserAdminListResponse]:
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
users = await user_service.get_all(current_user, skip=skip, limit=limit)
|
count, users = await user_service.get_all(
|
||||||
return UserListResponse(success=True, message="Список пользователей", result=users)
|
current_user,
|
||||||
|
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: Users = Depends(require_executor_dfip),
|
current_user: AppUser = 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)
|
||||||
@ -57,7 +81,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: Users = Depends(require_admin),
|
current_user: AppUser = 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)
|
||||||
@ -72,7 +96,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: Users = Depends(require_admin),
|
current_user: AppUser = 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(
|
||||||
@ -84,39 +108,50 @@ async def unset_many_ssp(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserSchema)
|
@router.get("/{user_id}")
|
||||||
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: Users = Depends(require_admin),
|
current_user: AppUser = Depends(require_admin),
|
||||||
):
|
) -> UserAdminListResponse:
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
user = await user_service.get(user_id, current_user)
|
user = await user_service.get(
|
||||||
|
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 user
|
return UserAdminListResponse.model_validate(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: Users = Depends(require_admin),
|
current_user: AppUser = 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=UserResponse)
|
@router.patch("/{user_id}", response_model=BaseSingleResponse[UserAdminListResponse])
|
||||||
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: Users = Depends(require_admin),
|
current_user: AppUser = Depends(require_admin),
|
||||||
):
|
):
|
||||||
user_service = UserService(db)
|
user_service = UserService(db)
|
||||||
user = await user_service.update_user(
|
user = await user_service.update_user(
|
||||||
@ -124,14 +159,15 @@ async def update_user(
|
|||||||
user_data=user_data,
|
user_data=user_data,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
return UserResponse(success=True, message="Пользователь обновлен", result=user)
|
result = UserAdminListResponse.model_validate(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: Users = Depends(require_admin),
|
current_user: AppUser = 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)
|
||||||
|
|||||||
183
api/src/api/v1/vsp.py
Normal file
183
api/src/api/v1/vsp.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
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,
|
||||||
|
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, 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"ВСП не найден",
|
||||||
|
)
|
||||||
844
api/src/api/v1/websocket.py
Normal file
844
api/src/api/v1/websocket.py
Normal file
@ -0,0 +1,844 @@
|
|||||||
|
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.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():
|
||||||
|
"""Контекстный менеджер для получения сессии базы данных."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
message: str,
|
||||||
|
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.user_id != user_id:
|
||||||
|
try:
|
||||||
|
await con_info.ws.send_text(message)
|
||||||
|
except:
|
||||||
|
pass # Игнорируем недоступные соединения
|
||||||
|
|
||||||
|
async def broadcast_to_all(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
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]:
|
||||||
|
try:
|
||||||
|
await con_info.ws.send_text(message)
|
||||||
|
except:
|
||||||
|
pass # Игнорируем недоступные соединения
|
||||||
|
|
||||||
|
async def send_back(self, message: str, websocket: WebSocket):
|
||||||
|
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(
|
||||||
|
dumps(data, default=_convert_error, ensure_ascii=False),
|
||||||
|
websocket,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
await manager.send_back(
|
||||||
|
dumps(data, default=_convert_error, ensure_ascii=False),
|
||||||
|
websocket,
|
||||||
|
)
|
||||||
|
await manager.broadcast_to_other(
|
||||||
|
message=dumps(data, default=_convert_error, ensure_ascii=False),
|
||||||
|
user_id=user_id,
|
||||||
|
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(
|
||||||
|
dumps(data, default=_convert_error, ensure_ascii=False),
|
||||||
|
websocket,
|
||||||
|
)
|
||||||
|
await manager.broadcast_to_other(
|
||||||
|
message=dumps(data, default=_convert_error, ensure_ascii=False),
|
||||||
|
user_id=user_id,
|
||||||
|
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(
|
||||||
|
dumps(data, default=_convert_error, ensure_ascii=False),
|
||||||
|
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(
|
||||||
|
dumps(data, default=_convert_error, ensure_ascii=False),
|
||||||
|
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(
|
||||||
|
dumps(data, default=_convert_error, ensure_ascii=False),
|
||||||
|
websocket,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
case _:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with get_db_session() 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(
|
||||||
|
message=dumps(data, default=_convert_error),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await manager.broadcast_to_other(
|
||||||
|
message=dumps(data, default=_convert_error),
|
||||||
|
user_id=user_id,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
except BasicAppException as e:
|
||||||
|
data["error"] = e.description or "Неизвестная ошибка"
|
||||||
|
await manager.send_back(
|
||||||
|
message=dumps(data, default=_convert_error),
|
||||||
|
websocket=websocket,
|
||||||
|
)
|
||||||
|
except IntegrityError as e:
|
||||||
|
data["error"] = str(e)
|
||||||
|
await manager.send_back(
|
||||||
|
message=dumps(data, default=_convert_error),
|
||||||
|
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(
|
||||||
|
message=dumps(data, default=_convert_error),
|
||||||
|
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,
|
||||||
|
)
|
||||||
31
api/src/core/CONSTANTS.py
Normal file
31
api/src/core/CONSTANTS.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
VALIDATION_PREFIXES = (
|
||||||
|
"computed_field:",
|
||||||
|
"normative_field:",
|
||||||
|
"unknown_column:",
|
||||||
|
"key_field:",
|
||||||
|
"structural_field:",
|
||||||
|
"bad_column_format:",
|
||||||
|
"bad_booking_year:",
|
||||||
|
"bad_booking_quarter:",
|
||||||
|
"unsupported_scope:",
|
||||||
|
)
|
||||||
|
|
||||||
|
PG_404_ERRORS = (
|
||||||
|
"не существует",
|
||||||
|
"не принадлежит",
|
||||||
|
"not found",
|
||||||
|
)
|
||||||
|
PG_409_ERRORS = (
|
||||||
|
"unique",
|
||||||
|
"duplicate",
|
||||||
|
"foreign key",
|
||||||
|
)
|
||||||
|
PG_400_ERRORS = (
|
||||||
|
"violates check constraint",
|
||||||
|
)
|
||||||
|
PG_403_ERRORS = (
|
||||||
|
"permission denied",
|
||||||
|
"insufficient privilege",
|
||||||
|
"role_not_allowed:",
|
||||||
|
"window_closed:",
|
||||||
|
)
|
||||||
@ -105,6 +105,11 @@ 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,6 +1,8 @@
|
|||||||
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(...)
|
||||||
@ -10,6 +12,14 @@ 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,5 +1,7 @@
|
|||||||
from fastapi import FastAPI, status
|
from fastapi import FastAPI, HTTPException, 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,
|
||||||
@ -7,41 +9,176 @@ 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.HTTP_403_FORBIDDEN,
|
status_code=status_code,
|
||||||
content={"detail": exc.description},
|
content=_error_payload(status_code, 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.HTTP_400_BAD_REQUEST,
|
status_code=status_code,
|
||||||
content={"detail": exc.description},
|
content=_error_payload(status_code, exc.description, field=exc.field or ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
@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.HTTP_404_NOT_FOUND,
|
status_code=status_code,
|
||||||
content={"detail": exc.description},
|
content=_error_payload(status_code, 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.HTTP_409_CONFLICT,
|
status_code=status_code,
|
||||||
content={"detail": exc.description},
|
content=_error_payload(status_code, 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={"detail": exc.description or "Application error"},
|
content=exc.errors,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,12 +7,15 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
30
api/src/db/models/__init__.py
Normal file
30
api/src/db/models/__init__.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from src.db.models.allocation import Allocation
|
||||||
|
from src.db.models.app_user import AppUser
|
||||||
|
from src.db.models.audit_log import AuditLog
|
||||||
|
from src.db.models.booking import Booking
|
||||||
|
from src.db.models.budget_form import BudgetForm
|
||||||
|
from src.db.models.budget_line import BudgetLine
|
||||||
|
from src.db.models.budget_line_quarter import BudgetLineQuarter
|
||||||
|
from src.db.models.ckk import Ckk
|
||||||
|
from src.db.models.collegial_approval import CollegialApproval
|
||||||
|
from src.db.models.contract_detail import ContractDetail
|
||||||
|
from src.db.models.contract_summary import ContractSummary
|
||||||
|
from src.db.models.form_limit import FormLimit
|
||||||
|
from src.db.models.form_phase import FormPhase
|
||||||
|
from src.db.models.form_type import FormType
|
||||||
|
from src.db.models.limit_template import LimitTemplate
|
||||||
|
from src.db.models.org_unit import OrgUnit
|
||||||
|
from src.db.models.phase_template import PhaseTemplate
|
||||||
|
from src.db.models.plan import Plan
|
||||||
|
from src.db.models.project import Project
|
||||||
|
from src.db.models.rent_detail import RentDetail
|
||||||
|
from src.db.models.reserve import Reserve
|
||||||
|
from src.db.models.rf_project_report import RfProjectReport
|
||||||
|
from src.db.models.rf_project_report_line import RfProjectReportLine
|
||||||
|
from src.db.models.rf_project_report_quarter import RfProjectReportQuarter
|
||||||
|
from src.db.models.role import Role, 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
|
||||||
19
api/src/db/models/allocation.py
Normal file
19
api/src/db/models/allocation.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Allocation(Base):
|
||||||
|
__tablename__ = "allocation"
|
||||||
|
__table_args__ = {"schema": "v3"}
|
||||||
|
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||||
|
internal_order: Mapped[str | None] = mapped_column(String)
|
||||||
|
property_object: Mapped[str | None] = mapped_column(String)
|
||||||
|
contract_ref: Mapped[str | None] = mapped_column(String)
|
||||||
|
allocation_purpose: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine", uselist=False)
|
||||||
56
api/src/db/models/app_user.py
Normal file
56
api/src/db/models/app_user.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AppUser(Base):
|
||||||
|
__tablename__ = "app_user"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_v4_app_user_active",
|
||||||
|
"is_active",
|
||||||
|
postgresql_where="is_active",
|
||||||
|
),
|
||||||
|
Index("ix_v4_app_user_role", "role_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
email: Mapped[str] = mapped_column(String)
|
||||||
|
username: Mapped[str] = mapped_column(String)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String)
|
||||||
|
full_name: Mapped[str | None] = mapped_column(String)
|
||||||
|
role_id: Mapped[int] = mapped_column(ForeignKey("v3.role.id"))
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
# role: Mapped["Role"] = relationship("Role", back_populates="users") # type: ignore
|
||||||
|
# created_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
|
||||||
|
# "BudgetForm", back_populates="creator", foreign_keys="BudgetForm.created_by"
|
||||||
|
# )
|
||||||
|
# updated_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
|
||||||
|
# "BudgetForm", back_populates="updater", foreign_keys="BudgetForm.updated_by"
|
||||||
|
# )
|
||||||
|
# created_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
|
||||||
|
# "RfProjectReport",
|
||||||
|
# back_populates="creator",
|
||||||
|
# foreign_keys="RfProjectReport.created_by",
|
||||||
|
# )
|
||||||
|
# updated_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
|
||||||
|
# "RfProjectReport",
|
||||||
|
# back_populates="updater",
|
||||||
|
# foreign_keys="RfProjectReport.updated_by",
|
||||||
|
# )
|
||||||
|
audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore
|
||||||
|
"AuditLog", back_populates="user"
|
||||||
|
)
|
||||||
|
user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="user")
|
||||||
|
org_units: Mapped[list["OrgUnit"]] = relationship("OrgUnit", secondary="v3.user_org", viewonly=True)
|
||||||
63
api/src/db/models/audit_log.py
Normal file
63
api/src/db/models/audit_log.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_v3_audit_log_dt", text("event_dt DESC")),
|
||||||
|
Index("ix_v3_audit_log_event", "event"),
|
||||||
|
Index(
|
||||||
|
"ix_v3_audit_log_form",
|
||||||
|
"form_id",
|
||||||
|
postgresql_where="form_id IS NOT NULL",
|
||||||
|
),
|
||||||
|
Index("ix_v3_audit_log_user", "user_id"),
|
||||||
|
Index("ix_v4_audit_log_dt", text("event_dt DESC")),
|
||||||
|
Index("ix_v4_audit_log_event", "event"),
|
||||||
|
Index(
|
||||||
|
"ix_v4_audit_log_form",
|
||||||
|
"form_id",
|
||||||
|
postgresql_where="form_id IS NOT NULL",
|
||||||
|
),
|
||||||
|
Index("ix_v4_audit_log_user", "user_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("v3.app_user.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
org_unit_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("v3.org_unit.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
task_id: Mapped[int | None] = mapped_column()
|
||||||
|
form_id: Mapped[int | None] = mapped_column()
|
||||||
|
event_dt: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
event: Mapped[str] = mapped_column(String)
|
||||||
|
event_type: Mapped[str] = mapped_column(String)
|
||||||
|
event_data: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
|
||||||
|
user: Mapped["AppUser | None"] = relationship(
|
||||||
|
"AppUser", back_populates="audit_logs"
|
||||||
|
)
|
||||||
|
# org_unit: Mapped["OrgUnit | None"] = relationship(
|
||||||
|
# "OrgUnit", back_populates="audit_logs"
|
||||||
|
# )
|
||||||
25
api/src/db/models/booking.py
Normal file
25
api/src/db/models/booking.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, SmallInteger, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Booking(Base):
|
||||||
|
__tablename__ = "booking"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("quarter BETWEEN 1 AND 4", name="ck_v3_booking_quarter"),
|
||||||
|
CheckConstraint("source IN ('CKK','CONTRACT')", name="ck_v3_booking_source"),
|
||||||
|
Index("ix_v3_booking_line", "line_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||||
|
quarter: Mapped[int] = mapped_column(SmallInteger)
|
||||||
|
source: Mapped[str] = mapped_column(String)
|
||||||
|
booked_amount_ckk: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
booked_amount_contract: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine")
|
||||||
57
api/src/db/models/budget_form.py
Normal file
57
api/src/db/models/budget_form.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.models.form_type import FormTypeEnum
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetForm(Base):
|
||||||
|
__tablename__ = "budget_form"
|
||||||
|
__table_args__ = {"schema": "v3"}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
form_type_code: Mapped[FormTypeEnum] = mapped_column( #type: ignore
|
||||||
|
String, ForeignKey("v3.form_type.code")
|
||||||
|
)
|
||||||
|
year: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
created_by: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_created_by")
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_by: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_updated_by")
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
org_unit_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
|
||||||
|
)
|
||||||
|
|
||||||
|
form_type: Mapped["FormType"] = relationship(
|
||||||
|
"FormType", #back_populates="budget_forms"
|
||||||
|
)
|
||||||
|
# creator: Mapped[typing.Optional["AppUser"]] = relationship(
|
||||||
|
# "AppUser", back_populates="created_budget_forms", foreign_keys=[created_by]
|
||||||
|
# )
|
||||||
|
# updater: Mapped[typing.Optional["AppUser"]] = relationship(
|
||||||
|
# "AppUser", back_populates="updated_budget_forms", foreign_keys=[updated_by]
|
||||||
|
# )
|
||||||
|
org_unit: Mapped[typing.Optional["OrgUnit"]] = relationship(
|
||||||
|
"OrgUnit", #back_populates="budget_forms"
|
||||||
|
)
|
||||||
|
# budget_lines: Mapped[list["BudgetLine"]] = relationship(
|
||||||
|
# "BudgetLine", back_populates="budget_form"
|
||||||
|
# )
|
||||||
|
# fixed_asset_reports: Mapped[list["FixedAssetReport"]] = relationship(
|
||||||
|
# "FixedAssetReport", back_populates="budget_form"
|
||||||
|
# )
|
||||||
|
# form_phases: Mapped[list["FormPhase"]] = relationship(
|
||||||
|
# "FormPhase", back_populates="budget_form"
|
||||||
|
# )
|
||||||
|
# form_limits: Mapped[list["FormLimit"]] = relationship(
|
||||||
|
# "FormLimit", back_populates="budget_form"
|
||||||
|
# )
|
||||||
51
api/src/db/models/budget_line.py
Normal file
51
api/src/db/models/budget_line.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetLine(Base):
|
||||||
|
__tablename__ = "budget_line"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_v3_budget_line_form_item", "budget_form_id", "expense_item_id"),
|
||||||
|
Index("ix_v3_budget_line_vsp", "vsp_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id"))
|
||||||
|
expense_item_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
|
||||||
|
name: Mapped[str | None] = mapped_column(String)
|
||||||
|
internal_order: Mapped[str | None] = mapped_column(String)
|
||||||
|
vsp_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
|
||||||
|
project_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
|
||||||
|
justification: Mapped[str | None] = mapped_column(String)
|
||||||
|
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||||
|
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||||
|
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||||
|
direction: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# budget_form = relationship("BudgetForm", back_populates="budget_lines")
|
||||||
|
# expense_item = relationship("ExpenseItem", back_populates="budget_lines")
|
||||||
|
# vsp = relationship("Vsp", back_populates="budget_lines")
|
||||||
|
# project = relationship("Project", back_populates="budget_lines")
|
||||||
|
|
||||||
|
# ckk = relationship("Ckk", uselist=False)
|
||||||
|
# collegial_approval = relationship("CollegialApproval", uselist=False)
|
||||||
|
# contract_detail = relationship("ContractDetail", uselist=False)
|
||||||
|
# contract_summary = relationship("ContractSummary", uselist=False)
|
||||||
|
# plan = relationship("Plan", uselist=False)
|
||||||
|
# reserve = relationship("Reserve", uselist=False)
|
||||||
|
# allocation = relationship("Allocation", uselist=False)
|
||||||
|
|
||||||
|
# quarters: Mapped[list["BudgetLineQuarter"]] = relationship("BudgetLineQuarter", back_populates="line") # type: ignore
|
||||||
|
# sequestration_records: Mapped[list["Sequestration"]] = relationship("Sequestration", back_populates="budget_line") # type: ignore
|
||||||
|
# booking_records: Mapped[list["Booking"]] = relationship("Booking", back_populates="budget_line") # type: ignore
|
||||||
|
# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="budget_line") # type: ignore
|
||||||
|
# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="budget_line") # type: ignore
|
||||||
|
# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="budget_line") # type: ignore
|
||||||
54
api/src/db/models/budget_line_quarter.py
Normal file
54
api/src/db/models/budget_line_quarter.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, SmallInteger, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetLineQuarter(Base):
|
||||||
|
__tablename__ = "budget_line_quarter"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("quarter >= 1 AND quarter <= 4", name="chk_budget_line_quarter_quarter"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||||
|
quarter: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
|
||||||
|
adj_current: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
adj_ssp: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
adj_rf: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
adj_reserve: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
adj_comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
target_change: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
base_plan_correction: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
base_plan_correction_comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
payment_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
payment_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
payment_amount_ho: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
payment_amount_rf: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
payment_comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
payment_act: Mapped[str | None] = mapped_column(String)
|
||||||
|
actual_m1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_m2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_m3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_spod: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
transfer_to_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
transfer_to_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
transfer_to_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
transfer_to_economy: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
transfer_delay_acts: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
transfer_delay_procurement: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
transfer_economy_rf: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
transfer_next_comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
transfer_far_comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
booking_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_revision_eco_change: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_revision_item_adj: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_revision_increase: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_revision_sequester: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_revision_comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine", back_populates="quarters")
|
||||||
29
api/src/db/models/ckk.py
Normal file
29
api/src/db/models/ckk.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Ckk(Base):
|
||||||
|
__tablename__ = "ckk"
|
||||||
|
__table_args__ = {"schema": "v3"}
|
||||||
|
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||||
|
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_next_year_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_next_year_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_next_year_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_next_year_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
rf_schedule: Mapped[str | None] = mapped_column(String)
|
||||||
|
delivery_deadline: Mapped[str | None] = mapped_column(String)
|
||||||
|
procurement_plan: Mapped[str | None] = mapped_column(String)
|
||||||
|
procurement_method: Mapped[str | None] = mapped_column(String)
|
||||||
|
comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine", uselist=False)
|
||||||
18
api/src/db/models/collegial_approval.py
Normal file
18
api/src/db/models/collegial_approval.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CollegialApproval(Base):
|
||||||
|
__tablename__ = "collegial_approval"
|
||||||
|
__table_args__ = {"schema": "v3"}
|
||||||
|
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||||
|
approved_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
protocol_reference: Mapped[str | None] = mapped_column(String)
|
||||||
|
note: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine", uselist=False)
|
||||||
34
api/src/db/models/contract_detail.py
Normal file
34
api/src/db/models/contract_detail.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ContractDetail(Base):
|
||||||
|
__tablename__ = "contract_detail"
|
||||||
|
__table_args__ = {"schema": "v3"}
|
||||||
|
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||||
|
counterparty: Mapped[str | None] = mapped_column(String)
|
||||||
|
reference: Mapped[str | None] = mapped_column(String)
|
||||||
|
addenda: Mapped[str | None] = mapped_column(String)
|
||||||
|
subject: Mapped[str | None] = mapped_column(String)
|
||||||
|
currency: Mapped[str | None] = mapped_column(String)
|
||||||
|
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
rf_schedule: Mapped[str | None] = mapped_column(String)
|
||||||
|
vat_rate: Mapped[str | None] = mapped_column(String)
|
||||||
|
exchange_rate: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
amount_foreign: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
deadline: Mapped[str | None] = mapped_column(String)
|
||||||
|
payment_scheme: Mapped[str | None] = mapped_column(String)
|
||||||
|
act: Mapped[str | None] = mapped_column(String)
|
||||||
|
comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
contract_date: Mapped[str | None] = mapped_column(Date)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine", uselist=False)
|
||||||
24
api/src/db/models/contract_summary.py
Normal file
24
api/src/db/models/contract_summary.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ContractSummary(Base):
|
||||||
|
__tablename__ = "contract_summary"
|
||||||
|
__table_args__ = {"schema": "v3"}
|
||||||
|
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||||
|
total_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
counterparty: Mapped[str | None] = mapped_column(String)
|
||||||
|
reference: Mapped[str | None] = mapped_column(String)
|
||||||
|
deadline: Mapped[str | None] = mapped_column(String)
|
||||||
|
comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
future_payments_y1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
future_payments_y2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
other_ssp_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
centralized_flag: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine", uselist=False)
|
||||||
25
api/src/db/models/form_limit.py
Normal file
25
api/src/db/models/form_limit.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Index, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FormLimit(Base):
|
||||||
|
__tablename__ = "form_limit"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_v3_form_limit_form", "budget_form_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
template_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.limit_template.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
qty_q1: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
qty_q2: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
qty_q3: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
qty_q4: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# budget_form = relationship("BudgetForm")
|
||||||
|
# template = relationship("LimitTemplate")
|
||||||
26
api/src/db/models/form_phase.py
Normal file
26
api/src/db/models/form_phase.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FormPhase(Base):
|
||||||
|
__tablename__ = "form_phase"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_form_phase_columns"),
|
||||||
|
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_form_phase_no_admin"),
|
||||||
|
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_form_phase_window"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
budget_form_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
sheet: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
|
||||||
|
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
|
||||||
|
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
79
api/src/db/models/form_type.py
Normal file
79
api/src/db/models/form_type.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
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, [])
|
||||||
33
api/src/db/models/limit_template.py
Normal file
33
api/src/db/models/limit_template.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LimitTemplate(Base):
|
||||||
|
__tablename__ = "limit_template"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"row_type IN ('SECTION','GROUP','LEAF')",
|
||||||
|
name="ck_v3_limit_template_row_type",
|
||||||
|
),
|
||||||
|
Index("ix_v3_limit_template_parent", "parent_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.limit_template.id"))
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer)
|
||||||
|
row_type: Mapped[str] = mapped_column(String)
|
||||||
|
section_no: Mapped[str | None] = mapped_column(String)
|
||||||
|
expense_item_code: Mapped[str | None] = mapped_column(String)
|
||||||
|
name: Mapped[str] = mapped_column(String)
|
||||||
|
unit: Mapped[str | None] = mapped_column(String)
|
||||||
|
limit_with_vat: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
limit_without_vat: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
|
||||||
|
# parent = relationship("LimitTemplate", remote_side="LimitTemplate.id")
|
||||||
|
# children: Mapped[list[LimitTemplate]] = relationship("LimitTemplate")
|
||||||
|
# form_limits: Mapped[list[FormLimit]] = relationship("FormLimit")
|
||||||
41
api/src/db/models/org_unit.py
Normal file
41
api/src/db/models/org_unit.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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")
|
||||||
29
api/src/db/models/phase_template.py
Normal file
29
api/src/db/models/phase_template.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseTemplate(Base):
|
||||||
|
__tablename__ = "phase_template"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_phase_template_columns"),
|
||||||
|
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_phase_template_no_admin"),
|
||||||
|
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_phase_template_window"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
form_type: Mapped[str] = mapped_column(String, ForeignKey("v3.form_type.code"), primary_key=True)
|
||||||
|
sheet: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
|
||||||
|
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
|
||||||
|
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
# form_type_rel = relationship("FormType", foreign_keys=[form_type])
|
||||||
|
# role_rel = relationship("Role", foreign_keys=[role], back_populates="phase_templates")
|
||||||
20
api/src/db/models/plan.py
Normal file
20
api/src/db/models/plan.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Plan(Base):
|
||||||
|
__tablename__ = "plan"
|
||||||
|
__table_args__ = {"schema": "v3"}
|
||||||
|
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||||
|
plan_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine", uselist=False)
|
||||||
49
api/src/db/models/project.py
Normal file
49
api/src/db/models/project.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
__tablename__ = "project"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("level IN ('program', 'project')", name="project_level_check"),
|
||||||
|
CheckConstraint(
|
||||||
|
"placement_type IS NULL OR placement_type IN ('own', 'rent', 'other')",
|
||||||
|
name="project_placement_type_check",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"project_type IS NULL OR project_type IN ('current', 'development')",
|
||||||
|
name="project_project_type_check",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"vsp_format IS NULL OR vsp_format IN ('office', 'standalone', 'atm', 'other')",
|
||||||
|
name="project_vsp_format_check",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
text("name IS NOT NULL AND name <> ''"),
|
||||||
|
name="chk_v3_project_name",
|
||||||
|
),
|
||||||
|
Index("ix_v3_project_ssp", "org_unit_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str | None] = mapped_column(String)
|
||||||
|
level: Mapped[str | None] = mapped_column(String)
|
||||||
|
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
|
||||||
|
project_type: Mapped[str | None] = mapped_column(String)
|
||||||
|
vsp_format: Mapped[str | None] = mapped_column(String)
|
||||||
|
placement_type: Mapped[str | None] = mapped_column(String)
|
||||||
|
object_address: Mapped[str | None] = mapped_column(String)
|
||||||
|
staff_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
total_area: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
|
||||||
|
|
||||||
|
# parent = relationship("Project", remote_side="Project.id", back_populates="children")
|
||||||
|
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore
|
||||||
|
# org_unit = relationship("OrgUnit", back_populates="projects")
|
||||||
|
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="project") # type: ignore
|
||||||
|
# rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore
|
||||||
43
api/src/db/models/rent_detail.py
Normal file
43
api/src/db/models/rent_detail.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RentDetail(Base):
|
||||||
|
__tablename__ = "rent_detail"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_v3_rent_detail_line", "line_id"),
|
||||||
|
Index("ix_v3_rent_detail_vsp", "vsp_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||||
|
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
|
||||||
|
contract_number: Mapped[str | None] = mapped_column(String)
|
||||||
|
contract_end_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
plan_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_jan: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_feb: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_mar: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_apr: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_may: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_jun: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_jul: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_aug: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_sep: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_oct: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_nov: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_dec: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine")
|
||||||
|
# vsp = relationship("Vsp")
|
||||||
20
api/src/db/models/reserve.py
Normal file
20
api/src/db/models/reserve.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Reserve(Base):
|
||||||
|
__tablename__ = "reserve"
|
||||||
|
__table_args__ = {"schema": "v3"}
|
||||||
|
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||||
|
amount_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
amount_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
amount_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
amount_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
justification: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine", uselist=False)
|
||||||
31
api/src/db/models/rf_project_report.py
Normal file
31
api/src/db/models/rf_project_report.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RfProjectReport(Base):
|
||||||
|
__tablename__ = "rf_project_report"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("project_id", "year", "report_type", name="uq_v3_rf_project_report"),
|
||||||
|
Index("ix_v3_rf_project_report_proj_year", "project_id", "year"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id"))
|
||||||
|
year: Mapped[int] = mapped_column(Integer)
|
||||||
|
report_type: Mapped[str] = mapped_column(String, CheckConstraint("report_type IN ('LIMIT','CURRENT_EXPENSES')"))
|
||||||
|
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||||
|
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
# project: Mapped[Project] = relationship("Project", back_populates="rf_project_reports")
|
||||||
|
# creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by])
|
||||||
|
# updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by])
|
||||||
|
# lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report")
|
||||||
27
api/src/db/models/rf_project_report_line.py
Normal file
27
api/src/db/models/rf_project_report_line.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RfProjectReportLine(Base):
|
||||||
|
__tablename__ = "rf_project_report_line"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"rf_project_report_id", "expense_item_id", name="uq_v3_rf_project_report_line"
|
||||||
|
),
|
||||||
|
Index("ix_v3_rf_project_report_line_rep", "rf_project_report_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
rf_project_report_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_report.id"))
|
||||||
|
expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
|
||||||
|
|
||||||
|
# report: Mapped[RfProjectReport] = relationship("RfProjectReport", back_populates="lines")
|
||||||
|
# expense_item: Mapped[ExpenseItem] = relationship("ExpenseItem")
|
||||||
|
# quarters: Mapped[list[RfProjectReportQuarter]] = relationship(
|
||||||
|
# "RfProjectReportQuarter", back_populates="line"
|
||||||
|
# )
|
||||||
30
api/src/db/models/rf_project_report_quarter.py
Normal file
30
api/src/db/models/rf_project_report_quarter.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Numeric, SmallInteger
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RfProjectReportQuarter(Base):
|
||||||
|
__tablename__ = "rf_project_report_quarter"
|
||||||
|
__table_args__ = {"schema": "v3"}
|
||||||
|
|
||||||
|
rf_project_report_line_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("v3.rf_project_report_line.id"), primary_key=True
|
||||||
|
)
|
||||||
|
quarter: Mapped[int] = mapped_column(
|
||||||
|
SmallInteger,
|
||||||
|
CheckConstraint("quarter >= 1 AND quarter <= 4"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
adj_by_items: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
adj_increase: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_m1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_m2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_m3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_spod: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
|
||||||
|
# line: Mapped[RfProjectReportLine] = relationship(
|
||||||
|
# "RfProjectReportLine", back_populates="quarters"
|
||||||
|
# )
|
||||||
31
api/src/db/models/role.py
Normal file
31
api/src/db/models/role.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import enum
|
||||||
|
|
||||||
|
from sqlalchemy import String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserRoleEnum(int, enum.Enum):
|
||||||
|
ADMIN = 1
|
||||||
|
EXECUTOR_DFIP = 2
|
||||||
|
EXECUTOR_RF = 3
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
__tablename__ = "role"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("code", name="uq_role_code"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
code: Mapped[str] = mapped_column(String)
|
||||||
|
|
||||||
|
# users: Mapped[list["AppUser"]] = relationship("AppUser", back_populates="role") # type: ignore
|
||||||
|
# form_phases: Mapped[list["FormPhase"]] = relationship( # type: ignore
|
||||||
|
# "FormPhase", back_populates="role"
|
||||||
|
# )
|
||||||
|
# phase_templates: Mapped[list["PhaseTemplate"]] = relationship( # type: ignore
|
||||||
|
# "PhaseTemplate", back_populates="role"
|
||||||
|
# )
|
||||||
43
api/src/db/models/security_detail.py
Normal file
43
api/src/db/models/security_detail.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityDetail(Base):
|
||||||
|
__tablename__ = "security_detail"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_v3_security_detail_line", "line_id"),
|
||||||
|
Index("ix_v3_security_detail_vsp", "vsp_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||||
|
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
|
||||||
|
contract_number: Mapped[str | None] = mapped_column(String)
|
||||||
|
contract_end_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
plan_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_jan: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_feb: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_mar: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_apr: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_may: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_jun: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_jul: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_aug: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_sep: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_oct: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_nov: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_dec: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine")
|
||||||
|
# vsp = relationship("Vsp")
|
||||||
27
api/src/db/models/sequestration.py
Normal file
27
api/src/db/models/sequestration.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Sequestration(Base):
|
||||||
|
__tablename__ = "sequestration"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("actor IN ('DFIP','SSP_GO')", name="ck_v3_sequestration_actor"),
|
||||||
|
UniqueConstraint("line_id", "actor", name="uq_v3_sequestration_line_actor"),
|
||||||
|
Index("ix_v3_sequestration_line_actor", "line_id", "actor"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||||
|
actor: Mapped[str] = mapped_column(String)
|
||||||
|
adj_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
adj_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
adj_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
adj_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
justification: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine")
|
||||||
23
api/src/db/models/user_org.py
Normal file
23
api/src/db/models/user_org.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserOrg(Base):
|
||||||
|
__tablename__ = "user_org"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "org_unit_id"),
|
||||||
|
Index("ix_v4_user_org_org", "org_unit_id"),
|
||||||
|
Index("ix_v4_user_org_user", "user_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.app_user.id", ondelete="CASCADE"))
|
||||||
|
org_unit_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id", ondelete="CASCADE"))
|
||||||
|
|
||||||
|
user: Mapped[AppUser] = relationship("AppUser", back_populates="user_orgs", lazy="selectin")
|
||||||
|
org_unit: Mapped[OrgUnit] = relationship("OrgUnit", back_populates="user_orgs", lazy="selectin")
|
||||||
43
api/src/db/models/utility_detail.py
Normal file
43
api/src/db/models/utility_detail.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UtilityDetail(Base):
|
||||||
|
__tablename__ = "utility_detail"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_v3_utility_detail_line", "line_id"),
|
||||||
|
Index("ix_v3_utility_detail_vsp", "vsp_id"),
|
||||||
|
{"schema": "v3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||||
|
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
|
||||||
|
contract_number: Mapped[str | None] = mapped_column(String)
|
||||||
|
contract_end_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
plan_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
plan_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_jan: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_feb: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_mar: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_apr: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_may: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_jun: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_jul: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_aug: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_sep: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_oct: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_nov: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
actual_dec: Mapped[float | None] = mapped_column(Numeric)
|
||||||
|
comment: Mapped[str | None] = mapped_column(String)
|
||||||
|
|
||||||
|
# line = relationship("BudgetLine")
|
||||||
|
# vsp = relationship("Vsp")
|
||||||
59
api/src/db/models/vsp.py
Normal file
59
api/src/db/models/vsp.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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,6 +5,8 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -12,6 +14,10 @@ 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,8 +1,23 @@
|
|||||||
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"
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
import enum
|
|
||||||
|
|
||||||
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
|
|
||||||
UniqueConstraint)
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
|
|
||||||
from src.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class UserRole(int, enum.Enum):
|
|
||||||
ADMIN = 1
|
|
||||||
EXECUTOR_DFIP = 2
|
|
||||||
EXECUTOR_RF = 3
|
|
||||||
|
|
||||||
|
|
||||||
class Roles(Base):
|
|
||||||
__tablename__ = "roles"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
role = Column(String, nullable=False)
|
|
||||||
|
|
||||||
user_role = relationship("Users", back_populates="role", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class Users(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
email = Column(String, unique=True, index=True, nullable=False)
|
|
||||||
username = Column(String, unique=True, index=True, nullable=False)
|
|
||||||
hashed_password = Column(String, nullable=True)
|
|
||||||
full_name = Column(String, nullable=True)
|
|
||||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
|
|
||||||
|
|
||||||
role = relationship(
|
|
||||||
"Roles",
|
|
||||||
foreign_keys=[role_id],
|
|
||||||
back_populates="user_role",
|
|
||||||
lazy="selectin",
|
|
||||||
)
|
|
||||||
user_ssp_link = relationship("UserSSPLink", back_populates="users", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class SSP(Base):
|
|
||||||
__tablename__ = "ssp"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
title = Column(String, nullable=False)
|
|
||||||
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
|
|
||||||
is_ssp = Column(Boolean, default=True, nullable=False, server_default="true")
|
|
||||||
|
|
||||||
user_ssp_link = relationship("UserSSPLink", back_populates="ssp", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class UserSSPLink(Base):
|
|
||||||
__tablename__ = "user_ssp_link"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("user_id", "ssp_id", name="uq_user_ssp_link_user_id_ssp_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False)
|
|
||||||
|
|
||||||
users = relationship("Users", back_populates="user_ssp_link", lazy="select")
|
|
||||||
ssp = relationship("SSP", back_populates="user_ssp_link", lazy="select")
|
|
||||||
@ -1,41 +1,124 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
from typing import Optional
|
import enum
|
||||||
|
from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
|
||||||
|
from sqlalchemy.exc import MissingGreenlet
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"]
|
||||||
|
|
||||||
|
|
||||||
|
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(None)
|
success: Optional[bool] = Field(True)
|
||||||
message: Optional[str] = Field(None)
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSingleResponse(ResponseBase, Generic[T]):
|
||||||
|
result: Optional[T] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BaseListResponse(ResponseBase, Generic[T]):
|
||||||
|
result: Optional[list[T]] = None
|
||||||
|
count: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr = Field(..., examples=["user@example.com"])
|
||||||
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 = None
|
password: str | None = Field(default=None, examples=["pass123"])
|
||||||
|
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] = None
|
email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"])
|
||||||
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):
|
||||||
@ -51,6 +134,37 @@ 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
|
||||||
|
|
||||||
@ -97,3 +211,405 @@ 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="Тип события")
|
||||||
|
date_from: Optional[datetime] = Field(None, description="Дата начала (ISO 8601)")
|
||||||
|
date_to: Optional[datetime] = 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
|
||||||
|
|||||||
120
api/src/main.py
120
api/src/main.py
@ -1,9 +1,13 @@
|
|||||||
|
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
|
from fastapi import FastAPI, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
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
|
||||||
@ -25,8 +29,72 @@ if not settings.DEBUG:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_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(
|
||||||
@ -36,9 +104,14 @@ async def lifespan(app: FastAPI):
|
|||||||
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()
|
||||||
|
|
||||||
|
|
||||||
@ -69,6 +142,41 @@ 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}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/readyz")
|
||||||
|
async def readyz(response: Response):
|
||||||
|
result = {"status": "ok", "db_status": "ok", "mv_expense_item_tree": "ok"}
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
if "postgresql" in settings.DATABASE_URL:
|
||||||
|
my_exists = (
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_matviews
|
||||||
|
WHERE schemaname = 'v3'
|
||||||
|
AND matviewname = 'mv_expense_item_tree'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if my_exists is None:
|
||||||
|
result["status"] = "fail"
|
||||||
|
result["mv_expense_item_tree"] = "missing"
|
||||||
|
response.status_code = 503
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
result["status"] = "fail"
|
||||||
|
result["db"] = f"error: {exc.__class__.__name__}"
|
||||||
|
response.status_code = 503
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@ -83,6 +191,16 @@ if not settings.DEBUG:
|
|||||||
path=settings.ROOT_PATH + "/healthcheck",
|
path=settings.ROOT_PATH + "/healthcheck",
|
||||||
type_search=SearchType.ABSOLUTE,
|
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="/openapi.json", type_search=SearchType.START),
|
||||||
OpenEndpoint(
|
OpenEndpoint(
|
||||||
path=settings.ROOT_PATH + "/openapi.json",
|
path=settings.ROOT_PATH + "/openapi.json",
|
||||||
|
|||||||
83
api/src/repository/auditlog_repository.py
Normal file
83
api/src/repository/auditlog_repository.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
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,
|
||||||
|
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 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()
|
||||||
|
|
||||||
115
api/src/repository/budget_form_repository.py
Normal file
115
api/src/repository/budget_form_repository.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
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:
|
||||||
|
if query.whereclause is not None:
|
||||||
|
count_query = select(func.count(BudgetForm.id)).where(
|
||||||
|
query.whereclause
|
||||||
|
)
|
||||||
|
return (await self.db.execute(count_query)).scalar(), []
|
||||||
|
else:
|
||||||
|
return (await self.db.execute(query)).scalars().all()
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
budget_form_id: int,
|
||||||
|
org_unit: int | list[int] | None = None,
|
||||||
|
load_form_type: bool = False,
|
||||||
|
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,
|
||||||
|
)
|
||||||
26
api/src/repository/budget_line_repository.py
Normal file
26
api/src/repository/budget_line_repository.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
123
api/src/repository/form_phase_repository.py
Normal file
123
api/src/repository/form_phase_repository.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
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)
|
||||||
117
api/src/repository/org_unit_repository.py
Normal file
117
api/src/repository/org_unit_repository.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
if query.whereclause is not None:
|
||||||
|
count_query = select(func.count(OrgUnit.id)).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
|
||||||
354
api/src/repository/project_repository.py
Normal file
354
api/src/repository/project_repository.py
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
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])
|
||||||
245
api/src/repository/sheet_repository.py
Normal file
245
api/src/repository/sheet_repository.py
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
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,96 +1,119 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, func, select, text, 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) -> Optional[Users]:
|
async def get(self, user_id: int, load_orgs: bool = False) -> Optional[AppUser]:
|
||||||
|
query = select(AppUser).where(AppUser.id == user_id)
|
||||||
|
if load_orgs:
|
||||||
|
query = query.options(
|
||||||
|
selectinload(AppUser.org_units),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(Users).where(Users.id == user_id).limit(1)
|
query.limit(1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_by_email(self, email: str) -> Optional[Users]:
|
async def get_by_email(self, email: str) -> Optional[AppUser]:
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(Users).where(Users.email == email).limit(1)
|
select(AppUser).where(AppUser.email == email).limit(1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_by_username(self, username: str) -> Optional[Users]:
|
async def get_by_username(self, username: str) -> Optional[AppUser]:
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(Users).where(Users.username == username).limit(1)
|
select(AppUser).where(AppUser.username == username).limit(1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]:
|
async def set_app_user_id(self, user_id: int) -> None:
|
||||||
query = (
|
if isinstance(user_id, int):
|
||||||
select(Users)
|
await self.db.execute(
|
||||||
.where(Users.is_active.is_(True))
|
text(
|
||||||
.offset(skip)
|
f"SET LOCAL app.user_id = {user_id}"
|
||||||
.limit(limit)
|
),
|
||||||
.order_by(Users.id)
|
)
|
||||||
)
|
|
||||||
return (await self.db.execute(query)).scalars().all()
|
|
||||||
|
|
||||||
async def create(self, user_data: dict) -> Users:
|
async def get_list(
|
||||||
hashed_password = (
|
self,
|
||||||
get_password_hash(user_data["password"]) if user_data.get("password") else None
|
skip: int = 0,
|
||||||
)
|
limit: int = 100,
|
||||||
db_user = Users(
|
with_count: bool = False,
|
||||||
email=user_data["email"],
|
load_orgs: bool = False,
|
||||||
username=user_data["username"],
|
) -> list[AppUser] | tuple[int, list[AppUser]]:
|
||||||
hashed_password=hashed_password,
|
|
||||||
full_name=user_data.get("full_name"),
|
where = [AppUser.is_active.is_(True)]
|
||||||
role_id=user_data.get("role_id"),
|
|
||||||
)
|
if with_count:
|
||||||
self.db.add(db_user)
|
query = select(func.count().over().label("total_count"), AppUser)
|
||||||
await self.db.commit()
|
else:
|
||||||
await self.db.refresh(db_user)
|
query = select(AppUser)
|
||||||
return db_user
|
|
||||||
|
|
||||||
async def update(self, user_id: int, user_data: dict) -> Optional[Users]:
|
|
||||||
user = await self.get(user_id)
|
query = query.where(*where)
|
||||||
if not user:
|
if load_orgs:
|
||||||
return None
|
query = query.options(
|
||||||
|
joinedload(AppUser.org_units)
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.order_by(AppUser.id)
|
||||||
|
|
||||||
for field, value in user_data.items():
|
if skip is not None:
|
||||||
if field == "password" and value:
|
query = query.offset(skip)
|
||||||
value = get_password_hash(value)
|
if limit is not None:
|
||||||
setattr(user, field, value)
|
query = query.limit(limit)
|
||||||
|
|
||||||
await self.db.commit()
|
if with_count:
|
||||||
await self.db.refresh(user)
|
result = (await self.db.execute(query)).unique().all()
|
||||||
return user
|
if result:
|
||||||
|
return result[0][0], [res[1] for res in result]
|
||||||
|
else:
|
||||||
|
if query.whereclause is not None:
|
||||||
|
count_query = select(func.count(AppUser.id)).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(Users).where(Users.id == user_id).values(is_active=False)
|
query = update(AppUser).where(AppUser.id == user_id).values(is_active=False)
|
||||||
result = await self.db.execute(query)
|
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[Users]:
|
async def authenticate(self, username: str, password: str) -> Optional[AppUser]:
|
||||||
user = await self.get_by_username(username)
|
user = await self.get_by_username(username)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
@ -98,52 +121,122 @@ class UserRepository:
|
|||||||
return None
|
return None
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def authenticate_via_email(self, email: str) -> Optional[Users]:
|
async def authenticate_via_email(self, email: str) -> Optional[AppUser]:
|
||||||
return await self.get_by_email(email)
|
return await self.get_by_email(email)
|
||||||
|
|
||||||
async def get_roles(self) -> list[Roles]:
|
async def get_roles(self) -> list[Role]:
|
||||||
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all()
|
return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
|
||||||
|
|
||||||
async def get_many_ssp(self, user_id: int) -> list[SSP]:
|
async def get_many_ssp(self, user_id: int) -> list[OrgUnit]:
|
||||||
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where(
|
query = select(OrgUnit).join(UserOrg, UserOrg.org_unit_id == OrgUnit.id).where(
|
||||||
UserSSPLink.user_id == user_id
|
UserOrg.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(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id)
|
query = select(UserOrg.org_unit_id).where(UserOrg.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:
|
||||||
try:
|
for ssp_id in ssp_ids:
|
||||||
for ssp_id in ssp_ids:
|
await self.db.execute(
|
||||||
link = UserSSPLink(user_id=user_id, ssp_id=ssp_id)
|
text(
|
||||||
self.db.add(link)
|
"SELECT v3.grant_user_org_access(:user_id, :org_unit_id)"
|
||||||
await self.db.commit()
|
),
|
||||||
return True
|
{"user_id": user_id, "org_unit_id": ssp_id},
|
||||||
except Exception:
|
)
|
||||||
await self.db.rollback()
|
await self.db.commit()
|
||||||
return False
|
return True
|
||||||
|
|
||||||
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:
|
||||||
try:
|
for ssp_id in ssp_ids:
|
||||||
for ssp_id in ssp_ids:
|
await self.db.execute(
|
||||||
query = delete(UserSSPLink).where(
|
text(
|
||||||
(UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id)
|
"SELECT v3.revoke_user_org_access(:user_id, :org_unit_id)"
|
||||||
)
|
),
|
||||||
await self.db.execute(query)
|
{"user_id": user_id, "org_unit_id": ssp_id},
|
||||||
await self.db.commit()
|
)
|
||||||
return True
|
await self.db.commit()
|
||||||
except Exception:
|
return True
|
||||||
await self.db.rollback()
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def clear_many_ssp(self, user_id: int) -> bool:
|
async def clear_many_ssp(self, user_id: int) -> None:
|
||||||
try:
|
org_unit_ids = await self.get_many_ssp_ids(user_id)
|
||||||
query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id)
|
await self.db.execute(
|
||||||
await self.db.execute(query)
|
text(
|
||||||
await self.db.commit()
|
"SELECT v3.revoke_many_user_org_access(:user_id, :org_unit_ids)"
|
||||||
return True
|
),
|
||||||
except Exception:
|
{"user_id": user_id, "org_unit_ids": org_unit_ids},
|
||||||
await self.db.rollback()
|
)
|
||||||
return False
|
await self.db.flush()
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
243
api/src/repository/vsp_repository.py
Normal file
243
api/src/repository/vsp_repository.py
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
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:
|
||||||
|
if query.whereclause is not None:
|
||||||
|
count_query = select(func.count(Vsp.id)).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
|
||||||
|
|
||||||
|
|
||||||
46
api/src/services/access_control_service.py
Normal file
46
api/src/services/access_control_service.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import enum
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db.models.app_user import AppUser
|
||||||
|
from src.db.models.budget_form import BudgetForm
|
||||||
|
from src.db.models.role import UserRoleEnum
|
||||||
|
from src.repository.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
class AccessEnum(int, enum.Enum):
|
||||||
|
RESTRICT = 0
|
||||||
|
VIEW = 1
|
||||||
|
EDIT = 2
|
||||||
|
|
||||||
|
|
||||||
|
class AccessControlService:
|
||||||
|
def __init__(self, db: AsyncSession, user: AppUser):
|
||||||
|
self.db = db
|
||||||
|
self.user_repo = UserRepository(db)
|
||||||
|
|
||||||
|
async def _check_budget_form(
|
||||||
|
self,
|
||||||
|
user: AppUser,
|
||||||
|
budget_form: BudgetForm,
|
||||||
|
) -> AccessEnum:
|
||||||
|
if user.role_id == UserRoleEnum.ADMIN:
|
||||||
|
return AccessEnum.EDIT
|
||||||
|
user = await self.user_repo.get(
|
||||||
|
user_id=user.id,
|
||||||
|
load_orgs=True,
|
||||||
|
)
|
||||||
|
if budget_form.org_unit_id in [ou.id for ou in user.org_units]:
|
||||||
|
return AccessEnum.EDIT
|
||||||
|
return AccessEnum.RESTRICT
|
||||||
|
|
||||||
|
async def check_object_access(
|
||||||
|
self,
|
||||||
|
user: AppUser,
|
||||||
|
object: typing.Any
|
||||||
|
) -> AccessEnum:
|
||||||
|
if isinstance(object, BudgetForm):
|
||||||
|
return await self._check_budget_form(user=user,budget_form=object)
|
||||||
|
return AccessEnum.EDIT
|
||||||
|
|
||||||
96
api/src/services/auditlog_service.py
Normal file
96
api/src/services/auditlog_service.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
98
api/src/services/budget_form_service.py
Normal file
98
api/src/services/budget_form_service.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
|
||||||
|
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
|
||||||
|
]
|
||||||
|
)
|
||||||
35
api/src/services/budget_line_service.py
Normal file
35
api/src/services/budget_line_service.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
|
||||||
|
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
api/src/services/export_service/__init__.py
Normal file
1
api/src/services/export_service/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .export_service import ExportService
|
||||||
2912
api/src/services/export_service/export_mappings.py
Normal file
2912
api/src/services/export_service/export_mappings.py
Normal file
File diff suppressed because it is too large
Load Diff
283
api/src/services/export_service/export_normalizers.py
Normal file
283
api/src/services/export_service/export_normalizers.py
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
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
|
||||||
449
api/src/services/export_service/export_service.py
Normal file
449
api/src/services/export_service/export_service.py
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
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
|
||||||
209
api/src/services/export_service/export_writers.py
Normal file
209
api/src/services/export_service/export_writers.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
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:
|
||||||
|
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)
|
||||||
139
api/src/services/form_phase_service.py
Normal file
139
api/src/services/form_phase_service.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.core.errors import AccessDeniedException, ValidationsError
|
||||||
|
from src.db.models.budget_form import BudgetForm
|
||||||
|
from src.db.models.role import UserRoleEnum
|
||||||
|
from src.repository.budget_form_repository import BudgetFormRepository
|
||||||
|
from src.db.models.app_user import AppUser
|
||||||
|
from src.db.models.form_phase import FormPhase
|
||||||
|
from src.domain.schemas import FormPhaseCreate, FormPhaseUpdate
|
||||||
|
from src.repository.form_phase_repository import FormPhaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class FormPhaseService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.fp_repo = FormPhaseRepository(db)
|
||||||
|
self.bf_repo = BudgetFormRepository(db)
|
||||||
|
|
||||||
|
async def get_list(
|
||||||
|
self,
|
||||||
|
budget_form_id: int,
|
||||||
|
user: AppUser,
|
||||||
|
sheet: str | None = None,
|
||||||
|
phase_code: str | None = None,
|
||||||
|
) -> list[FormPhase]:
|
||||||
|
return await self.fp_repo.get_list(
|
||||||
|
budget_form_id=budget_form_id,
|
||||||
|
sheet=sheet,
|
||||||
|
phase_code=phase_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
|
||||||
|
) -> FormPhase | None:
|
||||||
|
return await self.fp_repo.get(
|
||||||
|
budget_form_id=budget_form_id,
|
||||||
|
sheet=sheet,
|
||||||
|
phase_code=phase_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_form_phase(
|
||||||
|
self,
|
||||||
|
form_phase: FormPhaseCreate | FormPhaseUpdate,
|
||||||
|
budget_form: BudgetForm,
|
||||||
|
sheet: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
errors = {}
|
||||||
|
if budget_form.form_type.sheet_list:
|
||||||
|
sheet = sheet or form_phase.sheet
|
||||||
|
if sheet not in budget_form.form_type.sheet_list:
|
||||||
|
errors["sheet"] = [f"У формы нет листа {sheet}"]
|
||||||
|
if form_phase.column_keys and budget_form.form_type.section_list:
|
||||||
|
for column_key in form_phase.column_keys:
|
||||||
|
section = column_key.split(".")
|
||||||
|
if section[0] not in budget_form.form_type.section_list:
|
||||||
|
if "column_keys" in errors:
|
||||||
|
errors["column_keys"].append(f"Колонка {column_key} некорректна")
|
||||||
|
else:
|
||||||
|
errors["column_keys"] = [f"Колонка {column_key} некорректна"]
|
||||||
|
if errors:
|
||||||
|
raise ValidationsError(errors=errors)
|
||||||
|
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
body: FormPhaseCreate,
|
||||||
|
user: AppUser,
|
||||||
|
budget_form_id: int | None = None,
|
||||||
|
budget_form: BudgetForm | None = None,
|
||||||
|
) -> FormPhase:
|
||||||
|
assert budget_form or budget_form_id
|
||||||
|
if user.role_id != UserRoleEnum.ADMIN:
|
||||||
|
raise AccessDeniedException
|
||||||
|
|
||||||
|
if not budget_form or budget_form.form_type is None:
|
||||||
|
budget_form = await self.bf_repo.get(
|
||||||
|
budget_form_id=budget_form_id or budget_form.id,
|
||||||
|
load_form_type=True,
|
||||||
|
)
|
||||||
|
self._validate_form_phase(
|
||||||
|
form_phase=body,
|
||||||
|
budget_form=budget_form,
|
||||||
|
)
|
||||||
|
return await self.fp_repo.create(
|
||||||
|
budget_form_id=budget_form_id or budget_form.id,
|
||||||
|
sheet=body.sheet,
|
||||||
|
phase_code=body.phase_code,
|
||||||
|
role=body.role,
|
||||||
|
column_keys=body.column_keys,
|
||||||
|
opens_at=body.opens_at,
|
||||||
|
closes_at=body.closes_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
sheet: str,
|
||||||
|
phase_code: str,
|
||||||
|
body: FormPhaseUpdate,
|
||||||
|
user: AppUser,
|
||||||
|
budget_form_id: int | None = None,
|
||||||
|
budget_form: BudgetForm | None = None,
|
||||||
|
) -> FormPhase:
|
||||||
|
assert budget_form or budget_form_id
|
||||||
|
if user.role_id != UserRoleEnum.ADMIN:
|
||||||
|
raise AccessDeniedException
|
||||||
|
|
||||||
|
if not budget_form or budget_form.form_type is None:
|
||||||
|
budget_form = await self.bf_repo.get(
|
||||||
|
budget_form_id=budget_form_id or budget_form.id,
|
||||||
|
load_form_type=True,
|
||||||
|
)
|
||||||
|
self._validate_form_phase(
|
||||||
|
form_phase=body,
|
||||||
|
budget_form=budget_form,
|
||||||
|
sheet=sheet,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
if not data:
|
||||||
|
return await self.fp_repo.get(
|
||||||
|
budget_form_id=budget_form_id or budget_form.id,
|
||||||
|
sheet=sheet,
|
||||||
|
phase_code=phase_code,
|
||||||
|
)
|
||||||
|
return await self.fp_repo.update(
|
||||||
|
budget_form_id=budget_form_id or budget_form.id,
|
||||||
|
sheet=sheet,
|
||||||
|
phase_code=phase_code,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
|
||||||
|
) -> bool:
|
||||||
|
return await self.fp_repo.delete(
|
||||||
|
budget_form_id=budget_form_id,
|
||||||
|
sheet=sheet,
|
||||||
|
phase_code=phase_code,
|
||||||
|
)
|
||||||
104
api/src/services/org_unit_service.py
Normal file
104
api/src/services/org_unit_service.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
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
|
||||||
191
api/src/services/project_service.py
Normal file
191
api/src/services/project_service.py
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db.models.app_user import AppUser
|
||||||
|
from src.db.models.role import UserRoleEnum
|
||||||
|
from src.core.errors import AccessDeniedException, ValidationException
|
||||||
|
from src.repository.project_repository import ProjectRepository
|
||||||
|
from src.repository.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.project_repo = ProjectRepository(db)
|
||||||
|
self.user_repo = UserRepository(db)
|
||||||
|
|
||||||
|
async def get_list(
|
||||||
|
self,
|
||||||
|
user: AppUser,
|
||||||
|
year: int | None = None,
|
||||||
|
branch_id: int | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
with_count: bool = False,
|
||||||
|
with_org_unit: bool = False,
|
||||||
|
) -> list[dict] | tuple[int, list[dict]]:
|
||||||
|
org_unit_ids = await self._allowed_org_unit_ids(user)
|
||||||
|
return await self.project_repo.get_list(
|
||||||
|
year=year,
|
||||||
|
branch_id=branch_id,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
with_count=with_count,
|
||||||
|
org_unit_ids=org_unit_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(self, project_id: int, user: AppUser) -> dict | None:
|
||||||
|
org_unit_ids = await self._allowed_org_unit_ids(user)
|
||||||
|
project = await self.project_repo.get(project_id=project_id, org_unit_ids=org_unit_ids)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
project["reports"] = await self.project_repo.get_reports(project_id=project_id)
|
||||||
|
return project
|
||||||
|
|
||||||
|
async def resolve_report_id(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
user: AppUser,
|
||||||
|
) -> int | None:
|
||||||
|
normalized_type = report_type.upper()
|
||||||
|
if normalized_type not in ("LIMIT", "CURRENT_EXPENSES"):
|
||||||
|
raise ValidationException("report_type должен быть LIMIT или CURRENT_EXPENSES")
|
||||||
|
org_unit_ids = await self._allowed_org_unit_ids(user)
|
||||||
|
return await self.project_repo.resolve_report_id(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
report_type=normalized_type,
|
||||||
|
org_unit_ids=org_unit_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_report_rows(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
sections: list[str] | None,
|
||||||
|
user: AppUser,
|
||||||
|
) -> list[tuple]:
|
||||||
|
report_id = await self.resolve_report_id(project_id, year, report_type, user)
|
||||||
|
if not report_id:
|
||||||
|
raise ValidationException("Отчёт не найден")
|
||||||
|
return await self.project_repo.get_report_rows(report_id=report_id, sections=sections)
|
||||||
|
|
||||||
|
async def get_rf_rollup_rows(
|
||||||
|
self,
|
||||||
|
branch_id: int,
|
||||||
|
year: int,
|
||||||
|
sections: list[str] | None,
|
||||||
|
user: AppUser,
|
||||||
|
) -> list[tuple]:
|
||||||
|
org_unit_ids = await self._allowed_org_unit_ids(user)
|
||||||
|
if org_unit_ids is not None and branch_id not in org_unit_ids:
|
||||||
|
raise AccessDeniedException()
|
||||||
|
return await self.project_repo.get_rf_rollup_rows(
|
||||||
|
branch_id=branch_id,
|
||||||
|
year=year,
|
||||||
|
sections=sections,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upd_form3_cell(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
line_id: int,
|
||||||
|
column: str,
|
||||||
|
value,
|
||||||
|
user: AppUser,
|
||||||
|
) -> list[tuple]:
|
||||||
|
report_id = await self.resolve_report_id(project_id, year, report_type, user)
|
||||||
|
if not report_id:
|
||||||
|
raise ValidationException("Отчёт не найден")
|
||||||
|
return await self.project_repo.upd_form3_cell(report_id, line_id, column, value)
|
||||||
|
|
||||||
|
async def upd_form3_cells(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
changes: list[dict],
|
||||||
|
user: AppUser,
|
||||||
|
) -> list[tuple]:
|
||||||
|
report_id = await self.resolve_report_id(project_id, year, report_type, user)
|
||||||
|
if not report_id:
|
||||||
|
raise ValidationException("Отчёт не найден")
|
||||||
|
return await self.project_repo.upd_form3_cells(report_id, changes)
|
||||||
|
|
||||||
|
async def add_form3_line(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
expense_item_id: int,
|
||||||
|
user: AppUser,
|
||||||
|
) -> list[tuple]:
|
||||||
|
report_id = await self.resolve_report_id(project_id, year, report_type, user)
|
||||||
|
if not report_id:
|
||||||
|
raise ValidationException("Отчёт не найден")
|
||||||
|
return await self.project_repo.add_form3_line(report_id, expense_item_id)
|
||||||
|
|
||||||
|
async def del_form3_line(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
line_id: int,
|
||||||
|
user: AppUser,
|
||||||
|
) -> list[tuple]:
|
||||||
|
report_id = await self.resolve_report_id(project_id, year, report_type, user)
|
||||||
|
if not report_id:
|
||||||
|
raise ValidationException("Отчёт не найден")
|
||||||
|
return await self.project_repo.del_form3_line(line_id)
|
||||||
|
|
||||||
|
async def upd_project(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
column: str,
|
||||||
|
value,
|
||||||
|
user: AppUser,
|
||||||
|
):
|
||||||
|
project = await self.get(project_id, user)
|
||||||
|
if not project:
|
||||||
|
raise ValidationException("Проект не найден")
|
||||||
|
return await self.project_repo.upd_project(project_id, column, value)
|
||||||
|
|
||||||
|
async def add_project(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
year: int,
|
||||||
|
org_unit_id: int,
|
||||||
|
level: str = "project",
|
||||||
|
parent_id: int | None = None,
|
||||||
|
project_type: str | None = None,
|
||||||
|
vsp_format: str | None = None,
|
||||||
|
placement_type: str | None = None,
|
||||||
|
object_address: str | None = None,
|
||||||
|
staff_count: int | None = None,
|
||||||
|
total_area: float | None = None,
|
||||||
|
) -> tuple[int, int, int]:
|
||||||
|
return await self.project_repo.add_project(
|
||||||
|
name=name,
|
||||||
|
year=year,
|
||||||
|
org_unit_id=org_unit_id,
|
||||||
|
level=level,
|
||||||
|
parent_id=parent_id,
|
||||||
|
project_type=project_type,
|
||||||
|
vsp_format=vsp_format,
|
||||||
|
placement_type=placement_type,
|
||||||
|
object_address=object_address,
|
||||||
|
staff_count=staff_count,
|
||||||
|
total_area=total_area,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _allowed_org_unit_ids(self, user: AppUser) -> list[int] | None:
|
||||||
|
if user.role_id == UserRoleEnum.ADMIN:
|
||||||
|
return None
|
||||||
|
db_user = await self.user_repo.get(user_id=user.id, load_orgs=True)
|
||||||
|
if not db_user:
|
||||||
|
return []
|
||||||
|
return [ou.id for ou in db_user.org_units]
|
||||||
139
api/src/services/sheet_service.py
Normal file
139
api/src/services/sheet_service.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db.models.app_user import AppUser
|
||||||
|
from src.db.models.budget_form import BudgetForm
|
||||||
|
from src.repository.sheet_repository import SheetRepository
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SheetService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.sheet_repo = SheetRepository(db)
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
sheet: str,
|
||||||
|
direction: str | None,
|
||||||
|
sections: list[str] | None = None,
|
||||||
|
form_id: int | None = None,
|
||||||
|
form: BudgetForm | None = None
|
||||||
|
) -> list[tuple]:
|
||||||
|
return await self.sheet_repo.get(
|
||||||
|
form_id=form_id,
|
||||||
|
sheet=sheet,
|
||||||
|
direction=direction,
|
||||||
|
sections=sections,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_cell(
|
||||||
|
self,
|
||||||
|
form_id: int,
|
||||||
|
sheet: str,
|
||||||
|
direction: str | None,
|
||||||
|
sections: list[str] | None,
|
||||||
|
line_id: int,
|
||||||
|
column: str,
|
||||||
|
value: str | int | float | None = None,
|
||||||
|
user: AppUser | None = None,
|
||||||
|
) -> list[tuple]:
|
||||||
|
return await self.sheet_repo.update_cell(
|
||||||
|
form_id=form_id,
|
||||||
|
sheet=sheet,
|
||||||
|
direction=direction,
|
||||||
|
sections=sections,
|
||||||
|
line_id=line_id,
|
||||||
|
column=column,
|
||||||
|
value=value,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_cells(
|
||||||
|
self,
|
||||||
|
form_id: int,
|
||||||
|
sheet: str,
|
||||||
|
direction: str | None,
|
||||||
|
sections: list[str] | None,
|
||||||
|
changes: list[dict],
|
||||||
|
user: AppUser | None = None,
|
||||||
|
) -> list[tuple]:
|
||||||
|
return await self.sheet_repo.update_cells(
|
||||||
|
form_id=form_id,
|
||||||
|
sheet=sheet,
|
||||||
|
direction=direction,
|
||||||
|
sections=sections,
|
||||||
|
changes=changes,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_cell(
|
||||||
|
self,
|
||||||
|
form_id: int,
|
||||||
|
sheet: str,
|
||||||
|
direction: str | None,
|
||||||
|
sections: list[str] | None,
|
||||||
|
line_id: int,
|
||||||
|
column: str,
|
||||||
|
value: str | int | float | None = None,
|
||||||
|
user: AppUser | None = None,
|
||||||
|
) -> list[tuple]:
|
||||||
|
return await self.sheet_repo.update_cell(
|
||||||
|
form_id=form_id,
|
||||||
|
sheet=sheet,
|
||||||
|
direction=direction,
|
||||||
|
sections=sections,
|
||||||
|
line_id=line_id,
|
||||||
|
column=column,
|
||||||
|
value=value,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_line(
|
||||||
|
self,
|
||||||
|
form_id: int,
|
||||||
|
sheet: str,
|
||||||
|
expense_item_id: int | None = None,
|
||||||
|
item_id: str | None = None,
|
||||||
|
section_code: str | None = None,
|
||||||
|
direction: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
internal_order: str | None = None,
|
||||||
|
vsp_id: int | None = None,
|
||||||
|
project_id: int | None = None,
|
||||||
|
justification: str | None = None,
|
||||||
|
contract_number: str | None = None,
|
||||||
|
contract_end_date: str | None = None,
|
||||||
|
user: AppUser | None = None,
|
||||||
|
) -> list[tuple]:
|
||||||
|
return await self.sheet_repo.add_line(
|
||||||
|
form_id=form_id,
|
||||||
|
sheet=sheet,
|
||||||
|
expense_item_id=expense_item_id,
|
||||||
|
item_id=item_id,
|
||||||
|
section_code=section_code,
|
||||||
|
direction=direction,
|
||||||
|
name=name,
|
||||||
|
internal_order=internal_order,
|
||||||
|
vsp_id=vsp_id,
|
||||||
|
project_id=project_id,
|
||||||
|
justification=justification,
|
||||||
|
contract_number=contract_number,
|
||||||
|
contract_end_date=contract_end_date,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_line(
|
||||||
|
self,
|
||||||
|
form_id: int,
|
||||||
|
sheet: str,
|
||||||
|
row_id: int,
|
||||||
|
direction: str | None = None,
|
||||||
|
user: AppUser | None = None,
|
||||||
|
) -> list[tuple]:
|
||||||
|
return await self.sheet_repo.delete_line(
|
||||||
|
sheet=sheet,
|
||||||
|
row_id=row_id,
|
||||||
|
direction=direction,
|
||||||
|
)
|
||||||
@ -1,12 +1,13 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from 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
|
||||||
|
|
||||||
@ -16,23 +17,40 @@ class UserService:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.user_repo = UserRepository(db)
|
self.user_repo = UserRepository(db)
|
||||||
|
|
||||||
async def get_all(self, current_user: Users, skip: int = 0, limit: int = 100):
|
async def get_all(
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
self,
|
||||||
|
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(skip=skip, limit=limit)
|
return await self.user_repo.get_list(
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
with_count=with_count,
|
||||||
|
load_orgs=load_orgs,
|
||||||
|
)
|
||||||
|
|
||||||
async def get(self, user_id: int, current_user: Users):
|
async def get(
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
self,
|
||||||
|
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)
|
return await self.user_repo.get(user_id=user_id, load_orgs=load_orgs)
|
||||||
|
|
||||||
async def create_user(self, user_data: UserCreate, creator: Users):
|
async def create_user(self, user_data: UserCreate, creator: AppUser):
|
||||||
if creator.role_id != UserRole.ADMIN:
|
if creator.role_id != UserRoleEnum.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
|
|
||||||
role_id = user_data.role_id or UserRole.EXECUTOR_RF.value
|
role_id = user_data.role_id or UserRoleEnum.EXECUTOR_RF.value
|
||||||
try:
|
try:
|
||||||
role = UserRole(role_id)
|
role = UserRoleEnum(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
|
||||||
@ -43,8 +61,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: Users):
|
async def update_user(self, user_id: int, user_data: UserUpdate, user: AppUser):
|
||||||
if user.role_id != UserRole.ADMIN:
|
if user.role_id != UserRoleEnum.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
|
|
||||||
target = await self.user_repo.get(user_id)
|
target = await self.user_repo.get(user_id)
|
||||||
@ -57,7 +75,7 @@ class UserService:
|
|||||||
role = None
|
role = None
|
||||||
if role_id is not None:
|
if role_id is not None:
|
||||||
try:
|
try:
|
||||||
role = UserRole(role_id)
|
role = UserRoleEnum(role_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValidationException("Роль не найдена", field="role_id")
|
raise ValidationException("Роль не найдена", field="role_id")
|
||||||
|
|
||||||
@ -72,47 +90,56 @@ class UserService:
|
|||||||
if conflict:
|
if conflict:
|
||||||
raise UsernameConflictException()
|
raise UsernameConflictException()
|
||||||
|
|
||||||
updated = await self.user_repo.update(user_id, update_payload)
|
updated = await self.user_repo.update(
|
||||||
|
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 (
|
||||||
UserRole.ADMIN.value,
|
UserRoleEnum.ADMIN.value,
|
||||||
UserRole.EXECUTOR_DFIP.value,
|
UserRoleEnum.EXECUTOR_DFIP.value,
|
||||||
)
|
)
|
||||||
has_many_ssp_role_now = role.value in (
|
has_many_ssp_role_now = role.value in (
|
||||||
UserRole.ADMIN.value,
|
UserRoleEnum.ADMIN.value,
|
||||||
UserRole.EXECUTOR_DFIP.value,
|
UserRoleEnum.EXECUTOR_DFIP.value,
|
||||||
)
|
)
|
||||||
is_executor_to_dfip_transition = (
|
is_executor_to_dfip_transition = (
|
||||||
previous_role_id == UserRole.EXECUTOR_RF.value
|
previous_role_id == UserRoleEnum.EXECUTOR_RF.value
|
||||||
and role.value == UserRole.EXECUTOR_DFIP.value
|
and role.value == UserRoleEnum.EXECUTOR_DFIP.value
|
||||||
)
|
)
|
||||||
if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition:
|
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: Users):
|
async def delete_user(self, user_id: int, user: AppUser):
|
||||||
if user.role_id != UserRole.ADMIN:
|
if user.role_id != UserRoleEnum.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: Users):
|
async def get_roles(self, current_user: AppUser):
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
if current_user.role_id != UserRoleEnum.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: Users):
|
async def get_many_ssp(self, user_id: int, current_user: AppUser):
|
||||||
if current_user.role_id == UserRole.ADMIN:
|
if current_user.role_id == UserRoleEnum.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: Users):
|
async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser):
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
if current_user.role_id != UserRoleEnum.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
|
|
||||||
user = await self.user_repo.get(user_id)
|
user = await self.user_repo.get(user_id)
|
||||||
@ -121,9 +148,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 (
|
||||||
UserRole.ADMIN.value,
|
UserRoleEnum.ADMIN.value,
|
||||||
UserRole.EXECUTOR_DFIP.value,
|
UserRoleEnum.EXECUTOR_DFIP.value,
|
||||||
UserRole.EXECUTOR_RF.value,
|
UserRoleEnum.EXECUTOR_RF.value,
|
||||||
):
|
):
|
||||||
err = AccessDeniedException()
|
err = AccessDeniedException()
|
||||||
err.description = (
|
err.description = (
|
||||||
@ -132,7 +159,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 == UserRole.EXECUTOR_RF.value:
|
if target_role_id == UserRoleEnum.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(
|
||||||
@ -141,8 +168,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: Users):
|
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser):
|
||||||
if current_user.role_id != UserRole.ADMIN:
|
if current_user.role_id != UserRoleEnum.ADMIN:
|
||||||
raise AccessDeniedException()
|
raise AccessDeniedException()
|
||||||
|
|
||||||
user = await self.user_repo.get(user_id)
|
user = await self.user_repo.get(user_id)
|
||||||
@ -151,9 +178,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 (
|
||||||
UserRole.ADMIN.value,
|
UserRoleEnum.ADMIN.value,
|
||||||
UserRole.EXECUTOR_DFIP.value,
|
UserRoleEnum.EXECUTOR_DFIP.value,
|
||||||
UserRole.EXECUTOR_RF.value,
|
UserRoleEnum.EXECUTOR_RF.value,
|
||||||
):
|
):
|
||||||
err = AccessDeniedException()
|
err = AccessDeniedException()
|
||||||
err.description = (
|
err.description = (
|
||||||
@ -163,5 +190,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 | UserRole) -> int:
|
def _normalize_role_id(role_id: int | UserRoleEnum) -> int:
|
||||||
return role_id.value if isinstance(role_id, UserRole) else int(role_id)
|
return role_id.value if isinstance(role_id, UserRoleEnum) else int(role_id)
|
||||||
|
|||||||
283
api/src/services/vsp_service.py
Normal file
283
api/src/services/vsp_service.py
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
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.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.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,
|
||||||
|
with_count: bool = False,
|
||||||
|
) -> list[Vsp]:
|
||||||
|
ssp_ids = await self._get_scoped_ssp_ids(user=user)
|
||||||
|
|
||||||
|
if ssp_id is not None:
|
||||||
|
if ssp_ids is None:
|
||||||
|
ssp_ids = [ssp_id]
|
||||||
|
elif ssp_id not in set(ssp_ids):
|
||||||
|
if with_count:
|
||||||
|
return 0, []
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
ssp_ids = [ssp_id]
|
||||||
|
|
||||||
|
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,49 +1,81 @@
|
|||||||
|
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:
|
||||||
yield test_client
|
try:
|
||||||
|
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_tokens(client):
|
def admin_password() -> str:
|
||||||
|
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
|
||||||
|
return os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isp_password() -> str:
|
||||||
|
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
|
||||||
|
return os.getenv("OPENBAO__TEST_EXECUTOR_PASSWORD", "admin123")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_tokens(client, admin_password: str):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": "admin", "password": "admin"},
|
json={"username": "admin", "password": admin_password},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def 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()
|
||||||
|
|||||||
67
api/tests/integration/test_audit_api_integration.py
Normal file
67
api/tests/integration/test_audit_api_integration.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# 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
|
||||||
276
api/tests/integration/test_audit_events_integration.py
Normal file
276
api/tests/integration/test_audit_events_integration.py
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
|
||||||
|
# # закоментированы для избежания переполнения бд
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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):
|
def test_login_success(client, admin_password):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": "admin", "password": "admin"},
|
json={"username": "admin", "password": admin_password},
|
||||||
)
|
)
|
||||||
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):
|
|||||||
assert "refresh_token" in data
|
assert "refresh_token" in data
|
||||||
|
|
||||||
|
|
||||||
def test_login_invalid_password(client):
|
def test_login_invalid_password(client, admin_password):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": "admin", "password": "wrong"},
|
json={"username": "admin", "password": f"{admin_password}_wrong"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|||||||
332
api/tests/integration/test_db_functions_integration.py
Normal file
332
api/tests/integration/test_db_functions_integration.py
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
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)
|
||||||
38
api/tests/integration/test_export_projects_integration.py
Normal file
38
api/tests/integration/test_export_projects_integration.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _get_any_project_report(client, admin_tokens, auth_headers) -> tuple[int, int, str]:
|
||||||
|
list_response = client.get(
|
||||||
|
"/api/v1/projects?limit=50",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
projects = list_response.json().get("result") or []
|
||||||
|
for project in projects:
|
||||||
|
project_id = int(project["id"])
|
||||||
|
details_response = client.get(
|
||||||
|
f"/api/v1/projects/{project_id}",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
if details_response.status_code != 200:
|
||||||
|
continue
|
||||||
|
reports = (details_response.json().get("result") or {}).get("reports") or []
|
||||||
|
if reports:
|
||||||
|
report = reports[0]
|
||||||
|
return project_id, int(report["year"]), str(report["report_type"])
|
||||||
|
pytest.skip("Не найден проект FORM_3 с отчетами для smoke-экспорта")
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_project_report_smoke(client, admin_tokens, auth_headers):
|
||||||
|
project_id, report_year, report_type = _get_any_project_report(client, admin_tokens, auth_headers)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/export/project/{project_id}/report/{report_year}/{report_type}",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == (
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
)
|
||||||
|
assert "attachment; filename*=UTF-8''" in response.headers["content-disposition"]
|
||||||
|
assert len(response.content) > 0
|
||||||
140
api/tests/integration/test_form_phases_api_smoke.py
Normal file
140
api/tests/integration/test_form_phases_api_smoke.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(tokens: dict) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_list_smoke(client, admin_tokens):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/stages/form/1", headers=_auth_headers(admin_tokens)
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert "count" in payload
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_list_filter_smoke(client, admin_tokens):
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/stages/form/1", headers=_auth_headers(admin_tokens)
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "count" in payload
|
||||||
|
count = payload["count"]
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/stages/form/1?sheet=AHR&phase_code=test", headers=_auth_headers(admin_tokens)
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "count" in payload
|
||||||
|
assert payload["count"] <= count
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_create_forbidden(client, isp_tokens):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/stages/form/1",
|
||||||
|
json={
|
||||||
|
"sheet": "AHR",
|
||||||
|
"phase_code": "new_phase",
|
||||||
|
"role": "DFIP",
|
||||||
|
"column_keys": ["plan.q1"],
|
||||||
|
"opens_at": "2026-01-01T00:00:00Z",
|
||||||
|
"closes_at": "2026-12-31T00:00:00Z",
|
||||||
|
},
|
||||||
|
headers=_auth_headers(isp_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_update_forbidden(client, isp_tokens):
|
||||||
|
response = client.patch(
|
||||||
|
"/api/v1/stages/form/1/AHR/test",
|
||||||
|
json={
|
||||||
|
"role": "DFIP",
|
||||||
|
"column_keys": ["plan.q1"],
|
||||||
|
"opens_at": "2026-01-01T00:00:00Z",
|
||||||
|
"closes_at": "2026-12-31T00:00:00Z",
|
||||||
|
},
|
||||||
|
headers=_auth_headers(isp_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_delete_forbidden(client, isp_tokens):
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/stages/form/1/AHR/test",
|
||||||
|
headers=_auth_headers(isp_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_create(client, admin_tokens):
|
||||||
|
body = {
|
||||||
|
"budget_form_id": 1,
|
||||||
|
"sheet": "AHR",
|
||||||
|
"phase_code": "new_phase",
|
||||||
|
"role": "DFIP",
|
||||||
|
"column_keys": ["plan.q1"],
|
||||||
|
"opens_at": "2026-01-01T00:00:00Z",
|
||||||
|
"closes_at": "2026-12-31T00:00:00Z",
|
||||||
|
}
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/stages/form/1",
|
||||||
|
json=body,
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "success" in payload
|
||||||
|
assert payload["success"]
|
||||||
|
assert "result" in payload
|
||||||
|
assert isinstance(payload["result"], dict)
|
||||||
|
assert payload["result"] == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_update(client, admin_tokens):
|
||||||
|
body = {
|
||||||
|
"budget_form_id": 1,
|
||||||
|
"sheet": "AHR",
|
||||||
|
"phase_code": "test",
|
||||||
|
"role": "EXECUTOR_RF",
|
||||||
|
"column_keys": ["plan.q1"],
|
||||||
|
"opens_at": "2026-01-01T00:00:00Z",
|
||||||
|
"closes_at": "2026-12-31T00:00:00Z",
|
||||||
|
}
|
||||||
|
response = client.patch(
|
||||||
|
"/api/v1/stages/form/1/AHR/test",
|
||||||
|
json=body,
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
assert "success" in payload
|
||||||
|
assert payload["success"]
|
||||||
|
assert "result" in payload
|
||||||
|
assert isinstance(payload["result"], dict)
|
||||||
|
assert payload["result"] == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_delete(client, admin_tokens):
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/stages/form/1/AHR/test",
|
||||||
|
headers=_auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
185
api/tests/integration/test_forms_api_smoke.py
Normal file
185
api/tests/integration/test_forms_api_smoke.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_list_smoke(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert "count" in payload
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_smoke(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/form/1", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert isinstance(payload["result"], dict)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_sheets_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/form/999/sheets", headers=auth_headers(admin_tokens)
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
assert isinstance(payload["message"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_sheet_get_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/form/999/sheet/AHR", headers=auth_headers(admin_tokens)
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_cell_update_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
response = client.patch(
|
||||||
|
"/api/v1/form/999/sheet/AHR/cell",
|
||||||
|
json={"line_id": 1, "column": "q1.m1", "value": 100},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_cell_update(client, admin_tokens, auth_headers):
|
||||||
|
response = client.patch(
|
||||||
|
"/api/v1/form/1/sheet/AHR/cell?direction=Support§ions=plan,contract_summary",
|
||||||
|
json={
|
||||||
|
"line_id": 1,
|
||||||
|
"column": "plan.q1",
|
||||||
|
"value": 225
|
||||||
|
},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "result" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_cells_update_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
response = client.patch(
|
||||||
|
"/api/v1/form/999/sheet/AHR/cells",
|
||||||
|
json={"changes": [{"line_id": 1, "column": "q1.m1", "value": 100}]},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_cells_update(client, admin_tokens, auth_headers):
|
||||||
|
response = client.patch(
|
||||||
|
"/api/v1/form/1/sheet/AHR/cells?direction=Support§ions=plan,contract_summary",
|
||||||
|
json={
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"line_id": 1,
|
||||||
|
"column": "plan.q1",
|
||||||
|
"value": 226
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "result" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_add_line_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/form/999/sheet/AHR/line",
|
||||||
|
json={},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_add_line(client, admin_tokens, auth_headers):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary",
|
||||||
|
json={
|
||||||
|
"expense_item_id": 120,
|
||||||
|
"direction": "Support"
|
||||||
|
},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "result" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_delete_line_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/form/999/sheet/AHR/line/1",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_add_form_success(client, admin_tokens, auth_headers):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/form/",
|
||||||
|
json={"year": 2025, "form_type_code": "FORM_1", "org_unit_ids": [1]},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
assert "result" in payload
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
assert len(payload["result"]) == 1
|
||||||
|
assert "year" in payload["result"][0]
|
||||||
|
assert payload["result"][0]["year"] == 2025
|
||||||
|
|
||||||
|
assert "id" in payload["result"][0]
|
||||||
|
form_id = payload["result"][0]["id"]
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/form/{form_id}",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_add_form_org_unit_not_found(client, admin_tokens, auth_headers):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/form/",
|
||||||
|
json={"year": 2025, "form_type_code": "FORM_1", "org_unit_ids": [1, 999]},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_forms_add_form_forbidden_not_admin(client, isp_tokens, auth_headers):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/form/",
|
||||||
|
json={"year": 2025, "form_type_code": "FORM_1", "org_unit_ids": [1]},
|
||||||
|
headers=auth_headers(isp_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
111
api/tests/integration/test_org_units_integration.py
Normal file
111
api/tests/integration/test_org_units_integration.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import pydantic_core
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_units_list_smoke(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/org-unit/", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert "count" in payload
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_units_list_with_users_count(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/org-unit/?load_users_count=true",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
if payload["result"]:
|
||||||
|
assert "users_count" in payload["result"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_units_get(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/org-unit/1", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert payload["result"]["id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_units_get_not_found(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/org-unit/99999", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# def test_org_units_create_smoke(client, admin_tokens, auth_headers):
|
||||||
|
# suffix = uuid.uuid4().hex[:8]
|
||||||
|
# title = f"Test Org {suffix}"
|
||||||
|
# response = client.post(
|
||||||
|
# "/api/v1/org-unit/",
|
||||||
|
# json={"title": title, "is_ssp": True},
|
||||||
|
# headers=auth_headers(admin_tokens),
|
||||||
|
# )
|
||||||
|
# assert response.status_code == 200
|
||||||
|
# payload = response.json()
|
||||||
|
# assert payload["result"]["title"] == title
|
||||||
|
# assert payload["result"]["is_ssp"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# def test_org_units_create_non_ssp(client, admin_tokens, auth_headers):
|
||||||
|
# suffix = uuid.uuid4().hex[:8]
|
||||||
|
# title = f"Test RF {suffix}"
|
||||||
|
# response = client.post(
|
||||||
|
# "/api/v1/org-unit/",
|
||||||
|
# json={"title": title, "is_ssp": False},
|
||||||
|
# headers=auth_headers(admin_tokens),
|
||||||
|
# )
|
||||||
|
# assert response.status_code == 200
|
||||||
|
# payload = response.json()
|
||||||
|
# assert payload["result"]["title"] == title
|
||||||
|
# assert payload["result"]["is_ssp"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# def test_org_units_update_smoke(client, admin_tokens, auth_headers):
|
||||||
|
# new_title = f"Updated_{uuid.uuid4().hex[:8]}"
|
||||||
|
# response = client.patch(
|
||||||
|
# "/api/v1/org-unit/1",
|
||||||
|
# json={"title": new_title},
|
||||||
|
# headers=auth_headers(admin_tokens),
|
||||||
|
# )
|
||||||
|
# assert response.status_code == 200
|
||||||
|
# payload = response.json()
|
||||||
|
# assert payload["result"]["title"] == new_title
|
||||||
|
# assert payload["result"]["id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_units_update_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
response = client.patch(
|
||||||
|
"/api/v1/org-unit/99999",
|
||||||
|
json={"title": "No Matter"},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "code" in payload
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_units_delete_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/org-unit/99999", headers=auth_headers(admin_tokens)
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "code" in payload
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_org_units_access_denied_for_non_admin(client, isp_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/org-unit/", headers=auth_headers(isp_tokens))
|
||||||
|
assert response.status_code == 403
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "code" in payload
|
||||||
|
assert "message" in payload
|
||||||
119
api/tests/integration/test_projects_api_smoke.py
Normal file
119
api/tests/integration/test_projects_api_smoke.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_list_smoke(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/projects", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert "count" in payload
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_project(client, admin_tokens, auth_headers) -> tuple[int, str]:
|
||||||
|
name = f"PT_{uuid.uuid4().hex[:10]}"
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/projects",
|
||||||
|
json={"name": name, "year": 2026, "branch_id": 1},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
return payload["project_id"], name
|
||||||
|
|
||||||
|
|
||||||
|
def _add_line(client, admin_tokens, 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"])
|
||||||
|
|
||||||
|
pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД")
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_write_smoke(client, admin_tokens, auth_headers):
|
||||||
|
project_id, name = _create_project(client, admin_tokens, auth_headers)
|
||||||
|
|
||||||
|
upd_project_response = client.patch(
|
||||||
|
f"/api/v1/project/{project_id}",
|
||||||
|
json={"column": "name", "value": f"{name}_U"},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert upd_project_response.status_code == 200
|
||||||
|
|
||||||
|
line_id = _add_line(client, admin_tokens, auth_headers, project_id)
|
||||||
|
|
||||||
|
upd_cell_response = client.patch(
|
||||||
|
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell",
|
||||||
|
json={"line_id": line_id, "column": "q1.m1", "value": 100},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert upd_cell_response.status_code == 200
|
||||||
|
assert isinstance(upd_cell_response.json(), list)
|
||||||
|
|
||||||
|
upd_cells_response = client.patch(
|
||||||
|
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cells",
|
||||||
|
json={
|
||||||
|
"changes": [
|
||||||
|
{"line_id": line_id, "column": "q1.m2", "value": 200},
|
||||||
|
{"line_id": line_id, "column": "q1.m3", "value": 300},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert upd_cells_response.status_code == 200
|
||||||
|
assert isinstance(upd_cells_response.json(), list)
|
||||||
|
|
||||||
|
del_line_response = client.delete(
|
||||||
|
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert del_line_response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_write_invalid_report_type_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
project_id, _ = _create_project(client, admin_tokens, auth_headers)
|
||||||
|
line_id = _add_line(client, admin_tokens, auth_headers, project_id)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell",
|
||||||
|
json={"line_id": line_id, "column": "q1.m1", "value": 100},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
assert isinstance(payload["message"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_write_report_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
|
project_id, _ = _create_project(client, admin_tokens, auth_headers)
|
||||||
|
line_id = _add_line(client, admin_tokens, auth_headers, project_id)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell",
|
||||||
|
json={"line_id": line_id, "column": "q1.m1", "value": 100},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert payload.get("message") == "Отчёт не найден"
|
||||||
@ -1,30 +1,85 @@
|
|||||||
def _auth_headers(tokens: dict) -> dict:
|
import uuid
|
||||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_users_me(client, admin_tokens):
|
def test_users_me(client, admin_tokens, auth_headers):
|
||||||
response = client.get("/api/v1/users/me", headers=_auth_headers(admin_tokens))
|
response = client.get("/api/v1/users/me", headers=auth_headers(admin_tokens))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["username"] == "admin"
|
assert response.json()["username"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
def test_users_create_and_list(client, admin_tokens):
|
def test_users_list(client, admin_tokens, auth_headers):
|
||||||
create_payload = {
|
response = client.get("/api/v1/users/", headers=auth_headers(admin_tokens))
|
||||||
"email": "user1@example.com",
|
assert response.status_code == 200
|
||||||
"username": "user1",
|
result = response.json()
|
||||||
"password": "pass123",
|
assert "result" in result
|
||||||
"full_name": "User One",
|
assert isinstance(result["result"], list)
|
||||||
"role_id": 2,
|
|
||||||
}
|
|
||||||
created = client.put(
|
|
||||||
"/api/v1/users/",
|
|
||||||
json=create_payload,
|
|
||||||
headers=_auth_headers(admin_tokens),
|
|
||||||
)
|
|
||||||
assert created.status_code == 200
|
|
||||||
|
|
||||||
listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens))
|
|
||||||
assert listed.status_code == 200
|
def test_users_org_list(client, admin_tokens, auth_headers):
|
||||||
usernames = [row["username"] for row in listed.json()["result"]]
|
response = client.get("/api/v1/users/?load_orgs=true", headers=auth_headers(admin_tokens))
|
||||||
assert "admin" in usernames
|
assert response.status_code == 200
|
||||||
assert "user1" in usernames
|
result = response.json()
|
||||||
|
assert "result" in result
|
||||||
|
assert isinstance(result["result"], list)
|
||||||
|
assert len(result["result"]) > 0
|
||||||
|
assert "org_units" in result["result"][0]
|
||||||
|
assert len(result["result"][0]["org_units"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_users_get(client, admin_tokens, auth_headers):
|
||||||
|
|
||||||
|
response = client.get("/api/v1/users/me", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
user = response.json()
|
||||||
|
assert "id" in user
|
||||||
|
user_id = user["id"]
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1/users/{user_id}", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_get_org_list(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/users/me", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
user = response.json()
|
||||||
|
assert "id" in user
|
||||||
|
user_id = user["id"]
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1/users/{user_id}?load_orgs=true", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
assert "org_units" in result
|
||||||
|
assert len(result["org_units"]) > 0
|
||||||
|
|
||||||
|
# Задокуменировано что бы не было переполнения бд, так как юзер удаляется только логически
|
||||||
|
# def test_users_create_smoke(client, admin_tokens, auth_headers):
|
||||||
|
# suffix = uuid.uuid4().hex[:8]
|
||||||
|
# username = f"user_{suffix}"
|
||||||
|
# # create_payload = {
|
||||||
|
# # "email": f"{username}@example.com",
|
||||||
|
# # "username": username,
|
||||||
|
# # "password": "pass123",
|
||||||
|
# # "full_name": "User One",
|
||||||
|
# # "role_id": 2,
|
||||||
|
# # }
|
||||||
|
# created = client.put(
|
||||||
|
# "/api/v1/users/",
|
||||||
|
# json={
|
||||||
|
# "email": f"{username}@example.com",
|
||||||
|
# "username": username,
|
||||||
|
# "password": "pass123",
|
||||||
|
# "full_name": "User One",
|
||||||
|
# "role_id": 2,
|
||||||
|
# },
|
||||||
|
# headers=auth_headers(admin_tokens),
|
||||||
|
# )
|
||||||
|
# assert created.status_code == 200
|
||||||
|
# assert created.json()["result"]["username"] == username
|
||||||
|
|
||||||
|
# # Что бы не было переполнения бд
|
||||||
|
# deleted = client.delete(
|
||||||
|
# f"/api/v1/users/{created.json()['result']['id']}",
|
||||||
|
# headers=auth_headers(admin_tokens),
|
||||||
|
# )
|
||||||
|
# assert deleted.status_code == 200
|
||||||
|
|||||||
228
api/tests/integration/test_vsp.py
Normal file
228
api/tests/integration/test_vsp.py
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_reg_number() -> str:
|
||||||
|
return f"TEST_{uuid.uuid4().hex[:10]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_list_smoke(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/dict/info", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert "count" in payload
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_list_with_filters(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/dict/info?ssp_id=1",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_dropdown_smoke(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/dict/info/dropdown", headers=auth_headers(admin_tokens)
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert "count" in payload
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_dropdown_with_ssp_filter(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/dict/info/dropdown?ssp_id=1",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload["result"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_get_by_id(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get("/api/v1/dict/info/1", headers=auth_headers(admin_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert "result" in payload
|
||||||
|
assert isinstance(payload["result"], dict)
|
||||||
|
assert payload["result"]["id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_get_not_found(client, admin_tokens, auth_headers):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/dict/info/99999", headers=auth_headers(admin_tokens)
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_create_smoke(client, admin_tokens, auth_headers):
|
||||||
|
reg_number = _unique_reg_number()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/dict/info",
|
||||||
|
json={
|
||||||
|
"registration_number": reg_number,
|
||||||
|
"address": "Test Address",
|
||||||
|
"ssp_id": 1,
|
||||||
|
},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["result"]["registration_number"] == reg_number
|
||||||
|
assert payload["result"]["address"] == "Test Address"
|
||||||
|
assert payload["result"]["ssp_id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_create_with_all_fields(client, admin_tokens, auth_headers):
|
||||||
|
reg_number = _unique_reg_number()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/dict/info",
|
||||||
|
json={
|
||||||
|
"registration_number": reg_number,
|
||||||
|
"address": "Full Address",
|
||||||
|
"ssp_id": 1,
|
||||||
|
"system_code": "SYS001",
|
||||||
|
"vsp_type": "Тип 1",
|
||||||
|
"open_date": "2024-01-15",
|
||||||
|
"close_date": "2025-12-31",
|
||||||
|
"approved_format": "Стандарт",
|
||||||
|
"notes": "Тестовое примечание",
|
||||||
|
"location_form": "Встроенное",
|
||||||
|
"numbers": 10,
|
||||||
|
"area": 150.5,
|
||||||
|
"rent_contract_num": "Д-123/24",
|
||||||
|
"rent_end_date": "2025-12-31",
|
||||||
|
},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["result"]["registration_number"] == reg_number
|
||||||
|
assert payload["result"]["system_code"] == "SYS001"
|
||||||
|
assert payload["result"]["vsp_type"] == "Тип 1"
|
||||||
|
assert payload["result"]["open_date"] == "2024-01-15"
|
||||||
|
assert payload["result"]["close_date"] == "2025-12-31"
|
||||||
|
assert payload["result"]["approved_format"] == "Стандарт"
|
||||||
|
assert payload["result"]["location_form"] == "Встроенное"
|
||||||
|
assert payload["result"]["numbers"] == 10
|
||||||
|
assert payload["result"]["area"] == 150.5
|
||||||
|
assert payload["result"]["rent_contract_num"] == "Д-123/24"
|
||||||
|
assert payload["result"]["rent_end_date"] == "2025-12-31"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_create_forbidden_for_executor(client, isp_tokens, auth_headers):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/dict/info",
|
||||||
|
json={
|
||||||
|
"registration_number": _unique_reg_number(),
|
||||||
|
"address": "Should Fail",
|
||||||
|
"ssp_id": 1,
|
||||||
|
},
|
||||||
|
headers=auth_headers(isp_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_update_smoke(client, admin_tokens, auth_headers):
|
||||||
|
reg_number = _unique_reg_number()
|
||||||
|
created = client.post(
|
||||||
|
"/api/v1/dict/info",
|
||||||
|
json={
|
||||||
|
"registration_number": reg_number,
|
||||||
|
"address": "Original Address",
|
||||||
|
"ssp_id": 1,
|
||||||
|
},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
vsp_id = created.json()["result"]["id"]
|
||||||
|
|
||||||
|
new_address = f"Updated Address {uuid.uuid4().hex[:4]}"
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1/dict/info/{vsp_id}",
|
||||||
|
json={"address": new_address},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["result"]["address"] == new_address
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_update_not_found(client, admin_tokens, auth_headers):
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/dict/info/99999",
|
||||||
|
json={"address": "No Matter"},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_delete_smoke(client, admin_tokens, auth_headers):
|
||||||
|
reg_number = _unique_reg_number()
|
||||||
|
created = client.post(
|
||||||
|
"/api/v1/dict/info",
|
||||||
|
json={
|
||||||
|
"registration_number": reg_number,
|
||||||
|
"address": "To Be Deleted",
|
||||||
|
"ssp_id": 1,
|
||||||
|
},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
vsp_id = created.json()["result"]["id"]
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1/dict/info/{vsp_id}",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["message"] == "Запись INFO удалена"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_delete_not_found(client, admin_tokens, auth_headers):
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/dict/info/99999",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_delete_forbidden_for_executor(client, isp_tokens, auth_headers):
|
||||||
|
response = client.delete(
|
||||||
|
"/api/v1/dict/info/1",
|
||||||
|
headers=auth_headers(isp_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
payload = response.json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "message" in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_vsp_requires_auth(client):
|
||||||
|
response = client.get("/api/v1/dict/info")
|
||||||
|
assert response.status_code == 403
|
||||||
78
api/tests/unit/test_auditlog_repository.py
Normal file
78
api/tests/unit/test_auditlog_repository.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.repository.auditlog_repository import AuditLogRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _result_with_scalars(items):
|
||||||
|
result = MagicMock()
|
||||||
|
scalars = MagicMock()
|
||||||
|
scalars.first.return_value = items[0] if items else None
|
||||||
|
scalars.all.return_value = items
|
||||||
|
result.scalars.return_value = scalars
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _result_with_scalar_one(value):
|
||||||
|
result = MagicMock()
|
||||||
|
result.scalar_one.return_value = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db():
|
||||||
|
return AsyncMock(spec=AsyncSession)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repository(mock_db):
|
||||||
|
return AuditLogRepository(mock_db)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_returns_audit_log(repository, mock_db):
|
||||||
|
row = MagicMock(id=10, user_id=1, event_type="WRITE")
|
||||||
|
mock_db.execute.return_value = _result_with_scalars([row])
|
||||||
|
|
||||||
|
result = await repository.get(10)
|
||||||
|
|
||||||
|
assert result is row
|
||||||
|
mock_db.execute.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_returns_none_for_missing_id(repository, mock_db):
|
||||||
|
mock_db.execute.return_value = _result_with_scalars([])
|
||||||
|
|
||||||
|
result = await repository.get(999999)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_all_applies_filters(repository, mock_db):
|
||||||
|
row = MagicMock(id=11, user_id=2, org_unit_id=7, event_type="UPDATE")
|
||||||
|
mock_db.execute.side_effect = [
|
||||||
|
_result_with_scalars([row]),
|
||||||
|
_result_with_scalar_one(1),
|
||||||
|
]
|
||||||
|
|
||||||
|
result, count = await repository.get_all(
|
||||||
|
limit=10,
|
||||||
|
offset=5,
|
||||||
|
user_id=2,
|
||||||
|
org_unit_id=7,
|
||||||
|
task_id=3,
|
||||||
|
form_id=4,
|
||||||
|
event_type="UPDATE",
|
||||||
|
date_from=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
date_to=datetime(2026, 12, 31, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == [row]
|
||||||
|
assert count == 1
|
||||||
|
assert mock_db.execute.await_count == 2
|
||||||
|
|
||||||
91
api/tests/unit/test_auditlog_service.py
Normal file
91
api/tests/unit/test_auditlog_service.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.errors import AccessDeniedException
|
||||||
|
from src.db.models.role import UserRoleEnum
|
||||||
|
from src.services.auditlog_service import AuditLogService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service_with_mock_repo(monkeypatch):
|
||||||
|
mock_repo = MagicMock()
|
||||||
|
mock_repo.get = AsyncMock()
|
||||||
|
mock_repo.get_all = AsyncMock()
|
||||||
|
|
||||||
|
repo_cls = MagicMock(return_value=mock_repo)
|
||||||
|
monkeypatch.setattr("src.services.auditlog_service.AuditLogRepository", repo_cls)
|
||||||
|
|
||||||
|
service = AuditLogService(AsyncMock())
|
||||||
|
return service, mock_repo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_user():
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
role_id=UserRoleEnum.ADMIN.value,
|
||||||
|
email="admin@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def non_admin_user():
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=2,
|
||||||
|
role_id=UserRoleEnum.EXECUTOR_DFIP.value,
|
||||||
|
email="executor@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_all_allowed_for_admin(service_with_mock_repo, admin_user):
|
||||||
|
service, mock_repo = service_with_mock_repo
|
||||||
|
mock_row = SimpleNamespace(id=10)
|
||||||
|
mock_repo.get_all.return_value = [mock_row]
|
||||||
|
|
||||||
|
result = await service.get_all(admin_user, org_unit_id=77, event_type="form")
|
||||||
|
|
||||||
|
assert result == [mock_row]
|
||||||
|
mock_repo.get_all.assert_awaited_once_with(
|
||||||
|
limit=None,
|
||||||
|
offset=None,
|
||||||
|
user_id=None,
|
||||||
|
org_unit_id=77,
|
||||||
|
task_id=None,
|
||||||
|
form_id=None,
|
||||||
|
event_type="form",
|
||||||
|
date_from=None,
|
||||||
|
date_to=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_all_denied_for_non_admin(service_with_mock_repo, non_admin_user):
|
||||||
|
service, mock_repo = service_with_mock_repo
|
||||||
|
|
||||||
|
with pytest.raises(AccessDeniedException):
|
||||||
|
await service.get_all(non_admin_user)
|
||||||
|
|
||||||
|
mock_repo.get_all.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_orm_log_to_response_maps_entity_and_entity_id(service_with_mock_repo):
|
||||||
|
service, _ = service_with_mock_repo
|
||||||
|
log = SimpleNamespace(
|
||||||
|
id=55,
|
||||||
|
user_id=7,
|
||||||
|
event="UPDATE",
|
||||||
|
event_type="fallback_entity",
|
||||||
|
event_dt="2026-05-19T12:00:00Z",
|
||||||
|
event_data={"entity_type": "form_line", "entity_id": "42", "x": 1},
|
||||||
|
user=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
dto = service.orm_log_to_response(log)
|
||||||
|
|
||||||
|
assert dto.entity == "form_line"
|
||||||
|
assert dto.entity_id == 42
|
||||||
|
assert dto.action == "UPDATE"
|
||||||
|
assert dto.user_id == 7
|
||||||
159
api/tests/unit/test_exception_handlers.py
Normal file
159
api/tests/unit/test_exception_handlers.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
|
||||||
|
|
||||||
|
from src.core.exception_handlers import (
|
||||||
|
_error_payload,
|
||||||
|
map_http_exception,
|
||||||
|
map_sqlalchemy_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_sqlalchemy_error_unique_conflict():
|
||||||
|
exc = IntegrityError(
|
||||||
|
statement="INSERT INTO users ...",
|
||||||
|
params={},
|
||||||
|
orig=Exception("duplicate key value violates unique constraint users_username_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code, code, message, field = map_sqlalchemy_error(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_409_CONFLICT
|
||||||
|
assert code == status.HTTP_409_CONFLICT
|
||||||
|
assert "duplicate key value" in message
|
||||||
|
assert field == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_sqlalchemy_error_check_constraint_is_400():
|
||||||
|
exc = IntegrityError(
|
||||||
|
statement="UPDATE v3.project ...",
|
||||||
|
params={},
|
||||||
|
orig=Exception("new row for relation violates check constraint chk_v3_project_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code, code, message, field = map_sqlalchemy_error(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "violates check constraint" in message
|
||||||
|
assert field == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_sqlalchemy_error_validation_prefix():
|
||||||
|
exc = SQLAlchemyError()
|
||||||
|
exc.orig = Exception("unknown_column: q5.plan")
|
||||||
|
|
||||||
|
status_code, code, message, field = map_sqlalchemy_error(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
assert code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
assert message == "unknown_column: q5.plan"
|
||||||
|
assert field == "q5.plan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_sqlalchemy_error_not_found():
|
||||||
|
exc = SQLAlchemyError()
|
||||||
|
exc.orig = Exception("budget_form #99 не существует")
|
||||||
|
|
||||||
|
status_code, code, message, field = map_sqlalchemy_error(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
assert code == status.HTTP_404_NOT_FOUND
|
||||||
|
assert "не существует" in message
|
||||||
|
assert field == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_sqlalchemy_error_role_window_prefixes():
|
||||||
|
role_exc = SQLAlchemyError()
|
||||||
|
role_exc.orig = Exception("role_not_allowed: q1.m1")
|
||||||
|
status_code, code, message, field = map_sqlalchemy_error(role_exc)
|
||||||
|
assert status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert message == "role_not_allowed: q1.m1"
|
||||||
|
assert field == ""
|
||||||
|
|
||||||
|
window_exc = SQLAlchemyError()
|
||||||
|
window_exc.orig = Exception("window_closed: q1.m1 (closed 2026-05-01T00:00:00Z)")
|
||||||
|
status_code, code, message, field = map_sqlalchemy_error(window_exc)
|
||||||
|
assert status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert message.startswith("window_closed:")
|
||||||
|
assert field == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_sqlalchemy_error_operational():
|
||||||
|
exc = OperationalError(
|
||||||
|
statement="SELECT 1",
|
||||||
|
params={},
|
||||||
|
orig=Exception("connection refused"),
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code, code, message, field = map_sqlalchemy_error(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
assert code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
assert message == "Database unavailable"
|
||||||
|
assert field == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_payload_matches_spec_shape():
|
||||||
|
payload = _error_payload(
|
||||||
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
"unknown_column: q5.plan",
|
||||||
|
"q5.plan",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload == {
|
||||||
|
"code": status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
"field": "q5.plan",
|
||||||
|
"message": "unknown_column: q5.plan",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_http_exception_validation_prefix():
|
||||||
|
exc = HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="unknown_column: q5.plan",
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code, code, message, field = map_http_exception(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
assert code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
assert message == "unknown_column: q5.plan"
|
||||||
|
assert field == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_http_exception_from_structured_detail():
|
||||||
|
exc = HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"code": "validation_error", "message": "bad input", "field": "username"},
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code, code, message, field = map_http_exception(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert message == "bad input"
|
||||||
|
assert field == "username"
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_http_exception_unauthorized():
|
||||||
|
exc = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен")
|
||||||
|
|
||||||
|
status_code, code, message, field = map_http_exception(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
assert code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
assert message == "Недействительный токен"
|
||||||
|
assert field == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_http_exception_not_found_maps_code_from_status():
|
||||||
|
exc = HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
|
||||||
|
|
||||||
|
status_code, code, message, field = map_http_exception(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
assert code == status.HTTP_404_NOT_FOUND
|
||||||
|
assert message == "not found"
|
||||||
|
assert field == ""
|
||||||
84
api/tests/unit/test_exception_handlers_http_shape.py
Normal file
84
api/tests/unit/test_exception_handlers_http_shape.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, status
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from src.core.exception_handlers import register_exception_handlers
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app() -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
register_exception_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/http-401")
|
||||||
|
async def http_401():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Недействительный токен",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/http-422-prefixed")
|
||||||
|
async def http_422_prefixed():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="unknown_column: q5.plan",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/sql-error")
|
||||||
|
async def sql_error():
|
||||||
|
exc = SQLAlchemyError()
|
||||||
|
exc.orig = Exception("budget_form #99 не существует")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
@app.get("/form/{form_id}")
|
||||||
|
async def form_by_id(form_id: int):
|
||||||
|
return {"form_id": form_id}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_exception_returns_structured_payload():
|
||||||
|
client = TestClient(_make_app())
|
||||||
|
response = client.get("/http-401")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
assert response.json() == {
|
||||||
|
"code": status.HTTP_401_UNAUTHORIZED,
|
||||||
|
"field": "",
|
||||||
|
"message": "Недействительный токен",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_validation_exception_extracts_field():
|
||||||
|
client = TestClient(_make_app())
|
||||||
|
response = client.get("/http-422-prefixed")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
assert response.json() == {
|
||||||
|
"code": status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
"field": "",
|
||||||
|
"message": "unknown_column: q5.plan",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlalchemy_exception_returns_structured_payload():
|
||||||
|
client = TestClient(_make_app())
|
||||||
|
response = client.get("/sql-error")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
assert response.json() == {
|
||||||
|
"code": status.HTTP_404_NOT_FOUND,
|
||||||
|
"field": "",
|
||||||
|
"message": "budget_form #99 не существует",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_validation_422_returns_structured_payload():
|
||||||
|
client = TestClient(_make_app())
|
||||||
|
response = client.get("/form/not-int")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
assert response.json() == {
|
||||||
|
"code": status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
"field": "form_id",
|
||||||
|
"message": "Input should be a valid integer, unable to parse string as an integer",
|
||||||
|
}
|
||||||
75
api/tests/unit/test_export_normalizers.py
Normal file
75
api/tests/unit/test_export_normalizers.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
from src.services.export_service.export_normalizers import (
|
||||||
|
ExcelValueSerializer,
|
||||||
|
Form1RowNormalizer,
|
||||||
|
Form3ReportRowNormalizer,
|
||||||
|
SmetaRowNormalizer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_form1_normalizer_merges_header_and_contract_fields():
|
||||||
|
serializer = ExcelValueSerializer()
|
||||||
|
normalizer = Form1RowNormalizer(serializer)
|
||||||
|
row = {
|
||||||
|
"header": {"section": "1.00.0.", "item_id": 1, "name": "A"},
|
||||||
|
"contract_detail": {"reference": "DOG-1", "amount": 10},
|
||||||
|
"plan": {"q1": 7},
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalizer.normalize(row)
|
||||||
|
|
||||||
|
assert normalized["section"] == "1.00.0."
|
||||||
|
assert normalized["item_id"] == 1
|
||||||
|
assert normalized["name"] == "A"
|
||||||
|
assert normalized["q1"] == 7
|
||||||
|
assert normalized["contract"] == "DOG-1"
|
||||||
|
assert normalized["contract_sum"] == 10
|
||||||
|
assert "header" not in normalized
|
||||||
|
|
||||||
|
|
||||||
|
def test_smeta_normalizer_extracts_nested_values():
|
||||||
|
normalizer = SmetaRowNormalizer()
|
||||||
|
row = {
|
||||||
|
"section_code": "1",
|
||||||
|
"name": "SMETA row",
|
||||||
|
"plan": {"support": {"q1": 5}, "total_year": 20},
|
||||||
|
"approved": {"development": {"year": 8}},
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalizer.normalize(row)
|
||||||
|
|
||||||
|
assert normalized["section_code"] == "1"
|
||||||
|
assert normalized["name"] == "SMETA row"
|
||||||
|
assert normalized["plan_support_q1"] == 5
|
||||||
|
assert normalized["plan_total_year"] == 20
|
||||||
|
assert normalized["approved_development_year"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_excel_value_serializer_formats_collections():
|
||||||
|
serializer = ExcelValueSerializer()
|
||||||
|
|
||||||
|
assert serializer.to_excel_value(["a", 1, None]) == "a, 1, "
|
||||||
|
assert serializer.to_excel_value({"name": "X"}) == "X"
|
||||||
|
assert serializer.to_excel_value({"k": {"nested": 1}}) == "k=1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_form3_normalizer_flattens_quarters_and_totals_to_scalars():
|
||||||
|
normalizer = Form3ReportRowNormalizer()
|
||||||
|
row = {
|
||||||
|
"header": {"section_code": "1.00.0.", "item_id": "R001", "name": "Строка"},
|
||||||
|
"q1": {"quarter_actual": 10, "total_corr": 12},
|
||||||
|
"q2": {"total_corr": 22},
|
||||||
|
"q3": {"economy": 1.5},
|
||||||
|
"q4": {"m1": 100},
|
||||||
|
"totals": {"total_actual": 40, "economy": 2.1},
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalizer.normalize(row)
|
||||||
|
|
||||||
|
assert normalized["section"] == "1.00.0."
|
||||||
|
assert normalized["item_id"] == "R001"
|
||||||
|
assert normalized["name"] == "Строка"
|
||||||
|
assert normalized["q1"] == 10
|
||||||
|
assert normalized["q2"] == 22
|
||||||
|
assert normalized["q3"] == 1.5
|
||||||
|
assert normalized["q4"] is None
|
||||||
|
assert normalized["totals"] == 40
|
||||||
140
api/tests/unit/test_export_service_query_params.py
Normal file
140
api/tests/unit/test_export_service_query_params.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import io
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, call
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.db.models.form_type import FormTypeEnum
|
||||||
|
from src.domain.schemas import DirectionSchemaEnum
|
||||||
|
from src.services.export_service import ExportService
|
||||||
|
|
||||||
|
|
||||||
|
def _build_form(
|
||||||
|
*,
|
||||||
|
form_type_code: FormTypeEnum = FormTypeEnum.FORM_1,
|
||||||
|
sheet_list: list[str] | None = None,
|
||||||
|
section_list: list[str] | None = None,
|
||||||
|
sheet_list_with_directions: list[dict] | None = None,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
year=2026,
|
||||||
|
form_type_code=form_type_code.value,
|
||||||
|
org_unit_id=10,
|
||||||
|
org_unit=SimpleNamespace(title="Org"),
|
||||||
|
form_type=SimpleNamespace(
|
||||||
|
code=form_type_code,
|
||||||
|
sheet_list=sheet_list or ["AHR", "SMETA"],
|
||||||
|
section_list=section_list or ["plan", "q1"],
|
||||||
|
sheet_list_with_directions=sheet_list_with_directions or [
|
||||||
|
{
|
||||||
|
"sheet_name": "AHR",
|
||||||
|
"direction": "Support",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sheet_name": "AHR",
|
||||||
|
"direction": "Development",
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sheet_name": "SMETA",
|
||||||
|
"direction": None,
|
||||||
|
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_sheet_rejects_direction_for_unsupported_sheet():
|
||||||
|
service = ExportService(AsyncMock())
|
||||||
|
service.budget_form_service.get = AsyncMock(
|
||||||
|
return_value=_build_form(sheet_list=["SMETA"])
|
||||||
|
)
|
||||||
|
service.sheet_service.get = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="direction допустим только"):
|
||||||
|
await service.export_form_to_xlsx(
|
||||||
|
form_id=1,
|
||||||
|
current_user=SimpleNamespace(),
|
||||||
|
sheet="SMETA",
|
||||||
|
direction=DirectionSchemaEnum.SUPPORT.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_sheet_rejects_sections_for_unsupported_sheet():
|
||||||
|
service = ExportService(AsyncMock())
|
||||||
|
service.budget_form_service.get = AsyncMock(
|
||||||
|
return_value=_build_form(
|
||||||
|
sheet_list=["SMETA"],
|
||||||
|
sheet_list_with_directions=[
|
||||||
|
{
|
||||||
|
"sheet_name": "SMETA",
|
||||||
|
"direction": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service.sheet_service.get = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="sections не поддерживается"):
|
||||||
|
await service.export_form_to_xlsx(
|
||||||
|
form_id=1,
|
||||||
|
current_user=SimpleNamespace(),
|
||||||
|
sheet="SMETA",
|
||||||
|
sections=["q1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_full_form_ignores_non_applicable_params_for_other_sheets():
|
||||||
|
service = ExportService(AsyncMock())
|
||||||
|
service.budget_form_service.get = AsyncMock(return_value=_build_form())
|
||||||
|
service.sheet_service.get = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
stream, filename = await service.export_form_to_xlsx(
|
||||||
|
form_id=1,
|
||||||
|
current_user=SimpleNamespace(),
|
||||||
|
direction=DirectionSchemaEnum.SUPPORT.value,
|
||||||
|
sections=["q1"],
|
||||||
|
ignore_non_applicable_query_params=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stream.getbuffer().nbytes > 0
|
||||||
|
assert filename.endswith(".xlsx")
|
||||||
|
assert service.sheet_service.get.await_args_list == [
|
||||||
|
call(
|
||||||
|
form_id=1,
|
||||||
|
sheet="AHR",
|
||||||
|
direction=DirectionSchemaEnum.SUPPORT.value,
|
||||||
|
sections=["q1"],
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
form_id=1,
|
||||||
|
sheet="SMETA",
|
||||||
|
direction=None,
|
||||||
|
sections=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_form_payload_forwards_ignore_flag():
|
||||||
|
service = ExportService(AsyncMock())
|
||||||
|
service.export_form_to_xlsx = AsyncMock(return_value=(io.BytesIO(), "demo.xlsx"))
|
||||||
|
|
||||||
|
await service.export_form_payload(
|
||||||
|
form_id=1,
|
||||||
|
current_user=SimpleNamespace(),
|
||||||
|
ignore_non_applicable_query_params=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service.export_form_to_xlsx.await_count == 1
|
||||||
|
assert (
|
||||||
|
service.export_form_to_xlsx.await_args.kwargs[
|
||||||
|
"ignore_non_applicable_query_params"
|
||||||
|
]
|
||||||
|
is True
|
||||||
|
)
|
||||||
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