Compare commits
No commits in common. "test" and "main" have entirely different histories.
141
api/alembic.ini
141
api/alembic.ini
@ -1,141 +0,0 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@ -1 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@ -1,66 +0,0 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from src.core.config import settings
|
||||
from src.db.base import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = settings.DATABASE_URL
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
include_schemas=True,
|
||||
version_table_schema="v3",
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_schemas=True,
|
||||
version_table_schema="v3",
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.execute(f"CREATE SCHEMA IF NOT EXISTS v3")
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@ -1,28 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,79 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
||||
|
||||
|
||||
def _find_dollar_tag(line: str) -> Optional[str]:
|
||||
m = _DOLLAR_TAG_RE.search(line.strip())
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
def _split_statements(sql: str) -> List[str]:
|
||||
statements: List[str] = []
|
||||
current: List[str] = []
|
||||
in_dollar = False
|
||||
dollar_tag: Optional[str] = None
|
||||
|
||||
for line in sql.split("\n"):
|
||||
stripped = line.strip()
|
||||
|
||||
if not in_dollar:
|
||||
tag = _find_dollar_tag(stripped)
|
||||
if tag and tag.endswith("$") and tag.startswith("$"):
|
||||
dollar_tag = tag
|
||||
in_dollar = True
|
||||
current.append(line)
|
||||
continue
|
||||
|
||||
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
|
||||
after = stripped[len(dollar_tag):].strip()
|
||||
if after == ";" or after == "":
|
||||
in_dollar = False
|
||||
dollar_tag = None
|
||||
if after == ";":
|
||||
current.append(line)
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
|
||||
if not in_dollar and stripped.rstrip().endswith(";"):
|
||||
current.append(line)
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
|
||||
current.append(line)
|
||||
|
||||
remaining = "\n".join(current).strip()
|
||||
if remaining:
|
||||
statements.append(remaining)
|
||||
|
||||
return statements
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0002_functions.sql")
|
||||
with open(ddl_path) as f:
|
||||
content = f.read()
|
||||
|
||||
statements = _split_statements(content)
|
||||
for stmt in statements:
|
||||
stripped = stmt.strip().rstrip(";").strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
|
||||
continue
|
||||
op.execute(stripped)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@ -1,30 +0,0 @@
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
has_data = conn.execute(sa.text("SELECT 1 FROM v3.role LIMIT 1")).scalar()
|
||||
|
||||
if has_data:
|
||||
return
|
||||
sql_path = os.path.join(os.path.dirname(__file__), "sql", "0003_initial_data.sql")
|
||||
with open(sql_path) as f:
|
||||
content = f.read()
|
||||
|
||||
for stmt in content.split(";\n"):
|
||||
stripped = stmt.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
op.execute(stripped)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@ -1,63 +0,0 @@
|
||||
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
|
||||
@ -1,330 +0,0 @@
|
||||
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
|
||||
@ -1,79 +0,0 @@
|
||||
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
|
||||
@ -1,180 +0,0 @@
|
||||
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
|
||||
@ -1,16 +0,0 @@
|
||||
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
|
||||
@ -1,785 +0,0 @@
|
||||
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
|
||||
@ -1,84 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
||||
|
||||
|
||||
def _find_dollar_tag(line: str) -> str | None:
|
||||
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: str | None = None
|
||||
|
||||
for line in sql.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("--"):
|
||||
continue
|
||||
|
||||
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", "0010_form4.sql")
|
||||
with open(ddl_path) as f:
|
||||
content = f.read()
|
||||
|
||||
statements = _split_statements(content)
|
||||
for stmt in statements:
|
||||
stripped = stmt.strip().rstrip(";").strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
|
||||
continue
|
||||
op.execute(stripped)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@ -1,84 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
||||
|
||||
|
||||
def _find_dollar_tag(line: str) -> str | None:
|
||||
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: str | None = None
|
||||
|
||||
for line in sql.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("--"):
|
||||
continue
|
||||
|
||||
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", "0011_form3_log.sql")
|
||||
with open(ddl_path) as f:
|
||||
content = f.read()
|
||||
|
||||
statements = _split_statements(content)
|
||||
for stmt in statements:
|
||||
stripped = stmt.strip().rstrip(";").strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
|
||||
continue
|
||||
op.execute(stripped)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@ -1,84 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
||||
|
||||
|
||||
def _find_dollar_tag(line: str) -> str | None:
|
||||
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: str | None = None
|
||||
|
||||
for line in sql.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("--"):
|
||||
continue
|
||||
|
||||
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", "0012_del_project.sql")
|
||||
with open(ddl_path) as f:
|
||||
content = f.read()
|
||||
|
||||
statements = _split_statements(content)
|
||||
for stmt in statements:
|
||||
stripped = stmt.strip().rstrip(";").strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
|
||||
continue
|
||||
op.execute(stripped)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@ -1,84 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0013"
|
||||
down_revision = "0012"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
||||
|
||||
|
||||
def _find_dollar_tag(line: str) -> str | None:
|
||||
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: str | None = None
|
||||
|
||||
for line in sql.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("--"):
|
||||
continue
|
||||
|
||||
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", "0013_form2_limit.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
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,852 +0,0 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- migrate_form4_project.sql — выделение FORM_4 программ/проектов в отдельную
|
||||
-- сущность v3.form4_project (перестают делить таблицу с FORM_3 v3.project).
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Мотивация: v3.project исторически был FORM_4-таблицей (program/project +
|
||||
-- parent_id), FORM_3-редизайн навесил сверху свои атрибуты (project_type,
|
||||
-- vsp_format, address, area, staff, org_unit_id) и строгий chk_v3_project_name.
|
||||
-- Два разных концепта под одним словом «project» → разреженные колонки, нет
|
||||
-- защиты FK, констрейнт вынужден ветвиться по «виду» строки. Разводим.
|
||||
--
|
||||
-- Новая таблица v3.form4_project — точный костяк без FORM_3-полей:
|
||||
-- program (level='program', parent_id=NULL)
|
||||
-- project (level='project', parent_id=program)
|
||||
--
|
||||
-- Дискриминатор FORM_4 vs FORM_3: FORM_3-строки — те, что ссылаются из
|
||||
-- v3.rf_project_report (add_project всегда создаёт отчёты для КАЖДОЙ строки,
|
||||
-- включая программы). Всё остальное в v3.project — FORM_4.
|
||||
--
|
||||
-- Порядок: после init.sql + всех migrate_form3*/migrate_to_identity (нужна
|
||||
-- финальная форма v3.project с org_unit_id). Один прогон — copy защищён
|
||||
-- проверкой на пустоту form4_project.
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ───── 1. Новая таблица ─────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS v3.form4_project (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR NOT NULL,
|
||||
level VARCHAR CHECK (level IN ('program', 'project')),
|
||||
section_code VARCHAR,
|
||||
parent_id INTEGER REFERENCES v3.form4_project(id),
|
||||
-- org_unit_id INTEGER REFERENCES v3.org_unit(id),
|
||||
form_id INTEGER REFERENCES v3.budget_form(id),
|
||||
-- project (level='project') обязан иметь родителя-программу; program — нет.
|
||||
CONSTRAINT chk_form4_project_parent CHECK (
|
||||
(level = 'program' AND parent_id IS NULL)
|
||||
OR (level = 'project')
|
||||
),
|
||||
CONSTRAINT chk_form4_project_section_code CHECK (
|
||||
(level = 'program') = (section_code IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX ix_form4_project_section_code
|
||||
ON v3.form4_project(section_code) WHERE level = 'program';
|
||||
CREATE INDEX IF NOT EXISTS ix_v3_form4_project_parent ON v3.form4_project(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_v3_form4_project_form ON v3.form4_project(form_id);
|
||||
|
||||
-- ───── 2. Перенос FORM_4-строк из v3.project (с сохранением id) ──────────────
|
||||
-- FK budget_line.project_id ссылается по id → id сохраняем 1:1.
|
||||
-- Копируем обе роли (program + project) одним INSERT: self-FK parent_id внутри
|
||||
-- одного оператора проверяется в конце, порядок строк не важен.
|
||||
-- DO $migrate$
|
||||
-- BEGIN
|
||||
-- IF NOT EXISTS (SELECT 1 FROM v3.form4_project) THEN
|
||||
-- INSERT INTO v3.form4_project (id, name, level, parent_id, org_unit_id)
|
||||
-- SELECT p.id, p.name, p.level, p.parent_id, (p.org_unit_id
|
||||
-- FROM v3.project p
|
||||
-- WHERE p.id NOT IN (
|
||||
-- SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL
|
||||
-- );
|
||||
|
||||
-- -- Синхронизируем sequence, чтобы новые INSERT не конфликтовали по id.
|
||||
-- PERFORM setval('v3.form4_project_id_seq',
|
||||
-- GREATEST((SELECT COALESCE(MAX(id), 0) FROM v3.form4_project), 1));
|
||||
|
||||
-- RAISE NOTICE 'form4_project: перенесено % строк из v3.project',
|
||||
-- (SELECT count(*) FROM v3.form4_project);
|
||||
-- ELSE
|
||||
-- RAISE NOTICE 'form4_project уже наполнена — перенос пропущен';
|
||||
-- END IF;
|
||||
-- END
|
||||
-- $migrate$;
|
||||
|
||||
-- ───── 3. Перецепка FK budget_line.project_id на новую таблицу ──────────────
|
||||
ALTER TABLE v3.budget_line DROP CONSTRAINT IF EXISTS budget_line_project_id_fkey;
|
||||
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_project_id_fkey FOREIGN KEY (project_id) REFERENCES v3.form4_project(id);
|
||||
|
||||
-- ───── 4. Удаление перенесённых строк из v3.project ─────────────────────────
|
||||
-- После перецепки budget_line на v3.project больше не ссылается ничего FORM_4.
|
||||
-- Остаются только FORM_3-строки (referenced by rf_project_report).
|
||||
DELETE FROM v3.project p
|
||||
WHERE p.id NOT IN (
|
||||
SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL
|
||||
);
|
||||
|
||||
-- ───── 5. Восстановление chk_v3_project_name в строгой форме ─────────────────
|
||||
-- FORM_4-строки ушли — v3.project теперь чисто FORM_3, правило коротких имён
|
||||
-- ВСП применимо ко всей таблице (как до form4_write.sql). NOT VALID сохраняем.
|
||||
ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS chk_v3_project_name;
|
||||
ALTER TABLE v3.project
|
||||
ADD CONSTRAINT chk_v3_project_name
|
||||
CHECK (name IS NULL OR (
|
||||
length(name) <= 30 AND
|
||||
name !~ E'[+\\-/\\\\=&* ]'
|
||||
)) NOT VALID;
|
||||
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- v3.add_form4_program / v3.add_form4_project — создание программ и проектов
|
||||
-- FORM_4 в выделенной таблице v3.form4_project (иерархия program → project).
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- FORM_4 хранит иерархию отдельно от FORM_3 (v3.project) — см.
|
||||
-- migrate_form4_project.sql. Костяк:
|
||||
-- program (level='program', parent_id=NULL) → depth=1 (GROUP) в v_form4
|
||||
-- project (level='project', parent_id=program) → depth=2 (ITEM) в v_form4
|
||||
-- Строки бюджета привязываются к проекту через budget_line.project_id.
|
||||
--
|
||||
-- Никаких FORM_3-атрибутов / rf_project_report / snapshot фаз — это ветка
|
||||
-- budget_form. p_org_unit_id опционален (read-path FORM_4 по нему не фильтрует,
|
||||
-- заполняется для аудита/принадлежности).
|
||||
--
|
||||
-- Порядок применения: после migrate_form4_project.sql (нужна таблица
|
||||
-- v3.form4_project) и audit.sql (нужна v3.log_event).
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ───── 1. add_form4_program ─────────────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS v3.add_form4_program(VARCHAR, INT, VARCHAR, INT);
|
||||
|
||||
CREATE FUNCTION v3.add_form4_program(
|
||||
p_name VARCHAR,
|
||||
p_form_id INT,
|
||||
p_section_code VARCHAR DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
project_id INT,
|
||||
row_type VARCHAR,
|
||||
depth INT,
|
||||
sort_order BIGINT,
|
||||
data JSONB
|
||||
) AS $function$
|
||||
#variable_conflict use_column
|
||||
DECLARE
|
||||
v_pid INT;
|
||||
BEGIN
|
||||
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
|
||||
RAISE EXCEPTION 'p_name обязателен';
|
||||
END IF;
|
||||
IF p_form_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_form_id обязателен';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM v3.budget_form bf
|
||||
WHERE bf.form_type_code = 'FORM_4' AND bf.id = p_form_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Только FORM_4';
|
||||
END IF;
|
||||
IF p_section_code IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM v3.expense_item
|
||||
WHERE section_code = p_section_code AND depth = 0
|
||||
) THEN
|
||||
RAISE EXCEPTION 'section_code=% не существует в expense_item (depth=0)', p_section_code;
|
||||
END IF;
|
||||
|
||||
INSERT INTO v3.form4_project (name, level, parent_id, form_id, section_code)
|
||||
VALUES (p_name, 'program', NULL, p_form_id, p_section_code)
|
||||
RETURNING id INTO v_pid;
|
||||
|
||||
PERFORM v3.log_event(
|
||||
'PROJECT_CREATE', 'PROJECT',
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'project_id', v_pid,
|
||||
'name', p_name,
|
||||
'level', 'program',
|
||||
'form_id', p_form_id,
|
||||
'form_type', 'FORM_4'
|
||||
)),
|
||||
p_form_id, NULL, NULL
|
||||
);
|
||||
|
||||
RETURN QUERY
|
||||
SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data
|
||||
FROM v3.v_form4_sheet_jsonb(p_form_id, p_section_code, NULL) v
|
||||
WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_pid)
|
||||
OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = p_section_code)
|
||||
ORDER BY v.sort_order;
|
||||
END;
|
||||
$function$
|
||||
LANGUAGE plpgsql VOLATILE;
|
||||
|
||||
-- ───── 2. add_form4_project ─────────────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS v3.add_form4_project(VARCHAR, INT);
|
||||
|
||||
CREATE FUNCTION v3.add_form4_project(
|
||||
p_name VARCHAR,
|
||||
p_program_id INT
|
||||
)
|
||||
RETURNS TABLE (
|
||||
project_id INT,
|
||||
row_type VARCHAR,
|
||||
depth INT,
|
||||
sort_order BIGINT,
|
||||
data JSONB
|
||||
)
|
||||
AS $function$
|
||||
#variable_conflict use_column
|
||||
DECLARE
|
||||
v_pid INT;
|
||||
v_section_code VARCHAR;
|
||||
v_form_id INT;
|
||||
BEGIN
|
||||
IF p_name IS NULL OR length(trim(p_name)) = 0 THEN
|
||||
RAISE EXCEPTION 'p_name обязателен';
|
||||
END IF;
|
||||
IF p_program_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_program_id обязателен (проект создаётся под программой)';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM v3.form4_project fp
|
||||
WHERE fp.id = p_program_id AND fp.level = 'program') THEN
|
||||
RAISE EXCEPTION 'program id=% не существует или не имеет level=program', p_program_id;
|
||||
END IF;
|
||||
|
||||
SELECT form_id INTO v_form_id FROM v3.form4_project WHERE id = p_program_id;
|
||||
|
||||
INSERT INTO v3.form4_project (name, level, parent_id, form_id)
|
||||
VALUES (p_name, 'project', p_program_id, v_form_id)
|
||||
RETURNING id INTO v_pid;
|
||||
|
||||
PERFORM v3.log_event(
|
||||
'PROJECT_CREATE', 'PROJECT',
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'project_id', v_pid,
|
||||
'name', p_name,
|
||||
'level', 'project',
|
||||
'parent_id', p_program_id,
|
||||
'form_id', v_form_id,
|
||||
'form_type', 'FORM_4'
|
||||
)),
|
||||
v_form_id, NULL, NULL
|
||||
);
|
||||
|
||||
SELECT fp.section_code INTO v_section_code
|
||||
FROM v3.form4_project fp
|
||||
WHERE fp.id = p_program_id AND fp.level = 'program';
|
||||
|
||||
IF v_section_code IS NOT NULL THEN
|
||||
RETURN QUERY
|
||||
SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data
|
||||
FROM v3.v_form4_sheet_jsonb(v_form_id, v_section_code, NULL) v
|
||||
WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = v_pid)
|
||||
OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = p_program_id)
|
||||
OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code)
|
||||
ORDER BY v.sort_order;
|
||||
END IF;
|
||||
END;
|
||||
$function$
|
||||
LANGUAGE plpgsql VOLATILE;
|
||||
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- v3.v_form4_sheet_jsonb — JSONB-выдача FORM_4 для АХР_Р / КВ_Р / Операц
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Иерархия (4 уровня):
|
||||
-- ROOT depth=0 — section_code (1.00.0/3.00.0/4.00.0/5.00.0)
|
||||
-- PROGRAM depth=1 — v3.form4_project level=program
|
||||
-- PROJECT depth=2 — v3.form4_project level=project (parent_id=program)
|
||||
-- INPUT depth=3 — budget_line (R-код)
|
||||
--
|
||||
-- Финансовые блоки (plan, seq_dfip, approved, booking, q1-q4, totals)
|
||||
-- агрегируются на уровнях ROOT/PROGRAM/PROJECT через v3.jsonb_numeric_sum.
|
||||
-- Текстовые поля (counterparty, comment, justification…) на агрегатных
|
||||
-- уровнях NULL'ятся (jsonb_numeric_sum их игнорирует).
|
||||
--
|
||||
-- p_form_id — id формы FORM_4
|
||||
-- p_sheet — 'AHR' / 'CAP' / 'OPER'
|
||||
-- p_sections — массив включаемых блоков (как в v_form4_sheet_lines_jsonb)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ───── 1. Хелпер: рекурсивная сумма численных листьев двух JSONB ──────────
|
||||
CREATE OR REPLACE FUNCTION v3.jsonb_numeric_sum(a JSONB, b JSONB)
|
||||
RETURNS JSONB LANGUAGE sql IMMUTABLE AS $function$
|
||||
SELECT CASE
|
||||
WHEN a IS NULL THEN b
|
||||
WHEN b IS NULL THEN a
|
||||
WHEN jsonb_typeof(a) = 'number' AND jsonb_typeof(b) = 'number' THEN
|
||||
to_jsonb((a)::text::numeric + (b)::text::numeric)
|
||||
WHEN jsonb_typeof(a) = 'object' AND jsonb_typeof(b) = 'object' THEN (
|
||||
SELECT jsonb_object_agg(k,
|
||||
v3.jsonb_numeric_sum(a -> k, b -> k)
|
||||
)
|
||||
FROM (
|
||||
SELECT k FROM jsonb_object_keys(a) k
|
||||
UNION
|
||||
SELECT k FROM jsonb_object_keys(b) k
|
||||
) keys
|
||||
)
|
||||
-- одна сторона число/объект, другая нет → берём числовую/объектную
|
||||
WHEN jsonb_typeof(a) IN ('number','object') THEN a
|
||||
WHEN jsonb_typeof(b) IN ('number','object') THEN b
|
||||
ELSE NULL
|
||||
END;
|
||||
$function$;
|
||||
|
||||
DROP AGGREGATE IF EXISTS v3.jsonb_sum(JSONB);
|
||||
CREATE AGGREGATE v3.jsonb_sum(JSONB) (
|
||||
SFUNC = v3.jsonb_numeric_sum,
|
||||
STYPE = JSONB
|
||||
);
|
||||
|
||||
-- ───── 2. Основная функция ─────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb(
|
||||
p_form_id INT,
|
||||
p_sheet VARCHAR,
|
||||
p_sections TEXT[] DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
row_type VARCHAR,
|
||||
depth INT,
|
||||
sort_order BIGINT,
|
||||
data JSONB
|
||||
)
|
||||
AS $function$
|
||||
-- ═══ 1. Базовые INPUT-строки с расширенной иерархией ═══════════════════════
|
||||
WITH inp_raw AS (
|
||||
SELECT j.*
|
||||
FROM v3.v_form4_sheet_lines_jsonb(p_form_id, p_sheet, p_sections) j
|
||||
WHERE j.row_type = 'INPUT'
|
||||
),
|
||||
inp AS (
|
||||
SELECT
|
||||
i.*,
|
||||
bl.project_id AS line_project_id,
|
||||
|
||||
-- --- разрешение проекта ---
|
||||
-- A. bl.project_id → project (level='project')
|
||||
-- B. bl.project_id → program (level='program') — тогда проекта нет
|
||||
CASE WHEN prj.id IS NOT NULL THEN prj.id END AS prj_id,
|
||||
CASE WHEN prj.id IS NOT NULL THEN prj.name END AS prj_name,
|
||||
|
||||
-- --- разрешение программы ---
|
||||
-- A. через проект: prog_via_prj
|
||||
-- B. напрямую: prog_direct
|
||||
COALESCE(prog_via_prj.id, prog_direct.id) AS prog_id,
|
||||
COALESCE(prog_via_prj.name, prog_direct.name) AS prog_name,
|
||||
|
||||
-- --- section (depth=0) ---
|
||||
sec.id AS sec_id,
|
||||
sec.section_code AS sec_code,
|
||||
sec.name AS sec_name
|
||||
|
||||
FROM inp_raw i
|
||||
JOIN v3.budget_line bl ON bl.id = i.line_id
|
||||
|
||||
-- проект (level='project')
|
||||
LEFT JOIN v3.form4_project prj
|
||||
ON prj.id = bl.project_id AND prj.level = 'project'
|
||||
|
||||
-- программа через проект
|
||||
LEFT JOIN v3.form4_project prog_via_prj
|
||||
ON prog_via_prj.id = prj.parent_id AND prog_via_prj.level = 'program'
|
||||
|
||||
-- программа напрямую (bl.project_id → program)
|
||||
LEFT JOIN v3.form4_project prog_direct
|
||||
ON prog_direct.id = bl.project_id AND prog_direct.level = 'program'
|
||||
|
||||
-- раздел depth=0 (через expense_item)
|
||||
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
|
||||
LEFT JOIN LATERAL (
|
||||
WITH RECURSIVE up AS (
|
||||
SELECT id, parent_id, name, section_code, depth
|
||||
FROM v3.expense_item WHERE id = ei.id
|
||||
UNION ALL
|
||||
SELECT e.id, e.parent_id, e.name, e.section_code, e.depth
|
||||
FROM v3.expense_item e JOIN up u ON u.parent_id = e.id
|
||||
)
|
||||
SELECT id, name, section_code FROM up WHERE depth = 0 LIMIT 1
|
||||
) sec ON TRUE
|
||||
),
|
||||
|
||||
-- ═══ 2. Все программы (включая пустые) ═════════════════════════════════════
|
||||
all_programs AS (
|
||||
SELECT
|
||||
fp.id AS prog_id,
|
||||
fp.name AS prog_name,
|
||||
fp.section_code,
|
||||
sec.id AS sec_id,
|
||||
sec.section_code AS sec_code,
|
||||
sec.name AS sec_name
|
||||
FROM v3.form4_project fp
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, section_code, name
|
||||
FROM v3.expense_item
|
||||
WHERE section_code = fp.section_code AND depth = 0
|
||||
LIMIT 1
|
||||
) sec ON TRUE
|
||||
WHERE fp.level = 'program'
|
||||
AND (p_sections IS NULL OR fp.section_code = ANY(p_sections))
|
||||
AND (fp.section_code = p_sheet) AND fp.form_id = p_form_id
|
||||
),
|
||||
|
||||
-- ═══ 3. Все проекты под программами (включая пустые) ═══════════════════════
|
||||
all_projects AS (
|
||||
SELECT
|
||||
prj.id AS prj_id,
|
||||
prj.name AS prj_name,
|
||||
prj.parent_id AS prog_id,
|
||||
ap.sec_id, ap.sec_code, ap.sec_name
|
||||
FROM v3.form4_project prj
|
||||
JOIN all_programs ap ON ap.prog_id = prj.parent_id
|
||||
WHERE prj.level = 'project' AND (ap.section_code = p_sheet)
|
||||
),
|
||||
|
||||
-- ═══ 4. Агрегаты с финансовыми данными (только из inp) ════════════════════
|
||||
|
||||
-- PROJECT-агрегаты
|
||||
prj_agg AS (
|
||||
SELECT
|
||||
sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name,
|
||||
prog_id, MAX(prog_name) AS prog_name,
|
||||
prj_id, MAX(prj_name) AS prj_name,
|
||||
v3.jsonb_sum(plan_data) AS plan_data,
|
||||
v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data,
|
||||
v3.jsonb_sum(approved_data) AS approved_data,
|
||||
v3.jsonb_sum(contract_summary_data) AS contract_summary_data,
|
||||
v3.jsonb_sum(allocation_data) AS allocation_data,
|
||||
v3.jsonb_sum(reserve_data) AS reserve_data,
|
||||
v3.jsonb_sum(collegial_data) AS collegial_data,
|
||||
v3.jsonb_sum(ckk_data) AS ckk_data,
|
||||
v3.jsonb_sum(contract_data) AS contract_data,
|
||||
v3.jsonb_sum(booking_data) AS booking_data,
|
||||
v3.jsonb_sum(q1_data) AS q1_data,
|
||||
v3.jsonb_sum(q2_data) AS q2_data,
|
||||
v3.jsonb_sum(q3_data) AS q3_data,
|
||||
v3.jsonb_sum(q4_data) AS q4_data,
|
||||
v3.jsonb_sum(totals_data) AS totals_data
|
||||
FROM inp WHERE prj_id IS NOT NULL
|
||||
GROUP BY sec_id, prog_id, prj_id
|
||||
),
|
||||
|
||||
-- PROGRAM-агрегаты
|
||||
prog_agg AS (
|
||||
SELECT
|
||||
sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name,
|
||||
prog_id, MAX(prog_name) AS prog_name,
|
||||
v3.jsonb_sum(plan_data) AS plan_data,
|
||||
v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data,
|
||||
v3.jsonb_sum(approved_data) AS approved_data,
|
||||
v3.jsonb_sum(contract_summary_data) AS contract_summary_data,
|
||||
v3.jsonb_sum(allocation_data) AS allocation_data,
|
||||
v3.jsonb_sum(reserve_data) AS reserve_data,
|
||||
v3.jsonb_sum(collegial_data) AS collegial_data,
|
||||
v3.jsonb_sum(ckk_data) AS ckk_data,
|
||||
v3.jsonb_sum(contract_data) AS contract_data,
|
||||
v3.jsonb_sum(booking_data) AS booking_data,
|
||||
v3.jsonb_sum(q1_data) AS q1_data,
|
||||
v3.jsonb_sum(q2_data) AS q2_data,
|
||||
v3.jsonb_sum(q3_data) AS q3_data,
|
||||
v3.jsonb_sum(q4_data) AS q4_data,
|
||||
v3.jsonb_sum(totals_data) AS totals_data
|
||||
FROM inp WHERE prog_id IS NOT NULL
|
||||
GROUP BY sec_id, prog_id
|
||||
),
|
||||
|
||||
-- ROOT-агрегаты
|
||||
root_agg AS (
|
||||
SELECT
|
||||
sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name,
|
||||
v3.jsonb_sum(plan_data) AS plan_data,
|
||||
v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data,
|
||||
v3.jsonb_sum(approved_data) AS approved_data,
|
||||
v3.jsonb_sum(contract_summary_data) AS contract_summary_data,
|
||||
v3.jsonb_sum(allocation_data) AS allocation_data,
|
||||
v3.jsonb_sum(reserve_data) AS reserve_data,
|
||||
v3.jsonb_sum(collegial_data) AS collegial_data,
|
||||
v3.jsonb_sum(ckk_data) AS ckk_data,
|
||||
v3.jsonb_sum(contract_data) AS contract_data,
|
||||
v3.jsonb_sum(booking_data) AS booking_data,
|
||||
v3.jsonb_sum(q1_data) AS q1_data,
|
||||
v3.jsonb_sum(q2_data) AS q2_data,
|
||||
v3.jsonb_sum(q3_data) AS q3_data,
|
||||
v3.jsonb_sum(q4_data) AS q4_data,
|
||||
v3.jsonb_sum(totals_data) AS totals_data
|
||||
FROM inp
|
||||
GROUP BY sec_id
|
||||
),
|
||||
|
||||
-- Нулевой JSONB-шаблон (для пустых программ/проектов)
|
||||
zero AS (
|
||||
SELECT '{}'::jsonb AS z
|
||||
),
|
||||
|
||||
-- ═══ 5. UNION всех уровней ═════════════════════════════════════════════════
|
||||
unioned AS (
|
||||
-- ROOT — только из агрегатов (не имеет смысла пустой ROOT без строк)
|
||||
SELECT
|
||||
'ROOT'::VARCHAR AS row_type, 0 AS depth, NULL::INT AS line_id,
|
||||
jsonb_build_object('section_code', sec_code, 'name', sec_name, 'expense_item_id', sec_id) AS header,
|
||||
plan_data, seq_dfip_data, approved_data,
|
||||
contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data,
|
||||
contract_data, booking_data,
|
||||
q1_data, q2_data, q3_data, q4_data, totals_data,
|
||||
ARRAY[sec_id, 0, 0, 0]::INT[] AS sort_path
|
||||
FROM root_agg
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- GROUP — из агрегатов (программы с бюджетными строками)
|
||||
SELECT
|
||||
'GROUP', 1, NULL,
|
||||
jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id),
|
||||
plan_data, seq_dfip_data, approved_data,
|
||||
contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data,
|
||||
contract_data, booking_data,
|
||||
q1_data, q2_data, q3_data, q4_data, totals_data,
|
||||
ARRAY[sec_id, prog_id, 0, 0]::INT[]
|
||||
FROM prog_agg
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- GROUP — пустые программы (нет ни одной budget_line)
|
||||
SELECT
|
||||
'GROUP', 1, NULL,
|
||||
jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id),
|
||||
NULL::jsonb, NULL::jsonb, NULL::jsonb, -- plan, seq_dfip, approved
|
||||
NULL::jsonb, NULL::jsonb, NULL::jsonb, -- contract_summary, allocation, reserve
|
||||
NULL::jsonb, NULL::jsonb, -- collegial, ckk
|
||||
NULL::jsonb, NULL::jsonb, -- contract, booking
|
||||
NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, -- q1-q4, totals
|
||||
ARRAY[sec_id, prog_id, 0, 0]::INT[]
|
||||
FROM all_programs ap
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM v3.budget_line bl
|
||||
WHERE bl.budget_form_id = p_form_id
|
||||
AND (
|
||||
bl.project_id = ap.prog_id -- прямая привязка к программе
|
||||
OR bl.project_id IN (SELECT id FROM v3.form4_project pr -- через проект
|
||||
WHERE pr.parent_id = ap.prog_id AND pr.level = 'project')
|
||||
)
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- ITEM — из агрегатов (проекты с бюджетными строками)
|
||||
SELECT
|
||||
'ITEM', 2, NULL,
|
||||
jsonb_build_object('section_code', sec_code, 'name', prj_name,
|
||||
'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id),
|
||||
plan_data, seq_dfip_data, approved_data,
|
||||
contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data,
|
||||
contract_data, booking_data,
|
||||
q1_data, q2_data, q3_data, q4_data, totals_data,
|
||||
ARRAY[sec_id, prog_id, prj_id, 0]::INT[]
|
||||
FROM prj_agg
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- ITEM — пустые проекты (нет budget_line)
|
||||
SELECT
|
||||
'ITEM', 2, NULL,
|
||||
jsonb_build_object('section_code', sec_code, 'name', prj_name,
|
||||
'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id),
|
||||
NULL::jsonb, NULL::jsonb, NULL::jsonb,
|
||||
NULL::jsonb, NULL::jsonb, NULL::jsonb,
|
||||
NULL::jsonb, NULL::jsonb,
|
||||
NULL::jsonb, NULL::jsonb,
|
||||
NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb,
|
||||
ARRAY[sec_id, prog_id, prj_id, 0]::INT[]
|
||||
FROM all_projects apr
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM v3.budget_line bl
|
||||
WHERE bl.budget_form_id = p_form_id
|
||||
AND bl.project_id = apr.prj_id
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- INPUT — все строки бюджета
|
||||
SELECT
|
||||
'INPUT', 3, line_id,
|
||||
header || jsonb_build_object(
|
||||
'project_id', prj_id, 'project_name', prj_name,
|
||||
'program_id', prog_id, 'program_name', prog_name
|
||||
),
|
||||
plan_data, seq_dfip_data, approved_data,
|
||||
contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data,
|
||||
contract_data, booking_data,
|
||||
q1_data, q2_data, q3_data, q4_data, totals_data,
|
||||
ARRAY[sec_id, COALESCE(prog_id, 999999), COALESCE(prj_id, 999999), line_id]::INT[]
|
||||
FROM inp
|
||||
)
|
||||
|
||||
SELECT
|
||||
u.row_type, u.depth,
|
||||
ROW_NUMBER() OVER (ORDER BY u.sort_path) AS sort_order,
|
||||
jsonb_build_object(
|
||||
'line_id', u.line_id,
|
||||
'header', u.header,
|
||||
'plan', u.plan_data,
|
||||
'seq_dfip', u.seq_dfip_data,
|
||||
'approved', u.approved_data,
|
||||
'contract_summary', u.contract_summary_data,
|
||||
'allocation', u.allocation_data,
|
||||
'reserve', u.reserve_data,
|
||||
'collegial', u.collegial_data,
|
||||
'ckk', u.ckk_data,
|
||||
'contract', u.contract_data,
|
||||
'booking', u.booking_data,
|
||||
'q1', u.q1_data,
|
||||
'q2', u.q2_data,
|
||||
'q3', u.q3_data,
|
||||
'q4', u.q4_data,
|
||||
'totals', u.totals_data
|
||||
) AS data
|
||||
FROM unioned u
|
||||
ORDER BY u.sort_path;
|
||||
$function$
|
||||
LANGUAGE sql STABLE;
|
||||
|
||||
|
||||
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- v3.add_budget_line — создать новую INPUT-строку (budget_line) для FORM_1/2/4.
|
||||
--
|
||||
-- Способы передать expense_item:
|
||||
-- путь A — численный id: p_expense_item_id (берётся из header.expense_item_id)
|
||||
-- путь B — резолв по коду: p_sheet + p_item_id [+ p_section_code для коллизий]
|
||||
-- [+ p_direction для FORM_1]
|
||||
--
|
||||
-- Авто-direction:
|
||||
-- FORM_2 → 'Support'
|
||||
-- FORM_4 → 'Development'
|
||||
-- FORM_1/OPER → NULL
|
||||
-- FORM_1/AHR|CAP → p_direction обязателен (Support|Development)
|
||||
-- Если p_direction передан и противоречит авто-значению — ошибка.
|
||||
--
|
||||
-- Дубликаты на одном expense_item разрешены (UNIQUE не накладывается).
|
||||
-- vsp_id опционален — для FORM_2 фронт может не знать ВСП на момент
|
||||
-- создания, дозаполнит позже через upd_form_cell(.., 'header.vsp_id', ..).
|
||||
--
|
||||
-- FORM_3 не поддерживается — там строки живут в rf_project_report_line,
|
||||
-- API будет в add_form3_line.sql (отдельная итерация).
|
||||
--
|
||||
-- Возврат: вся иерархия (ROOT/GROUP/ITEM/SUB_ITEM/SECTION/LEAF) + новая
|
||||
-- INPUT (отфильтрована по новому line_id). Новый id извлекается из
|
||||
-- (data->>'line_id')::INT первой INPUT-строки.
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR);
|
||||
DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR, VARCHAR, DATE);
|
||||
|
||||
CREATE FUNCTION v3.add_budget_line(
|
||||
p_form_id INT,
|
||||
p_expense_item_id INT DEFAULT NULL,
|
||||
p_sheet VARCHAR DEFAULT NULL,
|
||||
p_item_id VARCHAR DEFAULT NULL,
|
||||
p_section_code VARCHAR DEFAULT NULL,
|
||||
p_direction VARCHAR DEFAULT NULL,
|
||||
p_name VARCHAR DEFAULT NULL,
|
||||
p_internal_order VARCHAR DEFAULT NULL,
|
||||
p_vsp_id INT DEFAULT NULL,
|
||||
p_project_id INT DEFAULT NULL,
|
||||
p_justification VARCHAR DEFAULT NULL,
|
||||
-- сателлитные листы (AHR_RENT/UTILITY/SECURITY) используют дополнительно:
|
||||
p_contract_number VARCHAR DEFAULT NULL,
|
||||
p_contract_end_date DATE DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
row_type VARCHAR,
|
||||
depth INT,
|
||||
sort_order BIGINT,
|
||||
data JSONB
|
||||
)
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_form_type VARCHAR;
|
||||
v_eid INT;
|
||||
v_sheet VARCHAR;
|
||||
v_auto_dir VARCHAR;
|
||||
v_final_dir VARCHAR;
|
||||
v_new_id INT;
|
||||
v_cnt INT;
|
||||
v_sat_table TEXT;
|
||||
v_sat_eid INT;
|
||||
v_parent_line INT;
|
||||
BEGIN
|
||||
-- 1. form_id → form_type
|
||||
SELECT form_type_code INTO v_form_type FROM v3.budget_form WHERE id = p_form_id;
|
||||
IF v_form_type IS NULL THEN
|
||||
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
|
||||
END IF;
|
||||
IF v_form_type = 'FORM_3' THEN
|
||||
RAISE EXCEPTION 'FORM_3 не использует budget_line — см. add_form3_line (TBD)';
|
||||
END IF;
|
||||
|
||||
-- ═══ Сателлитные листы (AHR_RENT/UTILITY/SECURITY) ═══════════════════
|
||||
-- Фиксированный expense_item_id на лист — UI не передаёт R-код.
|
||||
-- p_vsp_id обязателен. Опц.: p_contract_number, p_contract_end_date.
|
||||
-- Внутри: находим/создаём родительскую budget_line с фиксированным ei,
|
||||
-- затем INSERT в *_detail. Возврат — одна новая LEAF.
|
||||
IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN
|
||||
v_sat_table := CASE p_sheet
|
||||
WHEN 'AHR_RENT' THEN 'rent_detail'
|
||||
WHEN 'AHR_UTILITY' THEN 'utility_detail'
|
||||
WHEN 'AHR_SECURITY' THEN 'security_detail'
|
||||
END;
|
||||
v_sat_eid := CASE p_sheet
|
||||
WHEN 'AHR_RENT' THEN 293 -- R061031001 "Аренда_ОН"
|
||||
WHEN 'AHR_UTILITY' THEN 24 -- 1.05.1. "Коммунальные услуги"
|
||||
WHEN 'AHR_SECURITY' THEN 779 -- R061062001 "Пультовая охр.ОН"
|
||||
END;
|
||||
|
||||
IF p_vsp_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_vsp_id обязателен для сателлитного листа %', p_sheet;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN
|
||||
RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id;
|
||||
END IF;
|
||||
|
||||
-- Находим/создаём родительскую budget_line под фиксированным ei
|
||||
SELECT id INTO v_parent_line
|
||||
FROM v3.budget_line
|
||||
WHERE budget_form_id = p_form_id AND expense_item_id = v_sat_eid
|
||||
LIMIT 1;
|
||||
IF v_parent_line IS NULL THEN
|
||||
INSERT INTO v3.budget_line (budget_form_id, expense_item_id, direction)
|
||||
VALUES (
|
||||
p_form_id, v_sat_eid,
|
||||
CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END
|
||||
) RETURNING id INTO v_parent_line;
|
||||
END IF;
|
||||
|
||||
-- INSERT в сателлит
|
||||
EXECUTE format(
|
||||
'INSERT INTO v3.%I (line_id, vsp_id, contract_number, contract_end_date) '
|
||||
|| 'VALUES ($1, $2, $3, $4) RETURNING id',
|
||||
v_sat_table
|
||||
) USING v_parent_line, p_vsp_id, p_contract_number, p_contract_end_date
|
||||
INTO v_new_id;
|
||||
|
||||
-- Возврат — одна новая INPUT-строка (отфильтрована по новому id сателлита)
|
||||
RETURN QUERY
|
||||
SELECT v.row_type, v.depth, v.sort_order, v.data
|
||||
FROM v3.v_form_view(p_form_id, p_sheet, NULL, NULL) v
|
||||
WHERE v.row_type = 'INPUT' AND (v.data->>'id')::INT = v_new_id;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- 2. Резолв expense_item_id
|
||||
IF p_expense_item_id IS NOT NULL THEN
|
||||
SELECT ei.id, ei.sheet INTO v_eid, v_sheet
|
||||
FROM v3.expense_item ei WHERE ei.id = p_expense_item_id;
|
||||
IF v_eid IS NULL THEN
|
||||
RAISE EXCEPTION 'expense_item id=% не существует', p_expense_item_id;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM v3.expense_item_form_type
|
||||
WHERE expense_item_id = v_eid AND form_type_code = v_form_type
|
||||
) THEN
|
||||
RAISE EXCEPTION 'expense_item id=% не привязан к form_type %', v_eid, v_form_type;
|
||||
END IF;
|
||||
ELSE
|
||||
IF p_sheet IS NULL OR p_item_id IS NULL THEN
|
||||
RAISE EXCEPTION 'нужен либо p_expense_item_id, либо (p_sheet + p_item_id)';
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_cnt
|
||||
FROM v3.expense_item ei
|
||||
JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id
|
||||
WHERE ei.item_id = p_item_id
|
||||
AND ei.sheet = p_sheet
|
||||
AND eift.form_type_code = v_form_type
|
||||
AND (p_section_code IS NULL OR ei.section_code = p_section_code)
|
||||
AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction);
|
||||
|
||||
IF v_cnt = 0 THEN
|
||||
RAISE EXCEPTION 'expense_item не найден: sheet=%, item_id=%, section=%, direction=%, form=%',
|
||||
p_sheet, p_item_id, p_section_code, p_direction, v_form_type;
|
||||
END IF;
|
||||
IF v_cnt > 1 THEN
|
||||
RAISE EXCEPTION 'expense_item не уникален (% совпадений): sheet=%, item_id=%, section=%, direction=%. Уточните p_section_code или передайте p_expense_item_id напрямую',
|
||||
v_cnt, p_sheet, p_item_id, p_section_code, p_direction;
|
||||
END IF;
|
||||
|
||||
SELECT ei.id, ei.sheet INTO v_eid, v_sheet
|
||||
FROM v3.expense_item ei
|
||||
JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id
|
||||
WHERE ei.item_id = p_item_id
|
||||
AND ei.sheet = p_sheet
|
||||
AND eift.form_type_code = v_form_type
|
||||
AND (p_section_code IS NULL OR ei.section_code = p_section_code)
|
||||
AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction);
|
||||
END IF;
|
||||
|
||||
-- 3. Авто-direction (для FORM_1/AHR|CAP — обязателен у юзера)
|
||||
v_auto_dir := CASE
|
||||
WHEN v_form_type = 'FORM_2' THEN 'Support'
|
||||
WHEN v_form_type = 'FORM_4' THEN 'Development'
|
||||
WHEN v_form_type = 'FORM_1' AND v_sheet = 'OPER' THEN NULL
|
||||
ELSE NULL
|
||||
END;
|
||||
|
||||
IF v_form_type = 'FORM_1' AND v_sheet IN ('AHR','CAP') AND p_direction IS NULL THEN
|
||||
RAISE EXCEPTION 'p_direction обязателен для FORM_1 / sheet=% (Support|Development)', v_sheet;
|
||||
END IF;
|
||||
|
||||
IF v_form_type IN ('FORM_2','FORM_4') AND p_direction IS NOT NULL AND p_direction <> v_auto_dir THEN
|
||||
RAISE EXCEPTION 'p_direction=% несовместим с form_type=% (фиксирован=%)',
|
||||
p_direction, v_form_type, v_auto_dir;
|
||||
END IF;
|
||||
|
||||
IF v_form_type = 'FORM_1' AND v_sheet = 'OPER' AND p_direction IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'p_direction должен быть NULL для FORM_1 / sheet=OPER (получено: %)', p_direction;
|
||||
END IF;
|
||||
|
||||
v_final_dir := COALESCE(p_direction, v_auto_dir);
|
||||
|
||||
-- 4. Sanity на vsp/project
|
||||
IF p_vsp_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN
|
||||
RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id;
|
||||
END IF;
|
||||
IF p_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.form4_project WHERE id = p_project_id) THEN
|
||||
RAISE EXCEPTION 'form4_project id=% не существует', p_project_id;
|
||||
END IF;
|
||||
|
||||
-- 5. INSERT
|
||||
INSERT INTO v3.budget_line (
|
||||
budget_form_id, expense_item_id, name, internal_order,
|
||||
vsp_id, project_id, direction, justification
|
||||
) VALUES (
|
||||
p_form_id, v_eid, p_name, p_internal_order,
|
||||
p_vsp_id, p_project_id, v_final_dir, p_justification
|
||||
) RETURNING id INTO v_new_id;
|
||||
|
||||
-- 5а. Аудит: ROW_CREATE
|
||||
PERFORM v3.log_event(
|
||||
'ROW_CREATE', 'ROW',
|
||||
jsonb_build_object(
|
||||
'sheet', v_sheet,
|
||||
'line_id', v_new_id,
|
||||
'expense_item_id', v_eid,
|
||||
'direction', v_final_dir,
|
||||
'name', p_name,
|
||||
'vsp_id', p_vsp_id,
|
||||
'project_id', p_project_id
|
||||
),
|
||||
p_form_id
|
||||
);
|
||||
|
||||
-- 6. Возврат: только иерархия по пути нового ei + новая INPUT
|
||||
DECLARE v_ei_path INT[];
|
||||
BEGIN
|
||||
v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]);
|
||||
RETURN QUERY
|
||||
SELECT v.row_type, v.depth, v.sort_order, v.data
|
||||
FROM v3.v_form_view(p_form_id, v_sheet, NULL, v_final_dir) v
|
||||
WHERE (v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM')
|
||||
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path))
|
||||
OR (v.row_type = 'INPUT' AND (v.data->>'line_id')::INT = v_new_id)
|
||||
ORDER BY v.sort_order;
|
||||
END;
|
||||
END;
|
||||
$function$
|
||||
LANGUAGE plpgsql VOLATILE;
|
||||
@ -1,172 +0,0 @@
|
||||
CREATE OR REPLACE FUNCTION v3.add_project_year(p_project_id integer, p_year integer, p_emit_audit boolean DEFAULT true)
|
||||
RETURNS TABLE(limit_report_id integer, current_expenses_report_id integer)
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_lim INT;
|
||||
v_cur INT;
|
||||
BEGIN
|
||||
IF p_year IS NULL THEN
|
||||
RAISE EXCEPTION 'p_year обязателен';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_project_id) THEN
|
||||
RAISE EXCEPTION 'project id=% не существует', p_project_id;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM v3.rf_project_report
|
||||
WHERE project_id = p_project_id AND year = p_year) THEN
|
||||
RAISE EXCEPTION 'год % у проекта id=% уже заведён', p_year, p_project_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO v3.rf_project_report (project_id, year, report_type)
|
||||
VALUES (p_project_id, p_year, 'LIMIT') RETURNING id INTO v_lim;
|
||||
INSERT INTO v3.rf_project_report (project_id, year, report_type)
|
||||
VALUES (p_project_id, p_year, 'CURRENT_EXPENSES') RETURNING id INTO v_cur;
|
||||
|
||||
-- Snapshot RBAC-фаз (иначе default deny → отчёт read-only)
|
||||
PERFORM v3.copy_template_to_report(v_lim);
|
||||
PERFORM v3.copy_template_to_report(v_cur);
|
||||
|
||||
-- Фиксированная сетка статей на оба трека
|
||||
PERFORM v3.seed_form3_report_lines(v_lim);
|
||||
PERFORM v3.seed_form3_report_lines(v_cur);
|
||||
|
||||
IF p_emit_audit THEN
|
||||
PERFORM v3.log_event(
|
||||
'PROJECT_YEAR_ADD', 'PROJECT',
|
||||
jsonb_build_object(
|
||||
'project_id', p_project_id,
|
||||
'year', p_year,
|
||||
'limit_report_id', v_lim,
|
||||
'current_expenses_report_id', v_cur
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN QUERY SELECT v_lim, v_cur;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
|
||||
-- FKs
|
||||
ALTER TABLE v3.app_user ADD CONSTRAINT app_user_role_fk FOREIGN KEY (role_id) REFERENCES v3."role"(id);
|
||||
ALTER TABLE v3.audit_log ADD CONSTRAINT audit_log_app_user_fk FOREIGN KEY (user_id) REFERENCES v3.app_user(id);
|
||||
ALTER TABLE v3.audit_log ADD CONSTRAINT audit_log_org_unit_fk FOREIGN KEY (org_unit_id) REFERENCES v3.org_unit(id);
|
||||
ALTER TABLE v3.audit_log ADD CONSTRAINT audit_log_budget_form_fk FOREIGN KEY (form_id) REFERENCES v3.budget_form(id);
|
||||
ALTER TABLE v3.booking ADD CONSTRAINT booking_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.budget_form ADD CONSTRAINT budget_form_app_user_fk FOREIGN KEY (created_by) REFERENCES v3.app_user(id);
|
||||
ALTER TABLE v3.budget_form ADD CONSTRAINT budget_form_app_user_fk_1 FOREIGN KEY (updated_by) REFERENCES v3.app_user(id);
|
||||
ALTER TABLE v3.budget_form ADD CONSTRAINT budget_form_org_unit_fk FOREIGN KEY (org_unit_id) REFERENCES v3.org_unit(id);
|
||||
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_budget_form_fk FOREIGN KEY (budget_form_id) REFERENCES v3.budget_form(id);
|
||||
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_expense_item_fk FOREIGN KEY (expense_item_id) REFERENCES v3.expense_item(id);
|
||||
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_vsp_fk FOREIGN KEY (vsp_id) REFERENCES v3.vsp(id);
|
||||
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_app_user_fk FOREIGN KEY (created_by) REFERENCES v3.app_user(id);
|
||||
ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_app_user_fk_1 FOREIGN KEY (updated_by) REFERENCES v3.app_user(id);
|
||||
ALTER TABLE v3.budget_line_quarter ADD CONSTRAINT budget_line_quarter_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.ckk ADD CONSTRAINT ckk_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.collegial_approval ADD CONSTRAINT collegial_approval_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.contract_detail ADD CONSTRAINT contract_detail_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.contract_summary ADD CONSTRAINT contract_summary_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.expense_item ADD CONSTRAINT expense_item_expense_item_fk FOREIGN KEY (parent_id) REFERENCES v3.expense_item(id);
|
||||
ALTER TABLE v3.expense_item_form_type ADD CONSTRAINT expense_item_form_type_expense_item_fk FOREIGN KEY (expense_item_id) REFERENCES v3.expense_item(id);
|
||||
ALTER TABLE v3.fixed_asset_report ADD CONSTRAINT fixed_asset_report_budget_form_fk FOREIGN KEY (budget_form_id) REFERENCES v3.budget_form(id);
|
||||
ALTER TABLE v3.fixed_asset_report ADD CONSTRAINT fixed_asset_report_expense_item_fk FOREIGN KEY (expense_item_id) REFERENCES v3.expense_item(id);
|
||||
ALTER TABLE v3.form3_phase ADD CONSTRAINT form3_phase_rf_project_report_fk FOREIGN KEY (rf_project_report_id) REFERENCES v3.rf_project_report(id);
|
||||
ALTER TABLE v3.form_limit ADD CONSTRAINT form_limit_budget_form_fk FOREIGN KEY (budget_form_id) REFERENCES v3.budget_form(id);
|
||||
ALTER TABLE v3.form_phase ADD CONSTRAINT form_phase_budget_form_fk FOREIGN KEY (budget_form_id) REFERENCES v3.budget_form(id);
|
||||
ALTER TABLE v3.phase_template ADD CONSTRAINT phase_template_form_type_fk FOREIGN KEY (form_type) REFERENCES v3.form_type(code);
|
||||
ALTER TABLE v3."plan" ADD CONSTRAINT plan_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.project ADD CONSTRAINT project_org_unit_fk FOREIGN KEY (org_unit_id) REFERENCES v3.org_unit(id);
|
||||
ALTER TABLE v3.rent_detail ADD CONSTRAINT rent_detail_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.rent_detail ADD CONSTRAINT rent_detail_vsp_fk FOREIGN KEY (vsp_id) REFERENCES v3.vsp(id);
|
||||
ALTER TABLE v3.reserve ADD CONSTRAINT reserve_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.rf_project_report ADD CONSTRAINT rf_project_report_project_fk FOREIGN KEY (project_id) REFERENCES v3.project(id);
|
||||
ALTER TABLE v3.rf_project_report ADD CONSTRAINT rf_project_report_app_user_fk FOREIGN KEY (created_by) REFERENCES v3.app_user(id);
|
||||
ALTER TABLE v3.rf_project_report ADD CONSTRAINT rf_project_report_app_user_fk_1 FOREIGN KEY (updated_by) REFERENCES v3.app_user(id);
|
||||
ALTER TABLE v3.rf_project_report_line ADD CONSTRAINT rf_project_report_line_rf_project_report_fk FOREIGN KEY (rf_project_report_id) REFERENCES v3.rf_project_report(id);
|
||||
ALTER TABLE v3.rf_project_report_line ADD CONSTRAINT rf_project_report_line_expense_item_fk FOREIGN KEY (expense_item_id) REFERENCES v3.expense_item(id);
|
||||
ALTER TABLE v3.rf_project_report_quarter ADD CONSTRAINT rf_project_report_quarter_rf_project_report_line_fk FOREIGN KEY (rf_project_report_line_id) REFERENCES v3.rf_project_report_line(id);
|
||||
ALTER TABLE v3.security_detail ADD CONSTRAINT security_detail_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.security_detail ADD CONSTRAINT security_detail_vsp_fk FOREIGN KEY (vsp_id) REFERENCES v3.vsp(id);
|
||||
ALTER TABLE v3.sequestration ADD CONSTRAINT sequestration_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.user_org ADD CONSTRAINT user_org_app_user_fk FOREIGN KEY (user_id) REFERENCES v3.app_user(id);
|
||||
ALTER TABLE v3.user_org ADD CONSTRAINT user_org_org_unit_fk FOREIGN KEY (org_unit_id) REFERENCES v3.org_unit(id);
|
||||
ALTER TABLE v3.utility_detail ADD CONSTRAINT utility_detail_budget_line_fk FOREIGN KEY (line_id) REFERENCES v3.budget_line(id);
|
||||
ALTER TABLE v3.utility_detail ADD CONSTRAINT utility_detail_vsp_fk FOREIGN KEY (vsp_id) REFERENCES v3.vsp(id);
|
||||
ALTER TABLE v3.vsp ADD CONSTRAINT vsp_org_unit_fk FOREIGN KEY (branch_id) REFERENCES v3.org_unit(id);
|
||||
ALTER TABLE v3.vsp ADD CONSTRAINT vsp_app_user_fk FOREIGN KEY (created_by) REFERENCES v3.app_user(id);
|
||||
ALTER TABLE v3.vsp ADD CONSTRAINT vsp_app_user_fk_1 FOREIGN KEY (updated_by) REFERENCES v3.app_user(id);
|
||||
|
||||
|
||||
-- DROP FUNCTION v3.add_project(varchar, int4, int4, varchar, int4, varchar, varchar, varchar, varchar, int4, numeric, varchar);
|
||||
|
||||
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 ou WHERE id = p_org_unit_id AND ou.is_active ) 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$
|
||||
;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -14,4 +14,3 @@ aiosqlite==0.20.0
|
||||
asyncpg==0.30.0
|
||||
pytest==8.3.2
|
||||
pytest-asyncio==0.24.0
|
||||
openpyxl==3.1.5
|
||||
|
||||
@ -10,6 +10,8 @@ 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
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from src.api.v1.deps import require_admin
|
||||
from src.db.base import engine
|
||||
from src.db.models.app_user import AppUser
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.post("/refresh-tree")
|
||||
async def refresh_tree(current_user: AppUser = Depends(require_admin)):
|
||||
"""Обновляет MV дерева статей расходов."""
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
# CONCURRENTLY требует autocommit-режим.
|
||||
conn = await conn.execution_options(isolation_level="AUTOCOMMIT")
|
||||
await conn.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree"))
|
||||
except SQLAlchemyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Не удалось обновить mv_expense_item_tree: {exc}",
|
||||
) from exc
|
||||
return {"status": "ok", "message": "mv_expense_item_tree refreshed"}
|
||||
@ -1,46 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.v1.deps import require_admin
|
||||
from src.db.session import get_db
|
||||
# from src.domain.models import Users
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.domain.schemas import AuditLog, AuditLogQueryParams, BaseListResponse
|
||||
from src.services.auditlog_service import AuditLogService
|
||||
|
||||
router = APIRouter(tags=["audit"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/audit-logs",
|
||||
response_model=BaseListResponse[AuditLog],
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Получение журнала аудита",
|
||||
description="Возвращает список записей аудита с возможностью фильтрации. Доступно только администраторам.",
|
||||
)
|
||||
async def get_audit_logs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
params: AuditLogQueryParams = Depends(),
|
||||
):
|
||||
offset = (params.page - 1) * params.limit
|
||||
audit_service = AuditLogService(db)
|
||||
|
||||
logs, count = await audit_service.get_all(
|
||||
user=current_user,
|
||||
limit=params.limit,
|
||||
offset=offset,
|
||||
user_id=params.user_id,
|
||||
org_unit_id=params.org_unit_id,
|
||||
task_id=params.task_id,
|
||||
form_id=params.form_id,
|
||||
event_type=params.event_type,
|
||||
event=params.event,
|
||||
date_from=datetime.combine(params.date_from, datetime.min.time()) if params.date_from else None,
|
||||
date_to=datetime.combine(params.date_to, datetime.max.time()) if params.date_to else None,
|
||||
)
|
||||
|
||||
result = [audit_service.orm_log_to_response(log) for log in logs]
|
||||
return BaseListResponse(result=result, count=count)
|
||||
@ -6,46 +6,46 @@ from src.db.session import get_db
|
||||
from src.domain.schemas import LoginRequest, RefreshRequest, Token
|
||||
from src.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,89 +5,71 @@ 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.db.models.app_user import AppUser
|
||||
from src.db.models.role import UserRoleEnum
|
||||
from src.domain.models import UserRole, Users
|
||||
from src.repository.user_repository import UserRepository
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
# if settings.DEBUG:
|
||||
if settings.DEBUG:
|
||||
|
||||
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"},
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AppUser:
|
||||
return await get_user_by_token(token=credentials.credentials, db=db)
|
||||
|
||||
# else:
|
||||
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
||||
|
||||
# async def get_current_user(
|
||||
# user: UserInfo = Depends(get_user_dependency),
|
||||
# db: AsyncSession = Depends(get_db),
|
||||
# ) -> AppUser:
|
||||
# user_repo = UserRepository(db)
|
||||
# db_user = await user_repo.get_by_email(user.email)
|
||||
# if db_user is None:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
# detail="Пользователь не найден",
|
||||
# headers={"WWW-Authenticate": "Bearer"},
|
||||
# )
|
||||
# return db_user
|
||||
|
||||
|
||||
async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_active_user_with_set_db(
|
||||
current_user: AppUser = Depends(get_current_user),
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AppUser:
|
||||
) -> Users:
|
||||
token = credentials.credentials
|
||||
payload = verify_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Недействительный токен",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_repo = UserRepository(db)
|
||||
await user_repo.set_app_user_id(current_user.id)
|
||||
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
|
||||
|
||||
else:
|
||||
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
|
||||
|
||||
async def get_current_user(
|
||||
user: UserInfo = Depends(get_user_dependency),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Users:
|
||||
user_repo = UserRepository(db)
|
||||
db_user = await user_repo.get_by_email(user.email)
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Пользователь не найден",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return db_user
|
||||
|
||||
|
||||
async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users:
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin(
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
) -> AppUser:
|
||||
if current_user.role_id != UserRoleEnum.ADMIN:
|
||||
def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users:
|
||||
if current_user.role_id != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав",
|
||||
@ -95,13 +77,11 @@ def require_admin(
|
||||
return current_user
|
||||
|
||||
|
||||
def require_executor(
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
) -> AppUser:
|
||||
def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users:
|
||||
if current_user.role_id not in [
|
||||
UserRoleEnum.ADMIN,
|
||||
UserRoleEnum.EXECUTOR_DFIP,
|
||||
UserRoleEnum.EXECUTOR_RF,
|
||||
UserRole.ADMIN,
|
||||
UserRole.EXECUTOR_DFIP,
|
||||
UserRole.EXECUTOR_RF,
|
||||
]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@ -110,10 +90,8 @@ def require_executor(
|
||||
return current_user
|
||||
|
||||
|
||||
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]:
|
||||
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]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Недостаточно прав",
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.form_type import FormTypeEnum
|
||||
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,
|
||||
sheet: str | None = None,
|
||||
direction: str | None = None,
|
||||
form_type: FormTypeEnum | str | None = None
|
||||
):
|
||||
"""Получение записей expense item"""
|
||||
service = ExpenseItemService(db)
|
||||
result = await service.get_list(
|
||||
r_start=r_start,
|
||||
sheet=sheet,
|
||||
direction=direction,
|
||||
form_type=form_type,
|
||||
)
|
||||
|
||||
return BaseListResponse(
|
||||
success=True,
|
||||
message="Запись Expense Item",
|
||||
result=[ExpenseItemResponseSchema.model_validate(el) for el in result],
|
||||
)
|
||||
@ -1,155 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.v1.deps import get_current_active_user_with_set_db
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.db.session import get_db
|
||||
from src.domain.schemas import DirectionSchemaEnum, ExportBulkRequest
|
||||
from src.services.export_service import ExportService
|
||||
|
||||
router = APIRouter(prefix="/export", tags=["export"])
|
||||
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
|
||||
|
||||
|
||||
def _parse_sections_csv(sections: str | None) -> list[str] | None:
|
||||
if sections is None:
|
||||
return None
|
||||
raw = [section.strip() for section in sections.split(",") if section.strip()]
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
quarter_tokens = {"q1", "q2", "q3", "q4", "totals"}
|
||||
for section in raw:
|
||||
if "." in section:
|
||||
base_section, nested_section = section.split(".", 1)
|
||||
nested_section = nested_section.strip()
|
||||
tokens = [base_section.strip()]
|
||||
if nested_section in quarter_tokens:
|
||||
tokens.append(nested_section)
|
||||
for token in tokens:
|
||||
if token and token not in seen:
|
||||
seen.add(token)
|
||||
normalized.append(token)
|
||||
continue
|
||||
if section not in seen:
|
||||
seen.add(section)
|
||||
normalized.append(section)
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _parse_form3_sections_csv(sections: str | None) -> list[str] | None:
|
||||
if not sections:
|
||||
return None
|
||||
parsed = [section.strip() for section in sections.split(",") if section.strip()]
|
||||
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
|
||||
if invalid:
|
||||
raise ValueError(f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
|
||||
return parsed
|
||||
|
||||
|
||||
@router.get("/form/{form_id}")
|
||||
async def export_form(
|
||||
form_id: int,
|
||||
direction: DirectionSchemaEnum | None = None,
|
||||
sections: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
):
|
||||
export_service = ExportService(db)
|
||||
try:
|
||||
stream, filename, media_type = await export_service.export_form_payload(
|
||||
form_id=form_id,
|
||||
current_user=current_user,
|
||||
direction=direction.value if direction else None,
|
||||
sections=_parse_sections_csv(sections),
|
||||
ignore_non_applicable_query_params=True,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/form/{form_id}/sheet/{sheet}")
|
||||
async def export_form_sheet(
|
||||
form_id: int,
|
||||
sheet: str,
|
||||
direction: DirectionSchemaEnum | None = None,
|
||||
sections: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
):
|
||||
export_service = ExportService(db)
|
||||
try:
|
||||
stream, filename, media_type = await export_service.export_form_payload(
|
||||
form_id=form_id,
|
||||
current_user=current_user,
|
||||
sheet=sheet,
|
||||
direction=direction.value if direction else None,
|
||||
sections=_parse_sections_csv(sections),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk")
|
||||
async def export_forms_bulk(
|
||||
payload: ExportBulkRequest,
|
||||
direction: DirectionSchemaEnum | None = None,
|
||||
sections: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
):
|
||||
export_service = ExportService(db)
|
||||
try:
|
||||
stream, filename, media_type = await export_service.export_bulk_payload(
|
||||
form_ids=payload.form_ids,
|
||||
current_user=current_user,
|
||||
skip_failed=payload.skip_failed,
|
||||
direction=direction.value if direction else None,
|
||||
sections=_parse_sections_csv(sections),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/project/{project_id}/report/{year}/{report_type}")
|
||||
async def export_project_report(
|
||||
project_id: int,
|
||||
year: int,
|
||||
report_type: str,
|
||||
sections: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
):
|
||||
export_service = ExportService(db)
|
||||
try:
|
||||
stream, filename, media_type = await export_service.export_project_report_payload(
|
||||
project_id=project_id,
|
||||
year=year,
|
||||
report_type=report_type,
|
||||
current_user=current_user,
|
||||
sections=_parse_form3_sections_csv(sections),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
|
||||
)
|
||||
|
||||
|
||||
@ -1,344 +0,0 @@
|
||||
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, "Этап не найден")
|
||||
@ -1,468 +0,0 @@
|
||||
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),
|
||||
)
|
||||
@ -1,126 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.core.errors import AccessDeniedException
|
||||
from src.api.v1.deps import get_current_active_user, require_admin
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.db.session import get_db
|
||||
from src.domain.schemas import BaseListResponse, BaseSingleResponse, 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="ССП/РФ удалена")
|
||||
@ -1,290 +0,0 @@
|
||||
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,
|
||||
ResponseBase,
|
||||
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.delete("/projects/{project_id}")
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
) -> ResponseBase:
|
||||
project_service = ProjectService(db)
|
||||
await project_service.delete_project(project_id=project_id, user=current_user)
|
||||
return ResponseBase(success=True, message="Проект удалён")
|
||||
|
||||
|
||||
@router.post("/projects")
|
||||
async def add_project(
|
||||
body: AddProjectBody,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||
) -> dict:
|
||||
project_service = ProjectService(db)
|
||||
project_id, limit_report_id, current_expenses_report_id = await project_service.add_project(
|
||||
name=body.name,
|
||||
year=body.year,
|
||||
org_unit_id=body.branch_id,
|
||||
level=body.level,
|
||||
parent_id=body.parent_id,
|
||||
project_type=body.project_type,
|
||||
vsp_format=body.vsp_format,
|
||||
placement_type=body.placement_type,
|
||||
object_address=body.object_address,
|
||||
staff_count=body.staff_count,
|
||||
total_area=body.total_area,
|
||||
)
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"limit_report_id": limit_report_id,
|
||||
"current_expenses_report_id": current_expenses_report_id,
|
||||
}
|
||||
@ -1,22 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.v1 import (
|
||||
auth, users, admin, audit, forms, form_phases, projects, export, org_unit, vsp, expense_item
|
||||
)
|
||||
|
||||
from src.api.v1 import websocket
|
||||
|
||||
from src.api.v1 import auth, users
|
||||
|
||||
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,14 +1,10 @@
|
||||
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.db.models.app_user import AppUser
|
||||
from src.domain.schemas import BaseListResponse, BaseSingleResponse, User as UserSchema, UserAdminListResponse
|
||||
from src.domain.models import Users
|
||||
from src.domain.schemas import User as UserSchema
|
||||
from src.domain.schemas import (
|
||||
SSPIDList,
|
||||
ResponseBase,
|
||||
@ -24,48 +20,28 @@ from src.services.user_service import UserService
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@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("/me", response_model=UserSchema)
|
||||
async def get_current_user_info(current_user: Users = Depends(get_current_active_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/")
|
||||
@router.get("/", response_model=UserListResponse)
|
||||
async def get_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
load_orgs: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
) -> BaseListResponse[UserAdminListResponse]:
|
||||
current_user: Users = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
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]
|
||||
)
|
||||
users = await user_service.get_all(current_user, skip=skip, limit=limit)
|
||||
return UserListResponse(success=True, message="Список пользователей", result=users)
|
||||
|
||||
|
||||
@router.get("/dfip-many-ssp/", response_model=UserSSPLinkResponse)
|
||||
async def get_many_ssp(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_executor_dfip),
|
||||
current_user: Users = Depends(require_executor_dfip),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
ssp_list = await user_service.get_many_ssp(user_id, current_user)
|
||||
@ -81,7 +57,7 @@ async def set_many_ssp(
|
||||
user_id: int,
|
||||
ssp_ids_data: SSPIDList,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
current_user: Users = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user)
|
||||
@ -96,7 +72,7 @@ async def unset_many_ssp(
|
||||
user_id: int,
|
||||
ssp_ids_data: SSPIDList,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
current_user: Users = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
success = await user_service.unset_many_ssp(
|
||||
@ -108,50 +84,39 @@ async def unset_many_ssp(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}")
|
||||
@router.get("/{user_id}", response_model=UserSchema)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
load_orgs: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
) -> UserAdminListResponse:
|
||||
current_user: Users = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
user = await user_service.get(
|
||||
user_id=user_id,
|
||||
current_user=current_user,
|
||||
load_orgs=load_orgs,
|
||||
)
|
||||
user = await user_service.get(user_id, current_user)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден",
|
||||
)
|
||||
return UserAdminListResponse.model_validate(user)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@router.post("/", response_model=BaseSingleResponse[UserAdminListResponse])
|
||||
@router.put("/", response_model=UserResponse)
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
current_user: Users = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
user = await user_service.create_user(user_data=user_data, creator=current_user)
|
||||
|
||||
result = UserAdminListResponse.model_validate(user)
|
||||
if user_data.load_orgs:
|
||||
result.org_units = []
|
||||
|
||||
return BaseSingleResponse(success=True, message="Пользователь создан", result=result)
|
||||
return UserResponse(success=True, message="Пользователь создан", result=user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=BaseSingleResponse[UserAdminListResponse])
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
current_user: Users = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
user = await user_service.update_user(
|
||||
@ -159,15 +124,14 @@ async def update_user(
|
||||
user_data=user_data,
|
||||
user=current_user,
|
||||
)
|
||||
result = UserAdminListResponse.model_validate(user)
|
||||
return UserResponse(success=True, message="Пользователь обновлен", result=result)
|
||||
return UserResponse(success=True, message="Пользователь обновлен", result=user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=UserResponse)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AppUser = Depends(require_admin),
|
||||
current_user: Users = Depends(require_admin),
|
||||
):
|
||||
user_service = UserService(db)
|
||||
await user_service.delete_user(user_id=user_id, user=current_user)
|
||||
|
||||
@ -1,185 +0,0 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.api.v1.deps import require_admin, require_executor
|
||||
from src.db.session import get_db
|
||||
from src.domain.schemas import (
|
||||
BaseListResponse,
|
||||
BaseSingleResponse,
|
||||
VSPCreate,
|
||||
VSPExportRequest,
|
||||
VSPInDB,
|
||||
VSPUpdate,
|
||||
)
|
||||
from src.services.vsp_service import VSPService
|
||||
|
||||
router = APIRouter(prefix="/dict/info", tags=["info"])
|
||||
|
||||
|
||||
@router.get("", response_model=BaseListResponse[VSPInDB])
|
||||
async def get_vsp(
|
||||
registration_number: str | None = None,
|
||||
address: str | None = None,
|
||||
ssp_id: int | None = None,
|
||||
open_date_start: date | None = None,
|
||||
open_date_end: date | None = None,
|
||||
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,
|
||||
is_active=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"ВСП не найден",
|
||||
)
|
||||
@ -1,970 +0,0 @@
|
||||
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.repository.sheet_repository import SheetValidationError
|
||||
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 "program_added":
|
||||
return await self.__add_program(
|
||||
event_data=event_data["data"],
|
||||
form_id=form_id,
|
||||
user=curr_user,
|
||||
sheet=sheet,
|
||||
)
|
||||
case "project_added":
|
||||
return await self.__add_project(
|
||||
event_data=event_data["data"],
|
||||
user=curr_user,
|
||||
)
|
||||
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 __add_program(
|
||||
self,
|
||||
event_data: dict,
|
||||
form_id: int,
|
||||
sheet: str,
|
||||
user: AppUser,
|
||||
) -> list[tuple]:
|
||||
"""
|
||||
data = {
|
||||
name: str
|
||||
}
|
||||
"""
|
||||
return await self.sheet_service.add_program(
|
||||
form_id=form_id,
|
||||
sheet=sheet,
|
||||
name=event_data["name"],
|
||||
user=user,
|
||||
)
|
||||
|
||||
async def __add_project(
|
||||
self,
|
||||
event_data: dict,
|
||||
user: AppUser,
|
||||
) -> list[tuple]:
|
||||
"""
|
||||
data = {
|
||||
name: str,
|
||||
program_id: int
|
||||
}
|
||||
"""
|
||||
return await self.sheet_service.add_project(
|
||||
name=event_data["name"],
|
||||
program_id=event_data["program_id"],
|
||||
user=user,
|
||||
)
|
||||
|
||||
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 and "line_id_code" not in data) or "column" not in data:
|
||||
return None
|
||||
return {
|
||||
"line_id": data.get("line_id_code") or data.get("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",
|
||||
"program_added",
|
||||
"project_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 SheetValidationError as e:
|
||||
data["error"] = e.message
|
||||
await manager.send_back(
|
||||
data=data,
|
||||
websocket=websocket,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
tp = type(e)
|
||||
handlers = websocket.app.exception_handlers
|
||||
if tp in handlers:
|
||||
data["error"] = loads((await handlers[tp](request=None, exc=e)).body)
|
||||
await manager.send_back(
|
||||
data=data,
|
||||
websocket=websocket,
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await manager.disconnect(
|
||||
websocket=websocket,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Логируем ошибку, но не бросаем HTTPException — это WebSocket
|
||||
print(f"Error: {e}")
|
||||
await manager.disconnect(
|
||||
websocket=websocket,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/form/{form_id}/sheet/{sheet}")
|
||||
async def websocket_form(
|
||||
websocket: WebSocket,
|
||||
form_id: int,
|
||||
sheet: str,
|
||||
direction: Optional[str] = None,
|
||||
):
|
||||
await process_websocket(
|
||||
websocket=websocket,
|
||||
form_id=form_id,
|
||||
sheet=sheet,
|
||||
direction=direction,
|
||||
processor_cls=FormEventProcess,
|
||||
con_key=ConnectionKeyEnum.FORM,
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/projects/{project_id}/report/{year}/{report_type}")
|
||||
async def websocket_project(
|
||||
websocket: WebSocket,
|
||||
project_id: int,
|
||||
year: int,
|
||||
report_type: str,
|
||||
):
|
||||
await process_websocket(
|
||||
websocket=websocket,
|
||||
project_id=project_id,
|
||||
year=year,
|
||||
report_type=report_type,
|
||||
processor_cls=ProjectEventProcess,
|
||||
con_key=ConnectionKeyEnum.PROJECT,
|
||||
)
|
||||
@ -1,31 +0,0 @@
|
||||
VALIDATION_PREFIXES = (
|
||||
"computed_field:",
|
||||
"normative_field:",
|
||||
"unknown_column:",
|
||||
"key_field:",
|
||||
"structural_field:",
|
||||
"bad_column_format:",
|
||||
"bad_booking_year:",
|
||||
"bad_booking_quarter:",
|
||||
"unsupported_scope:",
|
||||
)
|
||||
|
||||
PG_404_ERRORS = (
|
||||
"не существует",
|
||||
"не принадлежит",
|
||||
"not found",
|
||||
)
|
||||
PG_409_ERRORS = (
|
||||
"unique",
|
||||
"duplicate",
|
||||
"foreign key",
|
||||
)
|
||||
PG_400_ERRORS = (
|
||||
"violates check constraint",
|
||||
)
|
||||
PG_403_ERRORS = (
|
||||
"permission denied",
|
||||
"insufficient privilege",
|
||||
"role_not_allowed:",
|
||||
"window_closed:",
|
||||
)
|
||||
@ -4,7 +4,10 @@ from typing import List
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
prefix = "OPENBAO__SETTINGS"
|
||||
if os.environ.get("APP_ENV", "dev") == "test":
|
||||
prefix = "OPENBAO__SETTINGS_TEST"
|
||||
else:
|
||||
prefix = "OPENBAO__SETTINGS"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@ -102,11 +105,6 @@ 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,8 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
|
||||
class ValidationError(BaseModel):
|
||||
field: str = Field(...)
|
||||
description: str = Field(...)
|
||||
@ -12,14 +10,6 @@ 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,7 +1,5 @@
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError
|
||||
|
||||
from src.core.errors import (
|
||||
AccessDeniedException,
|
||||
@ -9,176 +7,41 @@ 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_code,
|
||||
content=_error_payload(status_code, exc.description),
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": 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_code,
|
||||
content=_error_payload(status_code, exc.description, field=exc.field or ""),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={"detail": exc.description},
|
||||
)
|
||||
|
||||
@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_code,
|
||||
content=_error_payload(status_code, exc.description),
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={"detail": 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_code,
|
||||
content=_error_payload(status_code, exc.description),
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"detail": 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=exc.errors,
|
||||
content={"detail": exc.description or "Application error"},
|
||||
)
|
||||
|
||||
@ -7,15 +7,12 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
from src.db.models.allocation import Allocation
|
||||
from src.db.models.app_user import AppUser
|
||||
from src.db.models.audit_log import AuditLog
|
||||
from src.db.models.booking import Booking
|
||||
from src.db.models.budget_form import BudgetForm
|
||||
from src.db.models.budget_line import BudgetLine
|
||||
from src.db.models.budget_line_quarter import BudgetLineQuarter
|
||||
from src.db.models.ckk import Ckk
|
||||
from src.db.models.collegial_approval import CollegialApproval
|
||||
from src.db.models.contract_detail import ContractDetail
|
||||
from src.db.models.contract_summary import ContractSummary
|
||||
from src.db.models.expense_item import ExpenseItem, ExpenseItemFormType
|
||||
from src.db.models.form3_phase import Form3Phase
|
||||
from src.db.models.form4_project import Form4Project
|
||||
from src.db.models.form_limit import FormLimit
|
||||
from src.db.models.form_phase import FormPhase
|
||||
from src.db.models.form_type import FormType
|
||||
from src.db.models.limit_template import LimitTemplate
|
||||
from src.db.models.org_unit import OrgUnit
|
||||
from src.db.models.phase_template import PhaseTemplate
|
||||
from src.db.models.plan import Plan
|
||||
from src.db.models.project import Project
|
||||
from src.db.models.rent_detail import RentDetail
|
||||
from src.db.models.reserve import Reserve
|
||||
from src.db.models.rf_project_report import RfProjectReport
|
||||
from src.db.models.rf_project_report_line import RfProjectReportLine
|
||||
from src.db.models.rf_project_report_quarter import RfProjectReportQuarter
|
||||
from src.db.models.role import Role, UserRoleEnum
|
||||
from src.db.models.security_detail import SecurityDetail
|
||||
from src.db.models.sequestration import Sequestration
|
||||
from src.db.models.user_org import UserOrg
|
||||
from src.db.models.utility_detail import UtilityDetail
|
||||
from src.db.models.vsp import Vsp
|
||||
@ -1,19 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class Allocation(Base):
|
||||
__tablename__ = "allocation"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||
internal_order: Mapped[str | None] = mapped_column(String)
|
||||
property_object: Mapped[str | None] = mapped_column(String)
|
||||
contract_ref: Mapped[str | None] = mapped_column(String)
|
||||
allocation_purpose: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine", uselist=False)
|
||||
@ -1,56 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class AppUser(Base):
|
||||
__tablename__ = "app_user"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_v4_app_user_active",
|
||||
"is_active",
|
||||
postgresql_where="is_active",
|
||||
),
|
||||
Index("ix_v4_app_user_role", "role_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String)
|
||||
username: Mapped[str] = mapped_column(String)
|
||||
hashed_password: Mapped[str] = mapped_column(String)
|
||||
full_name: Mapped[str | None] = mapped_column(String)
|
||||
role_id: Mapped[int] = mapped_column(ForeignKey("v3.role.id"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# role: Mapped["Role"] = relationship("Role", back_populates="users") # type: ignore
|
||||
# created_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
|
||||
# "BudgetForm", back_populates="creator", foreign_keys="BudgetForm.created_by"
|
||||
# )
|
||||
# updated_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
|
||||
# "BudgetForm", back_populates="updater", foreign_keys="BudgetForm.updated_by"
|
||||
# )
|
||||
# created_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
|
||||
# "RfProjectReport",
|
||||
# back_populates="creator",
|
||||
# foreign_keys="RfProjectReport.created_by",
|
||||
# )
|
||||
# updated_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
|
||||
# "RfProjectReport",
|
||||
# back_populates="updater",
|
||||
# foreign_keys="RfProjectReport.updated_by",
|
||||
# )
|
||||
audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore
|
||||
"AuditLog", back_populates="user"
|
||||
)
|
||||
user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="user")
|
||||
org_units: Mapped[list["OrgUnit"]] = relationship("OrgUnit", secondary="v3.user_org", viewonly=True)
|
||||
@ -1,63 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
JSON,
|
||||
String,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_log"
|
||||
__table_args__ = (
|
||||
Index("ix_v3_audit_log_dt", text("event_dt DESC")),
|
||||
Index("ix_v3_audit_log_event", "event"),
|
||||
Index(
|
||||
"ix_v3_audit_log_form",
|
||||
"form_id",
|
||||
postgresql_where="form_id IS NOT NULL",
|
||||
),
|
||||
Index("ix_v3_audit_log_user", "user_id"),
|
||||
Index("ix_v4_audit_log_dt", text("event_dt DESC")),
|
||||
Index("ix_v4_audit_log_event", "event"),
|
||||
Index(
|
||||
"ix_v4_audit_log_form",
|
||||
"form_id",
|
||||
postgresql_where="form_id IS NOT NULL",
|
||||
),
|
||||
Index("ix_v4_audit_log_user", "user_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("v3.app_user.id", ondelete="SET NULL")
|
||||
)
|
||||
org_unit_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("v3.org_unit.id", ondelete="SET NULL")
|
||||
)
|
||||
task_id: Mapped[int | None] = mapped_column()
|
||||
form_id: Mapped[int | None] = mapped_column()
|
||||
event_dt: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
event: Mapped[str] = mapped_column(String)
|
||||
event_type: Mapped[str] = mapped_column(String)
|
||||
event_data: Mapped[dict | None] = mapped_column(JSON)
|
||||
|
||||
user: Mapped["AppUser | None"] = relationship(
|
||||
"AppUser", back_populates="audit_logs"
|
||||
)
|
||||
# org_unit: Mapped["OrgUnit | None"] = relationship(
|
||||
# "OrgUnit", back_populates="audit_logs"
|
||||
# )
|
||||
@ -1,25 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, SmallInteger, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class Booking(Base):
|
||||
__tablename__ = "booking"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quarter BETWEEN 1 AND 4", name="ck_v3_booking_quarter"),
|
||||
CheckConstraint("source IN ('CKK','CONTRACT')", name="ck_v3_booking_source"),
|
||||
Index("ix_v3_booking_line", "line_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||
quarter: Mapped[int] = mapped_column(SmallInteger)
|
||||
source: Mapped[str] = mapped_column(String)
|
||||
booked_amount_ckk: Mapped[float | None] = mapped_column(Numeric)
|
||||
booked_amount_contract: Mapped[float | None] = mapped_column(Numeric)
|
||||
|
||||
# line = relationship("BudgetLine")
|
||||
@ -1,61 +0,0 @@
|
||||
from datetime import datetime
|
||||
import typing
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.models.form_type import FormTypeEnum
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class BudgetForm(Base):
|
||||
__tablename__ = "budget_form"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
form_type_code: Mapped[FormTypeEnum] = mapped_column( #type: ignore
|
||||
String, ForeignKey("v3.form_type.code")
|
||||
)
|
||||
year: Mapped[int | None] = mapped_column(Integer)
|
||||
created_by: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_created_by")
|
||||
)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_by: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_updated_by")
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
org_unit_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
|
||||
)
|
||||
|
||||
form_type: Mapped["FormType"] = relationship(
|
||||
"FormType", #back_populates="budget_forms"
|
||||
)
|
||||
# creator: Mapped[typing.Optional["AppUser"]] = relationship(
|
||||
# "AppUser", back_populates="created_budget_forms", foreign_keys=[created_by]
|
||||
# )
|
||||
# updater: Mapped[typing.Optional["AppUser"]] = relationship(
|
||||
# "AppUser", back_populates="updated_budget_forms", foreign_keys=[updated_by]
|
||||
# )
|
||||
org_unit: Mapped[typing.Optional["OrgUnit"]] = relationship(
|
||||
"OrgUnit", #back_populates="budget_forms"
|
||||
)
|
||||
# budget_lines: Mapped[list["BudgetLine"]] = relationship(
|
||||
# "BudgetLine", back_populates="budget_form"
|
||||
# )
|
||||
# fixed_asset_reports: Mapped[list["FixedAssetReport"]] = relationship(
|
||||
# "FixedAssetReport", back_populates="budget_form"
|
||||
# )
|
||||
# form_phases: Mapped[list["FormPhase"]] = relationship(
|
||||
# "FormPhase", back_populates="budget_form"
|
||||
# )
|
||||
# form_limits: Mapped[list["FormLimit"]] = relationship(
|
||||
# "FormLimit", back_populates="budget_form"
|
||||
# )
|
||||
@ -1,51 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class BudgetLine(Base):
|
||||
__tablename__ = "budget_line"
|
||||
__table_args__ = (
|
||||
Index("ix_v3_budget_line_form_item", "budget_form_id", "expense_item_id"),
|
||||
Index("ix_v3_budget_line_vsp", "vsp_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id"))
|
||||
expense_item_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
|
||||
name: Mapped[str | None] = mapped_column(String)
|
||||
internal_order: Mapped[str | None] = mapped_column(String)
|
||||
vsp_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
|
||||
project_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
|
||||
justification: Mapped[str | None] = mapped_column(String)
|
||||
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
direction: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# budget_form = relationship("BudgetForm", back_populates="budget_lines")
|
||||
# expense_item = relationship("ExpenseItem", back_populates="budget_lines")
|
||||
# vsp = relationship("Vsp", back_populates="budget_lines")
|
||||
# project = relationship("Project", back_populates="budget_lines")
|
||||
|
||||
# ckk = relationship("Ckk", uselist=False)
|
||||
# collegial_approval = relationship("CollegialApproval", uselist=False)
|
||||
# contract_detail = relationship("ContractDetail", uselist=False)
|
||||
# contract_summary = relationship("ContractSummary", uselist=False)
|
||||
# plan = relationship("Plan", uselist=False)
|
||||
# reserve = relationship("Reserve", uselist=False)
|
||||
# allocation = relationship("Allocation", uselist=False)
|
||||
|
||||
# quarters: Mapped[list["BudgetLineQuarter"]] = relationship("BudgetLineQuarter", back_populates="line") # type: ignore
|
||||
# sequestration_records: Mapped[list["Sequestration"]] = relationship("Sequestration", back_populates="budget_line") # type: ignore
|
||||
# booking_records: Mapped[list["Booking"]] = relationship("Booking", back_populates="budget_line") # type: ignore
|
||||
# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="budget_line") # type: ignore
|
||||
# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="budget_line") # type: ignore
|
||||
# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="budget_line") # type: ignore
|
||||
@ -1,54 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, SmallInteger, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class BudgetLineQuarter(Base):
|
||||
__tablename__ = "budget_line_quarter"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quarter >= 1 AND quarter <= 4", name="chk_budget_line_quarter_quarter"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||
quarter: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
|
||||
adj_current: Mapped[float | None] = mapped_column(Numeric)
|
||||
adj_ssp: Mapped[float | None] = mapped_column(Numeric)
|
||||
adj_rf: Mapped[float | None] = mapped_column(Numeric)
|
||||
adj_reserve: Mapped[float | None] = mapped_column(Numeric)
|
||||
adj_comment: Mapped[str | None] = mapped_column(String)
|
||||
target_change: Mapped[float | None] = mapped_column(Numeric)
|
||||
base_plan_correction: Mapped[float | None] = mapped_column(Numeric)
|
||||
base_plan_correction_comment: Mapped[str | None] = mapped_column(String)
|
||||
payment_date: Mapped[date | None] = mapped_column(Date)
|
||||
payment_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||
payment_amount_ho: Mapped[float | None] = mapped_column(Numeric)
|
||||
payment_amount_rf: Mapped[float | None] = mapped_column(Numeric)
|
||||
payment_comment: Mapped[str | None] = mapped_column(String)
|
||||
payment_act: Mapped[str | None] = mapped_column(String)
|
||||
actual_m1: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_m2: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_m3: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_spod: Mapped[float | None] = mapped_column(Numeric)
|
||||
transfer_to_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
transfer_to_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
transfer_to_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
transfer_to_economy: Mapped[float | None] = mapped_column(Numeric)
|
||||
transfer_delay_acts: Mapped[float | None] = mapped_column(Numeric)
|
||||
transfer_delay_procurement: Mapped[float | None] = mapped_column(Numeric)
|
||||
transfer_economy_rf: Mapped[float | None] = mapped_column(Numeric)
|
||||
transfer_next_comment: Mapped[str | None] = mapped_column(String)
|
||||
transfer_far_comment: Mapped[str | None] = mapped_column(String)
|
||||
booking_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_revision_eco_change: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_revision_item_adj: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_revision_increase: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_revision_sequester: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_revision_comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine", back_populates="quarters")
|
||||
@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class Ckk(Base):
|
||||
__tablename__ = "ckk"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_next_year_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_next_year_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_next_year_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_next_year_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
rf_schedule: Mapped[str | None] = mapped_column(String)
|
||||
delivery_deadline: Mapped[str | None] = mapped_column(String)
|
||||
procurement_plan: Mapped[str | None] = mapped_column(String)
|
||||
procurement_method: Mapped[str | None] = mapped_column(String)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine", uselist=False)
|
||||
@ -1,18 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class CollegialApproval(Base):
|
||||
__tablename__ = "collegial_approval"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||
approved_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||
protocol_reference: Mapped[str | None] = mapped_column(String)
|
||||
note: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine", uselist=False)
|
||||
@ -1,34 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class ContractDetail(Base):
|
||||
__tablename__ = "contract_detail"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||
counterparty: Mapped[str | None] = mapped_column(String)
|
||||
reference: Mapped[str | None] = mapped_column(String)
|
||||
addenda: Mapped[str | None] = mapped_column(String)
|
||||
subject: Mapped[str | None] = mapped_column(String)
|
||||
currency: Mapped[str | None] = mapped_column(String)
|
||||
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
rf_schedule: Mapped[str | None] = mapped_column(String)
|
||||
vat_rate: Mapped[str | None] = mapped_column(String)
|
||||
exchange_rate: Mapped[float | None] = mapped_column(Numeric)
|
||||
amount_foreign: Mapped[float | None] = mapped_column(Numeric)
|
||||
deadline: Mapped[str | None] = mapped_column(String)
|
||||
payment_scheme: Mapped[str | None] = mapped_column(String)
|
||||
act: Mapped[str | None] = mapped_column(String)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
contract_date: Mapped[str | None] = mapped_column(Date)
|
||||
|
||||
# line = relationship("BudgetLine", uselist=False)
|
||||
@ -1,24 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class ContractSummary(Base):
|
||||
__tablename__ = "contract_summary"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||
total_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||
counterparty: Mapped[str | None] = mapped_column(String)
|
||||
reference: Mapped[str | None] = mapped_column(String)
|
||||
deadline: Mapped[str | None] = mapped_column(String)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
future_payments_y1: Mapped[float | None] = mapped_column(Numeric)
|
||||
future_payments_y2: Mapped[float | None] = mapped_column(Numeric)
|
||||
other_ssp_amount: Mapped[float | None] = mapped_column(Numeric)
|
||||
centralized_flag: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine", uselist=False)
|
||||
@ -1,36 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class ExpenseItem(Base):
|
||||
__tablename__ = "expense_item"
|
||||
__table_args__ = (
|
||||
CheckConstraint("direction IN ('Support', 'Development')", name="expense_item_direction_check"),
|
||||
CheckConstraint("sheet IN ('AHR', 'CAP', 'OPER')", name="expense_item_sheet_check"),
|
||||
Index("ix_v3_expense_item_parent", "parent_id"),
|
||||
Index("ix_v3_expense_item_sheet", "sheet"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
section_code: Mapped[str | None] = mapped_column(String)
|
||||
item_id: Mapped[str | None] = mapped_column(String)
|
||||
num_group_id: Mapped[str | None] = mapped_column(String)
|
||||
name: Mapped[str | None] = mapped_column(String)
|
||||
sheet: Mapped[str | None] = mapped_column(String)
|
||||
direction: Mapped[str | None] = mapped_column(String)
|
||||
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
|
||||
depth: Mapped[int | None] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
|
||||
class ExpenseItemFormType(Base):
|
||||
__tablename__ = "expense_item_form_type"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
form_type_code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id"), primary_key=True)
|
||||
@ -1,27 +0,0 @@
|
||||
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))
|
||||
@ -1,54 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class Form4ProjectLevelEnum(str, enum.Enum):
|
||||
PROGRAM = "program"
|
||||
PROJECT = "project"
|
||||
|
||||
|
||||
class Form4Project(Base):
|
||||
__tablename__ = "form4_project"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"level IN ('program', 'project')", name="chk_form4_project_level"
|
||||
),
|
||||
CheckConstraint(
|
||||
"(level = 'program' AND parent_id IS NULL) OR (level = 'project')",
|
||||
name="chk_form4_project_parent",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(level = 'program') = (section_code IS NOT NULL)",
|
||||
name="chk_form4_project_section_code",
|
||||
),
|
||||
Index(
|
||||
"ix_form4_project_section_code",
|
||||
"section_code",
|
||||
postgresql_where=text("level = 'program'"),
|
||||
),
|
||||
Index("ix_v3_form4_project_parent", "parent_id"),
|
||||
Index("ix_v3_form4_project_form", "form_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
level: Mapped[Form4ProjectLevelEnum] = mapped_column(String) # type: ignore
|
||||
section_code: Mapped[str | None] = mapped_column(String)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("v3.form4_project.id")
|
||||
)
|
||||
form_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("v3.budget_form.id")
|
||||
)
|
||||
|
||||
# parent = relationship("Form4Project", remote_side="Form4Project.id", back_populates="children")
|
||||
# children: Mapped[list["Form4Project"]] = relationship("Form4Project", back_populates="parent") # type: ignore
|
||||
# budget_form = relationship("BudgetForm")
|
||||
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="form4_project") # type: ignore
|
||||
@ -1,25 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class FormLimit(Base):
|
||||
__tablename__ = "form_limit"
|
||||
__table_args__ = (
|
||||
Index("ix_v3_form_limit_form", "budget_form_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True)
|
||||
template_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.limit_template.id", ondelete="CASCADE"), primary_key=True)
|
||||
qty_q1: Mapped[int | None] = mapped_column(Integer)
|
||||
qty_q2: Mapped[int | None] = mapped_column(Integer)
|
||||
qty_q3: Mapped[int | None] = mapped_column(Integer)
|
||||
qty_q4: Mapped[int | None] = mapped_column(Integer)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# budget_form = relationship("BudgetForm")
|
||||
# template = relationship("LimitTemplate")
|
||||
@ -1,26 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class FormPhase(Base):
|
||||
__tablename__ = "form_phase"
|
||||
__table_args__ = (
|
||||
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_form_phase_columns"),
|
||||
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_form_phase_no_admin"),
|
||||
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_form_phase_window"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
budget_form_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
sheet: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
|
||||
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
|
||||
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
@ -1,79 +0,0 @@
|
||||
import enum
|
||||
|
||||
from sqlalchemy import CheckConstraint, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class FormTypeEnum(str, enum.Enum):
|
||||
FORM_1 = "FORM_1"
|
||||
FORM_2 = "FORM_2"
|
||||
FORM_3 = "FORM_3"
|
||||
FORM_4 = "FORM_4"
|
||||
|
||||
|
||||
class FormType(Base):
|
||||
__tablename__ = "form_type"
|
||||
__table_args__ = (
|
||||
CheckConstraint("scope IN ('GO','PD','RF')", name="ck_form_type_scope"),
|
||||
CheckConstraint(
|
||||
"storage_entity IN ('BUDGET_FORM','RF_PROJECT_REPORT')",
|
||||
name="ck_form_type_storage_entity",
|
||||
),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
name: Mapped[str | None] = mapped_column(String)
|
||||
scope: Mapped[str | None] = mapped_column(String)
|
||||
storage_entity: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
|
||||
SHEETS_BY_FORM_TYPE = {
|
||||
FormTypeEnum.FORM_1: ["AHR", "CAP", "OPER", "SMETA"],
|
||||
FormTypeEnum.FORM_2: ["AHR", "CAP", "OPER", "AHR_LIMIT", "AHR_RENT",
|
||||
"AHR_UTILITY", "AHR_SECURITY", "OTCH9F", "SMETA"],
|
||||
FormTypeEnum.FORM_3: ["LIMIT", "CURRENT_EXPENSES"],
|
||||
FormTypeEnum.FORM_4: ["AHR", "CAP", "OPER", "STRUCTURE", "SMETA"],
|
||||
}
|
||||
SECTIONS_BY_FORM_TYPE = {
|
||||
FormTypeEnum.FORM_1: ["plan", "contract_summary", "allocation", "sequestration", "reserve", "approved", "collegial", "ckk", "contract_detail", "q1", "q2", "q3", "q4", "totals"],
|
||||
FormTypeEnum.FORM_2: ["plan", "seq_dfip", "seq_ssp", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals"],
|
||||
FormTypeEnum.FORM_3: ["q1", "q2", "q3", "q4", "year"],
|
||||
FormTypeEnum.FORM_4: ["plan", "seq_dfip", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals", "contract_summary", "allocation", "reserve", "collegial", "ckk"],
|
||||
}
|
||||
|
||||
_sheet_list_with_directions = {
|
||||
FormTypeEnum.FORM_1: [
|
||||
{
|
||||
"sheet_name": sheet_name,
|
||||
"direction": None,
|
||||
} for sheet_name in ["OPER", "SMETA"]
|
||||
] + [
|
||||
{
|
||||
"sheet_name": sheet_name,
|
||||
"direction": direction
|
||||
} for sheet_name in ["AHR", "CAP"] for direction in ["Support", "Development"]
|
||||
]
|
||||
}
|
||||
|
||||
@property
|
||||
def sheet_list_with_directions(self) -> dict:
|
||||
return self._sheet_list_with_directions.get(
|
||||
self.code,
|
||||
[
|
||||
{
|
||||
"sheet_name": sheet_name,
|
||||
"direction": None,
|
||||
} for sheet_name in self.sheet_list
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def sheet_list(self):
|
||||
return self.SHEETS_BY_FORM_TYPE.get(self.code, [])
|
||||
|
||||
@property
|
||||
def section_list(self):
|
||||
return self.SECTIONS_BY_FORM_TYPE.get(self.code, [])
|
||||
@ -1,33 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class LimitTemplate(Base):
|
||||
__tablename__ = "limit_template"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"row_type IN ('SECTION','GROUP','LEAF')",
|
||||
name="ck_v3_limit_template_row_type",
|
||||
),
|
||||
Index("ix_v3_limit_template_parent", "parent_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.limit_template.id"))
|
||||
sort_order: Mapped[int] = mapped_column(Integer)
|
||||
row_type: Mapped[str] = mapped_column(String)
|
||||
section_no: Mapped[str | None] = mapped_column(String)
|
||||
expense_item_code: Mapped[str | None] = mapped_column(String)
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
unit: Mapped[str | None] = mapped_column(String)
|
||||
limit_with_vat: Mapped[float | None] = mapped_column(Numeric)
|
||||
limit_without_vat: Mapped[float | None] = mapped_column(Numeric)
|
||||
|
||||
# parent = relationship("LimitTemplate", remote_side="LimitTemplate.id")
|
||||
# children: Mapped[list[LimitTemplate]] = relationship("LimitTemplate")
|
||||
# form_limits: Mapped[list[FormLimit]] = relationship("FormLimit")
|
||||
@ -1,42 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Index, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class OrgUnit(Base):
|
||||
__tablename__ = "org_unit"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_v4_org_unit_active",
|
||||
"is_active",
|
||||
postgresql_where="is_active",
|
||||
),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_ssp: Mapped[bool] = mapped_column(Boolean)
|
||||
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")
|
||||
@ -1,29 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class PhaseTemplate(Base):
|
||||
__tablename__ = "phase_template"
|
||||
__table_args__ = (
|
||||
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_phase_template_columns"),
|
||||
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_phase_template_no_admin"),
|
||||
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_phase_template_window"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
form_type: Mapped[str] = mapped_column(String, ForeignKey("v3.form_type.code"), primary_key=True)
|
||||
sheet: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
|
||||
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
|
||||
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=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")
|
||||
@ -1,20 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class Plan(Base):
|
||||
__tablename__ = "plan"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||
plan_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine", uselist=False)
|
||||
@ -1,52 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, Numeric, String, func, 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"))
|
||||
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
# parent = relationship("Project", remote_side="Project.id", back_populates="children")
|
||||
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore
|
||||
# org_unit = relationship("OrgUnit", back_populates="projects")
|
||||
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="project") # type: ignore
|
||||
rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore
|
||||
@ -1,43 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class RentDetail(Base):
|
||||
__tablename__ = "rent_detail"
|
||||
__table_args__ = (
|
||||
Index("ix_v3_rent_detail_line", "line_id"),
|
||||
Index("ix_v3_rent_detail_vsp", "vsp_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
|
||||
contract_number: Mapped[str | None] = mapped_column(String)
|
||||
contract_end_date: Mapped[date | None] = mapped_column(Date)
|
||||
plan_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_jan: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_feb: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_mar: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_apr: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_may: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_jun: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_jul: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_aug: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_sep: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_oct: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_nov: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_dec: Mapped[float | None] = mapped_column(Numeric)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine")
|
||||
# vsp = relationship("Vsp")
|
||||
@ -1,20 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class Reserve(Base):
|
||||
__tablename__ = "reserve"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
|
||||
amount_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||
amount_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
amount_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
amount_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
justification: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine", uselist=False)
|
||||
@ -1,31 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class RfProjectReport(Base):
|
||||
__tablename__ = "rf_project_report"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "year", "report_type", name="uq_v3_rf_project_report"),
|
||||
Index("ix_v3_rf_project_report_proj_year", "project_id", "year"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id"))
|
||||
year: Mapped[int] = mapped_column(Integer)
|
||||
report_type: Mapped[str] = mapped_column(String, CheckConstraint("report_type IN ('LIMIT','CURRENT_EXPENSES')"))
|
||||
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
project: Mapped["Project"] = relationship("Project", back_populates="rf_project_reports") # 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")
|
||||
@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class RfProjectReportLine(Base):
|
||||
__tablename__ = "rf_project_report_line"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"rf_project_report_id", "expense_item_id", name="uq_v3_rf_project_report_line"
|
||||
),
|
||||
Index("ix_v3_rf_project_report_line_rep", "rf_project_report_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
rf_project_report_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_report.id"))
|
||||
expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
|
||||
|
||||
# report: Mapped[RfProjectReport] = relationship("RfProjectReport", back_populates="lines")
|
||||
# expense_item: Mapped[ExpenseItem] = relationship("ExpenseItem")
|
||||
# quarters: Mapped[list[RfProjectReportQuarter]] = relationship(
|
||||
# "RfProjectReportQuarter", back_populates="line"
|
||||
# )
|
||||
@ -1,30 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Numeric, SmallInteger
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class RfProjectReportQuarter(Base):
|
||||
__tablename__ = "rf_project_report_quarter"
|
||||
__table_args__ = {"schema": "v3"}
|
||||
|
||||
rf_project_report_line_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("v3.rf_project_report_line.id"), primary_key=True
|
||||
)
|
||||
quarter: Mapped[int] = mapped_column(
|
||||
SmallInteger,
|
||||
CheckConstraint("quarter >= 1 AND quarter <= 4"),
|
||||
primary_key=True,
|
||||
)
|
||||
adj_by_items: Mapped[float | None] = mapped_column(Numeric)
|
||||
adj_increase: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_m1: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_m2: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_m3: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_spod: Mapped[float | None] = mapped_column(Numeric)
|
||||
|
||||
# line: Mapped[RfProjectReportLine] = relationship(
|
||||
# "RfProjectReportLine", back_populates="quarters"
|
||||
# )
|
||||
@ -1,31 +0,0 @@
|
||||
import enum
|
||||
|
||||
from sqlalchemy import String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class UserRoleEnum(int, enum.Enum):
|
||||
ADMIN = 1
|
||||
EXECUTOR_DFIP = 2
|
||||
EXECUTOR_RF = 3
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "role"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code", name="uq_role_code"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String)
|
||||
|
||||
# users: Mapped[list["AppUser"]] = relationship("AppUser", back_populates="role") # type: ignore
|
||||
# form_phases: Mapped[list["FormPhase"]] = relationship( # type: ignore
|
||||
# "FormPhase", back_populates="role"
|
||||
# )
|
||||
# phase_templates: Mapped[list["PhaseTemplate"]] = relationship( # type: ignore
|
||||
# "PhaseTemplate", back_populates="role"
|
||||
# )
|
||||
@ -1,43 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class SecurityDetail(Base):
|
||||
__tablename__ = "security_detail"
|
||||
__table_args__ = (
|
||||
Index("ix_v3_security_detail_line", "line_id"),
|
||||
Index("ix_v3_security_detail_vsp", "vsp_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
|
||||
contract_number: Mapped[str | None] = mapped_column(String)
|
||||
contract_end_date: Mapped[date | None] = mapped_column(Date)
|
||||
plan_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_jan: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_feb: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_mar: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_apr: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_may: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_jun: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_jul: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_aug: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_sep: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_oct: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_nov: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_dec: Mapped[float | None] = mapped_column(Numeric)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine")
|
||||
# vsp = relationship("Vsp")
|
||||
@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class Sequestration(Base):
|
||||
__tablename__ = "sequestration"
|
||||
__table_args__ = (
|
||||
CheckConstraint("actor IN ('DFIP','SSP_GO')", name="ck_v3_sequestration_actor"),
|
||||
UniqueConstraint("line_id", "actor", name="uq_v3_sequestration_line_actor"),
|
||||
Index("ix_v3_sequestration_line_actor", "line_id", "actor"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||
actor: Mapped[str] = mapped_column(String)
|
||||
adj_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||
adj_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
adj_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
adj_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
justification: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine")
|
||||
@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class UserOrg(Base):
|
||||
__tablename__ = "user_org"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "org_unit_id"),
|
||||
Index("ix_v4_user_org_org", "org_unit_id"),
|
||||
Index("ix_v4_user_org_user", "user_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.app_user.id", ondelete="CASCADE"))
|
||||
org_unit_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id", ondelete="CASCADE"))
|
||||
|
||||
user: Mapped[AppUser] = relationship("AppUser", back_populates="user_orgs", lazy="selectin")
|
||||
org_unit: Mapped[OrgUnit] = relationship("OrgUnit", back_populates="user_orgs", lazy="selectin")
|
||||
@ -1,43 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class UtilityDetail(Base):
|
||||
__tablename__ = "utility_detail"
|
||||
__table_args__ = (
|
||||
Index("ix_v3_utility_detail_line", "line_id"),
|
||||
Index("ix_v3_utility_detail_vsp", "vsp_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
|
||||
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
|
||||
contract_number: Mapped[str | None] = mapped_column(String)
|
||||
contract_end_date: Mapped[date | None] = mapped_column(Date)
|
||||
plan_q1: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q2: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q3: Mapped[float | None] = mapped_column(Numeric)
|
||||
plan_q4: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_jan: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_feb: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_mar: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_apr: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_may: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_jun: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_jul: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_aug: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_sep: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_oct: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_nov: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_dec: Mapped[float | None] = mapped_column(Numeric)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine")
|
||||
# vsp = relationship("Vsp")
|
||||
@ -1,58 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class Vsp(Base):
|
||||
__tablename__ = "vsp"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"reg_number", name="vsp_reg_number_unique",
|
||||
),
|
||||
CheckConstraint(
|
||||
"closed_at IS NULL OR opened_at IS NULL OR closed_at >= opened_at",
|
||||
name="chk_closed_after_opened",
|
||||
),
|
||||
|
||||
{"schema": "v3"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
branch_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
|
||||
reg_number: Mapped[str | None] = mapped_column(String)
|
||||
address: Mapped[str | None] = mapped_column(String)
|
||||
format: Mapped[str | None] = mapped_column(String)
|
||||
opened_at: Mapped[date | None] = mapped_column(Date)
|
||||
placement_type: Mapped[str | None] = mapped_column(String)
|
||||
staff_count: Mapped[int | None] = mapped_column(Integer)
|
||||
total_area: Mapped[float | None] = mapped_column(Numeric)
|
||||
closed_at: Mapped[date | None] = mapped_column(Date)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
#ToDo: а нужно ли нам и is_active и is_deleted?
|
||||
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,8 +5,6 @@ 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
|
||||
|
||||
|
||||
@ -14,10 +12,6 @@ async def get_db() -> AsyncGenerator:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
@ -1,23 +1,8 @@
|
||||
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"
|
||||
|
||||
70
api/src/domain/models.py
Normal file
70
api/src/domain/models.py
Normal file
@ -0,0 +1,70 @@
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
|
||||
UniqueConstraint)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class UserRole(int, enum.Enum):
|
||||
ADMIN = 1
|
||||
EXECUTOR_DFIP = 2
|
||||
EXECUTOR_RF = 3
|
||||
|
||||
|
||||
class Roles(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role = Column(String, nullable=False)
|
||||
|
||||
user_role = relationship("Users", back_populates="role", lazy="select")
|
||||
|
||||
|
||||
class Users(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=True)
|
||||
full_name = Column(String, nullable=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
|
||||
role = relationship(
|
||||
"Roles",
|
||||
foreign_keys=[role_id],
|
||||
back_populates="user_role",
|
||||
lazy="selectin",
|
||||
)
|
||||
user_ssp_link = relationship("UserSSPLink", back_populates="users", lazy="select")
|
||||
|
||||
|
||||
class SSP(Base):
|
||||
__tablename__ = "ssp"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
is_ssp = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
|
||||
user_ssp_link = relationship("UserSSPLink", back_populates="ssp", lazy="select")
|
||||
|
||||
|
||||
class UserSSPLink(Base):
|
||||
__tablename__ = "user_ssp_link"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "ssp_id", name="uq_user_ssp_link_user_id_ssp_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False)
|
||||
|
||||
users = relationship("Users", back_populates="user_ssp_link", lazy="select")
|
||||
ssp = relationship("SSP", back_populates="user_ssp_link", lazy="select")
|
||||
@ -1,124 +1,41 @@
|
||||
from datetime import datetime, date
|
||||
import enum
|
||||
from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
|
||||
from sqlalchemy.exc import MissingGreenlet
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
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
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class ResponseBase(BaseModel):
|
||||
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
|
||||
success: Optional[bool] = Field(None)
|
||||
message: Optional[str] = Field(None)
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr = Field(..., examples=["user@example.com"])
|
||||
email: EmailStr
|
||||
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 = 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,
|
||||
}
|
||||
}
|
||||
)
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"])
|
||||
email: Optional[EmailStr] = None
|
||||
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):
|
||||
@ -134,37 +51,6 @@ 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
|
||||
|
||||
@ -211,465 +97,3 @@ 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,16 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Response
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.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
|
||||
@ -21,104 +14,31 @@ from src.core.exception_handlers import register_exception_handlers
|
||||
from src.db.base import engine
|
||||
from src.db.session import create_tables
|
||||
|
||||
|
||||
|
||||
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))
|
||||
if not settings.DEBUG:
|
||||
from raisa_fastapi_protected_api import (
|
||||
AuthorizationMiddleware,
|
||||
OpenEndpoint,
|
||||
ProtectedOAuthSettings,
|
||||
ProtectedRolesSettings,
|
||||
ProtectedSettings,
|
||||
SearchType,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
# ),
|
||||
# )
|
||||
cleanup_task = asyncio.create_task(_audit_cleanup_loop())
|
||||
ProtectedSettings(
|
||||
ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
|
||||
ProtectedRolesSettings(
|
||||
APP_NAMESPACE=settings.APP_NAMESPACE,
|
||||
APP_NAME=settings.APP_NAME,
|
||||
),
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if cleanup_task:
|
||||
cleanup_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await cleanup_task
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@ -149,77 +69,32 @@ async def healthcheck2():
|
||||
"port": settings.PORT,
|
||||
}
|
||||
|
||||
@app.get("/healthz", status_code=200)
|
||||
async def healthz():
|
||||
return {"status": "ok", "uptime": UP_TIME, "version": VERSION}
|
||||
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readyz(response: Response):
|
||||
result = {"status": "ok", "db_status": "ok", "mv_expense_item_tree": "ok"}
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
if "postgresql" in settings.DATABASE_URL:
|
||||
my_exists = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM pg_matviews
|
||||
WHERE schemaname = 'v3'
|
||||
AND matviewname = 'mv_expense_item_tree'
|
||||
"""
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if my_exists is None:
|
||||
result["status"] = "fail"
|
||||
result["mv_expense_item_tree"] = "missing"
|
||||
response.status_code = 503
|
||||
except SQLAlchemyError as exc:
|
||||
result["status"] = "fail"
|
||||
result["db"] = f"error: {exc.__class__.__name__}"
|
||||
response.status_code = 503
|
||||
return result
|
||||
|
||||
|
||||
|
||||
# if not settings.DEBUG:
|
||||
# app.add_middleware(
|
||||
# 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,
|
||||
# ),
|
||||
# ],
|
||||
# )
|
||||
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.add_middleware(
|
||||
CORSMiddleware,
|
||||
@ -233,43 +108,4 @@ 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")
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.functions import func
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# from sqlalchemy.orm import selectinload
|
||||
|
||||
# from app.domain.models import AuditLog
|
||||
from src.db.models.audit_log import AuditLog
|
||||
|
||||
|
||||
class AuditLogRepository:
|
||||
"""Репозиторий для работы с журналом аудита."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(self, audit_log_id: int) -> Optional[AuditLog]:
|
||||
"""Получение записи аудита по ID."""
|
||||
return (
|
||||
(
|
||||
await self.db.execute(
|
||||
select(AuditLog).where(AuditLog.id == audit_log_id).limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
async def get_all(
|
||||
self,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
user_id: int | None = None,
|
||||
org_unit_id: int | None = None,
|
||||
task_id: int | None = None,
|
||||
form_id: int | None = None,
|
||||
event_type: str | None = None,
|
||||
event: str | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
) -> tuple[Iterable[AuditLog], int]:
|
||||
"""Получение всех записей аудита с опциональной фильтрацией."""
|
||||
query = select(AuditLog)
|
||||
query_count = select(func.count(AuditLog.id))
|
||||
|
||||
if user_id is not None:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
query_count = query_count.where(AuditLog.user_id == user_id)
|
||||
if org_unit_id is not None:
|
||||
query = query.where(AuditLog.org_unit_id == org_unit_id)
|
||||
query_count = query_count.where(AuditLog.org_unit_id == org_unit_id)
|
||||
if task_id is not None:
|
||||
query = query.where(AuditLog.task_id == task_id)
|
||||
query_count = query_count.where(AuditLog.task_id == task_id)
|
||||
if form_id is not None:
|
||||
query = query.where(AuditLog.form_id == form_id)
|
||||
query_count = query_count.where(AuditLog.form_id == form_id)
|
||||
if event_type is not None:
|
||||
query = query.where(AuditLog.event_type == event_type)
|
||||
query_count = query_count.where(AuditLog.event_type == event_type)
|
||||
if event is not None:
|
||||
query = query.where(AuditLog.event == event)
|
||||
query_count = query_count.where(AuditLog.event == event)
|
||||
if date_from is not None:
|
||||
query = query.where(AuditLog.event_dt >= date_from)
|
||||
query_count = query_count.where(AuditLog.event_dt >= date_from)
|
||||
if date_to is not None:
|
||||
query = query.where(AuditLog.event_dt <= date_to)
|
||||
query_count = query_count.where(AuditLog.event_dt <= date_to)
|
||||
|
||||
query = query.options(joinedload(AuditLog.user))
|
||||
|
||||
query = query.order_by(AuditLog.event_dt.desc())
|
||||
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
if offset is not None:
|
||||
query = query.offset(offset)
|
||||
|
||||
return (await self.db.execute(query)).scalars().all(), (
|
||||
await self.db.execute(query_count)
|
||||
).scalar_one()
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@ -1,26 +0,0 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.budget_line import BudgetLine
|
||||
|
||||
|
||||
class BudgetLineRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(self, budget_line_id: int) -> BudgetLine | None:
|
||||
query = select(BudgetLine).where(BudgetLine.id == budget_line_id).limit(1)
|
||||
return (await self.db.execute(query)).scalar_one_or_none()
|
||||
|
||||
async def get_list(self, budget_line_ids: list[int]) -> 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()
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.form_type import FormTypeEnum
|
||||
from src.db.models.expense_item import ExpenseItem, ExpenseItemFormType
|
||||
|
||||
|
||||
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,
|
||||
sheet: str | None = None,
|
||||
direction: str | None = None,
|
||||
form_type: FormTypeEnum | str | None = None,
|
||||
) -> list[ExpenseItem]:
|
||||
where = []
|
||||
query = select(ExpenseItem)
|
||||
if form_type is not None:
|
||||
query = query.join(
|
||||
ExpenseItemFormType,
|
||||
ExpenseItemFormType.expense_item_id == ExpenseItem.id,
|
||||
).where(
|
||||
ExpenseItemFormType.form_type_code == (form_type.value if isinstance(form_type, FormTypeEnum) else form_type)
|
||||
)
|
||||
if r_start is not None:
|
||||
where.append(ExpenseItem.item_id.startswith("R"))
|
||||
if sheet is not None:
|
||||
where.append(ExpenseItem.sheet == sheet)
|
||||
if direction is not None:
|
||||
where.append(ExpenseItem.direction == direction)
|
||||
query = query.where(*where)
|
||||
|
||||
|
||||
|
||||
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
@ -1,116 +0,0 @@
|
||||
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)
|
||||
@ -1,65 +0,0 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.form4_project import Form4Project, Form4ProjectLevelEnum
|
||||
|
||||
|
||||
class Form4ProjectRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(
|
||||
self,
|
||||
project_id: int,
|
||||
level: Form4ProjectLevelEnum | str | None = None,
|
||||
) -> Form4Project | None:
|
||||
where = [Form4Project.id == project_id]
|
||||
if level is not None:
|
||||
where.append(Form4Project.level == level)
|
||||
|
||||
return (
|
||||
(await self.db.execute(select(Form4Project).where(*where).limit(1)))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
level: Form4ProjectLevelEnum | None = None,
|
||||
form_id: int | None = None,
|
||||
parent_id: int | None = None,
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
with_count: bool = False,
|
||||
) -> list[Form4Project] | tuple[int, list[Form4Project]]:
|
||||
if with_count:
|
||||
query = select(func.count().over().label("total_count"), Form4Project)
|
||||
else:
|
||||
query = select(Form4Project)
|
||||
|
||||
where = []
|
||||
if level is not None:
|
||||
where.append(Form4Project.level == level)
|
||||
if form_id is not None:
|
||||
where.append(Form4Project.form_id == form_id)
|
||||
if parent_id is not None:
|
||||
where.append(Form4Project.parent_id == parent_id)
|
||||
|
||||
query = query.where(*where).order_by(Form4Project.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(Form4Project.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()
|
||||
@ -1,123 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.form_phase import FormPhase
|
||||
|
||||
|
||||
class FormPhaseRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
budget_form_id: int,
|
||||
sheet: str | None = None,
|
||||
phase_code: str | None = None,
|
||||
) -> list[FormPhase]:
|
||||
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
|
||||
if sheet is not None:
|
||||
query = query.where(FormPhase.sheet == sheet)
|
||||
if phase_code is not None:
|
||||
query = query.where(FormPhase.phase_code == phase_code)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
async def get(
|
||||
self, budget_form_id: int, sheet: str, phase_code: str
|
||||
) -> FormPhase | None:
|
||||
query = (
|
||||
select(FormPhase)
|
||||
.where(FormPhase.budget_form_id == budget_form_id)
|
||||
.where(FormPhase.sheet == sheet)
|
||||
.where(FormPhase.phase_code == phase_code)
|
||||
.limit(1)
|
||||
)
|
||||
return (await self.db.execute(query)).scalar_one_or_none()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
budget_form_id: int,
|
||||
sheet: str,
|
||||
phase_code: str,
|
||||
role: str,
|
||||
column_keys: list[str],
|
||||
opens_at: datetime,
|
||||
closes_at: datetime,
|
||||
) -> FormPhase:
|
||||
result = await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
(v3.add_form_phase(
|
||||
:budget_form_id,
|
||||
:sheet,
|
||||
:phase_code,
|
||||
:role,
|
||||
:column_keys,
|
||||
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)
|
||||
@ -1,138 +0,0 @@
|
||||
|
||||
|
||||
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
|
||||
@ -1,397 +0,0 @@
|
||||
import json
|
||||
|
||||
from sqlalchemy import exists, false, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.org_unit import OrgUnit
|
||||
from src.db.models.project import Project
|
||||
from src.db.models.rf_project_report import RfProjectReport
|
||||
from src.db.models.rf_project_report_line import RfProjectReportLine
|
||||
|
||||
|
||||
class ProjectRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _serialize_project(project: Project, org_unit_name: str | None, report_count: int, 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,
|
||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||
"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 del_project(self, project_id: int):
|
||||
query = text(
|
||||
"""
|
||||
SELECT v3.del_project(CAST(:project_id AS INT))
|
||||
"""
|
||||
)
|
||||
result = (await self.db.execute(query, {"project_id": project_id})).scalar_one()
|
||||
return result
|
||||
|
||||
async def upd_project(self, project_id: int, column: str, value):
|
||||
query = text(
|
||||
"""
|
||||
SELECT v3.upd_project(CAST(:project_id AS INT), CAST(:column AS TEXT), CAST(:value AS JSONB))
|
||||
"""
|
||||
)
|
||||
result = (
|
||||
await self.db.execute(
|
||||
query,
|
||||
{
|
||||
"project_id": project_id,
|
||||
"column": column,
|
||||
"value": json.dumps(value),
|
||||
},
|
||||
)
|
||||
).scalar_one()
|
||||
return result
|
||||
|
||||
async def add_project(
|
||||
self,
|
||||
name: str,
|
||||
year: int,
|
||||
org_unit_id: int,
|
||||
level: str = "project",
|
||||
parent_id: int | None = None,
|
||||
project_type: str | None = None,
|
||||
vsp_format: str | None = None,
|
||||
placement_type: str | None = None,
|
||||
object_address: str | None = None,
|
||||
staff_count: int | None = None,
|
||||
total_area: float | None = None,
|
||||
) -> tuple[int, int, int]:
|
||||
query = text(
|
||||
"""
|
||||
SELECT project_id, limit_report_id, current_expenses_report_id
|
||||
FROM v3.add_project(
|
||||
CAST(:name AS VARCHAR),
|
||||
CAST(:year AS INT),
|
||||
CAST(:org_unit_id AS INT),
|
||||
CAST(:level AS VARCHAR),
|
||||
CAST(:parent_id AS INT),
|
||||
CAST(:project_type AS VARCHAR),
|
||||
CAST(:vsp_format AS VARCHAR),
|
||||
CAST(:placement_type AS VARCHAR),
|
||||
CAST(:object_address AS VARCHAR),
|
||||
CAST(:staff_count AS INT),
|
||||
CAST(:total_area AS NUMERIC)
|
||||
)
|
||||
"""
|
||||
)
|
||||
row = (
|
||||
await self.db.execute(
|
||||
query,
|
||||
{
|
||||
"name": name,
|
||||
"year": year,
|
||||
"org_unit_id": org_unit_id,
|
||||
"level": level,
|
||||
"parent_id": parent_id,
|
||||
"project_type": project_type,
|
||||
"vsp_format": vsp_format,
|
||||
"placement_type": placement_type,
|
||||
"object_address": object_address,
|
||||
"staff_count": staff_count,
|
||||
"total_area": total_area,
|
||||
},
|
||||
)
|
||||
).first()
|
||||
return int(row[0]), int(row[1]), int(row[2])
|
||||
@ -1,39 +0,0 @@
|
||||
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()
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
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()
|
||||
@ -1,298 +0,0 @@
|
||||
import enum
|
||||
import itertools
|
||||
import json
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.models.form_type import FormType
|
||||
|
||||
|
||||
class DirectionEnum(str, enum.Enum):
|
||||
SUPPORT = "Support"
|
||||
DEVELOPMENT = "Development"
|
||||
|
||||
ACCEPTABLE_SHEETS = set(itertools.chain(*FormType.SHEETS_BY_FORM_TYPE.values()))
|
||||
ACCEPTABLE_SECTIONS = set(itertools.chain(*FormType.SECTIONS_BY_FORM_TYPE.values()))
|
||||
|
||||
|
||||
|
||||
class SheetValidationError(Exception):
|
||||
|
||||
def __init__(self, message, *args):
|
||||
super().__init__(*args)
|
||||
self.message = message
|
||||
|
||||
|
||||
class SheetRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get(
|
||||
self,
|
||||
form_id: int,
|
||||
sheet: str,
|
||||
direction: str | None,
|
||||
sections: list[str] | None = None,
|
||||
) -> list[tuple]:
|
||||
if any(
|
||||
[
|
||||
not isinstance(form_id, int),
|
||||
sheet not in ACCEPTABLE_SHEETS,
|
||||
direction is not None and direction not in [s.value for s in DirectionEnum],
|
||||
sections and (set(sections) - ACCEPTABLE_SECTIONS),
|
||||
]
|
||||
):
|
||||
raise SheetValidationError("Некорректные данные")
|
||||
|
||||
|
||||
if direction:
|
||||
func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)"
|
||||
else:
|
||||
func_query = "v3.v_form_view(:form_id, :sheet, :sections)"
|
||||
|
||||
return (
|
||||
await self.db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT row_type, depth, sort_order, data
|
||||
FROM {func_query}
|
||||
"""
|
||||
),
|
||||
{
|
||||
"form_id": form_id,
|
||||
"sheet": sheet,
|
||||
"direction": direction,
|
||||
"sections": sections,
|
||||
}
|
||||
)
|
||||
).all()
|
||||
|
||||
async def update_cell(
|
||||
self,
|
||||
form_id: int,
|
||||
sheet: str,
|
||||
direction: str | None,
|
||||
sections: list[str] | None,
|
||||
line_id: int,
|
||||
column: str,
|
||||
value: str | int | float | None = None,
|
||||
user_id: int = None,
|
||||
) -> list[tuple]:
|
||||
if any(
|
||||
[
|
||||
not isinstance(form_id, int),
|
||||
sheet not in ACCEPTABLE_SHEETS,
|
||||
direction is not None and direction not in [s.value for s in DirectionEnum],
|
||||
sections and (set(sections) - ACCEPTABLE_SECTIONS),
|
||||
]
|
||||
):
|
||||
raise SheetValidationError("Некорректные данные")
|
||||
if user_id:
|
||||
func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :user_id, :direction, :sections)"
|
||||
else:
|
||||
func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :direction, :sections)"
|
||||
|
||||
return [
|
||||
tuple(r) for r in (
|
||||
await self.db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT row_type, depth, sort_order, data
|
||||
FROM {func_query}
|
||||
"""
|
||||
),
|
||||
{
|
||||
"form_id": form_id,
|
||||
"sheet": sheet,
|
||||
"direction": direction,
|
||||
"sections": sections,
|
||||
"line_id": line_id,
|
||||
"column": column,
|
||||
"value": json.dumps(value),
|
||||
"user_id": user_id,
|
||||
}
|
||||
)
|
||||
).all()
|
||||
]
|
||||
|
||||
async def update_cells(
|
||||
self,
|
||||
form_id: int,
|
||||
sheet: str,
|
||||
direction: str | None,
|
||||
sections: list[str] | None,
|
||||
changes: list[dict],
|
||||
user_id: int = None,
|
||||
) -> list[tuple]:
|
||||
if any(
|
||||
[
|
||||
not isinstance(form_id, int),
|
||||
sheet not in ACCEPTABLE_SHEETS,
|
||||
direction is not None and direction not in [s.value for s in DirectionEnum],
|
||||
sections and (set(sections) - ACCEPTABLE_SECTIONS),
|
||||
]
|
||||
):
|
||||
raise SheetValidationError("Некорректные данные")
|
||||
changes = json.dumps(changes)
|
||||
if user_id:
|
||||
func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :user_id, :direction, :sections)"
|
||||
else:
|
||||
func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :direction, :sections)"
|
||||
|
||||
return [
|
||||
tuple(r) for r in (
|
||||
await self.db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT row_type, depth, sort_order, data
|
||||
FROM {func_query}
|
||||
"""
|
||||
),
|
||||
{
|
||||
"form_id": form_id,
|
||||
"sheet": sheet,
|
||||
"direction": direction,
|
||||
"sections": sections,
|
||||
"changes": changes,
|
||||
"user_id": user_id,
|
||||
}
|
||||
)
|
||||
).all()
|
||||
]
|
||||
|
||||
async def add_program(
|
||||
self,
|
||||
form_id: int,
|
||||
sheet: str,
|
||||
name: str,
|
||||
) -> list[tuple]:
|
||||
return [
|
||||
tuple(r) for r in (
|
||||
await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT project_id, row_type, depth, sort_order, data
|
||||
FROM v3.add_form4_program(
|
||||
:p_name,
|
||||
:p_form_id,
|
||||
:p_sheet
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"p_form_id": form_id,
|
||||
"p_name": name,
|
||||
"p_sheet": sheet,
|
||||
}
|
||||
)
|
||||
).all()
|
||||
]
|
||||
|
||||
async def add_project(
|
||||
self,
|
||||
name: str,
|
||||
program_id: int,
|
||||
) -> list[tuple]:
|
||||
return [
|
||||
tuple(r) for r in (
|
||||
await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT project_id, row_type, depth, sort_order, data
|
||||
FROM v3.add_form4_project(
|
||||
:p_name,
|
||||
:p_program_id
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"p_program_id": program_id,
|
||||
"p_name": name,
|
||||
}
|
||||
)
|
||||
).all()
|
||||
]
|
||||
|
||||
async def add_line(
|
||||
self,
|
||||
form_id: int,
|
||||
sheet: str,
|
||||
expense_item_id: int | None = None,
|
||||
item_id: str | None = None,
|
||||
section_code: str | None = None,
|
||||
direction: str | None = None,
|
||||
name: str | None = None,
|
||||
internal_order: str | None = None,
|
||||
vsp_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
justification: str | None = None,
|
||||
contract_number: str | None = None,
|
||||
contract_end_date: str | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> list[tuple]:
|
||||
return [
|
||||
tuple(r) for r in (
|
||||
await self.db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT row_type, depth, sort_order, data
|
||||
FROM v3.add_budget_line(
|
||||
:p_form_id,
|
||||
:p_expense_item_id,
|
||||
:p_sheet,
|
||||
:p_item_id,
|
||||
:p_section_code,
|
||||
:p_direction,
|
||||
:p_name,
|
||||
:p_internal_order,
|
||||
:p_vsp_id,
|
||||
:p_project_id,
|
||||
:p_justification,
|
||||
:p_contract_number,
|
||||
:p_contract_end_date
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"p_form_id": form_id,
|
||||
"p_expense_item_id": expense_item_id,
|
||||
"p_sheet": sheet,
|
||||
"p_item_id": item_id,
|
||||
"p_section_code": section_code,
|
||||
"p_direction": direction,
|
||||
"p_name": name,
|
||||
"p_internal_order": internal_order,
|
||||
"p_vsp_id": vsp_id,
|
||||
"p_project_id": project_id,
|
||||
"p_justification": justification,
|
||||
"p_contract_number": contract_number,
|
||||
"p_contract_end_date": contract_end_date,
|
||||
}
|
||||
)
|
||||
).all()
|
||||
]
|
||||
|
||||
async def delete_line(
|
||||
self,
|
||||
sheet: str,
|
||||
row_id: int,
|
||||
direction: str | None = None,
|
||||
) -> list[tuple]:
|
||||
|
||||
return [
|
||||
tuple(r) for r in (
|
||||
await self.db.execute(
|
||||
text(
|
||||
"SELECT row_type, depth, sort_order, data "
|
||||
"FROM v3.del_budget_line(:p_row_id, :p_direction, :p_sheet)"
|
||||
),
|
||||
{
|
||||
"p_row_id": row_id,
|
||||
"p_direction": direction,
|
||||
"p_sheet": sheet,
|
||||
}
|
||||
)
|
||||
).all()
|
||||
]
|
||||
|
||||
@ -1,120 +1,96 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import delete, func, select, text, update
|
||||
from sqlalchemy import delete, select, 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, load_orgs: bool = False) -> Optional[AppUser]:
|
||||
query = select(AppUser).where(AppUser.id == user_id)
|
||||
if load_orgs:
|
||||
query = query.options(
|
||||
selectinload(AppUser.org_units),
|
||||
)
|
||||
|
||||
async def get(self, user_id: int) -> Optional[Users]:
|
||||
return (
|
||||
(
|
||||
await self.db.execute(
|
||||
query.limit(1)
|
||||
select(Users).where(Users.id == user_id).limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
async def get_by_email(self, email: str) -> Optional[AppUser]:
|
||||
async def get_by_email(self, email: str) -> Optional[Users]:
|
||||
return (
|
||||
(
|
||||
await self.db.execute(
|
||||
select(AppUser).where(AppUser.email == email).limit(1)
|
||||
select(Users).where(Users.email == email).limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
async def get_by_username(self, username: str) -> Optional[AppUser]:
|
||||
async def get_by_username(self, username: str) -> Optional[Users]:
|
||||
return (
|
||||
(
|
||||
await self.db.execute(
|
||||
select(AppUser).where(AppUser.username == username).limit(1)
|
||||
select(Users).where(Users.username == username).limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
|
||||
async def set_app_user_id(self, user_id: int) -> None:
|
||||
if isinstance(user_id, int):
|
||||
await self.db.execute(
|
||||
text(
|
||||
f"SET LOCAL app.user_id = {user_id}"
|
||||
),
|
||||
)
|
||||
async def get_list(self, skip: int = 0, limit: int = 100) -> list[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 create(self, user_data: dict) -> Users:
|
||||
hashed_password = (
|
||||
get_password_hash(user_data["password"]) if user_data.get("password") else None
|
||||
)
|
||||
db_user = Users(
|
||||
email=user_data["email"],
|
||||
username=user_data["username"],
|
||||
hashed_password=hashed_password,
|
||||
full_name=user_data.get("full_name"),
|
||||
role_id=user_data.get("role_id"),
|
||||
)
|
||||
self.db.add(db_user)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
with_count: bool = False,
|
||||
load_orgs: bool = False,
|
||||
) -> list[AppUser] | tuple[int, list[AppUser]]:
|
||||
async def update(self, user_id: int, user_data: dict) -> Optional[Users]:
|
||||
user = await self.get(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
where = [AppUser.is_active.is_(True)]
|
||||
for field, value in user_data.items():
|
||||
if field == "password" and value:
|
||||
value = get_password_hash(value)
|
||||
setattr(user, field, value)
|
||||
|
||||
if with_count:
|
||||
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()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
|
||||
async def logical_delete(self, user_id: int) -> bool:
|
||||
query = update(AppUser).where(AppUser.id == user_id).values(is_active=False)
|
||||
query = update(Users).where(Users.id == user_id).values(is_active=False)
|
||||
result = await self.db.execute(query)
|
||||
await self.db.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def authenticate(self, username: str, password: str) -> Optional[AppUser]:
|
||||
async def authenticate(self, username: str, password: str) -> Optional[Users]:
|
||||
user = await self.get_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
@ -122,122 +98,52 @@ class UserRepository:
|
||||
return None
|
||||
return user
|
||||
|
||||
async def authenticate_via_email(self, email: str) -> Optional[AppUser]:
|
||||
async def authenticate_via_email(self, email: str) -> Optional[Users]:
|
||||
return await self.get_by_email(email)
|
||||
|
||||
async def get_roles(self) -> list[Role]:
|
||||
return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
|
||||
async def get_roles(self) -> list[Roles]:
|
||||
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all()
|
||||
|
||||
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
|
||||
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
|
||||
)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
async def get_many_ssp_ids(self, user_id: int) -> list[int]:
|
||||
query = select(UserOrg.org_unit_id).where(UserOrg.user_id == user_id)
|
||||
query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id)
|
||||
return (await self.db.execute(query)).scalars().all()
|
||||
|
||||
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
||||
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
|
||||
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
|
||||
|
||||
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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