Compare commits
306 Commits
main
...
header_for
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3f5dd962 | ||
|
|
d67dbf319b | ||
|
|
e91dfbb801 | ||
| 9f652e5b94 | |||
| 0aa053c0c4 | |||
| 74d191ba20 | |||
| c976ae8c92 | |||
| 28ce0c1511 | |||
|
|
225c68c4ac | ||
| 7c64938020 | |||
| ad7b374fec | |||
| ab77ce458d | |||
| 12d0be1351 | |||
| 79bf3231b9 | |||
|
|
34d5fbc247 | ||
|
|
096a8b817f | ||
|
|
dc621a1223 | ||
| 022aa7b186 | |||
|
|
c7f4fed6f6 | ||
| e5e029e763 | |||
| 520d64c04d | |||
| bad774f162 | |||
| c9346e5219 | |||
|
|
c34825d113 | ||
| 2a88240dd9 | |||
| ab2c1151fa | |||
| c8dbba7e57 | |||
| f27ea41875 | |||
|
|
e06b2dc4d4 | ||
|
|
2f505c7051 | ||
| b07e02e643 | |||
|
|
580d9db311 | ||
| f0358f640d | |||
|
|
27e79a3df5 | ||
| 569138530d | |||
| b664a39469 | |||
| 857827813b | |||
| ec73ab9a2b | |||
| 5537b8229e | |||
|
|
5eb73ddb37 | ||
|
|
4d5a39ef12 | ||
|
|
ccba554b86 | ||
| c4dd35b6bd | |||
| ffe4ff9edb | |||
| 0a99aeed0c | |||
| 8a280781bb | |||
|
|
88e12d1a8b | ||
|
|
6f0b3eb0cc | ||
|
|
97934a3404 | ||
|
|
0b33ba2b40 | ||
|
|
5ddf7479a4 | ||
|
|
bc4e5122c6 | ||
|
|
acd4645a4e | ||
| bdb4c389ce | |||
| 5e6ad47128 | |||
| 19dd443146 | |||
|
|
2cd80343ab | ||
|
|
c0e186baef | ||
|
|
1df748fb03 | ||
|
|
2cc53b6846 | ||
|
|
944fbe4d7e | ||
| 155888f7fc | |||
|
|
019b93092c | ||
|
|
a23a2dffc6 | ||
| 2e781ca809 | |||
|
|
cc62551c92 | ||
|
|
40c373b5ed | ||
| 4ec4936a0f | |||
| 88a9713fb3 | |||
|
|
4e7cc9b3bd | ||
|
|
c8cc1afec1 | ||
| 047cabe14b | |||
| 7fabb22c45 | |||
|
|
5d16915bad | ||
| 66846c6afa | |||
| b283bda007 | |||
|
|
4f8393bedc | ||
| 796640a491 | |||
|
|
03ea64d6b5 | ||
| e91c5e1846 | |||
|
|
8869423ea6 | ||
| c1059736fb | |||
| 7a3ef892cf | |||
|
|
0c10be7eb1 | ||
|
|
48c7ab3eb1 | ||
|
|
20d0520b7d | ||
| 2e5cf3d2af | |||
|
|
3b7c93f5a7 | ||
| 62a2f9bae4 | |||
|
|
e28f6a3887 | ||
|
|
0652ba4aee | ||
|
|
7da934399e | ||
| 3c1722293c | |||
| 905fa98ca5 | |||
| 9c725e9678 | |||
| f26691805a | |||
| 2343b5c9b1 | |||
| 23c5f053bf | |||
| 723a110035 | |||
|
|
8c88acab49 | ||
| 5bfcf1302f | |||
| ee12939515 | |||
| e2a16989ae | |||
| ecb2708b4e | |||
| b3b5574d86 | |||
| 4cc2c120e5 | |||
| bac2a7a780 | |||
| e71864c37f | |||
| 19dbc49dc0 | |||
| 873f0d84b3 | |||
| f1bb6dbf6f | |||
|
|
761a435b98 | ||
|
|
3f9595928e | ||
| 59f9170e9f | |||
| da07e527e9 | |||
| 230090ae22 | |||
| 840d5f5f13 | |||
| ab21b0e496 | |||
|
|
b27ee53a9e | ||
|
|
cc766238f2 | ||
| 63ae001a23 | |||
|
|
04ee7b7fb2 | ||
|
|
c6502e59a7 | ||
| fbf02d80d9 | |||
| c5c7963b5a | |||
| 2581589756 | |||
|
|
80c5b35299 | ||
|
|
3aa9c96f25 | ||
|
|
6d0b48bc8d | ||
|
|
a33ba86ee5 | ||
|
|
e5d2857e49 | ||
|
|
c4d1a10fb8 | ||
|
|
1797044a2b | ||
|
|
02aa4e8899 | ||
|
|
c7a8eb2e3e | ||
| eaad37ded7 | |||
| aceb1d38c1 | |||
|
|
41ccbe43d7 | ||
|
|
6dc31a43b3 | ||
|
|
57eba099c1 | ||
|
|
2fcaea1d8b | ||
|
|
0686b3ae8a | ||
|
|
c8165e8ca9 | ||
|
|
596ec13943 | ||
| d9e1cc4263 | |||
|
|
d138fdcfdb | ||
|
|
aa18a8e19c | ||
| a2841d8c83 | |||
|
|
361eeb24bb | ||
|
|
ca11d55228 | ||
|
|
2df64fb020 | ||
|
|
bb74f511af | ||
|
|
66c1f09df6 | ||
|
|
bafae2ca58 | ||
| 27d698383e | |||
|
|
d2b1b3a4d0 | ||
|
|
d1d1f90268 | ||
| b64120241c | |||
| 115c652887 | |||
| 75fa232bc6 | |||
| 5cc07c7704 | |||
| 6aa703e0f7 | |||
| 3af55f09e2 | |||
| 68ee519c39 | |||
| d99908c9b5 | |||
| b9d109bd23 | |||
|
|
1e50ce6acb | ||
|
|
10909a1122 | ||
|
|
70bf9fa5b5 | ||
| 5b6cda18b2 | |||
| c2c48789a1 | |||
| 7f7c9ab3eb | |||
|
|
8a02913f2e | ||
|
|
9eced76d4b | ||
| 8e5c92a132 | |||
| 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.
|
||||
66
api/alembic/env.py
Normal file
66
api/alembic/env.py
Normal file
@ -0,0 +1,66 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from src.core.config import settings
|
||||
from src.db.base import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = settings.DATABASE_URL
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
include_schemas=True,
|
||||
version_table_schema="v3",
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_schemas=True,
|
||||
version_table_schema="v3",
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.execute(f"CREATE SCHEMA IF NOT EXISTS v3")
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
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"}
|
||||
1074
api/alembic/versions/0001_initial_schema.py
Normal file
1074
api/alembic/versions/0001_initial_schema.py
Normal file
File diff suppressed because it is too large
Load Diff
79
api/alembic/versions/0002_functions.py
Normal file
79
api/alembic/versions/0002_functions.py
Normal file
@ -0,0 +1,79 @@
|
||||
import os
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
||||
|
||||
|
||||
def _find_dollar_tag(line: str) -> Optional[str]:
|
||||
m = _DOLLAR_TAG_RE.search(line.strip())
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
def _split_statements(sql: str) -> List[str]:
|
||||
statements: List[str] = []
|
||||
current: List[str] = []
|
||||
in_dollar = False
|
||||
dollar_tag: Optional[str] = None
|
||||
|
||||
for line in sql.split("\n"):
|
||||
stripped = line.strip()
|
||||
|
||||
if not in_dollar:
|
||||
tag = _find_dollar_tag(stripped)
|
||||
if tag and tag.endswith("$") and tag.startswith("$"):
|
||||
dollar_tag = tag
|
||||
in_dollar = True
|
||||
current.append(line)
|
||||
continue
|
||||
|
||||
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
|
||||
after = stripped[len(dollar_tag):].strip()
|
||||
if after == ";" or after == "":
|
||||
in_dollar = False
|
||||
dollar_tag = None
|
||||
if after == ";":
|
||||
current.append(line)
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
|
||||
if not in_dollar and stripped.rstrip().endswith(";"):
|
||||
current.append(line)
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
|
||||
current.append(line)
|
||||
|
||||
remaining = "\n".join(current).strip()
|
||||
if remaining:
|
||||
statements.append(remaining)
|
||||
|
||||
return statements
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0002_functions.sql")
|
||||
with open(ddl_path) as f:
|
||||
content = f.read()
|
||||
|
||||
statements = _split_statements(content)
|
||||
for stmt in statements:
|
||||
stripped = stmt.strip().rstrip(";").strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
|
||||
continue
|
||||
op.execute(stripped)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
30
api/alembic/versions/0003_initial_data.py
Normal file
30
api/alembic/versions/0003_initial_data.py
Normal file
@ -0,0 +1,30 @@
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
has_data = conn.execute(sa.text("SELECT 1 FROM v3.role LIMIT 1")).scalar()
|
||||
|
||||
if has_data:
|
||||
return
|
||||
sql_path = os.path.join(os.path.dirname(__file__), "sql", "0003_initial_data.sql")
|
||||
with open(sql_path) as f:
|
||||
content = f.read()
|
||||
|
||||
for stmt in content.split(";\n"):
|
||||
stripped = stmt.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
op.execute(stripped)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
63
api/alembic/versions/0004_vsp_delete_with_close_date.py
Normal file
63
api/alembic/versions/0004_vsp_delete_with_close_date.py
Normal file
@ -0,0 +1,63 @@
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0004"
|
||||
down_revision = "0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.del_vsp(p_vsp_id integer, p_deleted_by integer)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_old RECORD;
|
||||
v_usr RECORD;
|
||||
BEGIN
|
||||
SELECT vsp.id, vsp.branch_id
|
||||
INTO v_old
|
||||
FROM v3.vsp vsp WHERE vsp.id = p_vsp_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'vsp #% не существует', p_vsp_id;
|
||||
END IF;
|
||||
SELECT usr.id
|
||||
INTO v_usr
|
||||
FROM v3.app_user usr WHERE usr.id = p_deleted_by;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'user #% не существует', p_deleted_by;
|
||||
END IF;
|
||||
|
||||
UPDATE v3.vsp
|
||||
SET is_deleted = true,
|
||||
is_active = false,
|
||||
updated_by = p_deleted_by
|
||||
WHERE id = p_vsp_id;
|
||||
UPDATE v3.vsp
|
||||
SET closed_at = CURRENT_DATE
|
||||
WHERE id = p_vsp_id
|
||||
AND closed_at IS NULL;
|
||||
|
||||
PERFORM v3.log_event(
|
||||
'DELETE_VSP', 'VSP',
|
||||
jsonb_build_object(
|
||||
'vsp_id', p_vsp_id,
|
||||
'entity_id', p_vsp_id,
|
||||
'branch_id', v_old.branch_id,
|
||||
'updated_by', p_deleted_by
|
||||
),
|
||||
null, null, v_old.branch_id
|
||||
);
|
||||
|
||||
RETURN p_vsp_id;
|
||||
END;
|
||||
$function$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
330
api/alembic/versions/0005_phase_timestamps.py
Normal file
330
api/alembic/versions/0005_phase_timestamps.py
Normal file
@ -0,0 +1,330 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute('ALTER TABLE v3.phase_template ALTER COLUMN opens_at TYPE timestamp USING opens_at::timestamp')
|
||||
op.execute('ALTER TABLE v3.phase_template ALTER COLUMN closes_at TYPE timestamp USING closes_at::timestamp')
|
||||
op.execute("ALTER TABLE v3.org_unit ADD COLUMN utc_offset VARCHAR(3) DEFAULT '+03' NOT NULL")
|
||||
# заводим фазу с нужной таймзоной
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.copy_template_to_form(p_form_id integer)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_form_type VARCHAR;
|
||||
v_inserted INTEGER;
|
||||
v_utc_offset VARCHAR(3);
|
||||
v_org_unit INTEGER;
|
||||
BEGIN
|
||||
SELECT form_type_code, org_unit_id INTO v_form_type, v_org_unit
|
||||
FROM v3.budget_form
|
||||
WHERE id = p_form_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
|
||||
END IF;
|
||||
|
||||
SELECT utc_offset INTO v_utc_offset
|
||||
FROM v3.org_unit
|
||||
WHERE id = v_org_unit;
|
||||
|
||||
INSERT INTO v3.form_phase
|
||||
(budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at)
|
||||
SELECT p_form_id, pt.sheet, pt.phase_code, pt.role,
|
||||
pt.column_keys, (pt.opens_at::text || v_utc_offset)::timestamptz,
|
||||
(pt.closes_at::text || v_utc_offset)::timestamptz
|
||||
FROM v3.phase_template pt
|
||||
WHERE pt.form_type = v_form_type
|
||||
ON CONFLICT (budget_form_id, sheet, phase_code) DO NOTHING;
|
||||
|
||||
GET DIAGNOSTICS v_inserted = ROW_COUNT;
|
||||
RETURN v_inserted;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
# Создавая фазу добавляем таймзону орг юнита
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.add_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp, p_closes_at timestamp)
|
||||
RETURNS v3.form_phase
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_phase v3.form_phase;
|
||||
v_org_unit_id INT;
|
||||
v_org_unit_title VARCHAR;
|
||||
v_form_type_code VARCHAR;
|
||||
v_year INT;
|
||||
v_task_name TEXT;
|
||||
v_phase_id TEXT;
|
||||
v_utc_offset VARCHAR(3);
|
||||
BEGIN
|
||||
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
|
||||
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
|
||||
FROM v3.budget_form bf
|
||||
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
|
||||
WHERE bf.id = p_budget_form_id;
|
||||
|
||||
SELECT utc_offset INTO v_utc_offset
|
||||
FROM v3.org_unit
|
||||
WHERE id = v_org_unit_id;
|
||||
|
||||
INSERT INTO v3.form_phase(
|
||||
budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at
|
||||
)
|
||||
VALUES (
|
||||
p_budget_form_id,
|
||||
p_sheet,
|
||||
p_phase_code,
|
||||
p_role,
|
||||
p_column_keys,
|
||||
CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END,
|
||||
CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END
|
||||
|
||||
)
|
||||
RETURNING * INTO v_phase;
|
||||
|
||||
|
||||
v_task_name := trim(concat(
|
||||
COALESCE(v_form_type_code, ''),
|
||||
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
|
||||
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
|
||||
));
|
||||
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
|
||||
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_WINDOW_CHANGE', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'form_phase',
|
||||
'entity_id', p_budget_form_id,
|
||||
'action', 'create',
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_id', p_budget_form_id,
|
||||
'form_id', p_budget_form_id,
|
||||
'budget_form_id', p_budget_form_id,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'sheet', p_sheet,
|
||||
'phase_code', p_phase_code,
|
||||
'role', v_phase.role,
|
||||
'column_keys', v_phase.column_keys,
|
||||
'opens_at', v_phase.opens_at,
|
||||
'closes_at', v_phase.closes_at
|
||||
),
|
||||
p_budget_form_id, null, v_org_unit_id
|
||||
);
|
||||
|
||||
RETURN v_phase;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
# Обнволяя фазу добавляем таймзону орг юнита
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone)
|
||||
RETURNS v3.form_phase
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_old v3.form_phase;
|
||||
v_new v3.form_phase;
|
||||
v_is_extend BOOLEAN := FALSE;
|
||||
v_has_non_extend_change BOOLEAN := FALSE;
|
||||
v_has_window_change BOOLEAN := FALSE;
|
||||
v_changes JSONB := '{}'::jsonb;
|
||||
v_org_unit_id INT;
|
||||
v_org_unit_title VARCHAR;
|
||||
v_form_type_code VARCHAR;
|
||||
v_year INT;
|
||||
v_task_name TEXT;
|
||||
v_phase_id TEXT;
|
||||
v_utc_offset VARCHAR(3);
|
||||
v_opens_at timestamptz;
|
||||
v_closes_at timestamptz;
|
||||
BEGIN
|
||||
SELECT * INTO v_old
|
||||
FROM v3.form_phase
|
||||
WHERE budget_form_id = p_budget_form_id
|
||||
AND sheet = p_sheet
|
||||
AND phase_code = p_phase_code;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'form_phase не найден (form_id=%, sheet=%, phase=%)',
|
||||
p_budget_form_id, p_sheet, p_phase_code;
|
||||
END IF;
|
||||
|
||||
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year, ou.utc_offset
|
||||
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year, v_utc_offset
|
||||
FROM v3.budget_form bf
|
||||
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
|
||||
WHERE bf.id = p_budget_form_id;
|
||||
v_task_name := trim(concat(
|
||||
COALESCE(v_form_type_code, ''),
|
||||
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
|
||||
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
|
||||
));
|
||||
|
||||
IF p_opens_at is not null THEN
|
||||
v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz;
|
||||
END IF;
|
||||
IF p_closes_at is not null THEN
|
||||
v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz;
|
||||
END IF;
|
||||
|
||||
UPDATE v3.form_phase
|
||||
SET role = COALESCE(p_role, role),
|
||||
column_keys = COALESCE(p_column_keys, column_keys),
|
||||
opens_at = COALESCE(v_opens_at, opens_at),
|
||||
closes_at = COALESCE(v_closes_at, closes_at)
|
||||
WHERE budget_form_id = p_budget_form_id
|
||||
AND sheet = p_sheet
|
||||
AND phase_code = p_phase_code
|
||||
RETURNING * INTO v_new;
|
||||
|
||||
|
||||
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
|
||||
|
||||
v_is_extend := v_new.closes_at > v_old.closes_at;
|
||||
v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role
|
||||
OR v_old.column_keys IS DISTINCT FROM v_new.column_keys
|
||||
OR v_old.opens_at IS DISTINCT FROM v_new.opens_at;
|
||||
v_has_window_change := v_has_non_extend_change
|
||||
OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend);
|
||||
|
||||
IF v_old.role IS DISTINCT FROM v_new.role THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'role', jsonb_build_object('before', v_old.role, 'after', v_new.role)
|
||||
);
|
||||
END IF;
|
||||
IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys)
|
||||
);
|
||||
END IF;
|
||||
IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'opens_at', jsonb_build_object(
|
||||
'before', v_old.opens_at,
|
||||
'after', v_new.opens_at
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'closes_at', jsonb_build_object(
|
||||
'before', v_old.closes_at,
|
||||
'after', v_new.closes_at
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_is_extend THEN
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_EXTEND', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_id', p_budget_form_id,
|
||||
'task_name', NULLIF(v_task_name, ''),
|
||||
'form_id', p_budget_form_id,
|
||||
'budget_form_id', p_budget_form_id,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'sheet', p_sheet,
|
||||
'phase_code', p_phase_code,
|
||||
'closes_at_before', v_old.closes_at,
|
||||
'closes_at_after', v_new.closes_at
|
||||
),
|
||||
p_budget_form_id
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_has_window_change THEN
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_WINDOW_CHANGE', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'form_phase',
|
||||
'entity_id', p_budget_form_id,
|
||||
'action', 'update',
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_id', p_budget_form_id,
|
||||
'task_name', NULLIF(v_task_name, ''),
|
||||
'form_id', p_budget_form_id,
|
||||
'budget_form_id', p_budget_form_id,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'sheet', p_sheet,
|
||||
'phase_code', p_phase_code,
|
||||
'changes', v_changes
|
||||
),
|
||||
p_budget_form_id, null, v_org_unit_id
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN v_new;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
|
||||
# при обновлении таймзоны орг юнита обновляем актуальные фазы, связанные с ними
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.upd_phases_timezones(p_org_unit_id integer, p_old_tz varchar(3), p_new_tz varchar(3))
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_result integer;
|
||||
BEGIN
|
||||
IF p_old_tz = p_new_tz THEN
|
||||
RETURN 0;
|
||||
END IF;
|
||||
WITH updated AS (
|
||||
UPDATE v3.form_phase
|
||||
SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz,
|
||||
closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz
|
||||
WHERE budget_form_id IN (SELECT id FROM v3.budget_form WHERE org_unit_id = p_org_unit_id)
|
||||
AND (
|
||||
opens_at >= CURRENT_TIMESTAMP
|
||||
OR closes_at >= CURRENT_TIMESTAMP
|
||||
)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT COUNT(*) INTO v_result FROM updated;
|
||||
RETURN v_result;
|
||||
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
# op.add_column(
|
||||
# 'org_unit',
|
||||
# sa.Column(
|
||||
# 'utc_offset',
|
||||
# sa.String(3),
|
||||
# nullable=False,
|
||||
# server_default='+03',
|
||||
# )
|
||||
# )
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
79
api/alembic/versions/0006_form3.py
Normal file
79
api/alembic/versions/0006_form3.py
Normal file
@ -0,0 +1,79 @@
|
||||
import os
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0006"
|
||||
down_revision = "0005"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
||||
|
||||
|
||||
def _find_dollar_tag(line: str) -> Optional[str]:
|
||||
m = _DOLLAR_TAG_RE.search(line.strip())
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
def _split_statements(sql: str) -> List[str]:
|
||||
statements: List[str] = []
|
||||
current: List[str] = []
|
||||
in_dollar = False
|
||||
dollar_tag: Optional[str] = None
|
||||
|
||||
for line in sql.split("\n"):
|
||||
stripped = line.strip()
|
||||
|
||||
if not in_dollar:
|
||||
tag = _find_dollar_tag(stripped)
|
||||
if tag and tag.endswith("$") and tag.startswith("$"):
|
||||
dollar_tag = tag
|
||||
in_dollar = True
|
||||
current.append(line)
|
||||
continue
|
||||
|
||||
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
|
||||
after = stripped[len(dollar_tag):].strip()
|
||||
if after == ";" or after == "":
|
||||
in_dollar = False
|
||||
dollar_tag = None
|
||||
if after == ";":
|
||||
current.append(line)
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
|
||||
if not in_dollar and stripped.rstrip().endswith(";"):
|
||||
current.append(line)
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
|
||||
current.append(line)
|
||||
|
||||
remaining = "\n".join(current).strip()
|
||||
if remaining:
|
||||
statements.append(remaining)
|
||||
|
||||
return statements
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0006_form3.sql")
|
||||
with open(ddl_path) as f:
|
||||
content = f.read()
|
||||
|
||||
statements = _split_statements(content)
|
||||
for stmt in statements:
|
||||
stripped = stmt.strip().rstrip(";").strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
|
||||
continue
|
||||
op.execute(stripped)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
180
api/alembic/versions/0007_form_constraints.py
Normal file
180
api/alembic/versions/0007_form_constraints.py
Normal file
@ -0,0 +1,180 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0007"
|
||||
down_revision = "0006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION v3.v_form1_smeta(p_year integer, p_org_unit_id integer)
|
||||
RETURNS TABLE(row_type character varying, depth integer, section_code character varying, name character varying, supp_plan_q1 numeric, supp_plan_q2 numeric, supp_plan_q3 numeric, supp_plan_q4 numeric, supp_plan_year numeric, dev_plan_q1 numeric, dev_plan_q2 numeric, dev_plan_q3 numeric, dev_plan_q4 numeric, dev_plan_year numeric, total_plan_year numeric, supp_appr_q1 numeric, supp_appr_q2 numeric, supp_appr_q3 numeric, supp_appr_q4 numeric, supp_appr_year numeric, dev_appr_q1 numeric, dev_appr_q2 numeric, dev_appr_q3 numeric, dev_appr_q4 numeric, dev_appr_year numeric, total_appr_year numeric, supp_act_q1 numeric, supp_act_q2 numeric, supp_act_q3 numeric, supp_act_q4 numeric, supp_act_year numeric, dev_act_q1 numeric, dev_act_q2 numeric, dev_act_q3 numeric, dev_act_q4 numeric, dev_act_year numeric, total_act_year numeric, supp_corr_q2 numeric, supp_corr_q3 numeric, supp_corr_q4 numeric, dev_corr_q2 numeric, dev_corr_q3 numeric, dev_corr_q4 numeric)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SET search_path TO 'v3', 'pg_catalog'
|
||||
AS $function$
|
||||
#variable_conflict use_column
|
||||
DECLARE
|
||||
v_ahr INT; v_cap INT; v_oper INT;
|
||||
v_sections TEXT[] := ARRAY['plan','approved','q1','q2','q3','q4'];
|
||||
BEGIN
|
||||
-- Одна budget_form на лист содержит ОБА направления (budget_line.direction).
|
||||
-- Support/Development различаются параметром p_direction, а не отдельной формой.
|
||||
WITH ranked AS (
|
||||
SELECT bf.id AS fid, ei.sheet AS sh
|
||||
FROM budget_form bf
|
||||
JOIN budget_line bl ON bl.budget_form_id = bf.id
|
||||
JOIN expense_item ei ON ei.id = bl.expense_item_id
|
||||
WHERE bf.year = p_year AND bf.org_unit_id = p_org_unit_id AND bf.form_type_code = 'FORM_1'
|
||||
GROUP BY bf.id, ei.sheet
|
||||
)
|
||||
SELECT
|
||||
MAX(CASE WHEN sh='AHR' THEN fid END),
|
||||
MAX(CASE WHEN sh='CAP' THEN fid END),
|
||||
MAX(CASE WHEN sh='OPER' THEN fid END)
|
||||
INTO v_ahr, v_cap, v_oper
|
||||
FROM ranked;
|
||||
|
||||
RETURN QUERY
|
||||
WITH
|
||||
ahr_s AS (
|
||||
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
|
||||
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
|
||||
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
|
||||
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
|
||||
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
|
||||
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
|
||||
FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Support', v_sections)
|
||||
WHERE row_type IN ('ROOT','GROUP','ITEM')
|
||||
),
|
||||
ahr_d AS (
|
||||
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
|
||||
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
|
||||
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
|
||||
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
|
||||
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
|
||||
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
|
||||
FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Development', v_sections)
|
||||
WHERE row_type IN ('ROOT','GROUP','ITEM')
|
||||
),
|
||||
cap_s AS (
|
||||
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
|
||||
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
|
||||
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
|
||||
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
|
||||
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
|
||||
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
|
||||
FROM v_form1_sheet_sections(v_cap, 'CAP', 'Support', v_sections)
|
||||
WHERE row_type IN ('ROOT','GROUP','ITEM')
|
||||
),
|
||||
cap_d AS (
|
||||
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
|
||||
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
|
||||
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
|
||||
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
|
||||
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
|
||||
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
|
||||
FROM v_form1_sheet_sections(v_cap, 'CAP', 'Development', v_sections)
|
||||
WHERE row_type IN ('ROOT','GROUP','ITEM')
|
||||
),
|
||||
oper_s AS (
|
||||
SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type,
|
||||
col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr,
|
||||
col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr,
|
||||
col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3,
|
||||
COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4,
|
||||
col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4
|
||||
FROM v_form1_sheet_sections(v_oper, 'OPER', 'Support', v_sections)
|
||||
WHERE row_type IN ('ROOT','GROUP','ITEM')
|
||||
),
|
||||
merged AS (
|
||||
SELECT COALESCE(s.row_type, d.row_type) AS row_type,
|
||||
COALESCE(s.depth, d.depth) AS depth,
|
||||
COALESCE(s.sc, d.sc) AS sc,
|
||||
COALESCE(s.nm, d.nm) AS nm,
|
||||
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
|
||||
d.pq1 AS d_pq1, d.pq2 AS d_pq2, d.pq3 AS d_pq3, d.pq4 AS d_pq4, d.pyr AS d_pyr,
|
||||
s.aq1, s.aq2, s.aq3, s.aq4, s.ayr,
|
||||
d.aq1 AS d_aq1, d.aq2 AS d_aq2, d.aq3 AS d_aq3, d.aq4 AS d_aq4, d.ayr AS d_ayr,
|
||||
s.fq1, s.fq2, s.fq3, s.fq4,
|
||||
d.fq1 AS d_fq1, d.fq2 AS d_fq2, d.fq3 AS d_fq3, d.fq4 AS d_fq4,
|
||||
s.np2, s.np3, s.np4,
|
||||
d.np2 AS d_np2, d.np3 AS d_np3, d.np4 AS d_np4
|
||||
FROM ahr_s s FULL OUTER JOIN ahr_d d ON s.sc = d.sc
|
||||
UNION ALL
|
||||
SELECT COALESCE(s.row_type, d.row_type), COALESCE(s.depth, d.depth),
|
||||
COALESCE(s.sc, d.sc), COALESCE(s.nm, d.nm),
|
||||
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
|
||||
d.pq1, d.pq2, d.pq3, d.pq4, d.pyr,
|
||||
s.aq1, s.aq2, s.aq3, s.aq4, s.ayr,
|
||||
d.aq1, d.aq2, d.aq3, d.aq4, d.ayr,
|
||||
s.fq1, s.fq2, s.fq3, s.fq4,
|
||||
d.fq1, d.fq2, d.fq3, d.fq4,
|
||||
s.np2, s.np3, s.np4,
|
||||
d.np2, d.np3, d.np4
|
||||
FROM cap_s s FULL OUTER JOIN cap_d d ON s.sc = d.sc
|
||||
UNION ALL
|
||||
SELECT s.row_type, s.depth, s.sc, s.nm,
|
||||
s.pq1, s.pq2, s.pq3, s.pq4, s.pyr,
|
||||
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
|
||||
s.aq1, s.aq2, s.aq3, s.aq4, s.ayr,
|
||||
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
|
||||
s.fq1, s.fq2, s.fq3, s.fq4,
|
||||
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC,
|
||||
s.np2, s.np3, s.np4,
|
||||
NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC
|
||||
FROM oper_s s
|
||||
)
|
||||
SELECT
|
||||
m.row_type, m.depth, m.sc, m.nm,
|
||||
m.pq1, m.pq2, m.pq3, m.pq4, m.pyr,
|
||||
m.d_pq1, m.d_pq2, m.d_pq3, m.d_pq4, m.d_pyr,
|
||||
COALESCE(m.pyr,0) + COALESCE(m.d_pyr,0) AS total_plan_year,
|
||||
m.aq1, m.aq2, m.aq3, m.aq4, m.ayr,
|
||||
m.d_aq1, m.d_aq2, m.d_aq3, m.d_aq4, m.d_ayr,
|
||||
COALESCE(m.ayr,0) + COALESCE(m.d_ayr,0) AS total_appr_year,
|
||||
m.fq1, m.fq2, m.fq3, m.fq4, COALESCE(m.fq1,0)+COALESCE(m.fq2,0)+COALESCE(m.fq3,0)+COALESCE(m.fq4,0),
|
||||
m.d_fq1, m.d_fq2, m.d_fq3, m.d_fq4, COALESCE(m.d_fq1,0)+COALESCE(m.d_fq2,0)+COALESCE(m.d_fq3,0)+COALESCE(m.d_fq4,0),
|
||||
COALESCE(m.fq1,0)+COALESCE(m.fq2,0)+COALESCE(m.fq3,0)+COALESCE(m.fq4,0)
|
||||
+ COALESCE(m.d_fq1,0)+COALESCE(m.d_fq2,0)+COALESCE(m.d_fq3,0)+COALESCE(m.d_fq4,0),
|
||||
m.np2, m.np3, m.np4,
|
||||
m.d_np2, m.d_np3, m.d_np4
|
||||
FROM merged m
|
||||
ORDER BY m.sc;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
""")
|
||||
for t_name in (
|
||||
"reserve", "contract_detail", "ckk", "allocation", "sequestration", "contract_summary",
|
||||
"collegial_approval", "rent_detail", "utility_detail", "plan", "budget_line_quarter"
|
||||
):
|
||||
op.execute(f"""
|
||||
delete from v3.{t_name} where line_id in (
|
||||
select id from v3.budget_line where budget_form_id not in (select min(fid) from (
|
||||
SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui
|
||||
FROM v3.budget_form bf
|
||||
) ranked
|
||||
group by yr, ftc, oui));
|
||||
""")
|
||||
op.execute("""
|
||||
delete from v3.budget_line where budget_form_id not in (select min(fid) from (
|
||||
SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui
|
||||
FROM v3.budget_form bf
|
||||
) ranked
|
||||
group by yr, ftc, oui);
|
||||
""")
|
||||
op.execute("""
|
||||
delete from v3.budget_form where id not in (select min(fid) from (
|
||||
SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui
|
||||
FROM v3.budget_form bf
|
||||
) ranked
|
||||
group by yr, ftc, oui);
|
||||
""")
|
||||
op.execute("ALTER TABLE v3.budget_form ADD CONSTRAINT uq_ftype_org_year UNIQUE (form_type_code, org_unit_id, year)")
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
16
api/alembic/versions/0008_rent_end_constraint.py
Normal file
16
api/alembic/versions/0008_rent_end_constraint.py
Normal file
@ -0,0 +1,16 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0008"
|
||||
down_revision = "0007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("update v3.vsp set rent_end_date = coalesce(closed_at, opened_at) where rent_end_date is not null and opened_at is not null and rent_end_date < opened_at")
|
||||
op.execute("alter table v3.vsp add CONSTRAINT chk_rent_date CHECK (((rent_end_date IS NULL) OR (opened_at IS NULL) OR (rent_end_date >= opened_at)))")
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
785
api/alembic/versions/0009_phase3_timestamps.py
Normal file
785
api/alembic/versions/0009_phase3_timestamps.py
Normal file
@ -0,0 +1,785 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0009"
|
||||
down_revision = "0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.copy_template_to_report(p_report_id integer)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_report_type VARCHAR;
|
||||
v_inserted INTEGER;
|
||||
v_org_unit INTEGER;
|
||||
v_utc_offset VARCHAR(3);
|
||||
BEGIN
|
||||
SELECT report_type, org_unit_id INTO v_report_type, v_org_unit
|
||||
FROM v3.rf_project_report AS rpr
|
||||
INNER JOIN v3.project AS p ON p.id = rpr.project_id
|
||||
WHERE rpr.id = p_report_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id;
|
||||
END IF;
|
||||
|
||||
SELECT utc_offset INTO v_utc_offset
|
||||
FROM v3.org_unit
|
||||
WHERE id = v_org_unit;
|
||||
|
||||
INSERT INTO v3.form3_phase
|
||||
(rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at)
|
||||
SELECT p_report_id, pt.phase_code, pt.role,
|
||||
pt.column_keys, (pt.opens_at::text || v_utc_offset)::timestamptz,
|
||||
(pt.closes_at::text || v_utc_offset)::timestamptz
|
||||
FROM v3.phase_template pt
|
||||
WHERE pt.form_type = 'FORM_3'
|
||||
AND pt.sheet = v_report_type
|
||||
ON CONFLICT (rf_project_report_id, phase_code) DO NOTHING;
|
||||
|
||||
GET DIAGNOSTICS v_inserted = ROW_COUNT;
|
||||
RETURN v_inserted;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.add_form3_phase(p_rf_project_report_id integer, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp without time zone, p_closes_at timestamp without time zone)
|
||||
RETURNS v3.form3_phase
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_phase v3.form3_phase;
|
||||
v_org_unit_id INT;
|
||||
v_org_unit_title VARCHAR;
|
||||
v_year INT;
|
||||
v_task_name TEXT;
|
||||
v_phase_id TEXT;
|
||||
v_utc_offset VARCHAR(3);
|
||||
v_report_type VARCHAR;
|
||||
v_project_id INT;
|
||||
BEGIN
|
||||
SELECT p.org_unit_id, ou.title, rpr.year, ou.utc_offset, rpr.report_type, rpr.project_id
|
||||
INTO v_org_unit_id, v_org_unit_title, v_year, v_utc_offset, v_report_type, v_project_id
|
||||
FROM v3.rf_project_report rpr
|
||||
LEFT JOIN v3.project p ON p.id = rpr.project_id
|
||||
LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id
|
||||
WHERE rpr.id = p_rf_project_report_id;
|
||||
|
||||
INSERT INTO v3.form3_phase
|
||||
(rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at)
|
||||
VALUES (
|
||||
p_rf_project_report_id,
|
||||
p_phase_code,
|
||||
p_role,
|
||||
p_column_keys,
|
||||
CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END,
|
||||
CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END
|
||||
|
||||
)
|
||||
RETURNING * INTO v_phase;
|
||||
|
||||
|
||||
v_task_name := trim(concat(
|
||||
'FORM_3',
|
||||
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
|
||||
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
|
||||
));
|
||||
v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code);
|
||||
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_WINDOW_CHANGE', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'project',
|
||||
'entity_id', v_project_id,
|
||||
'core_entity_type', 'form3_phase',
|
||||
'core_entity_id', v_phase_id,
|
||||
'action', 'create',
|
||||
'phase_id', v_phase_id,
|
||||
'task_name', NULLIF(v_task_name, ''),
|
||||
'report_id', p_rf_project_report_id,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'year', v_year,
|
||||
'project_id', v_project_id,
|
||||
'report_type', v_report_type,
|
||||
'phase_code', p_phase_code,
|
||||
'role', v_phase.role,
|
||||
'column_keys', v_phase.column_keys,
|
||||
'opens_at', v_phase.opens_at,
|
||||
'closes_at', v_phase.closes_at
|
||||
),
|
||||
null, null, v_org_unit_id
|
||||
);
|
||||
|
||||
RETURN v_phase;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.upd_form3_phase(p_rf_project_report_id integer, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone)
|
||||
RETURNS v3.form3_phase
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_old v3.form3_phase;
|
||||
v_new v3.form3_phase;
|
||||
v_is_extend BOOLEAN := FALSE;
|
||||
v_has_non_extend_change BOOLEAN := FALSE;
|
||||
v_has_window_change BOOLEAN := FALSE;
|
||||
v_changes JSONB := '{}'::jsonb;
|
||||
v_org_unit_id INT;
|
||||
v_org_unit_title VARCHAR;
|
||||
v_form_type_code VARCHAR;
|
||||
v_year INT;
|
||||
v_task_name TEXT;
|
||||
v_phase_id TEXT;
|
||||
v_utc_offset VARCHAR(3);
|
||||
v_opens_at timestamptz;
|
||||
v_closes_at timestamptz;
|
||||
v_report_type VARCHAR;
|
||||
v_project_id INT;
|
||||
BEGIN
|
||||
SELECT * INTO v_old
|
||||
FROM v3.form3_phase
|
||||
WHERE rf_project_report_id = p_rf_project_report_id
|
||||
AND phase_code = p_phase_code;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'form3_phase не найден (rf_project_report_id=%, phase=%)',
|
||||
p_rf_project_report_id, p_phase_code;
|
||||
END IF;
|
||||
|
||||
SELECT p.org_unit_id, ou.title, rpr.year, ou.utc_offset, rpr.report_type, rpr.project_id
|
||||
INTO v_org_unit_id, v_org_unit_title, v_year, v_utc_offset, v_report_type, v_project_id
|
||||
FROM v3.rf_project_report rpr
|
||||
LEFT JOIN v3.project p ON p.id = rpr.project_id
|
||||
LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id
|
||||
WHERE rpr.id = p_rf_project_report_id;
|
||||
v_task_name := trim(concat(
|
||||
'FORM_3',
|
||||
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
|
||||
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
|
||||
));
|
||||
|
||||
IF p_opens_at is not null THEN
|
||||
v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz;
|
||||
END IF;
|
||||
IF p_closes_at is not null THEN
|
||||
v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz;
|
||||
END IF;
|
||||
|
||||
UPDATE v3.form3_phase
|
||||
SET role = COALESCE(p_role, role),
|
||||
column_keys = COALESCE(p_column_keys, column_keys),
|
||||
opens_at = COALESCE(v_opens_at, opens_at),
|
||||
closes_at = COALESCE(v_closes_at, closes_at)
|
||||
WHERE rf_project_report_id = p_rf_project_report_id
|
||||
AND phase_code = p_phase_code
|
||||
RETURNING * INTO v_new;
|
||||
|
||||
|
||||
v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code);
|
||||
|
||||
v_is_extend := v_new.closes_at > v_old.closes_at;
|
||||
v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role
|
||||
OR v_old.column_keys IS DISTINCT FROM v_new.column_keys
|
||||
OR v_old.opens_at IS DISTINCT FROM v_new.opens_at;
|
||||
v_has_window_change := v_has_non_extend_change
|
||||
OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend);
|
||||
|
||||
IF v_old.role IS DISTINCT FROM v_new.role THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'role', jsonb_build_object('before', v_old.role, 'after', v_new.role)
|
||||
);
|
||||
END IF;
|
||||
IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys)
|
||||
);
|
||||
END IF;
|
||||
IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'opens_at', jsonb_build_object(
|
||||
'before', v_old.opens_at,
|
||||
'after', v_new.opens_at
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'closes_at', jsonb_build_object(
|
||||
'before', v_old.closes_at,
|
||||
'after', v_new.closes_at
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_is_extend THEN
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_EXTEND', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'project',
|
||||
'entity_id', v_project_id,
|
||||
'core_entity_type', 'form3_phase',
|
||||
'core_entity_id', v_phase_id,
|
||||
'action', 'update',
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_name', NULLIF(v_task_name, ''),
|
||||
'report_id', p_rf_project_report_id,
|
||||
'report_type', v_report_type,
|
||||
'project_id', v_project_id,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'year', v_year,
|
||||
'phase_code', p_phase_code,
|
||||
'closes_at_before', v_old.closes_at,
|
||||
'closes_at_after', v_new.closes_at
|
||||
),
|
||||
null, null, v_org_unit_id
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_has_window_change THEN
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_WINDOW_CHANGE', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'project',
|
||||
'entity_id', v_project_id,
|
||||
'core_entity_type', 'form3_phase',
|
||||
'core_entity_id', v_phase_id,
|
||||
'action', 'update',
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_id', p_rf_project_report_id,
|
||||
'task_name', NULLIF(v_task_name, ''),
|
||||
'project_id', v_project_id,
|
||||
'report_type', v_report_type,
|
||||
'year', v_year,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'phase_code', p_phase_code,
|
||||
'changes', v_changes
|
||||
),
|
||||
null, null, v_org_unit_id
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN v_new;
|
||||
END;
|
||||
$function$;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.del_form3_phase(p_rf_project_report_id integer, p_phase_code character varying)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_old v3.form3_phase;
|
||||
v_org_unit_id INT;
|
||||
v_org_unit_title VARCHAR;
|
||||
v_year INT;
|
||||
v_task_name TEXT;
|
||||
v_phase_id TEXT;
|
||||
v_report_type VARCHAR;
|
||||
v_project_id INT;
|
||||
BEGIN
|
||||
DELETE FROM v3.form3_phase
|
||||
WHERE rf_project_report_id = p_rf_project_report_id
|
||||
AND phase_code = p_phase_code
|
||||
RETURNING * INTO v_old;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
SELECT p.org_unit_id, ou.title, rpr.year, rpr.report_type, rpr.project_id
|
||||
INTO v_org_unit_id, v_org_unit_title, v_year, v_report_type, v_project_id
|
||||
FROM v3.rf_project_report rpr
|
||||
LEFT JOIN v3.project p ON p.id = rpr.project_id
|
||||
LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id
|
||||
WHERE rpr.id = p_rf_project_report_id;
|
||||
v_task_name := trim(concat(
|
||||
'FORM_3',
|
||||
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
|
||||
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
|
||||
));
|
||||
v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code);
|
||||
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_WINDOW_CHANGE', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'project',
|
||||
'entity_id', v_project_id,
|
||||
'core_entity_type', 'form3_phase',
|
||||
'core_entity_id', v_phase_id,
|
||||
'action', 'delete',
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_name', NULLIF(v_task_name, ''),
|
||||
'project_id', v_project_id,
|
||||
'year', v_year,
|
||||
'report_type', v_report_type,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'phase_code', p_phase_code,
|
||||
'role', v_old.role,
|
||||
'column_keys', v_old.column_keys,
|
||||
'opens_at', v_old.opens_at,
|
||||
'closes_at', v_old.closes_at
|
||||
),
|
||||
null, null, v_org_unit_id
|
||||
);
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.add_project(p_name character varying, p_year integer, p_org_unit_id integer, p_level character varying DEFAULT 'project'::character varying, p_parent_id integer DEFAULT NULL::integer, p_project_type character varying DEFAULT NULL::character varying, p_vsp_format character varying DEFAULT NULL::character varying, p_placement_type character varying DEFAULT NULL::character varying, p_object_address character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_system_code character varying DEFAULT NULL::character varying)
|
||||
RETURNS TABLE(project_id integer, limit_report_id integer, current_expenses_report_id integer)
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_pid INT;
|
||||
v_lim INT;
|
||||
v_cur INT;
|
||||
BEGIN
|
||||
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
|
||||
RAISE EXCEPTION 'p_name обязателен';
|
||||
END IF;
|
||||
IF p_year IS NULL THEN
|
||||
RAISE EXCEPTION 'p_year обязателен';
|
||||
END IF;
|
||||
IF p_org_unit_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_org_unit_id обязателен';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM v3.org_unit WHERE id = p_org_unit_id) THEN
|
||||
RAISE EXCEPTION 'org_unit id=% не существует', p_org_unit_id;
|
||||
END IF;
|
||||
IF p_parent_id IS NOT NULL AND
|
||||
NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_parent_id AND level = 'program') THEN
|
||||
RAISE EXCEPTION 'parent project id=% не существует или не имеет level=program', p_parent_id;
|
||||
END IF;
|
||||
|
||||
-- INSERT project (CHECK-ы name/project_type/vsp_format/placement_type
|
||||
-- из migrate_form3_v1.sql применятся автоматически).
|
||||
INSERT INTO v3.project (
|
||||
name, level, parent_id, org_unit_id,
|
||||
project_type, vsp_format, placement_type, object_address, staff_count, total_area,
|
||||
system_code
|
||||
) VALUES (
|
||||
p_name, p_level, p_parent_id, p_org_unit_id,
|
||||
p_project_type, p_vsp_format, p_placement_type, p_object_address, p_staff_count, p_total_area,
|
||||
p_system_code
|
||||
) RETURNING id INTO v_pid;
|
||||
|
||||
-- Первый год: 2 отчёта (LIMIT + CURRENT_EXPENSES) + snapshot фаз + сетка статей.
|
||||
-- p_emit_audit=false — событие года покрыто PROJECT_CREATE ниже. Дальнейшие
|
||||
-- годы добавляются отдельно через v3.add_project_year.
|
||||
SELECT y.limit_report_id, y.current_expenses_report_id
|
||||
INTO v_lim, v_cur
|
||||
FROM v3.add_project_year(v_pid, p_year, false) y;
|
||||
|
||||
-- Аудит: PROJECT_CREATE — все заполненные поля + id отчётов
|
||||
PERFORM v3.log_event(
|
||||
'PROJECT_CREATE', 'PROJECT',
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'project_id', v_pid,
|
||||
'name', p_name,
|
||||
'year', p_year,
|
||||
'level', p_level,
|
||||
'parent_id', p_parent_id,
|
||||
'org_unit_id', p_org_unit_id,
|
||||
'project_type', p_project_type,
|
||||
'vsp_format', p_vsp_format,
|
||||
'placement_type', p_placement_type,
|
||||
'object_address', p_object_address,
|
||||
'system_code', p_system_code,
|
||||
'staff_count', p_staff_count,
|
||||
'total_area', p_total_area,
|
||||
'limit_report_id', v_lim,
|
||||
'current_expenses_report_id', v_cur
|
||||
))
|
||||
);
|
||||
|
||||
RETURN QUERY SELECT v_pid, v_lim, v_cur;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.upd_phases_timezones(p_org_unit_id integer, p_old_tz character varying, p_new_tz character varying)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_result integer;
|
||||
v_result3 integer;
|
||||
BEGIN
|
||||
IF p_old_tz = p_new_tz THEN
|
||||
RETURN 0;
|
||||
END IF;
|
||||
WITH updated AS (
|
||||
UPDATE v3.form_phase
|
||||
SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz,
|
||||
closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz
|
||||
WHERE budget_form_id IN (SELECT id FROM v3.budget_form WHERE org_unit_id = p_org_unit_id)
|
||||
AND (
|
||||
opens_at >= CURRENT_TIMESTAMP
|
||||
OR closes_at >= CURRENT_TIMESTAMP
|
||||
)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT COUNT(*) INTO v_result FROM updated;
|
||||
|
||||
WITH updated AS (
|
||||
UPDATE v3.form3_phase
|
||||
SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz,
|
||||
closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz
|
||||
WHERE rf_project_report_id IN (
|
||||
SELECT id FROM V3.rf_project_report rpr WHERE project_id IN (
|
||||
SELECT id FROM v3.project WHERE org_unit_id = p_org_unit_id
|
||||
)
|
||||
)
|
||||
AND (
|
||||
opens_at >= CURRENT_TIMESTAMP
|
||||
OR closes_at >= CURRENT_TIMESTAMP
|
||||
)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT COUNT(*) INTO v_result3 FROM updated;
|
||||
RETURN v_result + v_result3;
|
||||
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.add_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp without time zone, p_closes_at timestamp without time zone)
|
||||
RETURNS v3.form_phase
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_phase v3.form_phase;
|
||||
v_org_unit_id INT;
|
||||
v_org_unit_title VARCHAR;
|
||||
v_form_type_code VARCHAR;
|
||||
v_year INT;
|
||||
v_task_name TEXT;
|
||||
v_phase_id TEXT;
|
||||
v_utc_offset VARCHAR(3);
|
||||
BEGIN
|
||||
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
|
||||
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
|
||||
FROM v3.budget_form bf
|
||||
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
|
||||
WHERE bf.id = p_budget_form_id;
|
||||
|
||||
SELECT utc_offset INTO v_utc_offset
|
||||
FROM v3.org_unit
|
||||
WHERE id = v_org_unit_id;
|
||||
|
||||
INSERT INTO v3.form_phase(
|
||||
budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at
|
||||
)
|
||||
VALUES (
|
||||
p_budget_form_id,
|
||||
p_sheet,
|
||||
p_phase_code,
|
||||
p_role,
|
||||
p_column_keys,
|
||||
CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END,
|
||||
CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END
|
||||
|
||||
)
|
||||
RETURNING * INTO v_phase;
|
||||
|
||||
|
||||
v_task_name := trim(concat(
|
||||
COALESCE(v_form_type_code, ''),
|
||||
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
|
||||
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
|
||||
));
|
||||
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
|
||||
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_WINDOW_CHANGE', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'task',
|
||||
'entity_id', p_budget_form_id,
|
||||
'core_entity_type', 'form_phase',
|
||||
'core_entity_id', v_phase_id,
|
||||
'action', 'create',
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_id', p_budget_form_id,
|
||||
'form_id', p_budget_form_id,
|
||||
'budget_form_id', p_budget_form_id,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'sheet', p_sheet,
|
||||
'phase_code', p_phase_code,
|
||||
'role', v_phase.role,
|
||||
'column_keys', v_phase.column_keys,
|
||||
'opens_at', v_phase.opens_at,
|
||||
'closes_at', v_phase.closes_at
|
||||
),
|
||||
p_budget_form_id, null, v_org_unit_id
|
||||
);
|
||||
|
||||
RETURN v_phase;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.del_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_old v3.form_phase;
|
||||
v_org_unit_id INT;
|
||||
v_org_unit_title VARCHAR;
|
||||
v_form_type_code VARCHAR;
|
||||
v_year INT;
|
||||
v_task_name TEXT;
|
||||
v_phase_id TEXT;
|
||||
BEGIN
|
||||
DELETE FROM v3.form_phase
|
||||
WHERE budget_form_id = p_budget_form_id
|
||||
AND sheet = p_sheet
|
||||
AND phase_code = p_phase_code
|
||||
RETURNING * INTO v_old;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
|
||||
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
|
||||
FROM v3.budget_form bf
|
||||
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
|
||||
WHERE bf.id = p_budget_form_id;
|
||||
v_task_name := trim(concat(
|
||||
COALESCE(v_form_type_code, ''),
|
||||
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
|
||||
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
|
||||
));
|
||||
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
|
||||
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_WINDOW_CHANGE', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'task',
|
||||
'entity_id', p_budget_form_id,
|
||||
'core_entity_type', 'form_phase',
|
||||
'core_entity_id', v_phase_id,
|
||||
'action', 'delete',
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_id', p_budget_form_id,
|
||||
'task_name', NULLIF(v_task_name, ''),
|
||||
'form_id', p_budget_form_id,
|
||||
'budget_form_id', p_budget_form_id,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'sheet', p_sheet,
|
||||
'phase_code', p_phase_code,
|
||||
'role', v_old.role,
|
||||
'column_keys', v_old.column_keys,
|
||||
'opens_at', v_old.opens_at,
|
||||
'closes_at', v_old.closes_at
|
||||
),
|
||||
p_budget_form_id, null, v_org_unit_id
|
||||
);
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone)
|
||||
RETURNS v3.form_phase
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_old v3.form_phase;
|
||||
v_new v3.form_phase;
|
||||
v_is_extend BOOLEAN := FALSE;
|
||||
v_has_non_extend_change BOOLEAN := FALSE;
|
||||
v_has_window_change BOOLEAN := FALSE;
|
||||
v_changes JSONB := '{}'::jsonb;
|
||||
v_org_unit_id INT;
|
||||
v_org_unit_title VARCHAR;
|
||||
v_form_type_code VARCHAR;
|
||||
v_year INT;
|
||||
v_task_name TEXT;
|
||||
v_phase_id TEXT;
|
||||
v_utc_offset VARCHAR(3);
|
||||
v_opens_at timestamptz;
|
||||
v_closes_at timestamptz;
|
||||
BEGIN
|
||||
SELECT * INTO v_old
|
||||
FROM v3.form_phase
|
||||
WHERE budget_form_id = p_budget_form_id
|
||||
AND sheet = p_sheet
|
||||
AND phase_code = p_phase_code;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'form_phase не найден (form_id=%, sheet=%, phase=%)',
|
||||
p_budget_form_id, p_sheet, p_phase_code;
|
||||
END IF;
|
||||
|
||||
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year, ou.utc_offset
|
||||
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year, v_utc_offset
|
||||
FROM v3.budget_form bf
|
||||
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
|
||||
WHERE bf.id = p_budget_form_id;
|
||||
v_task_name := trim(concat(
|
||||
COALESCE(v_form_type_code, ''),
|
||||
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
|
||||
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
|
||||
));
|
||||
|
||||
IF p_opens_at is not null THEN
|
||||
v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz;
|
||||
END IF;
|
||||
IF p_closes_at is not null THEN
|
||||
v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz;
|
||||
END IF;
|
||||
|
||||
UPDATE v3.form_phase
|
||||
SET role = COALESCE(p_role, role),
|
||||
column_keys = COALESCE(p_column_keys, column_keys),
|
||||
opens_at = COALESCE(v_opens_at, opens_at),
|
||||
closes_at = COALESCE(v_closes_at, closes_at)
|
||||
WHERE budget_form_id = p_budget_form_id
|
||||
AND sheet = p_sheet
|
||||
AND phase_code = p_phase_code
|
||||
RETURNING * INTO v_new;
|
||||
|
||||
|
||||
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
|
||||
|
||||
v_is_extend := v_new.closes_at > v_old.closes_at;
|
||||
v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role
|
||||
OR v_old.column_keys IS DISTINCT FROM v_new.column_keys
|
||||
OR v_old.opens_at IS DISTINCT FROM v_new.opens_at;
|
||||
v_has_window_change := v_has_non_extend_change
|
||||
OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend);
|
||||
|
||||
IF v_old.role IS DISTINCT FROM v_new.role THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'role', jsonb_build_object('before', v_old.role, 'after', v_new.role)
|
||||
);
|
||||
END IF;
|
||||
IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys)
|
||||
);
|
||||
END IF;
|
||||
IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'opens_at', jsonb_build_object(
|
||||
'before', v_old.opens_at,
|
||||
'after', v_new.opens_at
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN
|
||||
v_changes := v_changes || jsonb_build_object(
|
||||
'closes_at', jsonb_build_object(
|
||||
'before', v_old.closes_at,
|
||||
'after', v_new.closes_at
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_is_extend THEN
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_EXTEND', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'task',
|
||||
'entity_id', p_budget_form_id,
|
||||
'core_entity_type', 'form_phase',
|
||||
'core_entity_id', v_phase_id,
|
||||
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_id', p_budget_form_id,
|
||||
'task_name', NULLIF(v_task_name, ''),
|
||||
'form_id', p_budget_form_id,
|
||||
'budget_form_id', p_budget_form_id,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'sheet', p_sheet,
|
||||
'phase_code', p_phase_code,
|
||||
'closes_at_before', v_old.closes_at,
|
||||
'closes_at_after', v_new.closes_at
|
||||
),
|
||||
p_budget_form_id
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_has_window_change THEN
|
||||
PERFORM v3.log_event(
|
||||
'ACCESS_WINDOW_CHANGE', 'ACCESS',
|
||||
jsonb_build_object(
|
||||
'entity_type', 'task',
|
||||
'entity_id', p_budget_form_id,
|
||||
'core_entity_type', 'form_phase',
|
||||
'core_entity_id', v_phase_id,
|
||||
'action', 'update',
|
||||
'phase_id', v_phase_id,
|
||||
'phase_name', p_phase_code,
|
||||
'task_id', p_budget_form_id,
|
||||
'task_name', NULLIF(v_task_name, ''),
|
||||
'form_id', p_budget_form_id,
|
||||
'budget_form_id', p_budget_form_id,
|
||||
'org_unit_id', v_org_unit_id,
|
||||
'org_unit_name', v_org_unit_title,
|
||||
'sheet', p_sheet,
|
||||
'phase_code', p_phase_code,
|
||||
'changes', v_changes
|
||||
),
|
||||
p_budget_form_id, null, v_org_unit_id
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN v_new;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
0
api/alembic/versions/__init__.py
Normal file
0
api/alembic/versions/__init__.py
Normal file
8178
api/alembic/versions/sql/0002_functions.sql
Normal file
8178
api/alembic/versions/sql/0002_functions.sql
Normal file
File diff suppressed because one or more lines are too long
4278
api/alembic/versions/sql/0003_initial_data.sql
Normal file
4278
api/alembic/versions/sql/0003_initial_data.sql
Normal file
File diff suppressed because it is too large
Load Diff
2602
api/alembic/versions/sql/0006_form3.sql
Normal file
2602
api/alembic/versions/sql/0006_form3.sql
Normal file
File diff suppressed because it is too large
Load Diff
3
api/back_static/swagger-ui-bundle.js
Normal file
3
api/back_static/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
api/back_static/swagger-ui.css
Normal file
3
api/back_static/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
@ -14,3 +14,4 @@ aiosqlite==0.20.0
|
||||
asyncpg==0.30.0
|
||||
pytest==8.3.2
|
||||
pytest-asyncio==0.24.0
|
||||
openpyxl==3.1.5
|
||||
|
||||
@ -10,8 +10,6 @@ python-multipart==0.0.9
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.27.0
|
||||
alembic==1.16.1
|
||||
aiosqlite==0.20.0
|
||||
asyncpg==0.30.0
|
||||
pytest==8.3.2
|
||||
pytest-asyncio==0.24.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"}
|
||||
46
api/src/api/v1/audit.py
Normal file
46
api/src/api/v1/audit.py
Normal file
@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.v1.deps import require_admin
|
||||
from src.db.session import get_db
|
||||
# from src.domain.models import Users
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.domain.schemas import AuditLog, AuditLogQueryParams, BaseListResponse
|
||||
from src.services.auditlog_service import AuditLogService
|
||||
|
||||
router = APIRouter(tags=["audit"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/audit-logs",
|
||||
response_model=BaseListResponse[AuditLog],
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Получение журнала аудита",
|
||||
description="Возвращает список записей аудита с возможностью фильтрации. Доступно только администраторам.",
|
||||
)
|
||||
async def get_audit_logs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
params: AuditLogQueryParams = Depends(),
|
||||
):
|
||||
offset = (params.page - 1) * params.limit
|
||||
audit_service = AuditLogService(db)
|
||||
|
||||
logs, count = await audit_service.get_all(
|
||||
user=current_user,
|
||||
limit=params.limit,
|
||||
offset=offset,
|
||||
user_id=params.user_id,
|
||||
org_unit_id=params.org_unit_id,
|
||||
task_id=params.task_id,
|
||||
form_id=params.form_id,
|
||||
event_type=params.event_type,
|
||||
event=params.event,
|
||||
date_from=datetime.combine(params.date_from, datetime.min.time()) if params.date_from else None,
|
||||
date_to=datetime.combine(params.date_to, datetime.max.time()) if params.date_to else None,
|
||||
)
|
||||
|
||||
result = [audit_service.orm_log_to_response(log) for log in logs]
|
||||
return BaseListResponse(result=result, count=count)
|
||||
@ -6,46 +6,46 @@ from src.db.session import get_db
|
||||
from src.domain.schemas import LoginRequest, RefreshRequest, Token
|
||||
from src.services.auth_service import AuthService
|
||||
|
||||
if not settings.DEBUG:
|
||||
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
||||
# if not settings.DEBUG:
|
||||
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
if settings.DEBUG:
|
||||
# if settings.DEBUG:
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
auth_service = AuthService(db)
|
||||
token = await auth_service.authenticate_user(login_data.username, login_data.password)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверное имя пользователя или пароль",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return token
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
auth_service = AuthService(db)
|
||||
token = await auth_service.authenticate_user(login_data.username, login_data.password)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверное имя пользователя или пароль",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return token
|
||||
|
||||
else:
|
||||
# else:
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: UserInfo = Depends(get_user_dependency),
|
||||
):
|
||||
auth_service = AuthService(db)
|
||||
token = await auth_service.authenticate_user_via_email(user.email)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверное имя пользователя или пароль",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return token
|
||||
# @router.post("/login", response_model=Token)
|
||||
# async def login(
|
||||
# login_data: LoginRequest,
|
||||
# db: AsyncSession = Depends(get_db),
|
||||
# user: UserInfo = Depends(get_user_dependency),
|
||||
# ):
|
||||
# auth_service = AuthService(db)
|
||||
# token = await auth_service.authenticate_user_via_email(user.email)
|
||||
# if not token:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
# detail="Неверное имя пользователя или пароль",
|
||||
# headers={"WWW-Authenticate": "Bearer"},
|
||||
# )
|
||||
# return token
|
||||
|
||||
|
||||
@router.post("/login-form", response_model=Token)
|
||||
|
||||
@ -5,71 +5,89 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.core.config import settings
|
||||
from src.core.security import verify_token
|
||||
from src.db.session import get_db
|
||||
from src.domain.models import UserRole, Users
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.db.models.role import UserRoleEnum
|
||||
from src.repository.user_repository import UserRepository
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
if settings.DEBUG:
|
||||
# if settings.DEBUG:
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Users:
|
||||
token = credentials.credentials
|
||||
payload = verify_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Недействительный токен",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
async def get_user_by_token(
|
||||
token: str,
|
||||
db: AsyncSession,
|
||||
) -> AppUser:
|
||||
payload = verify_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Недействительный токен",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
username: str | None = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Недействительный токен",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
username: str | None = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Недействительный токен",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
user = await user_repo.get_by_username(username)
|
||||
if user is None:
|
||||
user = await user_repo.get_by_email(username)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Пользователь не найден",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
user_repo = UserRepository(db)
|
||||
user = await user_repo.get_by_username(username)
|
||||
if user is None or not user.is_active:
|
||||
user = await user_repo.get_by_email(username)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Пользователь не найден",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
else:
|
||||
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AppUser:
|
||||
return await get_user_by_token(token=credentials.credentials, db=db)
|
||||
|
||||
async def get_current_user(
|
||||
user: UserInfo = Depends(get_user_dependency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Users:
|
||||
user_repo = UserRepository(db)
|
||||
db_user = await user_repo.get_by_email(user.email)
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Пользователь не найден",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return db_user
|
||||
# else:
|
||||
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
||||
|
||||
# async def get_current_user(
|
||||
# user: UserInfo = Depends(get_user_dependency),
|
||||
# db: AsyncSession = Depends(get_db),
|
||||
# ) -> AppUser:
|
||||
# user_repo = UserRepository(db)
|
||||
# db_user = await user_repo.get_by_email(user.email)
|
||||
# if db_user is None:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
# detail="Пользователь не найден",
|
||||
# headers={"WWW-Authenticate": "Bearer"},
|
||||
# )
|
||||
# return db_user
|
||||
|
||||
|
||||
async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users:
|
||||
async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users:
|
||||
if current_user.role_id != UserRole.ADMIN:
|
||||
async def get_current_active_user_with_set_db(
|
||||
current_user: AppUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AppUser:
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
await user_repo.set_app_user_id(current_user.id)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin(
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
) -> AppUser:
|
||||
if current_user.role_id != UserRoleEnum.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав",
|
||||
@ -77,11 +95,13 @@ def require_admin(current_user: Users = Depends(get_current_active_user)) -> Use
|
||||
return current_user
|
||||
|
||||
|
||||
def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users:
|
||||
def require_executor(
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
) -> AppUser:
|
||||
if current_user.role_id not in [
|
||||
UserRole.ADMIN,
|
||||
UserRole.EXECUTOR_DFIP,
|
||||
UserRole.EXECUTOR_RF,
|
||||
UserRoleEnum.ADMIN,
|
||||
UserRoleEnum.EXECUTOR_DFIP,
|
||||
UserRoleEnum.EXECUTOR_RF,
|
||||
]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@ -90,8 +110,10 @@ def require_executor(current_user: Users = Depends(get_current_active_user)) ->
|
||||
return current_user
|
||||
|
||||
|
||||
def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users:
|
||||
if current_user.role_id not in [UserRole.ADMIN, UserRole.EXECUTOR_DFIP]:
|
||||
def require_executor_dfip(
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
) -> AppUser:
|
||||
if current_user.role_id not in [UserRoleEnum.ADMIN, UserRoleEnum.EXECUTOR_DFIP]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав",
|
||||
|
||||
53
api/src/api/v1/expense_item.py
Normal file
53
api/src/api/v1/expense_item.py
Normal file
@ -0,0 +1,53 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.services.expense_item_service import ExpenseItemService
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.api.v1.deps import require_admin, require_executor
|
||||
from src.db.session import get_db
|
||||
from src.domain.schemas import (
|
||||
BaseListResponse,
|
||||
BaseSingleResponse,
|
||||
ExpenseItemResponseSchema,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/expense-item", tags=["expense-item"])
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=BaseSingleResponse[ExpenseItemResponseSchema])
|
||||
async def get_expense_item_by_id(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
):
|
||||
"""Получение записи expense item по ID."""
|
||||
service = ExpenseItemService(db)
|
||||
result = await service.get(item_id, user=current_user)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Expense Item не найден",
|
||||
)
|
||||
return BaseSingleResponse(
|
||||
success=True,
|
||||
message="Запись Expense Item",
|
||||
result=ExpenseItemResponseSchema.model_validate(result),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=BaseListResponse[ExpenseItemResponseSchema])
|
||||
async def get_expense_item_by_id(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_executor),
|
||||
r_start: bool | None = None,
|
||||
):
|
||||
"""Получение записей expense item"""
|
||||
service = ExpenseItemService(db)
|
||||
result = await service.get_list(r_start=r_start)
|
||||
|
||||
return BaseListResponse(
|
||||
success=True,
|
||||
message="Запись Expense Item",
|
||||
result=[ExpenseItemResponseSchema.model_validate(el) for el in result],
|
||||
)
|
||||
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)},
|
||||
)
|
||||
|
||||
|
||||
344
api/src/api/v1/form_phases.py
Normal file
344
api/src/api/v1/form_phases.py
Normal file
@ -0,0 +1,344 @@
|
||||
import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.services.project_service import ProjectService
|
||||
from src.services.form3_phase_service import Form3PhaseService
|
||||
from src.services.rf_project_report_service import RfProjectReportService
|
||||
from src.services.org_unit_service import OrgUnitService
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.domain.schemas import (
|
||||
BaseListResponse,
|
||||
BaseSingleResponse,
|
||||
Form3PhaseCreate,
|
||||
Form3PhaseResponse,
|
||||
Form3PhaseUpdate,
|
||||
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"])
|
||||
|
||||
|
||||
def timezone_from_offset(offset: str) -> datetime.timezone | None:
|
||||
if not offset:
|
||||
return None
|
||||
|
||||
hours = int(offset[1:])
|
||||
return datetime.timezone(
|
||||
datetime.timedelta(
|
||||
hours=hours if offset[0] == '+' else -hours
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
org_unit_service = OrgUnitService(db)
|
||||
bf_service = BudgetFormService(db)
|
||||
|
||||
phases = await fp_service.get_list(
|
||||
budget_form_id=form_id,
|
||||
user=current_user,
|
||||
sheet=sheet,
|
||||
phase_code=phase_code,
|
||||
)
|
||||
if phases:
|
||||
form = await bf_service.get(user=current_user, budget_form_id=form_id)
|
||||
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
|
||||
result = [FormPhaseResponse.model_validate(p) for p in phases]
|
||||
if org_unit.utc_offset:
|
||||
tz = timezone_from_offset(org_unit.utc_offset)
|
||||
for element in result:
|
||||
if element.opens_at:
|
||||
element.opens_at = element.opens_at.astimezone(tz)
|
||||
if element.closes_at:
|
||||
element.closes_at = element.closes_at.astimezone(tz)
|
||||
else:
|
||||
result = []
|
||||
return BaseListResponse(
|
||||
result=result,
|
||||
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, "Форма не найдена")
|
||||
|
||||
org_unit_service = OrgUnitService(db)
|
||||
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
|
||||
|
||||
fp_service = FormPhaseService(db)
|
||||
phase = await fp_service.create(
|
||||
budget_form=form,
|
||||
body=body,
|
||||
user=current_user,
|
||||
)
|
||||
|
||||
result = FormPhaseResponse.model_validate(phase)
|
||||
|
||||
if org_unit.utc_offset:
|
||||
tz = timezone_from_offset(org_unit.utc_offset)
|
||||
if result.opens_at:
|
||||
result.opens_at = result.opens_at.astimezone(tz)
|
||||
if result.closes_at:
|
||||
result.closes_at = result.closes_at.astimezone(tz)
|
||||
return BaseSingleResponse(result=result)
|
||||
|
||||
|
||||
@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, "Этап не найден")
|
||||
|
||||
org_unit_service = OrgUnitService(db)
|
||||
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
|
||||
result = FormPhaseResponse.model_validate(phase)
|
||||
|
||||
if org_unit.utc_offset:
|
||||
tz = timezone_from_offset(org_unit.utc_offset)
|
||||
if result.opens_at:
|
||||
result.opens_at = result.opens_at.astimezone(tz)
|
||||
if result.closes_at:
|
||||
result.closes_at = result.closes_at.astimezone(tz)
|
||||
return BaseSingleResponse(result=result)
|
||||
|
||||
|
||||
@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, "Этап не найден")
|
||||
|
||||
|
||||
@router.get("/project/{project_id}")
|
||||
async def get_form3_phases(
|
||||
project_id: int,
|
||||
year: int | None = None,
|
||||
report_type: 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[Form3PhaseResponse]:
|
||||
f3p_service = Form3PhaseService(db)
|
||||
report_service = RfProjectReportService(db)
|
||||
project_service = ProjectService(db)
|
||||
org_unit_service = OrgUnitService(db)
|
||||
|
||||
reports = await report_service.get_list(
|
||||
project_id=project_id,
|
||||
year=year,
|
||||
report_type=report_type,
|
||||
)
|
||||
|
||||
phases = await f3p_service.get_list(
|
||||
report_id=[el.id for el in reports],
|
||||
phase_code=phase_code,
|
||||
)
|
||||
if phases:
|
||||
project = await project_service.get_instance(user=current_user, project_id=project_id)
|
||||
org_unit = await org_unit_service.get(user=current_user, org_unit_id=project.org_unit_id)
|
||||
result = [Form3PhaseResponse.model_validate(p) for p in phases]
|
||||
if org_unit.utc_offset:
|
||||
tz = timezone_from_offset(org_unit.utc_offset)
|
||||
for element in result:
|
||||
if element.opens_at:
|
||||
element.opens_at = element.opens_at.astimezone(tz)
|
||||
if element.closes_at:
|
||||
element.closes_at = element.closes_at.astimezone(tz)
|
||||
else:
|
||||
result = []
|
||||
if result:
|
||||
reports = {report.id: report for report in reports}
|
||||
for i, element in enumerate(result):
|
||||
element.year = reports[phases[i].rf_project_report_id].year
|
||||
element.report_type = reports[phases[i].rf_project_report_id].report_type
|
||||
return BaseListResponse(
|
||||
result=result,
|
||||
count=len(phases),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/project/{project_id}")
|
||||
async def create_form3_phase(
|
||||
project_id: int,
|
||||
body: Form3PhaseCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
) -> BaseSingleResponse[Form3PhaseResponse]:
|
||||
report_service = RfProjectReportService(db)
|
||||
report = await report_service.get(
|
||||
project_id=project_id,
|
||||
report_type=body.report_type,
|
||||
year=body.year,
|
||||
user=current_user,
|
||||
load_project=True,
|
||||
)
|
||||
if not report:
|
||||
raise HTTPException(404, "Форма не найдена")
|
||||
|
||||
org_unit_service = OrgUnitService(db)
|
||||
org_unit = await org_unit_service.get(user=current_user, org_unit_id=report.project.org_unit_id)
|
||||
|
||||
f3p_service = Form3PhaseService(db)
|
||||
phase = await f3p_service.create(
|
||||
report_id=report.id,
|
||||
body=body,
|
||||
user=current_user,
|
||||
)
|
||||
|
||||
result = Form3PhaseResponse.model_validate(phase)
|
||||
|
||||
if org_unit.utc_offset:
|
||||
tz = timezone_from_offset(org_unit.utc_offset)
|
||||
if result.opens_at:
|
||||
result.opens_at = result.opens_at.astimezone(tz)
|
||||
if result.closes_at:
|
||||
result.closes_at = result.closes_at.astimezone(tz)
|
||||
|
||||
result.year = body.year
|
||||
result.report_type = body.report_type
|
||||
|
||||
return BaseSingleResponse(result=result)
|
||||
|
||||
|
||||
@router.patch("/project/{project_id}/{year}/{report_type}/{phase_code}")
|
||||
async def update_form3_phase(
|
||||
project_id: int,
|
||||
year: int,
|
||||
report_type: str,
|
||||
phase_code: str,
|
||||
body: Form3PhaseUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
) -> BaseSingleResponse[Form3PhaseResponse]:
|
||||
report_service = RfProjectReportService(db)
|
||||
report = await report_service.get(
|
||||
project_id=project_id,
|
||||
report_type=report_type,
|
||||
year=year,
|
||||
user=current_user,
|
||||
load_project=True,
|
||||
)
|
||||
if not report:
|
||||
raise HTTPException(404, "Форма не найдена")
|
||||
|
||||
f3p_service = Form3PhaseService(db)
|
||||
phase = await f3p_service.update(
|
||||
report_id=report.id,
|
||||
phase_code=phase_code,
|
||||
body=body,
|
||||
user=current_user,
|
||||
)
|
||||
|
||||
if not phase:
|
||||
raise HTTPException(404, "Этап не найден")
|
||||
|
||||
org_unit_service = OrgUnitService(db)
|
||||
org_unit = await org_unit_service.get(user=current_user, org_unit_id=report.project.org_unit_id)
|
||||
result = Form3PhaseResponse.model_validate(phase)
|
||||
|
||||
if org_unit.utc_offset:
|
||||
tz = timezone_from_offset(org_unit.utc_offset)
|
||||
if result.opens_at:
|
||||
result.opens_at = result.opens_at.astimezone(tz)
|
||||
if result.closes_at:
|
||||
result.closes_at = result.closes_at.astimezone(tz)
|
||||
|
||||
result.year = year
|
||||
result.report_type = report_type
|
||||
|
||||
return BaseSingleResponse(result=result)
|
||||
|
||||
|
||||
@router.delete("/project/{project_id}/{year}/{report_type}/{phase_code}")
|
||||
async def delete_form3_phase(
|
||||
project_id: int,
|
||||
year: int,
|
||||
report_type: str,
|
||||
phase_code: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
):
|
||||
report_service = RfProjectReportService(db)
|
||||
report = await report_service.get(
|
||||
project_id=project_id,
|
||||
report_type=report_type,
|
||||
year=year,
|
||||
user=current_user,
|
||||
load_project=True,
|
||||
)
|
||||
if not report:
|
||||
raise HTTPException(404, "Форма не найдена")
|
||||
|
||||
f3p_service = Form3PhaseService(db)
|
||||
deleted = await f3p_service.delete(
|
||||
report_id=report.id,
|
||||
phase_code=phase_code,
|
||||
user=current_user,
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(404, "Этап не найден")
|
||||
468
api/src/api/v1/forms.py
Normal file
468
api/src/api/v1/forms.py
Normal file
@ -0,0 +1,468 @@
|
||||
import logging
|
||||
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"}
|
||||
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
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}"
|
||||
extra={
|
||||
"type": "db_time",
|
||||
"time_ms": f"{db_ms:.2f}",
|
||||
"handler": "get_sheet",
|
||||
"form_id": form_id,
|
||||
"sheet": sheet,
|
||||
"direction": direction,
|
||||
"sections": sections,
|
||||
"user": current_user.id,
|
||||
}
|
||||
logger.info(
|
||||
f"db_time: {extra}",
|
||||
extra=extra,
|
||||
)
|
||||
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))}")
|
||||
|
||||
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}"
|
||||
extra={
|
||||
"type": "db_time",
|
||||
"time_ms": f"{db_ms:.2f}",
|
||||
"handler": "update_form_cell",
|
||||
"form_id": form_id,
|
||||
"sheet": sheet,
|
||||
"direction": direction,
|
||||
"sections": sections,
|
||||
"user": current_user.id,
|
||||
"line_id": cell_body.line_id,
|
||||
"column": cell_body.column,
|
||||
}
|
||||
logger.info(
|
||||
f"db_time: {extra}",
|
||||
extra=extra,
|
||||
)
|
||||
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))}")
|
||||
|
||||
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}"
|
||||
extra={
|
||||
"type": "db_time",
|
||||
"time_ms": f"{db_ms:.2f}",
|
||||
"handler": "update_form_cells",
|
||||
"form_id": form_id,
|
||||
"sheet": sheet,
|
||||
"direction": direction,
|
||||
"sections": sections,
|
||||
"user": current_user.id,
|
||||
"count": len(cells_body.changes)
|
||||
}
|
||||
logger.info(
|
||||
f"db_time: {extra}",
|
||||
extra=extra,
|
||||
)
|
||||
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}"
|
||||
|
||||
extra={
|
||||
"type": "db_time",
|
||||
"time_ms": f"{db_ms:.2f}",
|
||||
"handler": "add_form_line",
|
||||
"form_id": form_id,
|
||||
"sheet": sheet,
|
||||
"direction": body.direction,
|
||||
"user": current_user.id,
|
||||
"expense_item_id": body.expense_item_id
|
||||
}
|
||||
logger.info(
|
||||
f"db_time: {extra}",
|
||||
extra=extra,
|
||||
)
|
||||
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}"
|
||||
extra={
|
||||
"type": "db_time",
|
||||
"time_ms": f"{db_ms:.2f}",
|
||||
"handler": "del_form_line",
|
||||
"form_id": form_id,
|
||||
"sheet": sheet,
|
||||
"direction": direction,
|
||||
"user": current_user.id,
|
||||
"line_id": row_id,
|
||||
}
|
||||
logger.info(
|
||||
f"db_time: {extra}",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
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_if_not_exists(
|
||||
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}"
|
||||
extra={
|
||||
"type": "db_time",
|
||||
"time_ms": f"{db_ms:.2f}",
|
||||
"handler": "add_form",
|
||||
"form_type_code": form.form_type_code,
|
||||
"user": current_user.id,
|
||||
"count_orgs": len(form.org_unit_ids),
|
||||
}
|
||||
logger.info(
|
||||
f"db_time: {extra}",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
return BaseListResponse(
|
||||
result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms],
|
||||
count=len(new_forms),
|
||||
)
|
||||
126
api/src/api/v1/org_unit.py
Normal file
126
api/src/api/v1/org_unit.py
Normal file
@ -0,0 +1,126 @@
|
||||
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, OrgUnitCreateSchema, 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: OrgUnitCreateSchema,
|
||||
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,
|
||||
utc_offset=org_data.utc_offset,
|
||||
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="ССП/РФ удалена")
|
||||
278
api/src/api/v1/projects.py
Normal file
278
api/src/api/v1/projects.py
Normal file
@ -0,0 +1,278 @@
|
||||
import logging
|
||||
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
|
||||
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
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}"
|
||||
extra={
|
||||
"type": "db_time",
|
||||
"time_ms": f"{db_ms:.2f}",
|
||||
"handler": "get_project_report",
|
||||
"project_id": project_id,
|
||||
"year": year,
|
||||
"report_type": report_type,
|
||||
"sections": sections,
|
||||
"user": current_user.id,
|
||||
}
|
||||
logger.info(
|
||||
f"db_time: {extra}",
|
||||
extra=extra,
|
||||
)
|
||||
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}"
|
||||
extra={
|
||||
"type": "db_time",
|
||||
"time_ms": f"{db_ms:.2f}",
|
||||
"handler": "get_project_report",
|
||||
"branch_id": branch_id,
|
||||
"year": year,
|
||||
"sections": sections,
|
||||
"user": current_user.id,
|
||||
}
|
||||
logger.info(
|
||||
f"db_time: {extra}",
|
||||
extra=extra,
|
||||
)
|
||||
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,22 @@
|
||||
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, expense_item
|
||||
)
|
||||
|
||||
from src.api.v1 import websocket
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(admin.router)
|
||||
api_router.include_router(audit.router)
|
||||
api_router.include_router(forms.router)
|
||||
api_router.include_router(projects.router)
|
||||
api_router.include_router(form_phases.router)
|
||||
api_router.include_router(export.router)
|
||||
api_router.include_router(websocket.router)
|
||||
api_router.include_router(org_unit.router)
|
||||
api_router.include_router(vsp.router)
|
||||
api_router.include_router(expense_item.router)
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.domain.models import Users
|
||||
from src.domain.schemas import User as UserSchema
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.domain.schemas import BaseListResponse, BaseSingleResponse, User as UserSchema, UserAdminListResponse
|
||||
from src.domain.schemas import (
|
||||
SSPIDList,
|
||||
ResponseBase,
|
||||
@ -20,28 +24,48 @@ from src.services.user_service import UserService
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserSchema)
|
||||
async def get_current_user_info(current_user: Users = Depends(get_current_active_user)):
|
||||
return current_user
|
||||
@router.get("/me", response_model=UserAdminListResponse)
|
||||
async def get_current_user_info(
|
||||
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(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
load_orgs: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Users = Depends(require_admin),
|
||||
):
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
) -> BaseListResponse[UserAdminListResponse]:
|
||||
user_service = UserService(db)
|
||||
users = await user_service.get_all(current_user, skip=skip, limit=limit)
|
||||
return UserListResponse(success=True, message="Список пользователей", result=users)
|
||||
count, users = await user_service.get_all(
|
||||
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)
|
||||
async def get_many_ssp(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Users = Depends(require_executor_dfip),
|
||||
current_user: AppUser = Depends(require_executor_dfip),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
ssp_list = await user_service.get_many_ssp(user_id, current_user)
|
||||
@ -57,7 +81,7 @@ async def set_many_ssp(
|
||||
user_id: int,
|
||||
ssp_ids_data: SSPIDList,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Users = Depends(require_admin),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user)
|
||||
@ -72,7 +96,7 @@ async def unset_many_ssp(
|
||||
user_id: int,
|
||||
ssp_ids_data: SSPIDList,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Users = Depends(require_admin),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
success = await user_service.unset_many_ssp(
|
||||
@ -84,39 +108,50 @@ async def unset_many_ssp(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserSchema)
|
||||
@router.get("/{user_id}")
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
load_orgs: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Users = Depends(require_admin),
|
||||
):
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
) -> UserAdminListResponse:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден",
|
||||
)
|
||||
return user
|
||||
return UserAdminListResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/", response_model=UserResponse)
|
||||
|
||||
@router.post("/", response_model=BaseSingleResponse[UserAdminListResponse])
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Users = Depends(require_admin),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
user = await user_service.create_user(user_data=user_data, creator=current_user)
|
||||
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(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Users = Depends(require_admin),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
user = await user_service.update_user(
|
||||
@ -124,14 +159,15 @@ async def update_user(
|
||||
user_data=user_data,
|
||||
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)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Users = Depends(require_admin),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
await user_service.delete_user(user_id=user_id, user=current_user)
|
||||
|
||||
184
api/src/api/v1/vsp.py
Normal file
184
api/src/api/v1/vsp.py
Normal file
@ -0,0 +1,184 @@
|
||||
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,
|
||||
placement_type: str | None = None,
|
||||
staff_count_min: int | None = None,
|
||||
staff_count_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,
|
||||
placement_type=placement_type,
|
||||
staff_count_min=staff_count_min,
|
||||
staff_count_max=staff_count_max,
|
||||
load_org_unit=True,
|
||||
with_count=True,
|
||||
)
|
||||
return BaseListResponse(
|
||||
success=True,
|
||||
message="Список INFO",
|
||||
result=[VSPInDB.model_validate(vsp) for vsp in result[1]],
|
||||
count=result[0]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dropdown", response_model=BaseListResponse[VSPInDB])
|
||||
async def get_vsp_dropdown(
|
||||
ssp_id: int | None = None,
|
||||
form_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_executor),
|
||||
):
|
||||
"""Получение записей для выпадающего списка INFO."""
|
||||
service = VSPService(db)
|
||||
result = await service.get_dropdown(user=current_user, ssp_id=ssp_id, form_id=form_id, with_count=True)
|
||||
return BaseListResponse(
|
||||
success=True,
|
||||
message="Список INFO для выпадающего списка",
|
||||
result=[VSPInDB.model_validate(vsp) for vsp in result[1]],
|
||||
count=result[0],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
async def export_info(
|
||||
body: VSPExportRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_executor),
|
||||
):
|
||||
"""Экспорт справочника INFO."""
|
||||
service = VSPService(db)
|
||||
stream, filename = await service.export_xlsx(
|
||||
user=current_user,
|
||||
registration_number=body.registration_number,
|
||||
address=body.address,
|
||||
ssp_ids=body.ssp_ids,
|
||||
open_date_start=body.open_date_start,
|
||||
open_date_end=body.open_date_end,
|
||||
placement_type=body.placement_type,
|
||||
staff_count_min=body.staff_count_min,
|
||||
staff_count_max=body.staff_count_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"ВСП не найден",
|
||||
)
|
||||
914
api/src/api/v1/websocket.py
Normal file
914
api/src/api/v1/websocket.py
Normal file
@ -0,0 +1,914 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import asdict
|
||||
import dataclasses
|
||||
import enum
|
||||
from json import dumps, loads
|
||||
import logging
|
||||
import time
|
||||
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.rf_project_report_service import RfProjectReportService
|
||||
from src.services.rf_project_report_line_service import RfProjectReportLineService
|
||||
from src.repository.user_repository import UserRepository
|
||||
from src.services.budget_line_service import BudgetLineService
|
||||
from src.api.v1.deps import get_user_by_token
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.db.models.form_type import FormTypeEnum
|
||||
from src.services.budget_form_service import BudgetFormService
|
||||
from src.services.project_service import ProjectService
|
||||
from src.services.sheet_service import SheetService
|
||||
from src.core.errors import BasicAppException, ValidationsError
|
||||
from src.db.session import SessionLocal
|
||||
|
||||
from src.services.user_service import UserService
|
||||
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_session(user_id: int | None = None):
|
||||
"""Контекстный менеджер для получения сессии базы данных."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if user_id is not None:
|
||||
user_repo = UserRepository(db)
|
||||
await user_repo.set_app_user_id(user_id=user_id)
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
router = APIRouter(prefix="/ws", tags=["websocket"])
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ConnectionInfo:
|
||||
ws: WebSocket
|
||||
user_id: int | None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class FormConnectionInfo(ConnectionInfo):
|
||||
form_id: int | None = None
|
||||
sheet: str | None = None
|
||||
direction: str | None = None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ProjectConnectionInfo(ConnectionInfo):
|
||||
project_id: int | None = None
|
||||
year: int | None = None
|
||||
report_type: str | None = None
|
||||
|
||||
|
||||
class ConnectionKeyEnum(str, enum.Enum):
|
||||
FORM = "FORM"
|
||||
PROJECT = "PROJECT"
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
con_info_mapping = {
|
||||
ConnectionKeyEnum.FORM: FormConnectionInfo,
|
||||
ConnectionKeyEnum.PROJECT: ProjectConnectionInfo,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.connections: dict[int, dict[ConnectionKeyEnum, list[ConnectionInfo]]] = {
|
||||
ConnectionKeyEnum.FORM: {},
|
||||
ConnectionKeyEnum.PROJECT: {},
|
||||
}
|
||||
self.cell_locks: dict[Any, int] = {}
|
||||
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
con_key: ConnectionKeyEnum,
|
||||
**kwargs,
|
||||
# form_id: int,
|
||||
# sheet: str,
|
||||
# direction: str | None = None,
|
||||
):
|
||||
await websocket.accept()
|
||||
|
||||
key = frozenset(kwargs.items())
|
||||
cls = self.con_info_mapping[con_key]
|
||||
|
||||
if key not in self.connections[con_key]:
|
||||
self.connections[con_key][key] = [
|
||||
cls(
|
||||
ws=websocket,
|
||||
user_id=None,
|
||||
**kwargs,
|
||||
)
|
||||
]
|
||||
else:
|
||||
self.connections[con_key][key].append(
|
||||
cls(
|
||||
ws=websocket,
|
||||
user_id=None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
def set_user(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
user_id: int,
|
||||
con_key: ConnectionKeyEnum,
|
||||
**kwargs,
|
||||
):
|
||||
key = frozenset(kwargs.items())
|
||||
|
||||
if key not in self.connections[con_key]:
|
||||
return
|
||||
for con_info in self.connections[con_key][key]:
|
||||
if con_info.ws == websocket:
|
||||
con_info.user_id = user_id
|
||||
|
||||
async def disconnect(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
con_key: ConnectionKeyEnum,
|
||||
code: int = status.WS_1008_POLICY_VIOLATION,
|
||||
reason: str = "Ошибка",
|
||||
**kwargs,
|
||||
):
|
||||
key = frozenset(kwargs.items())
|
||||
|
||||
if key not in self.connections[con_key]:
|
||||
return
|
||||
for con_info in self.connections[con_key][key]:
|
||||
if con_info.ws == websocket:
|
||||
self.connections[con_key][key].remove(con_info)
|
||||
if not len(self.connections[con_key][key]):
|
||||
del self.connections[con_key][key]
|
||||
|
||||
self.release_locks_for_user(con_key=con_key, user_id=con_info.user_id, form_key=kwargs)
|
||||
await websocket.close(
|
||||
code=code, reason=reason,
|
||||
)
|
||||
|
||||
async def broadcast_to_other(
|
||||
self,
|
||||
data: dict,
|
||||
websocket: WebSocket,
|
||||
con_key: ConnectionKeyEnum,
|
||||
**kwargs,
|
||||
):
|
||||
key = frozenset(kwargs.items())
|
||||
|
||||
if key not in self.connections[con_key]:
|
||||
return
|
||||
|
||||
data = data.copy()
|
||||
data["is_self"] = False
|
||||
message = dumps(data, default=_convert_error, ensure_ascii=False)
|
||||
|
||||
for con_info in self.connections[con_key][key]:
|
||||
if con_info.ws != websocket:
|
||||
try:
|
||||
await con_info.ws.send_text(message)
|
||||
except:
|
||||
pass # Игнорируем недоступные соединения
|
||||
|
||||
async def broadcast_to_all(
|
||||
self,
|
||||
data: dict,
|
||||
con_key: ConnectionKeyEnum,
|
||||
websocket: WebSocket | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
key = frozenset(kwargs.items())
|
||||
if key not in self.connections[con_key]:
|
||||
return
|
||||
|
||||
data = data.copy()
|
||||
if websocket is None:
|
||||
data["is_self"] = None
|
||||
default_message = dumps(data, default=_convert_error, ensure_ascii=False)
|
||||
self_message = None
|
||||
else:
|
||||
data["is_self"] = False
|
||||
default_message = dumps(data, default=_convert_error, ensure_ascii=False)
|
||||
data["is_self"] = True
|
||||
self_message = dumps(data, default=_convert_error, ensure_ascii=False)
|
||||
|
||||
for con_info in self.connections[con_key][key]:
|
||||
try:
|
||||
if websocket is not None and con_info.ws == websocket:
|
||||
await con_info.ws.send_text(self_message)
|
||||
else:
|
||||
await con_info.ws.send_text(default_message)
|
||||
|
||||
|
||||
except:
|
||||
pass # Игнорируем недоступные соединения
|
||||
|
||||
async def send_back(self, data: dict, websocket: WebSocket):
|
||||
data = data.copy()
|
||||
data["is_self"] = True
|
||||
message = dumps(data, default=_convert_error, ensure_ascii=False)
|
||||
await websocket.send_text(message)
|
||||
|
||||
async def get_data(self, websocket: WebSocket) -> dict:
|
||||
return loads(await websocket.receive_text())
|
||||
|
||||
def acquire_cell_lock(
|
||||
self,
|
||||
con_key: ConnectionKeyEnum,
|
||||
user_id: int,
|
||||
cell_key: dict,
|
||||
form_key: dict,
|
||||
) -> bool:
|
||||
if not cell_key:
|
||||
return False
|
||||
cell_key = frozenset(cell_key.items())
|
||||
|
||||
lock_key = (con_key, frozenset(form_key.items()), cell_key)
|
||||
lock_owner_id = self.cell_locks.get(lock_key)
|
||||
if lock_owner_id is not None and lock_owner_id != user_id:
|
||||
return False
|
||||
self.cell_locks[lock_key] = user_id
|
||||
return True
|
||||
|
||||
def release_cell_lock(
|
||||
self,
|
||||
con_key: ConnectionKeyEnum,
|
||||
user_id: int,
|
||||
cell_key: dict,
|
||||
form_key: dict,
|
||||
):
|
||||
if not cell_key:
|
||||
return
|
||||
cell_key = frozenset(cell_key.items())
|
||||
|
||||
lock_key = (con_key, frozenset(form_key.items()), cell_key)
|
||||
lock_owner_id = self.cell_locks.get(lock_key)
|
||||
if lock_owner_id == user_id:
|
||||
self.cell_locks.pop(lock_key, None)
|
||||
|
||||
def release_locks_for_user(
|
||||
self,
|
||||
con_key: ConnectionKeyEnum,
|
||||
user_id: int,
|
||||
form_key: dict
|
||||
):
|
||||
filter_key = (con_key, frozenset(form_key.items()))
|
||||
lock_keys = [
|
||||
lock_key
|
||||
for lock_key, lock_owner_id in self.cell_locks.items()
|
||||
if lock_key[:-1] == filter_key and lock_owner_id == user_id
|
||||
]
|
||||
for lock_key in lock_keys:
|
||||
self.cell_locks.pop(lock_key, None)
|
||||
|
||||
def is_cell_available_for_edit(
|
||||
self,
|
||||
con_key: ConnectionKeyEnum,
|
||||
user_id: int,
|
||||
cell_key: dict,
|
||||
form_key,
|
||||
) -> bool:
|
||||
if not cell_key:
|
||||
return True
|
||||
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.prl_service: RfProjectReportLineService = RfProjectReportLineService(db)
|
||||
self.pr_service: RfProjectReportService = RfProjectReportService(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_report = await self.pr_service.get(
|
||||
year=year,
|
||||
report_type=report_type,
|
||||
project_id=project_id,
|
||||
)
|
||||
if not project_report:
|
||||
return None
|
||||
|
||||
ids = await self.prl_service.get_ids(project_report_id=project_report.id)
|
||||
result = 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,
|
||||
)
|
||||
|
||||
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,
|
||||
project_id: int,
|
||||
year: int,
|
||||
report_type: str,
|
||||
user: AppUser,
|
||||
) -> list[tuple]:
|
||||
"""
|
||||
data = {
|
||||
"row_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["row_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.get("line_id_code", data["line_id"]),
|
||||
"column": data["column"],
|
||||
}
|
||||
|
||||
|
||||
async def process_websocket(
|
||||
websocket: WebSocket,
|
||||
processor_cls,
|
||||
**kwargs,
|
||||
):
|
||||
await manager.connect(
|
||||
websocket=websocket,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
kwargs_process = kwargs.copy()
|
||||
if "con_key" in kwargs_process:
|
||||
kwargs_process.pop("con_key")
|
||||
con_key = kwargs.get("con_key")
|
||||
|
||||
try:
|
||||
user_id = await login(
|
||||
websocket=websocket,
|
||||
**kwargs,
|
||||
)
|
||||
if user_id is None:
|
||||
|
||||
await manager.disconnect(
|
||||
websocket=websocket,
|
||||
reason="Ошибка авторизации",
|
||||
**kwargs,
|
||||
)
|
||||
return
|
||||
|
||||
while True:
|
||||
data = loads(await websocket.receive_text())
|
||||
|
||||
try:
|
||||
event_name = data.get("event")
|
||||
cell_key = resolve_cell_key(data)
|
||||
|
||||
match event_name:
|
||||
case "cell_edit_start":
|
||||
if not manager.acquire_cell_lock(
|
||||
con_key=con_key,
|
||||
user_id=user_id,
|
||||
cell_key=cell_key,
|
||||
form_key=kwargs_process,
|
||||
):
|
||||
data["error"] = "Ячейка уже редактируется другим пользователем"
|
||||
await manager.send_back(
|
||||
data,
|
||||
websocket,
|
||||
)
|
||||
continue
|
||||
await manager.send_back(
|
||||
data,
|
||||
websocket,
|
||||
)
|
||||
await manager.broadcast_to_other(
|
||||
data=data,
|
||||
# user_id=user_id,
|
||||
websocket=websocket,
|
||||
con_key=con_key,
|
||||
**kwargs_process,
|
||||
)
|
||||
continue
|
||||
case "cell_edit_end":
|
||||
manager.release_cell_lock(
|
||||
con_key=con_key,
|
||||
user_id=user_id,
|
||||
cell_key=cell_key,
|
||||
form_key=kwargs_process,
|
||||
)
|
||||
await manager.send_back(
|
||||
data,
|
||||
websocket,
|
||||
)
|
||||
await manager.broadcast_to_other(
|
||||
data=data,
|
||||
# user_id=user_id,
|
||||
websocket=websocket,
|
||||
con_key=con_key,
|
||||
**kwargs_process,
|
||||
)
|
||||
continue
|
||||
case "cell_updated":
|
||||
if not manager.is_cell_available_for_edit(
|
||||
con_key=con_key,
|
||||
user_id=user_id,
|
||||
cell_key=cell_key,
|
||||
form_key=kwargs_process,
|
||||
):
|
||||
data["error"] = "Ячейка уже редактируется другим пользователем"
|
||||
await manager.send_back(
|
||||
data,
|
||||
websocket,
|
||||
)
|
||||
continue
|
||||
# if cell_key and not manager.acquire_cell_lock(
|
||||
# con_key=con_key,
|
||||
# user_id=user_id,
|
||||
# cell_key=cell_key,
|
||||
# form_key=kwargs_process,
|
||||
# ):
|
||||
# data["error"] = "Ячейка уже редактируется другим пользователем"
|
||||
# await manager.send_back(
|
||||
# data,
|
||||
# websocket,
|
||||
# )
|
||||
# continue
|
||||
case "row_deleted":
|
||||
if manager.is_row_locked_by_other(
|
||||
con_key=con_key,
|
||||
user_id=user_id,
|
||||
line_id=data["data"]["row_id"]
|
||||
):
|
||||
data["error"] = (
|
||||
"Нельзя удалить строку: в этой строке есть редактируемые ячейки"
|
||||
)
|
||||
await manager.send_back(
|
||||
data,
|
||||
websocket,
|
||||
)
|
||||
continue
|
||||
case _:
|
||||
pass
|
||||
|
||||
async with get_db_session(user_id=user_id) as db:
|
||||
processor = processor_cls(db)
|
||||
t0 = time.perf_counter()
|
||||
data_old = data.copy()
|
||||
data["result"] = await processor.process(
|
||||
event_data=data,
|
||||
user_id=user_id,
|
||||
**kwargs_process
|
||||
)
|
||||
await db.commit()
|
||||
db_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
extra = {
|
||||
"type": "db_time",
|
||||
"time_ms": f"{db_ms:.2f}",
|
||||
"event": data.get("event"),
|
||||
}
|
||||
extra.update(data_old)
|
||||
logger.info(
|
||||
f"db_time: {extra}",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
if data.get("event") == "row_deleted":
|
||||
manager.release_locks_for_row(
|
||||
con_key=con_key,
|
||||
user_id=user_id,
|
||||
line_id=data["data"]["row_id"]
|
||||
)
|
||||
|
||||
if data.get("event") in [
|
||||
"cell_updated",
|
||||
"row_added",
|
||||
"row_deleted",
|
||||
]:
|
||||
await manager.broadcast_to_all(
|
||||
data=data,
|
||||
websocket=websocket,
|
||||
# user_id=user_id,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
await manager.broadcast_to_other(
|
||||
data=data,
|
||||
# user_id=user_id,
|
||||
websocket=websocket,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
except BasicAppException as e:
|
||||
data["error"] = e.description or "Неизвестная ошибка"
|
||||
await manager.send_back(
|
||||
data=data,
|
||||
websocket=websocket,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
data["error"] = str(e)
|
||||
await manager.send_back(
|
||||
data=data,
|
||||
websocket=websocket,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
tp = type(e)
|
||||
handlers = websocket.app.exception_handlers
|
||||
if tp in handlers:
|
||||
data["error"] = loads((await handlers[tp](request=None, exc=e)).body)
|
||||
await manager.send_back(
|
||||
data=data,
|
||||
websocket=websocket,
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await manager.disconnect(
|
||||
websocket=websocket,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Логируем ошибку, но не бросаем HTTPException — это WebSocket
|
||||
print(f"Error: {e}")
|
||||
await manager.disconnect(
|
||||
websocket=websocket,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/form/{form_id}/sheet/{sheet}")
|
||||
async def websocket_form(
|
||||
websocket: WebSocket,
|
||||
form_id: int,
|
||||
sheet: str,
|
||||
direction: Optional[str] = None,
|
||||
):
|
||||
await process_websocket(
|
||||
websocket=websocket,
|
||||
form_id=form_id,
|
||||
sheet=sheet,
|
||||
direction=direction,
|
||||
processor_cls=FormEventProcess,
|
||||
con_key=ConnectionKeyEnum.FORM,
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/projects/{project_id}/report/{year}/{report_type}")
|
||||
async def websocket_project(
|
||||
websocket: WebSocket,
|
||||
project_id: int,
|
||||
year: int,
|
||||
report_type: str,
|
||||
):
|
||||
await process_websocket(
|
||||
websocket=websocket,
|
||||
project_id=project_id,
|
||||
year=year,
|
||||
report_type=report_type,
|
||||
processor_cls=ProjectEventProcess,
|
||||
con_key=ConnectionKeyEnum.PROJECT,
|
||||
)
|
||||
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:",
|
||||
)
|
||||
@ -4,10 +4,7 @@ from typing import List
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
if os.environ.get("APP_ENV", "dev") == "test":
|
||||
prefix = "OPENBAO__SETTINGS_TEST"
|
||||
else:
|
||||
prefix = "OPENBAO__SETTINGS"
|
||||
prefix = "OPENBAO__SETTINGS"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@ -105,6 +102,11 @@ class Settings(BaseSettings):
|
||||
description="Размер батча для очистки аудита",
|
||||
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(
|
||||
env_file=".env",
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
|
||||
class ValidationError(BaseModel):
|
||||
field: str = Field(...)
|
||||
description: str = Field(...)
|
||||
@ -10,6 +12,14 @@ class BasicAppException(Exception):
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ValidationsError(BasicAppException):
|
||||
errors: dict[str, list[str]]
|
||||
|
||||
def __init__(self, errors, *args):
|
||||
super().__init__(*args)
|
||||
self.errors = errors
|
||||
|
||||
|
||||
class AccessDeniedException(BasicAppException):
|
||||
description = "Доступ запрещен"
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError
|
||||
|
||||
from src.core.errors import (
|
||||
AccessDeniedException,
|
||||
@ -7,41 +9,176 @@ from src.core.errors import (
|
||||
UserNotFoundException,
|
||||
UsernameConflictException,
|
||||
ValidationException,
|
||||
ValidationsError,
|
||||
)
|
||||
from src.core.CONSTANTS import (
|
||||
VALIDATION_PREFIXES,
|
||||
PG_404_ERRORS,
|
||||
PG_409_ERRORS,
|
||||
PG_400_ERRORS,
|
||||
PG_403_ERRORS,
|
||||
)
|
||||
|
||||
def _error_payload(code: 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:
|
||||
@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)
|
||||
async def access_denied_exception_handler(request, exc: AccessDeniedException):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": exc.description},
|
||||
status_code=status_code,
|
||||
content=_error_payload(status_code, exc.description),
|
||||
)
|
||||
|
||||
@app.exception_handler(ValidationException)
|
||||
async def validation_exception_handler(request, exc: ValidationException):
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={"detail": exc.description},
|
||||
status_code=status_code,
|
||||
content=_error_payload(status_code, exc.description, field=exc.field or ""),
|
||||
)
|
||||
|
||||
@app.exception_handler(UserNotFoundException)
|
||||
async def user_not_found_exception_handler(request, exc: UserNotFoundException):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={"detail": exc.description},
|
||||
status_code=status_code,
|
||||
content=_error_payload(status_code, exc.description),
|
||||
)
|
||||
|
||||
@app.exception_handler(UsernameConflictException)
|
||||
async def username_conflict_exception_handler(request, exc: UsernameConflictException):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"detail": exc.description},
|
||||
status_code=status_code,
|
||||
content=_error_payload(status_code, exc.description),
|
||||
)
|
||||
|
||||
@app.exception_handler(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(
|
||||
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:
|
||||
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
|
||||
|
||||
|
||||
if "sqlite" in settings.DATABASE_URL:
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
|
||||
|
||||
32
api/src/db/models/__init__.py
Normal file
32
api/src/db/models/__init__.py
Normal file
@ -0,0 +1,32 @@
|
||||
from src.db.models.allocation import Allocation
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.db.models.audit_log import AuditLog
|
||||
from src.db.models.booking import Booking
|
||||
from src.db.models.budget_form import BudgetForm
|
||||
from src.db.models.budget_line import BudgetLine
|
||||
from src.db.models.budget_line_quarter import BudgetLineQuarter
|
||||
from src.db.models.ckk import Ckk
|
||||
from src.db.models.collegial_approval import CollegialApproval
|
||||
from src.db.models.contract_detail import ContractDetail
|
||||
from src.db.models.contract_summary import ContractSummary
|
||||
from src.db.models.expense_item import ExpenseItem
|
||||
from src.db.models.form3_phase import Form3Phase
|
||||
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")
|
||||
61
api/src/db/models/budget_form.py
Normal file
61
api/src/db/models/budget_form.py
Normal file
@ -0,0 +1,61 @@
|
||||
from datetime import datetime
|
||||
import typing
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.models.form_type import FormTypeEnum
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class BudgetForm(Base):
|
||||
__tablename__ = "budget_form"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
form_type_code: Mapped[FormTypeEnum] = mapped_column( #type: ignore
|
||||
String, ForeignKey("v3.form_type.code")
|
||||
)
|
||||
year: Mapped[int | None] = mapped_column(Integer)
|
||||
created_by: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_created_by")
|
||||
)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_by: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_updated_by")
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
org_unit_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
|
||||
)
|
||||
|
||||
form_type: Mapped["FormType"] = relationship(
|
||||
"FormType", #back_populates="budget_forms"
|
||||
)
|
||||
# creator: Mapped[typing.Optional["AppUser"]] = relationship(
|
||||
# "AppUser", back_populates="created_budget_forms", foreign_keys=[created_by]
|
||||
# )
|
||||
# updater: Mapped[typing.Optional["AppUser"]] = relationship(
|
||||
# "AppUser", back_populates="updated_budget_forms", foreign_keys=[updated_by]
|
||||
# )
|
||||
org_unit: Mapped[typing.Optional["OrgUnit"]] = relationship(
|
||||
"OrgUnit", #back_populates="budget_forms"
|
||||
)
|
||||
# budget_lines: Mapped[list["BudgetLine"]] = relationship(
|
||||
# "BudgetLine", back_populates="budget_form"
|
||||
# )
|
||||
# fixed_asset_reports: Mapped[list["FixedAssetReport"]] = relationship(
|
||||
# "FixedAssetReport", back_populates="budget_form"
|
||||
# )
|
||||
# form_phases: Mapped[list["FormPhase"]] = relationship(
|
||||
# "FormPhase", back_populates="budget_form"
|
||||
# )
|
||||
# form_limits: Mapped[list["FormLimit"]] = relationship(
|
||||
# "FormLimit", back_populates="budget_form"
|
||||
# )
|
||||
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)
|
||||
27
api/src/db/models/expense_item.py
Normal file
27
api/src/db/models/expense_item.py
Normal file
@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class ExpenseItem(Base):
|
||||
__tablename__ = "expense_item"
|
||||
__table_args__ = (
|
||||
CheckConstraint("direction IN ('Support', 'Development')", name="expense_item_direction_check"),
|
||||
CheckConstraint("sheet IN ('AHR', 'CAP', 'OPER')", name="expense_item_sheet_check"),
|
||||
Index("ix_v3_expense_item_parent", "parent_id"),
|
||||
Index("ix_v3_expense_item_sheet", "sheet"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
section_code: Mapped[str | None] = mapped_column(String)
|
||||
item_id: Mapped[str | None] = mapped_column(String)
|
||||
num_group_id: Mapped[str | None] = mapped_column(String)
|
||||
name: Mapped[str | None] = mapped_column(String)
|
||||
sheet: Mapped[str | None] = mapped_column(String)
|
||||
direction: Mapped[str | None] = mapped_column(String)
|
||||
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
|
||||
depth: Mapped[int | None] = mapped_column(Integer, default=0)
|
||||
27
api/src/db/models/form3_phase.py
Normal file
27
api/src/db/models/form3_phase.py
Normal file
@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class Form3Phase(Base):
|
||||
__tablename__ = "form3_phase"
|
||||
__table_args__ = (
|
||||
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v3_form3_phase_columns"),
|
||||
CheckConstraint(text("role != 'ADMIN'"), name="chk_v3_form3_phase_no_admin"),
|
||||
CheckConstraint(text("closes_at > opens_at"), name="chk_v3_form3_phase_window"),
|
||||
Index("ix_v3_form3_phase_active", "rf_project_report_id", "role", "opens_at", "closes_at"),
|
||||
Index("ix_v3_form3_phase_columns_gin", "column_keys", postgresql_using="gin"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
rf_project_report_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("v3.rf_project_report.id", ondelete="CASCADE"), 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))
|
||||
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")
|
||||
42
api/src/db/models/org_unit.py
Normal file
42
api/src/db/models/org_unit.py
Normal file
@ -0,0 +1,42 @@
|
||||
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)
|
||||
utc_offset: Mapped[str] = mapped_column(String(3), default="+03")
|
||||
|
||||
@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=False))
|
||||
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
|
||||
|
||||
# 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") # type: ignore
|
||||
# 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")
|
||||
57
api/src/db/models/vsp.py
Normal file
57
api/src/db/models/vsp.py
Normal file
@ -0,0 +1,57 @@
|
||||
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)
|
||||
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 src.core.config import settings
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from src.db.base import SessionLocal
|
||||
|
||||
|
||||
@ -12,6 +14,10 @@ async def get_db() -> AsyncGenerator:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
@ -1,8 +1,23 @@
|
||||
import enum
|
||||
|
||||
|
||||
#ToDo: вероятно это теперь AuditEvent. А еще он нигде не юзается больше, вероятно, выпилить
|
||||
class AuditEventType(str, enum.Enum):
|
||||
LOGIN = "LOGIN"
|
||||
USER_CREATE = "USER_CREATE"
|
||||
USER_UPDATE = "USER_UPDATE"
|
||||
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 typing import Optional
|
||||
from datetime import datetime, date
|
||||
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):
|
||||
success: Optional[bool] = Field(None)
|
||||
message: Optional[str] = Field(None)
|
||||
success: Optional[bool] = Field(True)
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class BaseSingleResponse(ResponseBase, Generic[T]):
|
||||
result: Optional[T] = None
|
||||
|
||||
|
||||
class BaseListResponse(ResponseBase, Generic[T]):
|
||||
result: Optional[list[T]] = None
|
||||
count: Optional[int] = None
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
email: EmailStr = Field(..., examples=["user@example.com"])
|
||||
username: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=64,
|
||||
pattern=r"^[A-Za-z0-9_.-]+$",
|
||||
examples=["user_1"],
|
||||
)
|
||||
full_name: Optional[str] = Field(
|
||||
None,
|
||||
max_length=255,
|
||||
pattern=r"^[^<>]*$",
|
||||
examples=["Иван Иванов"],
|
||||
)
|
||||
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
|
||||
role_id: int = 3
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
|
||||
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):
|
||||
email: Optional[EmailStr] = None
|
||||
email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"])
|
||||
username: Optional[str] = Field(
|
||||
None,
|
||||
min_length=3,
|
||||
max_length=64,
|
||||
pattern=r"^[A-Za-z0-9_.-]+$",
|
||||
examples=["user_1"],
|
||||
)
|
||||
full_name: Optional[str] = Field(
|
||||
None,
|
||||
max_length=255,
|
||||
pattern=r"^[^<>]*$",
|
||||
examples=["Иван Иванов"],
|
||||
)
|
||||
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
|
||||
role_id: Optional[int] = None
|
||||
is_active: Optional[bool] = True
|
||||
load_orgs: bool = Field(default=False)
|
||||
|
||||
|
||||
class UserInDB(UserBase):
|
||||
@ -51,6 +134,37 @@ class User(UserInDB):
|
||||
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):
|
||||
result: Optional[UserInDB] = None
|
||||
|
||||
@ -97,3 +211,465 @@ class LoginRequest(BaseModel):
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class FormTypeSchemaEnum(str, enum.Enum):
|
||||
FORM_1 = "FORM_1"
|
||||
FORM_2 = "FORM_2"
|
||||
FORM_3 = "FORM_3"
|
||||
FORM_4 = "FORM_4"
|
||||
|
||||
|
||||
class 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 OrgUnitCreateSchema(OrgUnitBaseSchema):
|
||||
title: str = Field(..., min_length=1)
|
||||
utc_offset: str = Field('+03', min_length=3, pattern=r'^[+]\d\d$')
|
||||
|
||||
|
||||
class OrgUnitUpdateSchema(OrgUnitBaseSchema):
|
||||
title: Optional[str] = None
|
||||
is_ssp: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
utc_offset: Optional[str] = Field('+03', min_length=3, pattern=r'^[+]\d\d$')
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class OrgUnitResponseSchema(OrgUnitBaseSchema):
|
||||
id: int
|
||||
is_active: bool
|
||||
utc_offset: str
|
||||
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,
|
||||
"utc_offset": data.utc_offset
|
||||
}
|
||||
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 = Field(default_factory=lambda: datetime.now().year)
|
||||
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
|
||||
|
||||
@field_validator("year", mode="before")
|
||||
@classmethod
|
||||
def optional_year(cls, v: Any) -> Any:
|
||||
return v or datetime.now().year
|
||||
|
||||
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 Form3PhaseResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
phase_code: str
|
||||
role: str
|
||||
column_keys: list[str]
|
||||
opens_at: datetime
|
||||
closes_at: datetime
|
||||
year: int | None = None
|
||||
report_type: str | None = None
|
||||
|
||||
|
||||
class Form3PhaseCreate(BaseModel):
|
||||
year: int
|
||||
report_type: str
|
||||
phase_code: str
|
||||
role: FormPhaseRole
|
||||
column_keys: list[str]
|
||||
opens_at: datetime
|
||||
closes_at: datetime
|
||||
|
||||
|
||||
class Form3PhaseUpdate(BaseModel):
|
||||
role: FormPhaseRole | None = None
|
||||
column_keys: list[str] | None = None
|
||||
opens_at: datetime | None = None
|
||||
closes_at: datetime | None = None
|
||||
|
||||
|
||||
class AuditLogBase(BaseModel):
|
||||
"""Базовая схема записи аудита (единый формат вывода как у auditlog)."""
|
||||
|
||||
entity: str = Field(..., description="Тип сущности")
|
||||
entity_id: Optional[int] = Field(None, description="ID сущности")
|
||||
action: str = Field(..., description="Действие")
|
||||
payload_json: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AuditLogInDB(AuditLogBase):
|
||||
"""Схема записи аудита в базе данных."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int = Field(..., description="ID записи")
|
||||
user_id: Optional[int] = Field(None, description="ID пользователя")
|
||||
at: datetime = Field(..., description="Дата/время события")
|
||||
|
||||
|
||||
class AuditLog(AuditLogInDB):
|
||||
"""Схема записи аудита для ответа API."""
|
||||
|
||||
user: Optional[User] = None
|
||||
|
||||
|
||||
class AuditLogListResponse(ResponseBase):
|
||||
"""Схема всех записей аудита для ответа API."""
|
||||
|
||||
result: List[AuditLog] = Field(..., description="Вывод записей аудита")
|
||||
|
||||
|
||||
class AuditLogQueryParams(BaseModel):
|
||||
"""Query-параметры для фильтрации журнала аудита."""
|
||||
|
||||
page: int = Field(1, ge=1, description="Номер страницы")
|
||||
limit: int = Field(
|
||||
20, ge=1, le=100, description="Количество записей на странице"
|
||||
)
|
||||
user_id: Optional[int] = Field(None, description="ID пользователя")
|
||||
org_unit_id: Optional[int] = Field(None, description="ID ССП")
|
||||
task_id: Optional[int] = Field(None, description="ID задачи")
|
||||
form_id: Optional[int] = Field(None, description="ID формы")
|
||||
event_type: Optional[str] = Field(None, description="Тип события")
|
||||
event: Optional[str] = Field(None, description="Cобытиу")
|
||||
date_from: Optional[date] = Field(None, description="Дата начала (ISO 8601)")
|
||||
date_to: Optional[date] = Field(None, description="Дата окончания (ISO 8601)")
|
||||
|
||||
|
||||
|
||||
class VSPBase(BaseModel):
|
||||
system_code: Optional[str] = Field(None, description="Системный код ВСП")
|
||||
vsp_type: Optional[str] = Field(None, description="Вид ВСП")
|
||||
registration_number: str = Field(
|
||||
...,
|
||||
description="Значение для выпадающего списка",
|
||||
validation_alias='reg_number',
|
||||
serialization_alias='registration_number',
|
||||
)
|
||||
address: str = Field(..., description="Автоподставляемый адрес")
|
||||
open_date: Optional[date] = Field(
|
||||
None,
|
||||
description="Дата открытия",
|
||||
validation_alias='opened_at',
|
||||
serialization_alias='open_date',
|
||||
)
|
||||
close_date: Optional[date] = Field(
|
||||
None,
|
||||
description="Дата закрытия",
|
||||
validation_alias='closed_at',
|
||||
serialization_alias='close_date',
|
||||
)
|
||||
approved_format: Optional[str] = Field(
|
||||
None,
|
||||
description="Утвержденный формат",
|
||||
validation_alias='format',
|
||||
serialization_alias='approved_format',
|
||||
)
|
||||
notes: Optional[str] = Field(None, description="Примечания")
|
||||
placement_type: Optional[str] = Field(None, description="Тип размещения")
|
||||
staff_count: 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="Примечания")
|
||||
placement_type: Optional[str] = Field(None, description="Тип размещения")
|
||||
staff_count: 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
|
||||
placement_type: Optional[str] = None
|
||||
staff_count_min: Optional[int] = None
|
||||
staff_count_max: Optional[int] = None
|
||||
|
||||
|
||||
class ExpenseItemBaseSchema(BaseModel):
|
||||
section_code: Optional[str] = None
|
||||
item_id: Optional[str] = None
|
||||
num_group_id: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
sheet: Optional[str] = None
|
||||
direction: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
depth: Optional[int] = Field(default=0)
|
||||
|
||||
model_config = {
|
||||
"from_attributes": True
|
||||
}
|
||||
|
||||
class ExpenseItemResponseSchema(ExpenseItemBaseSchema):
|
||||
id: int
|
||||
|
||||
248
api/src/main.py
248
api/src/main.py
@ -1,9 +1,16 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
@ -14,31 +21,104 @@ from src.core.exception_handlers import register_exception_handlers
|
||||
from src.db.base import engine
|
||||
from src.db.session import create_tables
|
||||
|
||||
if not settings.DEBUG:
|
||||
from raisa_fastapi_protected_api import (
|
||||
AuthorizationMiddleware,
|
||||
OpenEndpoint,
|
||||
ProtectedOAuthSettings,
|
||||
ProtectedRolesSettings,
|
||||
ProtectedSettings,
|
||||
SearchType,
|
||||
)
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# if not settings.DEBUG:
|
||||
# from raisa_fastapi_protected_api import (
|
||||
# AuthorizationMiddleware,
|
||||
# OpenEndpoint,
|
||||
# ProtectedOAuthSettings,
|
||||
# ProtectedRolesSettings,
|
||||
# ProtectedSettings,
|
||||
# SearchType,
|
||||
# )
|
||||
|
||||
|
||||
_AUDIT_CLEANUP_LOCK_KEY = 21987431
|
||||
|
||||
|
||||
async def _cleanup_audit_log_once() -> int:
|
||||
if "postgresql" not in settings.DATABASE_URL:
|
||||
return 0
|
||||
|
||||
total_deleted = 0
|
||||
async with engine.begin() as conn:
|
||||
lock_ok = (
|
||||
await conn.execute(
|
||||
text("SELECT pg_try_advisory_lock(:k)"),
|
||||
{"k": _AUDIT_CLEANUP_LOCK_KEY},
|
||||
)
|
||||
).scalar_one()
|
||||
if not lock_ok:
|
||||
return 0
|
||||
try:
|
||||
while True:
|
||||
deleted = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
WITH doomed AS (
|
||||
SELECT id
|
||||
FROM v3.audit_log
|
||||
WHERE event_dt < now() - make_interval(days => :retention_days)
|
||||
ORDER BY id
|
||||
LIMIT :batch_size
|
||||
)
|
||||
DELETE FROM v3.audit_log a
|
||||
USING doomed d
|
||||
WHERE a.id = d.id
|
||||
RETURNING a.id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"retention_days": settings.AUDIT_LOG_RETENTION_DAYS,
|
||||
"batch_size": settings.AUDIT_LOG_CLEANUP_BATCH_SIZE,
|
||||
},
|
||||
)
|
||||
).rowcount
|
||||
if not deleted:
|
||||
break
|
||||
total_deleted += deleted
|
||||
finally:
|
||||
await conn.execute(
|
||||
text("SELECT pg_advisory_unlock(:k)"),
|
||||
{"k": _AUDIT_CLEANUP_LOCK_KEY},
|
||||
)
|
||||
return total_deleted
|
||||
|
||||
|
||||
async def _audit_cleanup_loop() -> None:
|
||||
# Первый прогон сразу после старта, дальше — по интервалу.
|
||||
while True:
|
||||
try:
|
||||
await _cleanup_audit_log_once()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(max(60, settings.AUDIT_LOG_CLEANUP_INTERVAL_SECONDS))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
cleanup_task: asyncio.Task | None = None
|
||||
if not settings.DEBUG:
|
||||
await create_tables()
|
||||
ProtectedSettings(
|
||||
ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
|
||||
ProtectedRolesSettings(
|
||||
APP_NAMESPACE=settings.APP_NAMESPACE,
|
||||
APP_NAME=settings.APP_NAME,
|
||||
),
|
||||
)
|
||||
# ProtectedSettings(
|
||||
# ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
|
||||
# ProtectedRolesSettings(
|
||||
# APP_NAMESPACE=settings.APP_NAMESPACE,
|
||||
# APP_NAME=settings.APP_NAME,
|
||||
# ),
|
||||
# )
|
||||
cleanup_task = asyncio.create_task(_audit_cleanup_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if cleanup_task:
|
||||
cleanup_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await cleanup_task
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@ -69,32 +149,77 @@ async def healthcheck2():
|
||||
"port": settings.PORT,
|
||||
}
|
||||
|
||||
@app.get("/healthz", status_code=200)
|
||||
async def healthz():
|
||||
return {"status": "ok", "uptime": UP_TIME, "version": VERSION}
|
||||
|
||||
if not settings.DEBUG:
|
||||
app.add_middleware(
|
||||
AuthorizationMiddleware,
|
||||
open_endpoints=[
|
||||
OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
|
||||
OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
|
||||
OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
|
||||
OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
|
||||
OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
|
||||
OpenEndpoint(
|
||||
path=settings.ROOT_PATH + "/healthcheck",
|
||||
type_search=SearchType.ABSOLUTE,
|
||||
),
|
||||
OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
|
||||
OpenEndpoint(
|
||||
path=settings.ROOT_PATH + "/openapi.json",
|
||||
type_search=SearchType.START,
|
||||
),
|
||||
OpenEndpoint(path="/docs", type_search=SearchType.START),
|
||||
OpenEndpoint(
|
||||
path=settings.ROOT_PATH + "/docs",
|
||||
type_search=SearchType.START,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readyz(response: Response):
|
||||
result = {"status": "ok", "db_status": "ok", "mv_expense_item_tree": "ok"}
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
if "postgresql" in settings.DATABASE_URL:
|
||||
my_exists = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM pg_matviews
|
||||
WHERE schemaname = 'v3'
|
||||
AND matviewname = 'mv_expense_item_tree'
|
||||
"""
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if my_exists is None:
|
||||
result["status"] = "fail"
|
||||
result["mv_expense_item_tree"] = "missing"
|
||||
response.status_code = 503
|
||||
except SQLAlchemyError as exc:
|
||||
result["status"] = "fail"
|
||||
result["db"] = f"error: {exc.__class__.__name__}"
|
||||
response.status_code = 503
|
||||
return result
|
||||
|
||||
|
||||
|
||||
# if not settings.DEBUG:
|
||||
# app.add_middleware(
|
||||
# AuthorizationMiddleware,
|
||||
# open_endpoints=[
|
||||
# OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
|
||||
# OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
|
||||
# OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
|
||||
# OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
|
||||
# OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
|
||||
# OpenEndpoint(
|
||||
# path=settings.ROOT_PATH + "/healthcheck",
|
||||
# type_search=SearchType.ABSOLUTE,
|
||||
# ),
|
||||
# OpenEndpoint(path="/healthz", type_search=SearchType.ABSOLUTE),
|
||||
# OpenEndpoint(
|
||||
# path=settings.ROOT_PATH + "/healthz",
|
||||
# type_search=SearchType.ABSOLUTE,
|
||||
# ),
|
||||
# OpenEndpoint(path="/readyz", type_search=SearchType.ABSOLUTE),
|
||||
# OpenEndpoint(
|
||||
# path=settings.ROOT_PATH + "/readyz",
|
||||
# type_search=SearchType.ABSOLUTE,
|
||||
# ),
|
||||
# OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
|
||||
# OpenEndpoint(
|
||||
# path=settings.ROOT_PATH + "/openapi.json",
|
||||
# type_search=SearchType.START,
|
||||
# ),
|
||||
# OpenEndpoint(path="/docs", type_search=SearchType.START),
|
||||
# OpenEndpoint(
|
||||
# path=settings.ROOT_PATH + "/docs",
|
||||
# type_search=SearchType.START,
|
||||
# ),
|
||||
# ],
|
||||
# )
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@ -108,4 +233,43 @@ app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
||||
|
||||
os.makedirs("web", exist_ok=True)
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
|
||||
@app.get("/docs-local", include_in_schema=False)
|
||||
async def custom_swagger_ui_html():
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=f"{settings.ROOT_PATH}{app.openapi_url}", # Путь к OpenAPI схеме (обычно /openapi.json)
|
||||
title=app.title + " - Swagger UI",
|
||||
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
||||
# Указываем пути к локальным файлам
|
||||
swagger_js_url=f"{settings.ROOT_PATH}/back-static/swagger-ui-bundle.js",
|
||||
swagger_css_url=f"{settings.ROOT_PATH}/back-static/swagger-ui.css",
|
||||
)
|
||||
|
||||
|
||||
def sep_static(path, stat_dir):
|
||||
sep_path = path.split("/")
|
||||
index = sep_path.index(stat_dir)
|
||||
return "/".join(sep_path[index:])
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
except (HTTPException, StarletteHTTPException) as ex:
|
||||
if ex.status_code == 404:
|
||||
if "images" in path:
|
||||
return await super().get_response(sep_static(path, "images"), scope)
|
||||
if "static" in path:
|
||||
return await super().get_response(sep_static(path, "static"), scope)
|
||||
return await super().get_response("index.html", scope)
|
||||
else:
|
||||
raise ex
|
||||
|
||||
|
||||
app.mount("/back-static", StaticFiles(directory="back_static"), name="back-static")
|
||||
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
|
||||
|
||||
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
||||
|
||||
87
api/src/repository/auditlog_repository.py
Normal file
87
api/src/repository/auditlog_repository.py
Normal file
@ -0,0 +1,87 @@
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.functions import func
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# from sqlalchemy.orm import selectinload
|
||||
|
||||
# from app.domain.models import AuditLog
|
||||
from src.db.models.audit_log import AuditLog
|
||||
|
||||
|
||||
class AuditLogRepository:
|
||||
"""Репозиторий для работы с журналом аудита."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(self, audit_log_id: int) -> Optional[AuditLog]:
|
||||
"""Получение записи аудита по ID."""
|
||||
return (
|
||||
(
|
||||
await self.db.execute(
|
||||
select(AuditLog).where(AuditLog.id == audit_log_id).limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
async def get_all(
|
||||
self,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
user_id: int | None = None,
|
||||
org_unit_id: int | None = None,
|
||||
task_id: int | None = None,
|
||||
form_id: int | None = None,
|
||||
event_type: str | None = None,
|
||||
event: str | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
) -> tuple[Iterable[AuditLog], int]:
|
||||
"""Получение всех записей аудита с опциональной фильтрацией."""
|
||||
query = select(AuditLog)
|
||||
query_count = select(func.count(AuditLog.id))
|
||||
|
||||
if user_id is not None:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
query_count = query_count.where(AuditLog.user_id == user_id)
|
||||
if org_unit_id is not None:
|
||||
query = query.where(AuditLog.org_unit_id == org_unit_id)
|
||||
query_count = query_count.where(AuditLog.org_unit_id == org_unit_id)
|
||||
if task_id is not None:
|
||||
query = query.where(AuditLog.task_id == task_id)
|
||||
query_count = query_count.where(AuditLog.task_id == task_id)
|
||||
if form_id is not None:
|
||||
query = query.where(AuditLog.form_id == form_id)
|
||||
query_count = query_count.where(AuditLog.form_id == form_id)
|
||||
if event_type is not None:
|
||||
query = query.where(AuditLog.event_type == event_type)
|
||||
query_count = query_count.where(AuditLog.event_type == event_type)
|
||||
if event is not None:
|
||||
query = query.where(AuditLog.event == event)
|
||||
query_count = query_count.where(AuditLog.event == event)
|
||||
if date_from is not None:
|
||||
query = query.where(AuditLog.event_dt >= date_from)
|
||||
query_count = query_count.where(AuditLog.event_dt >= date_from)
|
||||
if date_to is not None:
|
||||
query = query.where(AuditLog.event_dt <= date_to)
|
||||
query_count = query_count.where(AuditLog.event_dt <= date_to)
|
||||
|
||||
query = query.options(joinedload(AuditLog.user))
|
||||
|
||||
query = query.order_by(AuditLog.event_dt.desc())
|
||||
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
if offset is not None:
|
||||
query = query.offset(offset)
|
||||
|
||||
return (await self.db.execute(query)).scalars().all(), (
|
||||
await self.db.execute(query_count)
|
||||
).scalar_one()
|
||||
|
||||
132
api/src/repository/budget_form_repository.py
Normal file
132
api/src/repository/budget_form_repository.py
Normal file
@ -0,0 +1,132 @@
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.form_type import FormTypeEnum
|
||||
from src.db.models.budget_form import BudgetForm
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
|
||||
|
||||
class BudgetFormRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
org_unit: int | list[int] | None = None,
|
||||
with_count: bool = False,
|
||||
load_org: bool = False,
|
||||
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
|
||||
if with_count:
|
||||
query = select(func.count().over().label("total_count"), BudgetForm)
|
||||
else:
|
||||
query = select(BudgetForm)
|
||||
where = []
|
||||
if org_unit is not None:
|
||||
if isinstance(org_unit, int):
|
||||
where.append(BudgetForm.org_unit_id == org_unit)
|
||||
else:
|
||||
where.append(BudgetForm.org_unit_id.in_(org_unit))
|
||||
|
||||
|
||||
query = query.where(*where)
|
||||
if load_org:
|
||||
query = query.options(
|
||||
joinedload(BudgetForm.org_unit)
|
||||
)
|
||||
query = query.order_by(BudgetForm.id)
|
||||
|
||||
if offset is not None:
|
||||
query = query.offset(offset)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
if with_count:
|
||||
result = (await self.db.execute(query)).all()
|
||||
if result:
|
||||
return result[0][0], [res[1] for res in result]
|
||||
else:
|
||||
count_query = select(func.count(BudgetForm.id))
|
||||
if query.whereclause is not None:
|
||||
count_query = count_query.where(
|
||||
query.whereclause
|
||||
)
|
||||
return (await self.db.execute(count_query)).scalar(), []
|
||||
else:
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
async def get(
|
||||
self,
|
||||
budget_form_id: int | None = None,
|
||||
org_unit: int | list[int] | None = None,
|
||||
form_type_code: FormTypeEnum | str | None = None,
|
||||
year: int | None = None,
|
||||
load_form_type: bool = False,
|
||||
load_org: bool = False,
|
||||
) -> BudgetForm | None:
|
||||
assert budget_form_id or all((form_type_code, year, org_unit))
|
||||
query = select(BudgetForm)
|
||||
|
||||
where = []
|
||||
if budget_form_id is not None:
|
||||
where.append(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))
|
||||
|
||||
if form_type_code is not None:
|
||||
if isinstance(form_type_code, FormTypeEnum):
|
||||
where.append(BudgetForm.form_type_code == form_type_code.value)
|
||||
else:
|
||||
where.append(BudgetForm.form_type_code == form_type_code)
|
||||
|
||||
if year is not None:
|
||||
where.append(BudgetForm.year == year)
|
||||
|
||||
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]) -> list[BudgetLine]:
|
||||
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()
|
||||
|
||||
21
api/src/repository/expense_item_repository.py
Normal file
21
api/src/repository/expense_item_repository.py
Normal file
@ -0,0 +1,21 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.expense_item import ExpenseItem
|
||||
|
||||
|
||||
class ExpenseItemRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(self, item_id: int) -> ExpenseItem | None:
|
||||
query = select(ExpenseItem).where(ExpenseItem.id == item_id).limit(1)
|
||||
return (await self.db.execute(query)).scalar_one_or_none()
|
||||
|
||||
async def get_list(self, r_start: bool | None = None) -> list[ExpenseItem]:
|
||||
where = []
|
||||
if r_start is not None:
|
||||
where.append(ExpenseItem.item_id.startswith("R"))
|
||||
|
||||
query = select(ExpenseItem).where(*where)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
116
api/src/repository/form3_phase_repository.py
Normal file
116
api/src/repository/form3_phase_repository.py
Normal file
@ -0,0 +1,116 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.form3_phase import Form3Phase
|
||||
|
||||
|
||||
class Form3PhaseRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
report_id: int | list[int],
|
||||
phase_code: str | None = None,
|
||||
) -> list[Form3Phase]:
|
||||
if isinstance(report_id, int):
|
||||
query = select(Form3Phase).where(Form3Phase.rf_project_report_id == report_id)
|
||||
else:
|
||||
query = select(Form3Phase).where(Form3Phase.rf_project_report_id.in_(report_id))
|
||||
if phase_code is not None:
|
||||
query = query.where(Form3Phase.phase_code == phase_code)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
async def get(
|
||||
self, report_id: int, phase_code: str
|
||||
) -> Form3Phase | None:
|
||||
query = (
|
||||
select(Form3Phase)
|
||||
.where(Form3Phase.rf_project_report_id == report_id)
|
||||
.where(Form3Phase.phase_code == phase_code)
|
||||
.limit(1)
|
||||
)
|
||||
return (await self.db.execute(query)).scalar_one_or_none()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
report_id: int,
|
||||
phase_code: str,
|
||||
role: str,
|
||||
column_keys: list[str],
|
||||
opens_at: datetime,
|
||||
closes_at: datetime,
|
||||
) -> Form3Phase:
|
||||
result = await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
(v3.add_form3_phase(
|
||||
:report_id,
|
||||
:phase_code,
|
||||
:role,
|
||||
:column_keys,
|
||||
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
|
||||
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
|
||||
)).rf_project_report_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"report_id": report_id,
|
||||
"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(report_id, phase_code)
|
||||
|
||||
async def update(
|
||||
self, report_id: int, phase_code: str, data: dict
|
||||
) -> Form3Phase | None:
|
||||
result = await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
(v3.upd_form3_phase(
|
||||
:report_id,
|
||||
:phase_code,
|
||||
:role,
|
||||
:column_keys,
|
||||
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
|
||||
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
|
||||
)).rf_project_report_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"report_id": report_id,
|
||||
"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(report_id, phase_code)
|
||||
|
||||
async def delete(
|
||||
self, report_id: int, phase_code: str
|
||||
) -> bool:
|
||||
result = await self.db.execute(
|
||||
text(
|
||||
"SELECT v3.del_form3_phase(:report_id, :phase_code)"
|
||||
),
|
||||
{
|
||||
"report_id": report_id,
|
||||
"phase_code": phase_code,
|
||||
},
|
||||
)
|
||||
deleted = result.scalar_one_or_none()
|
||||
return bool(deleted)
|
||||
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,
|
||||
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
|
||||
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
|
||||
)).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,
|
||||
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
|
||||
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
|
||||
)).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)
|
||||
138
api/src/repository/org_unit_repository.py
Normal file
138
api/src/repository/org_unit_repository.py
Normal file
@ -0,0 +1,138 @@
|
||||
|
||||
|
||||
from sqlalchemy import func, select, text, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.org_unit import OrgUnit
|
||||
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
|
||||
|
||||
|
||||
class OrgUnitRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
is_ssp: bool | None = None,
|
||||
is_active: bool | None = None,
|
||||
org_unit_id: int | list[int] | None = None,
|
||||
with_count: bool = False,
|
||||
load_users: bool = False,
|
||||
) -> list[OrgUnit] | tuple[int, list[OrgUnit]]:
|
||||
if with_count:
|
||||
query = select(func.count().over().label("total_count"), OrgUnit)
|
||||
else:
|
||||
query = select(OrgUnit)
|
||||
where = []
|
||||
if is_ssp is not None:
|
||||
where.append(OrgUnit.is_ssp == is_ssp)
|
||||
if is_active is not None:
|
||||
where.append(OrgUnit.is_active == is_active)
|
||||
if org_unit_id is not None:
|
||||
if isinstance(org_unit_id, int):
|
||||
where.append(OrgUnit.id == org_unit_id)
|
||||
else:
|
||||
where.append(OrgUnit.id.in_(org_unit_id))
|
||||
|
||||
query = query.where(*where)
|
||||
if load_users:
|
||||
query = query.options(
|
||||
joinedload(OrgUnit.users)
|
||||
)
|
||||
|
||||
query = query.order_by(OrgUnit.id)
|
||||
|
||||
if offset is not None:
|
||||
query = query.offset(offset)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
if with_count:
|
||||
result = (await self.db.execute(query)).unique().all()
|
||||
if result:
|
||||
return result[0][0], [res[1] for res in result]
|
||||
else:
|
||||
count_query = select(func.count(OrgUnit.id))
|
||||
if query.whereclause is not None:
|
||||
count_query = count_query.where(
|
||||
query.whereclause
|
||||
)
|
||||
return (await self.db.execute(count_query)).scalar(), []
|
||||
else:
|
||||
return (await self.db.execute(query)).scalars().unique().all()
|
||||
|
||||
|
||||
async def get(
|
||||
self,
|
||||
org_unit_id: int,
|
||||
load_users: bool = False
|
||||
) -> OrgUnit | None:
|
||||
query = select(OrgUnit).where(OrgUnit.id == org_unit_id)
|
||||
if load_users:
|
||||
query = query.options(
|
||||
joinedload(OrgUnit.users)
|
||||
)
|
||||
|
||||
return (await self.db.execute(query)).scalars().first()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
title: str,
|
||||
is_ssp: bool,
|
||||
utc_offset: str,
|
||||
) -> OrgUnit:
|
||||
"""Создание SSP."""
|
||||
ssp = OrgUnit(title=title, is_ssp=is_ssp, utc_offset=utc_offset)
|
||||
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
|
||||
|
||||
old_offset = org_unit.utc_offset
|
||||
for field, value in kwargs.items():
|
||||
setattr(org_unit, field, value)
|
||||
|
||||
await self.db.flush()
|
||||
|
||||
if org_unit.utc_offset != old_offset:
|
||||
await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT v3.upd_phases_timezones(
|
||||
:org_unit_id,
|
||||
:old_tz,
|
||||
:new_tz
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"org_unit_id": org_unit.id,
|
||||
"old_tz": old_offset,
|
||||
"new_tz": org_unit.utc_offset,
|
||||
},
|
||||
)
|
||||
# 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
|
||||
387
api/src/repository/project_repository.py
Normal file
387
api/src/repository/project_repository.py
Normal file
@ -0,0 +1,387 @@
|
||||
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, years: list[InterruptedError] | None = None) -> 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),
|
||||
"years": years,
|
||||
}
|
||||
|
||||
@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()
|
||||
)
|
||||
|
||||
report_years_subq = (
|
||||
select(RfProjectReport.year)
|
||||
.where(RfProjectReport.project_id == Project.id)
|
||||
.distinct()
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
query = (
|
||||
select(
|
||||
Project,
|
||||
OrgUnit.title.label("org_unit_name"),
|
||||
report_count_subq.label("report_count"),
|
||||
func.array(report_years_subq).label("years"),
|
||||
)
|
||||
.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], row[3]) 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()
|
||||
)
|
||||
report_years_subq = (
|
||||
select(RfProjectReport.year)
|
||||
.where(RfProjectReport.project_id == Project.id)
|
||||
.distinct()
|
||||
.scalar_subquery()
|
||||
)
|
||||
query = (
|
||||
select(
|
||||
Project,
|
||||
OrgUnit.title.label("org_unit_name"),
|
||||
report_count_subq.label("report_count"),
|
||||
func.array(report_years_subq).label("years"),
|
||||
)
|
||||
.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], row[3])
|
||||
|
||||
async def get_instance(
|
||||
self,
|
||||
project_id: int,
|
||||
org_unit_ids: list[int] | None = None,
|
||||
) -> Project | None:
|
||||
query = (
|
||||
select(
|
||||
Project,
|
||||
)
|
||||
.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))
|
||||
return (await self.db.execute(query)).scalar_one_or_none()
|
||||
|
||||
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])
|
||||
39
api/src/repository/rf_project_report_line_repository.py
Normal file
39
api/src/repository/rf_project_report_line_repository.py
Normal file
@ -0,0 +1,39 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.rf_project_report_line import RfProjectReportLine
|
||||
|
||||
|
||||
|
||||
class RfProjectReportLineRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(self, line_id: int) -> RfProjectReportLine | None:
|
||||
query = select(
|
||||
RfProjectReportLine
|
||||
).where(
|
||||
RfProjectReportLine.id == line_id
|
||||
).limit(1)
|
||||
return (await self.db.execute(query)).scalar_one_or_none()
|
||||
|
||||
async def get_list(self, line_ids: list[int]) -> list[RfProjectReportLine]:
|
||||
query = select(
|
||||
RfProjectReportLine
|
||||
).where(
|
||||
RfProjectReportLine.id.in_(line_ids)
|
||||
)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
async def get_ids(
|
||||
self,
|
||||
project_report_id: int,
|
||||
) -> list[int]:
|
||||
|
||||
query = select(
|
||||
RfProjectReportLine.id
|
||||
).where(
|
||||
RfProjectReportLine.rf_project_report_id == project_report_id
|
||||
)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
58
api/src/repository/rf_project_report_repository.py
Normal file
58
api/src/repository/rf_project_report_repository.py
Normal file
@ -0,0 +1,58 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from src.db.models.rf_project_report import RfProjectReport
|
||||
|
||||
|
||||
|
||||
class RfProjectReportRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(
|
||||
self,
|
||||
report_id: int | None = None,
|
||||
year: int | None = None,
|
||||
report_type: str | None = None,
|
||||
project_id: int | None = None,
|
||||
load_project: bool = False,
|
||||
) -> RfProjectReport | None:
|
||||
assert report_id is not None or all((el is not None for el in (year, report_type, project_id)))
|
||||
query = select(
|
||||
RfProjectReport
|
||||
)
|
||||
if report_id is not None:
|
||||
query = query.where(RfProjectReport.id == report_id)
|
||||
else:
|
||||
query = query.where(
|
||||
RfProjectReport.year == year,
|
||||
RfProjectReport.report_type == report_type,
|
||||
RfProjectReport.project_id == project_id
|
||||
)
|
||||
|
||||
if load_project:
|
||||
query = query.options(joinedload(RfProjectReport.project))
|
||||
query = query.limit(1)
|
||||
return (await self.db.execute(query)).scalar_one_or_none()
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
report_id: int | None = None,
|
||||
year: int | None = None,
|
||||
report_type: str | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> list[RfProjectReport]:
|
||||
query = select(
|
||||
RfProjectReport
|
||||
)
|
||||
if report_id is not None:
|
||||
query = query.where(RfProjectReport.id == report_id)
|
||||
if year is not None:
|
||||
query = query.where(RfProjectReport.year == year)
|
||||
if report_type is not None:
|
||||
query = query.where(RfProjectReport.report_type == report_type)
|
||||
if project_id is not None:
|
||||
query = query.where(RfProjectReport.project_id == project_id)
|
||||
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
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,120 @@
|
||||
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.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.domain.models import Roles, SSP, UserSSPLink, Users
|
||||
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(self, user_id: int) -> Optional[Users]:
|
||||
async def get(self, user_id: int, load_orgs: bool = False) -> Optional[AppUser]:
|
||||
query = select(AppUser).where(AppUser.id == user_id)
|
||||
if load_orgs:
|
||||
query = query.options(
|
||||
selectinload(AppUser.org_units),
|
||||
)
|
||||
|
||||
return (
|
||||
(
|
||||
await self.db.execute(
|
||||
select(Users).where(Users.id == user_id).limit(1)
|
||||
query.limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
async def get_by_email(self, email: str) -> Optional[Users]:
|
||||
async def get_by_email(self, email: str) -> Optional[AppUser]:
|
||||
return (
|
||||
(
|
||||
await self.db.execute(
|
||||
select(Users).where(Users.email == email).limit(1)
|
||||
select(AppUser).where(AppUser.email == email).limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
async def get_by_username(self, username: str) -> Optional[Users]:
|
||||
async def get_by_username(self, username: str) -> Optional[AppUser]:
|
||||
return (
|
||||
(
|
||||
await self.db.execute(
|
||||
select(Users).where(Users.username == username).limit(1)
|
||||
select(AppUser).where(AppUser.username == username).limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]:
|
||||
query = (
|
||||
select(Users)
|
||||
.where(Users.is_active.is_(True))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.order_by(Users.id)
|
||||
)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
async def set_app_user_id(self, user_id: int) -> None:
|
||||
if isinstance(user_id, int):
|
||||
await self.db.execute(
|
||||
text(
|
||||
f"SET LOCAL app.user_id = {user_id}"
|
||||
),
|
||||
)
|
||||
|
||||
async def create(self, user_data: dict) -> Users:
|
||||
hashed_password = (
|
||||
get_password_hash(user_data["password"]) if user_data.get("password") else None
|
||||
)
|
||||
db_user = Users(
|
||||
email=user_data["email"],
|
||||
username=user_data["username"],
|
||||
hashed_password=hashed_password,
|
||||
full_name=user_data.get("full_name"),
|
||||
role_id=user_data.get("role_id"),
|
||||
)
|
||||
self.db.add(db_user)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
async def update(self, user_id: int, user_data: dict) -> Optional[Users]:
|
||||
user = await self.get(user_id)
|
||||
if not user:
|
||||
return None
|
||||
async def get_list(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
with_count: bool = False,
|
||||
load_orgs: bool = False,
|
||||
) -> list[AppUser] | tuple[int, list[AppUser]]:
|
||||
|
||||
for field, value in user_data.items():
|
||||
if field == "password" and value:
|
||||
value = get_password_hash(value)
|
||||
setattr(user, field, value)
|
||||
where = [AppUser.is_active.is_(True)]
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
if with_count:
|
||||
query = select(func.count().over().label("total_count"), AppUser)
|
||||
else:
|
||||
query = select(AppUser)
|
||||
|
||||
|
||||
query = query.where(*where)
|
||||
if load_orgs:
|
||||
query = query.options(
|
||||
joinedload(AppUser.org_units)
|
||||
)
|
||||
|
||||
query = query.order_by(AppUser.id)
|
||||
|
||||
if skip is not None:
|
||||
query = query.offset(skip)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
if with_count:
|
||||
result = (await self.db.execute(query)).unique().all()
|
||||
if result:
|
||||
return result[0][0], [res[1] for res in result]
|
||||
else:
|
||||
count_query = select(func.count(AppUser.id))
|
||||
if query.whereclause is not None:
|
||||
count_query = count_query.where(
|
||||
query.whereclause
|
||||
)
|
||||
return (await self.db.execute(count_query)).scalar(), []
|
||||
else:
|
||||
return (await self.db.execute(query)).scalars().unique().all()
|
||||
|
||||
async def logical_delete(self, user_id: int) -> bool:
|
||||
query = update(Users).where(Users.id == user_id).values(is_active=False)
|
||||
query = update(AppUser).where(AppUser.id == user_id).values(is_active=False)
|
||||
result = await self.db.execute(query)
|
||||
await self.db.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def authenticate(self, username: str, password: str) -> Optional[Users]:
|
||||
async def authenticate(self, username: str, password: str) -> Optional[AppUser]:
|
||||
user = await self.get_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
@ -98,52 +122,122 @@ class UserRepository:
|
||||
return None
|
||||
return user
|
||||
|
||||
async def authenticate_via_email(self, email: str) -> Optional[Users]:
|
||||
async def authenticate_via_email(self, email: str) -> Optional[AppUser]:
|
||||
return await self.get_by_email(email)
|
||||
|
||||
async def get_roles(self) -> list[Roles]:
|
||||
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all()
|
||||
async def get_roles(self) -> list[Role]:
|
||||
return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
|
||||
|
||||
async def get_many_ssp(self, user_id: int) -> list[SSP]:
|
||||
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where(
|
||||
UserSSPLink.user_id == user_id
|
||||
async def get_many_ssp(self, user_id: int) -> list[OrgUnit]:
|
||||
query = select(OrgUnit).join(UserOrg, UserOrg.org_unit_id == OrgUnit.id).where(
|
||||
UserOrg.user_id == user_id
|
||||
)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
async def get_many_ssp_ids(self, user_id: int) -> list[int]:
|
||||
query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id)
|
||||
query = select(UserOrg.org_unit_id).where(UserOrg.user_id == user_id)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
||||
try:
|
||||
for ssp_id in ssp_ids:
|
||||
link = UserSSPLink(user_id=user_id, ssp_id=ssp_id)
|
||||
self.db.add(link)
|
||||
await self.db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await self.db.rollback()
|
||||
return False
|
||||
for ssp_id in ssp_ids:
|
||||
await self.db.execute(
|
||||
text(
|
||||
"SELECT v3.grant_user_org_access(:user_id, :org_unit_id)"
|
||||
),
|
||||
{"user_id": user_id, "org_unit_id": ssp_id},
|
||||
)
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
||||
try:
|
||||
for ssp_id in ssp_ids:
|
||||
query = delete(UserSSPLink).where(
|
||||
(UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id)
|
||||
)
|
||||
await self.db.execute(query)
|
||||
await self.db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await self.db.rollback()
|
||||
return False
|
||||
for ssp_id in ssp_ids:
|
||||
await self.db.execute(
|
||||
text(
|
||||
"SELECT v3.revoke_user_org_access(:user_id, :org_unit_id)"
|
||||
),
|
||||
{"user_id": user_id, "org_unit_id": ssp_id},
|
||||
)
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
async def clear_many_ssp(self, user_id: int) -> bool:
|
||||
try:
|
||||
query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id)
|
||||
await self.db.execute(query)
|
||||
await self.db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await self.db.rollback()
|
||||
return False
|
||||
async def clear_many_ssp(self, user_id: int) -> None:
|
||||
org_unit_ids = await self.get_many_ssp_ids(user_id)
|
||||
await self.db.execute(
|
||||
text(
|
||||
"SELECT v3.revoke_many_user_org_access(:user_id, :org_unit_ids)"
|
||||
),
|
||||
{"user_id": user_id, "org_unit_ids": org_unit_ids},
|
||||
)
|
||||
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
|
||||
|
||||
240
api/src/repository/vsp_repository.py
Normal file
240
api/src/repository/vsp_repository.py
Normal file
@ -0,0 +1,240 @@
|
||||
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,
|
||||
placement_type: str | None = None,
|
||||
staff_count_min: int | None = None,
|
||||
staff_count_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),
|
||||
("placement_type", placement_type),
|
||||
("open_date_start", open_date_start),
|
||||
("open_date_end", open_date_end),
|
||||
("staff_count_min", staff_count_min),
|
||||
("staff_count_max", staff_count_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 ("placement_type", str(value)) if value:
|
||||
queries.append(Vsp.placement_type.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 ("staff_count_min", value) if value is not None:
|
||||
queries.append(Vsp.staff_count >= value)
|
||||
case ("staff_count_max", value) if value is not None:
|
||||
queries.append(Vsp.staff_count <= value)
|
||||
case ("is_active", value) if value is not None:
|
||||
queries.append(Vsp.is_active == value)
|
||||
case _:
|
||||
continue
|
||||
|
||||
if close_date_is_null is True:
|
||||
queries.append(Vsp.closed_at.is_(None))
|
||||
elif close_date_is_null is False:
|
||||
queries.append(Vsp.closed_at.is_not(None))
|
||||
|
||||
if ssp_ids is not None:
|
||||
if not ssp_ids:
|
||||
if with_count:
|
||||
return 0, []
|
||||
return []
|
||||
queries.append(Vsp.branch_id.in_(ssp_ids))
|
||||
|
||||
query = query.where(*queries)
|
||||
if load_org_unit:
|
||||
query = query.options(joinedload(Vsp.org_unit))
|
||||
query = query.order_by(Vsp.id)
|
||||
|
||||
if with_count:
|
||||
result = (await self.db.execute(query)).all()
|
||||
if result:
|
||||
return result[0][0], [res[1] for res in result]
|
||||
else:
|
||||
count_query = select(func.count(Vsp.id))
|
||||
if query.whereclause is not None:
|
||||
count_query = count_query.where(
|
||||
query.whereclause
|
||||
)
|
||||
return (await self.db.execute(count_query)).scalar(), []
|
||||
else:
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
return (await self.db.execute(query.order_by(Vsp.id))).scalars().all()
|
||||
|
||||
async def create(self, payload: VSPCreate, created_by: int, load_org_unit: bool = False) -> Vsp:
|
||||
data = payload.model_dump()
|
||||
|
||||
vsp_id = (
|
||||
await self.db.execute(
|
||||
text("""
|
||||
SELECT v3.add_vsp(
|
||||
:p_branch_id, :p_reg_number, :p_address, :p_format,
|
||||
:p_opened_at, :p_placement_type, :p_staff_count,
|
||||
:p_total_area, :p_closed_at, :p_is_active, :p_is_deleted,
|
||||
:p_system_code, :p_created_by, :p_vsp_type, :p_notes,
|
||||
:p_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_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",
|
||||
"placement_type": "p_placement_type",
|
||||
"staff_count": "p_staff_count",
|
||||
"notes": "p_notes",
|
||||
"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)
|
||||
|
||||
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_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
|
||||
|
||||
98
api/src/services/auditlog_service.py
Normal file
98
api/src/services/auditlog_service.py
Normal file
@ -0,0 +1,98 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.core.errors import AccessDeniedException
|
||||
|
||||
# from src.domain.models import AuditLog, UserRole, Users
|
||||
from src.db.models.audit_log import AuditLog
|
||||
from src.db.models.role import UserRoleEnum
|
||||
from src.db.models.app_user import AppUser
|
||||
|
||||
from src.repository.auditlog_repository import AuditLogRepository
|
||||
|
||||
from src.domain.schemas import AuditLog as AuditLogSchema
|
||||
|
||||
|
||||
class AuditLogService:
|
||||
"""Сервис для работы с журналом аудита."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.audit_repo = AuditLogRepository(db)
|
||||
|
||||
async def get(self, audit_log_id: int, user: AppUser) -> Optional[AuditLog]:
|
||||
"""Получение записи аудита по ID."""
|
||||
if not self._can_view_audit_logs(user):
|
||||
raise AccessDeniedException(
|
||||
"Недостаточно прав для просмотра журнала аудита"
|
||||
)
|
||||
|
||||
return await self.audit_repo.get(audit_log_id)
|
||||
|
||||
async def get_all(
|
||||
self,
|
||||
user: AppUser,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
user_id: int | None = None,
|
||||
org_unit_id: int | None = None,
|
||||
task_id: int | None = None,
|
||||
form_id: int | None = None,
|
||||
event_type: str | None = None,
|
||||
event: str | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
) -> tuple[Iterable[AuditLog], int]:
|
||||
"""Получение всех записей аудита с опциональной фильтрацией."""
|
||||
if not self._can_view_audit_logs(user):
|
||||
raise AccessDeniedException(
|
||||
"Недостаточно прав для просмотра журнала аудита"
|
||||
)
|
||||
|
||||
return await self.audit_repo.get_all(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
user_id=user_id,
|
||||
org_unit_id=org_unit_id,
|
||||
task_id=task_id,
|
||||
form_id=form_id,
|
||||
event_type=event_type,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
event=event,
|
||||
)
|
||||
|
||||
def _can_view_audit_logs(self, user: AppUser) -> bool:
|
||||
"""Проверяет, может ли пользователь просматривать журнал аудита."""
|
||||
return user.role_id == UserRoleEnum.ADMIN.value
|
||||
|
||||
def orm_log_to_response(self, log: AuditLog) -> AuditLogSchema:
|
||||
"""Маппинг записи ORM audit_log в формат ответа API (entity, entity_id, action, at, payload_json).
|
||||
model_validate(orm) не подходит: в БД поля event_dt/event/event_type/event_data, в API — at/action/entity/entity_id; entity и entity_id из event_data JSON.
|
||||
"""
|
||||
event_data: Optional[Dict[str, Any]] = getattr(log, "event_data", None) or {}
|
||||
entity = (
|
||||
event_data.get("entity_type") if isinstance(event_data, dict) else None
|
||||
) or getattr(log, "event_type", "unknown")
|
||||
entity_id = (
|
||||
event_data.get("entity_id") if isinstance(event_data, dict) else None
|
||||
)
|
||||
if entity_id is not None and not isinstance(entity_id, int):
|
||||
try:
|
||||
entity_id = int(entity_id)
|
||||
except (TypeError, ValueError):
|
||||
entity_id = None
|
||||
action = getattr(log, "event", "")
|
||||
at = getattr(log, "event_dt", None)
|
||||
return AuditLogSchema(
|
||||
entity=entity,
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
payload_json=event_data if isinstance(event_data, dict) else None,
|
||||
id=log.id,
|
||||
user_id=getattr(log, "user_id", None),
|
||||
at=at,
|
||||
user=getattr(log, "user", None),
|
||||
)
|
||||
@ -14,7 +14,7 @@ class AuthService:
|
||||
|
||||
async def authenticate_user(self, username: str, password: str) -> Optional[Token]:
|
||||
user = await self.user_repo.authenticate(username, password)
|
||||
if not user:
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
access_token = create_access_token(data={"sub": user.username})
|
||||
refresh_token = create_refresh_token(data={"sub": user.username})
|
||||
@ -26,7 +26,7 @@ class AuthService:
|
||||
|
||||
async def authenticate_user_via_email(self, email: str) -> Optional[Token]:
|
||||
user = await self.user_repo.authenticate_via_email(email)
|
||||
if not user:
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
refresh_token = create_refresh_token(data={"sub": user.email})
|
||||
|
||||
134
api/src/services/budget_form_service.py
Normal file
134
api/src/services/budget_form_service.py
Normal file
@ -0,0 +1,134 @@
|
||||
|
||||
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 create_if_not_exists(
|
||||
self,
|
||||
form_type_code: str,
|
||||
year: int,
|
||||
org_unit_id: int,
|
||||
) -> BudgetForm | None:
|
||||
existing_bf = await self.bf_repo.get(
|
||||
form_type_code=form_type_code,
|
||||
year=year,
|
||||
org_unit=org_unit_id,
|
||||
)
|
||||
if existing_bf:
|
||||
return existing_bf
|
||||
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
|
||||
]
|
||||
)
|
||||
|
||||
async def bulk_create_if_not_exists(
|
||||
self,
|
||||
form_type_code: str,
|
||||
year: int,
|
||||
org_unit_ids: list[int],
|
||||
) -> BudgetForm | None:
|
||||
return await asyncio.gather(
|
||||
*[
|
||||
self.create_if_not_exists(
|
||||
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)
|
||||
|
||||
26
api/src/services/expense_item_service.py
Normal file
26
api/src/services/expense_item_service.py
Normal file
@ -0,0 +1,26 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.role import UserRoleEnum
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.db.models.expense_item import ExpenseItem
|
||||
from src.repository.expense_item_repository import ExpenseItemRepository
|
||||
|
||||
|
||||
class ExpenseItemService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.repo = ExpenseItemRepository(db)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
item_id: int,
|
||||
user: AppUser | None = None,
|
||||
) -> ExpenseItem | None:
|
||||
if not user or user.role_id == UserRoleEnum.ADMIN:
|
||||
return await self.repo.get(item_id=item_id)
|
||||
return None
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
r_start: bool | None = None,
|
||||
) -> list[ExpenseItem]:
|
||||
return await self.repo.get_list(r_start=r_start)
|
||||
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
|
||||
3144
api/src/services/export_service/export_mappings.py
Normal file
3144
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
|
||||
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