From c67c94ba1f5955ebc19c1b961d29c3adf9108feb Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Fri, 8 May 2026 12:33:05 +0300 Subject: [PATCH 1/9] =?UTF-8?q?WIP:=20READ=20=D1=80=D1=83=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC=201=202?= =?UTF-8?q?=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic.ini | 141 ++++++++++++++++++ api/alembic/README | 1 + api/alembic/env.py | 78 ++++++++++ api/alembic/script.py.mako | 28 ++++ api/src/api/v1/deps.py | 10 ++ api/src/api/v1/forms.py | 131 ++++++++++++++++ api/src/api/v1/projects.py | 34 +++++ api/src/api/v1/router.py | 4 + api/src/db/base.py | 3 + api/src/db/models/__init__.py | 29 ++++ api/src/db/models/allocation.py | 19 +++ api/src/db/models/app_user.py | 56 +++++++ api/src/db/models/audit_log.py | 63 ++++++++ api/src/db/models/booking.py | 25 ++++ api/src/db/models/budget_form.py | 56 +++++++ api/src/db/models/budget_line.py | 51 +++++++ api/src/db/models/budget_line_quarter.py | 54 +++++++ api/src/db/models/ckk.py | 29 ++++ api/src/db/models/collegial_approval.py | 18 +++ api/src/db/models/contract_detail.py | 34 +++++ api/src/db/models/contract_summary.py | 24 +++ api/src/db/models/form_limit.py | 25 ++++ api/src/db/models/form_type.py | 63 ++++++++ api/src/db/models/limit_template.py | 33 ++++ api/src/db/models/org_unit.py | 35 +++++ api/src/db/models/phase_template.py | 29 ++++ api/src/db/models/plan.py | 20 +++ api/src/db/models/project.py | 49 ++++++ api/src/db/models/rent_detail.py | 43 ++++++ api/src/db/models/reserve.py | 20 +++ api/src/db/models/rf_project_report.py | 31 ++++ api/src/db/models/rf_project_report_line.py | 27 ++++ .../db/models/rf_project_report_quarter.py | 30 ++++ api/src/db/models/role.py | 31 ++++ api/src/db/models/security_detail.py | 43 ++++++ api/src/db/models/sequestration.py | 27 ++++ api/src/db/models/user_org.py | 23 +++ api/src/db/models/utility_detail.py | 43 ++++++ api/src/db/models/vsp.py | 30 ++++ api/src/db/session.py | 3 + api/src/domain/schemas.py | 55 ++++++- api/src/repository/budget_form_repository.py | 73 +++++++++ api/src/repository/project_repository.py | 8 + api/src/repository/sheet_repository.py | 69 +++++++++ api/src/repository/user_repository.py | 77 ++++++---- api/src/services/access_control_service.py | 46 ++++++ api/src/services/budget_form_service.py | 61 ++++++++ api/src/services/project_service.py | 29 ++++ api/src/services/sheet_service.py | 27 ++++ api/src/services/user_service.py | 75 +++++----- 50 files changed, 1945 insertions(+), 68 deletions(-) create mode 100644 api/alembic.ini create mode 100644 api/alembic/README create mode 100644 api/alembic/env.py create mode 100644 api/alembic/script.py.mako create mode 100644 api/src/api/v1/forms.py create mode 100644 api/src/api/v1/projects.py create mode 100644 api/src/db/models/__init__.py create mode 100644 api/src/db/models/allocation.py create mode 100644 api/src/db/models/app_user.py create mode 100644 api/src/db/models/audit_log.py create mode 100644 api/src/db/models/booking.py create mode 100644 api/src/db/models/budget_form.py create mode 100644 api/src/db/models/budget_line.py create mode 100644 api/src/db/models/budget_line_quarter.py create mode 100644 api/src/db/models/ckk.py create mode 100644 api/src/db/models/collegial_approval.py create mode 100644 api/src/db/models/contract_detail.py create mode 100644 api/src/db/models/contract_summary.py create mode 100644 api/src/db/models/form_limit.py create mode 100644 api/src/db/models/form_type.py create mode 100644 api/src/db/models/limit_template.py create mode 100644 api/src/db/models/org_unit.py create mode 100644 api/src/db/models/phase_template.py create mode 100644 api/src/db/models/plan.py create mode 100644 api/src/db/models/project.py create mode 100644 api/src/db/models/rent_detail.py create mode 100644 api/src/db/models/reserve.py create mode 100644 api/src/db/models/rf_project_report.py create mode 100644 api/src/db/models/rf_project_report_line.py create mode 100644 api/src/db/models/rf_project_report_quarter.py create mode 100644 api/src/db/models/role.py create mode 100644 api/src/db/models/security_detail.py create mode 100644 api/src/db/models/sequestration.py create mode 100644 api/src/db/models/user_org.py create mode 100644 api/src/db/models/utility_detail.py create mode 100644 api/src/db/models/vsp.py create mode 100644 api/src/repository/budget_form_repository.py create mode 100644 api/src/repository/project_repository.py create mode 100644 api/src/repository/sheet_repository.py create mode 100644 api/src/services/access_control_service.py create mode 100644 api/src/services/budget_form_service.py create mode 100644 api/src/services/project_service.py create mode 100644 api/src/services/sheet_service.py diff --git a/api/alembic.ini b/api/alembic.ini new file mode 100644 index 0000000..54f756d --- /dev/null +++ b/api/alembic.ini @@ -0,0 +1,141 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /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 diff --git a/api/alembic/README b/api/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/api/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/api/alembic/env.py b/api/alembic/env.py new file mode 100644 index 0000000..36112a3 --- /dev/null +++ b/api/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/api/alembic/script.py.mako b/api/alembic/script.py.mako new file mode 100644 index 0000000..480b130 --- /dev/null +++ b/api/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/api/src/api/v1/deps.py b/api/src/api/v1/deps.py index 21266fd..d38a39b 100644 --- a/api/src/api/v1/deps.py +++ b/api/src/api/v1/deps.py @@ -68,6 +68,16 @@ async def get_current_active_user(current_user: Users = Depends(get_current_user return current_user +async def get_current_active_user_with_set_db( + current_user: Users = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Users: + + user_repo = UserRepository(db) + await user_repo.set_app_user_id(current_user.id) + return current_user + + def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users: if current_user.role_id != UserRole.ADMIN: raise HTTPException( diff --git a/api/src/api/v1/forms.py b/api/src/api/v1/forms.py new file mode 100644 index 0000000..0b1b66d --- /dev/null +++ b/api/src/api/v1/forms.py @@ -0,0 +1,131 @@ +import time +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Response + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.form_type import FormTypeEnum +from src.services.sheet_service import SheetService +from src.domain.schemas import BaseListResponse, BaseSingleResponse, BudgetFormResponse, DirectionSchemaEnum, SheetFormTypeResponse, SheetResponse +from src.db.models.app_user import AppUser +from src.services.budget_form_service import BudgetFormService +from src.api.v1.deps import get_current_active_user_with_set_db +from src.db.session import get_db + +router = APIRouter(prefix="/form", tags=["forms"]) + + +@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, + ) + return BaseListResponse( + result = [ + BudgetFormResponse.model_validate(form) for form in forms + ], + count=count, + ) + + +@router.get("{form_id}/sheets") +async def get_form_sheets( + form_id: int, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseSingleResponse[SheetFormTypeResponse]: + bf_service = BudgetFormService(db) + form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True) + + if not form: + raise HTTPException(404, f"Форма {form_id} не найдена") + return BaseSingleResponse( + result=SheetFormTypeResponse( + form_type=form.form_type.code, + sheets=form.form_type.sheet_list, + ) + ) + + + +@router.get("/{form_id}/sheet/{sheet}") +async def get_sheet( + form_id: int, + sheet: str, + response: Response, + direction: Optional[DirectionSchemaEnum] = None, + sections: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[SheetResponse]: + if sections is not None: + sections = sections.split(",") + 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} не найден") + if sections and (rem_sects := set(sections) - set(form.form_type.section_list)): + raise HTTPException(404, f"Секций {', '.join(rem_sects)} формы {form_id} не найден") + if form.form_type.code == FormTypeEnum.FORM_1 and not direction: + raise HTTPException(400, f"Форма типа {form.form_type.code} должна содержать в запросе direction") + + result = await sheet_service.get( + form_id=form_id, + sheet=sheet, + direction=direction.value if direction else None, + sections=sections, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + return BaseListResponse( + count=len(result), + result=[ + SheetResponse( + row_type=el[0], + depth=el[1], + sort_order=el[2], + data=el[3], + ) for el in result + ] + ) + # try: + + # with db_cursor() as cur: + # t0 = time.perf_counter() + # cur.execute( + # "SELECT row_type, depth, sort_order, data " + # "FROM v3.v_form_view(%s, %s, NULL, %s)", + # (form_id, sheet, direction), + # ) + # rows = cur.fetchall() + # db_ms = (time.perf_counter() - t0) * 1000 + # payload = [ + # {"row_type": r[0], "depth": r[1], "sort_order": r[2], "data": r[3]} + # for r in rows + # ] + # return JSONResponse( + # content=payload, + # headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}, + # ) + # except psycopg2.Error as e: + # raise HTTPException(400, str(e).strip()) diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py new file mode 100644 index 0000000..39ee2c1 --- /dev/null +++ b/api/src/api/v1/projects.py @@ -0,0 +1,34 @@ + +from typing import Optional + +from fastapi import APIRouter, Depends +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 BaseListResponse +from src.services.project_service import ProjectService + + +router = APIRouter(prefix="/projects", tags=["forms"]) + + +@router.get("/") +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[str]: + 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, + ) \ No newline at end of file diff --git a/api/src/api/v1/router.py b/api/src/api/v1/router.py index 388ff1d..4f748b3 100644 --- a/api/src/api/v1/router.py +++ b/api/src/api/v1/router.py @@ -1,7 +1,11 @@ from fastapi import APIRouter +from src.api.v1 import projects +from src.api.v1 import forms 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(forms.router) +api_router.include_router(projects.router) diff --git a/api/src/db/base.py b/api/src/db/base.py index 4458cb8..d579c59 100644 --- a/api/src/db/base.py +++ b/api/src/db/base.py @@ -7,12 +7,15 @@ connect_args = {} if settings.DATABASE_SCHEMA: connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA} + if "sqlite" in settings.DATABASE_URL: connect_args["check_same_thread"] = False engine = create_async_engine( settings.DATABASE_URL, pool_pre_ping=True, + pool_size=5, + max_overflow=10, connect_args=connect_args, ) diff --git a/api/src/db/models/__init__.py b/api/src/db/models/__init__.py new file mode 100644 index 0000000..9205fd7 --- /dev/null +++ b/api/src/db/models/__init__.py @@ -0,0 +1,29 @@ +from src.db.models.allocation import Allocation +from src.db.models.app_user import AppUser +from src.db.models.audit_log import AuditLog +from src.db.models.booking import Booking +from src.db.models.budget_form import BudgetForm +from src.db.models.budget_line import BudgetLine +from src.db.models.budget_line_quarter import BudgetLineQuarter +from src.db.models.ckk import Ckk +from src.db.models.collegial_approval import CollegialApproval +from src.db.models.contract_detail import ContractDetail +from src.db.models.contract_summary import ContractSummary +from src.db.models.form_limit import FormLimit +from src.db.models.form_type import FormType +from src.db.models.limit_template import LimitTemplate +from src.db.models.org_unit import OrgUnit +from src.db.models.phase_template import PhaseTemplate +from src.db.models.plan import Plan +from src.db.models.project import Project +from src.db.models.rent_detail import RentDetail +from src.db.models.reserve import Reserve +from src.db.models.rf_project_report import RfProjectReport +from src.db.models.rf_project_report_line import RfProjectReportLine +from src.db.models.rf_project_report_quarter import RfProjectReportQuarter +from src.db.models.role import Role +from src.db.models.security_detail import SecurityDetail +from src.db.models.sequestration import Sequestration +from src.db.models.user_org import UserOrg +from src.db.models.utility_detail import UtilityDetail +from src.db.models.vsp import Vsp diff --git a/api/src/db/models/allocation.py b/api/src/db/models/allocation.py new file mode 100644 index 0000000..0a8f52b --- /dev/null +++ b/api/src/db/models/allocation.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Allocation(Base): + __tablename__ = "allocation" + __table_args__ = {"schema": "v3"} + + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True) + internal_order: Mapped[str | None] = mapped_column(String) + property_object: Mapped[str | None] = mapped_column(String) + contract_ref: Mapped[str | None] = mapped_column(String) + allocation_purpose: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine", uselist=False) diff --git a/api/src/db/models/app_user.py b/api/src/db/models/app_user.py new file mode 100644 index 0000000..33eecc1 --- /dev/null +++ b/api/src/db/models/app_user.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class AppUser(Base): + __tablename__ = "app_user" + __table_args__ = ( + Index( + "ix_v4_app_user_active", + "is_active", + postgresql_where="is_active", + ), + Index("ix_v4_app_user_role", "role_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String) + username: Mapped[str] = mapped_column(String) + hashed_password: Mapped[str] = mapped_column(String) + full_name: Mapped[str | None] = mapped_column(String) + role_id: Mapped[int] = mapped_column(ForeignKey("v3.role.id")) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + +# role: Mapped["Role"] = relationship("Role", back_populates="users") # type: ignore +# created_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore +# "BudgetForm", back_populates="creator", foreign_keys="BudgetForm.created_by" +# ) +# updated_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore +# "BudgetForm", back_populates="updater", foreign_keys="BudgetForm.updated_by" +# ) +# created_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore +# "RfProjectReport", +# back_populates="creator", +# foreign_keys="RfProjectReport.created_by", +# ) +# updated_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore +# "RfProjectReport", +# back_populates="updater", +# foreign_keys="RfProjectReport.updated_by", +# ) +# audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore +# "AuditLog", back_populates="user" +# ) + user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="user") + org_units: Mapped[list["OrgUnit"]] = relationship("OrgUnit", secondary="v3.user_org", viewonly=True) diff --git a/api/src/db/models/audit_log.py b/api/src/db/models/audit_log.py new file mode 100644 index 0000000..89682da --- /dev/null +++ b/api/src/db/models/audit_log.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + DateTime, + ForeignKey, + Index, + JSON, + String, + func, + text, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class AuditLog(Base): + __tablename__ = "audit_log" + __table_args__ = ( + Index("ix_v3_audit_log_dt", text("event_dt DESC")), + Index("ix_v3_audit_log_event", "event"), + Index( + "ix_v3_audit_log_form", + "form_id", + postgresql_where="form_id IS NOT NULL", + ), + Index("ix_v3_audit_log_user", "user_id"), + Index("ix_v4_audit_log_dt", text("event_dt DESC")), + Index("ix_v4_audit_log_event", "event"), + Index( + "ix_v4_audit_log_form", + "form_id", + postgresql_where="form_id IS NOT NULL", + ), + Index("ix_v4_audit_log_user", "user_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("v3.app_user.id", ondelete="SET NULL") + ) + org_unit_id: Mapped[int | None] = mapped_column( + ForeignKey("v3.org_unit.id", ondelete="SET NULL") + ) + task_id: Mapped[int | None] = mapped_column() + form_id: Mapped[int | None] = mapped_column() + event_dt: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + event: Mapped[str] = mapped_column(String) + event_type: Mapped[str] = mapped_column(String) + event_data: Mapped[dict | None] = mapped_column(JSON) + +# user: Mapped["AppUser | None"] = relationship( +# "AppUser", back_populates="audit_logs" +# ) +# org_unit: Mapped["OrgUnit | None"] = relationship( +# "OrgUnit", back_populates="audit_logs" +# ) diff --git a/api/src/db/models/booking.py b/api/src/db/models/booking.py new file mode 100644 index 0000000..af2fc32 --- /dev/null +++ b/api/src/db/models/booking.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, SmallInteger, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Booking(Base): + __tablename__ = "booking" + __table_args__ = ( + CheckConstraint("quarter BETWEEN 1 AND 4", name="ck_v3_booking_quarter"), + CheckConstraint("source IN ('CKK','CONTRACT')", name="ck_v3_booking_source"), + Index("ix_v3_booking_line", "line_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id")) + quarter: Mapped[int] = mapped_column(SmallInteger) + source: Mapped[str] = mapped_column(String) + booked_amount_ckk: Mapped[float | None] = mapped_column(Numeric) + booked_amount_contract: Mapped[float | None] = mapped_column(Numeric) + +# line = relationship("BudgetLine") diff --git a/api/src/db/models/budget_form.py b/api/src/db/models/budget_form.py new file mode 100644 index 0000000..3800da9 --- /dev/null +++ b/api/src/db/models/budget_form.py @@ -0,0 +1,56 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.models.form_type import FormTypeEnum +from src.db.base import Base + + +class BudgetForm(Base): + __tablename__ = "budget_form" + __table_args__ = {"schema": "v3"} + + id: Mapped[int] = mapped_column(primary_key=True) + form_type_code: Mapped[FormTypeEnum] = mapped_column( #type: ignore + String, ForeignKey("v3.form_type.code") + ) + year: Mapped[int | None] = mapped_column(Integer) + created_by: Mapped[int | None] = mapped_column( + Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_created_by") + ) + created_at: Mapped[datetime | None] = mapped_column( + DateTime, server_default=func.now() + ) + updated_by: Mapped[int | None] = mapped_column( + Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_updated_by") + ) + updated_at: Mapped[datetime | None] = mapped_column(DateTime) + org_unit_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit") + ) + + form_type: Mapped["FormType"] = relationship( + "FormType", #back_populates="budget_forms" + ) +# creator: Mapped[typing.Optional["AppUser"]] = relationship( +# "AppUser", back_populates="created_budget_forms", foreign_keys=[created_by] +# ) +# updater: Mapped[typing.Optional["AppUser"]] = relationship( +# "AppUser", back_populates="updated_budget_forms", foreign_keys=[updated_by] +# ) +# org_unit: Mapped[typing.Optional["OrgUnit"]] = relationship( +# "OrgUnit", back_populates="budget_forms" +# ) +# budget_lines: Mapped[list["BudgetLine"]] = relationship( +# "BudgetLine", back_populates="budget_form" +# ) +# fixed_asset_reports: Mapped[list["FixedAssetReport"]] = relationship( +# "FixedAssetReport", back_populates="budget_form" +# ) +# form_phases: Mapped[list["FormPhase"]] = relationship( +# "FormPhase", back_populates="budget_form" +# ) +# form_limits: Mapped[list["FormLimit"]] = relationship( +# "FormLimit", back_populates="budget_form" +# ) diff --git a/api/src/db/models/budget_line.py b/api/src/db/models/budget_line.py new file mode 100644 index 0000000..7ff54ef --- /dev/null +++ b/api/src/db/models/budget_line.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class BudgetLine(Base): + __tablename__ = "budget_line" + __table_args__ = ( + Index("ix_v3_budget_line_form_item", "budget_form_id", "expense_item_id"), + Index("ix_v3_budget_line_vsp", "vsp_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id")) + expense_item_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id")) + name: Mapped[str | None] = mapped_column(String) + internal_order: Mapped[str | None] = mapped_column(String) + vsp_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.vsp.id")) + project_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id")) + justification: Mapped[str | None] = mapped_column(String) + created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id")) + created_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) + updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id")) + updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) + direction: Mapped[str | None] = mapped_column(String) + +# budget_form = relationship("BudgetForm", back_populates="budget_lines") +# expense_item = relationship("ExpenseItem", back_populates="budget_lines") +# vsp = relationship("Vsp", back_populates="budget_lines") +# project = relationship("Project", back_populates="budget_lines") + +# ckk = relationship("Ckk", uselist=False) +# collegial_approval = relationship("CollegialApproval", uselist=False) +# contract_detail = relationship("ContractDetail", uselist=False) +# contract_summary = relationship("ContractSummary", uselist=False) +# plan = relationship("Plan", uselist=False) +# reserve = relationship("Reserve", uselist=False) +# allocation = relationship("Allocation", uselist=False) + +# quarters: Mapped[list["BudgetLineQuarter"]] = relationship("BudgetLineQuarter", back_populates="line") # type: ignore +# sequestration_records: Mapped[list["Sequestration"]] = relationship("Sequestration", back_populates="budget_line") # type: ignore +# booking_records: Mapped[list["Booking"]] = relationship("Booking", back_populates="budget_line") # type: ignore +# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="budget_line") # type: ignore +# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="budget_line") # type: ignore +# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="budget_line") # type: ignore diff --git a/api/src/db/models/budget_line_quarter.py b/api/src/db/models/budget_line_quarter.py new file mode 100644 index 0000000..8a6f4c5 --- /dev/null +++ b/api/src/db/models/budget_line_quarter.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from datetime import date + +from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, SmallInteger, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class BudgetLineQuarter(Base): + __tablename__ = "budget_line_quarter" + __table_args__ = ( + CheckConstraint("quarter >= 1 AND quarter <= 4", name="chk_budget_line_quarter_quarter"), + {"schema": "v3"}, + ) + + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True) + quarter: Mapped[int] = mapped_column(SmallInteger, primary_key=True) + adj_current: Mapped[float | None] = mapped_column(Numeric) + adj_ssp: Mapped[float | None] = mapped_column(Numeric) + adj_rf: Mapped[float | None] = mapped_column(Numeric) + adj_reserve: Mapped[float | None] = mapped_column(Numeric) + adj_comment: Mapped[str | None] = mapped_column(String) + target_change: Mapped[float | None] = mapped_column(Numeric) + base_plan_correction: Mapped[float | None] = mapped_column(Numeric) + base_plan_correction_comment: Mapped[str | None] = mapped_column(String) + payment_date: Mapped[date | None] = mapped_column(Date) + payment_amount: Mapped[float | None] = mapped_column(Numeric) + payment_amount_ho: Mapped[float | None] = mapped_column(Numeric) + payment_amount_rf: Mapped[float | None] = mapped_column(Numeric) + payment_comment: Mapped[str | None] = mapped_column(String) + payment_act: Mapped[str | None] = mapped_column(String) + actual_m1: Mapped[float | None] = mapped_column(Numeric) + actual_m2: Mapped[float | None] = mapped_column(Numeric) + actual_m3: Mapped[float | None] = mapped_column(Numeric) + actual_spod: Mapped[float | None] = mapped_column(Numeric) + transfer_to_q2: Mapped[float | None] = mapped_column(Numeric) + transfer_to_q3: Mapped[float | None] = mapped_column(Numeric) + transfer_to_q4: Mapped[float | None] = mapped_column(Numeric) + transfer_to_economy: Mapped[float | None] = mapped_column(Numeric) + transfer_delay_acts: Mapped[float | None] = mapped_column(Numeric) + transfer_delay_procurement: Mapped[float | None] = mapped_column(Numeric) + transfer_economy_rf: Mapped[float | None] = mapped_column(Numeric) + transfer_next_comment: Mapped[str | None] = mapped_column(String) + transfer_far_comment: Mapped[str | None] = mapped_column(String) + booking_amount: Mapped[float | None] = mapped_column(Numeric) + plan_revision_eco_change: Mapped[float | None] = mapped_column(Numeric) + plan_revision_item_adj: Mapped[float | None] = mapped_column(Numeric) + plan_revision_increase: Mapped[float | None] = mapped_column(Numeric) + plan_revision_sequester: Mapped[float | None] = mapped_column(Numeric) + plan_revision_comment: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine", back_populates="quarters") diff --git a/api/src/db/models/ckk.py b/api/src/db/models/ckk.py new file mode 100644 index 0000000..b0123ec --- /dev/null +++ b/api/src/db/models/ckk.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Ckk(Base): + __tablename__ = "ckk" + __table_args__ = {"schema": "v3"} + + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True) + ceiling_amount: Mapped[float | None] = mapped_column(Numeric) + expenses_q1: Mapped[float | None] = mapped_column(Numeric) + expenses_q2: Mapped[float | None] = mapped_column(Numeric) + expenses_q3: Mapped[float | None] = mapped_column(Numeric) + expenses_q4: Mapped[float | None] = mapped_column(Numeric) + expenses_next_year_q1: Mapped[float | None] = mapped_column(Numeric) + expenses_next_year_q2: Mapped[float | None] = mapped_column(Numeric) + expenses_next_year_q3: Mapped[float | None] = mapped_column(Numeric) + expenses_next_year_q4: Mapped[float | None] = mapped_column(Numeric) + rf_schedule: Mapped[str | None] = mapped_column(String) + delivery_deadline: Mapped[str | None] = mapped_column(String) + procurement_plan: Mapped[str | None] = mapped_column(String) + procurement_method: Mapped[str | None] = mapped_column(String) + comment: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine", uselist=False) diff --git a/api/src/db/models/collegial_approval.py b/api/src/db/models/collegial_approval.py new file mode 100644 index 0000000..6a09345 --- /dev/null +++ b/api/src/db/models/collegial_approval.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class CollegialApproval(Base): + __tablename__ = "collegial_approval" + __table_args__ = {"schema": "v3"} + + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True) + approved_amount: Mapped[float | None] = mapped_column(Numeric) + protocol_reference: Mapped[str | None] = mapped_column(String) + note: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine", uselist=False) diff --git a/api/src/db/models/contract_detail.py b/api/src/db/models/contract_detail.py new file mode 100644 index 0000000..51c183b --- /dev/null +++ b/api/src/db/models/contract_detail.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class ContractDetail(Base): + __tablename__ = "contract_detail" + __table_args__ = {"schema": "v3"} + + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True) + counterparty: Mapped[str | None] = mapped_column(String) + reference: Mapped[str | None] = mapped_column(String) + addenda: Mapped[str | None] = mapped_column(String) + subject: Mapped[str | None] = mapped_column(String) + currency: Mapped[str | None] = mapped_column(String) + ceiling_amount: Mapped[float | None] = mapped_column(Numeric) + expenses_q1: Mapped[float | None] = mapped_column(Numeric) + expenses_q2: Mapped[float | None] = mapped_column(Numeric) + expenses_q3: Mapped[float | None] = mapped_column(Numeric) + expenses_q4: Mapped[float | None] = mapped_column(Numeric) + rf_schedule: Mapped[str | None] = mapped_column(String) + vat_rate: Mapped[str | None] = mapped_column(String) + exchange_rate: Mapped[float | None] = mapped_column(Numeric) + amount_foreign: Mapped[float | None] = mapped_column(Numeric) + deadline: Mapped[str | None] = mapped_column(String) + payment_scheme: Mapped[str | None] = mapped_column(String) + act: Mapped[str | None] = mapped_column(String) + comment: Mapped[str | None] = mapped_column(String) + contract_date: Mapped[str | None] = mapped_column(Date) + +# line = relationship("BudgetLine", uselist=False) diff --git a/api/src/db/models/contract_summary.py b/api/src/db/models/contract_summary.py new file mode 100644 index 0000000..7fda094 --- /dev/null +++ b/api/src/db/models/contract_summary.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class ContractSummary(Base): + __tablename__ = "contract_summary" + __table_args__ = {"schema": "v3"} + + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True) + total_amount: Mapped[float | None] = mapped_column(Numeric) + counterparty: Mapped[str | None] = mapped_column(String) + reference: Mapped[str | None] = mapped_column(String) + deadline: Mapped[str | None] = mapped_column(String) + comment: Mapped[str | None] = mapped_column(String) + future_payments_y1: Mapped[float | None] = mapped_column(Numeric) + future_payments_y2: Mapped[float | None] = mapped_column(Numeric) + other_ssp_amount: Mapped[float | None] = mapped_column(Numeric) + centralized_flag: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine", uselist=False) diff --git a/api/src/db/models/form_limit.py b/api/src/db/models/form_limit.py new file mode 100644 index 0000000..199981b --- /dev/null +++ b/api/src/db/models/form_limit.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from sqlalchemy import ForeignKey, Index, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class FormLimit(Base): + __tablename__ = "form_limit" + __table_args__ = ( + Index("ix_v3_form_limit_form", "budget_form_id"), + {"schema": "v3"}, + ) + + budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True) + template_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.limit_template.id", ondelete="CASCADE"), primary_key=True) + qty_q1: Mapped[int | None] = mapped_column(Integer) + qty_q2: Mapped[int | None] = mapped_column(Integer) + qty_q3: Mapped[int | None] = mapped_column(Integer) + qty_q4: Mapped[int | None] = mapped_column(Integer) + comment: Mapped[str | None] = mapped_column(String) + +# budget_form = relationship("BudgetForm") +# template = relationship("LimitTemplate") diff --git a/api/src/db/models/form_type.py b/api/src/db/models/form_type.py new file mode 100644 index 0000000..359eb8e --- /dev/null +++ b/api/src/db/models/form_type.py @@ -0,0 +1,63 @@ +import enum + +from sqlalchemy import CheckConstraint, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class FormTypeEnum(str, enum.Enum): + FORM_1 = "FORM_1" + FORM_2 = "FORM_2" + FORM_3 = "FORM_3" + FORM_4 = "FORM_4" + + +class FormType(Base): + __tablename__ = "form_type" + __table_args__ = ( + CheckConstraint("scope IN ('GO','PD','RF')", name="ck_form_type_scope"), + CheckConstraint( + "storage_entity IN ('BUDGET_FORM','RF_PROJECT_REPORT')", + name="ck_form_type_storage_entity", + ), + {"schema": "v3"}, + ) + + code: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str | None] = mapped_column(String) + scope: Mapped[str | None] = mapped_column(String) + storage_entity: Mapped[str | None] = mapped_column(String) + + + SHEETS_BY_FORM_TYPE = { + FormTypeEnum.FORM_1: ["AHR", "CAP", "OPER", "SMETA"], + FormTypeEnum.FORM_2: ["AHR", "CAP", "OPER", "AHR_LIMIT", "AHR_RENT", + "AHR_UTILITY", "AHR_SECURITY", "OTCH9F", "SMETA"], + FormTypeEnum.FORM_4: ["AHR", "CAP", "OPER", "STRUCTURE", "SMETA"], + } + SECTIONS_BY_FORM_TYPE = { + FormTypeEnum.FORM_1: ["plan", "contract_summary", "allocation", "sequestration", "reserve", "approved", "collegial", "ckk", "contract_detail", "q1", "q2", "q3", "q4", "totals"], + FormTypeEnum.FORM_2: ["plan", "seq_dfip", "seq_ssp", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals"], + FormTypeEnum.FORM_4: ["plan", "seq_dfip", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals", "contract_summary", "allocation", "reserve", "collegial", "ckk"], + } + + @property + def sheet_list(self): + return self.SHEETS_BY_FORM_TYPE.get(self.code, []) + + @property + def section_list(self): + return self.SECTIONS_BY_FORM_TYPE.get(self.code, []) + +# budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore +# "BudgetForm", back_populates="form_type" +# ) +# expense_item_form_types: Mapped[list["ExpenseItemFormType"]] = relationship( # type: ignore +# "ExpenseItemFormType", back_populates="form_type" +# ) +# phase_templates: Mapped[list["PhaseTemplate"]] = relationship( # type: ignore +# "PhaseTemplate", back_populates="form_type" +# ) + + diff --git a/api/src/db/models/limit_template.py b/api/src/db/models/limit_template.py new file mode 100644 index 0000000..99c8006 --- /dev/null +++ b/api/src/db/models/limit_template.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class LimitTemplate(Base): + __tablename__ = "limit_template" + __table_args__ = ( + CheckConstraint( + "row_type IN ('SECTION','GROUP','LEAF')", + name="ck_v3_limit_template_row_type", + ), + Index("ix_v3_limit_template_parent", "parent_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.limit_template.id")) + sort_order: Mapped[int] = mapped_column(Integer) + row_type: Mapped[str] = mapped_column(String) + section_no: Mapped[str | None] = mapped_column(String) + expense_item_code: Mapped[str | None] = mapped_column(String) + name: Mapped[str] = mapped_column(String) + unit: Mapped[str | None] = mapped_column(String) + limit_with_vat: Mapped[float | None] = mapped_column(Numeric) + limit_without_vat: Mapped[float | None] = mapped_column(Numeric) + +# parent = relationship("LimitTemplate", remote_side="LimitTemplate.id") +# children: Mapped[list[LimitTemplate]] = relationship("LimitTemplate") +# form_limits: Mapped[list[FormLimit]] = relationship("FormLimit") diff --git a/api/src/db/models/org_unit.py b/api/src/db/models/org_unit.py new file mode 100644 index 0000000..ac41166 --- /dev/null +++ b/api/src/db/models/org_unit.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from sqlalchemy import Boolean, Index, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class OrgUnit(Base): + __tablename__ = "org_unit" + __table_args__ = ( + Index( + "ix_v4_org_unit_active", + "is_active", + postgresql_where="is_active", + ), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_ssp: Mapped[bool] = mapped_column(Boolean) + +# budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore +# "BudgetForm", back_populates="org_unit" +# ) +# audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore +# "AuditLog", back_populates="org_unit" +# ) +# projects: Mapped[list["Project"]] = relationship( # type: ignore +# "Project", back_populates="org_unit" +# ) +# vsps: Mapped[list["Vsp"]] = relationship("Vsp", back_populates="org_unit") # type: ignore + user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="org_unit") diff --git a/api/src/db/models/phase_template.py b/api/src/db/models/phase_template.py new file mode 100644 index 0000000..d69d7aa --- /dev/null +++ b/api/src/db/models/phase_template.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class PhaseTemplate(Base): + __tablename__ = "phase_template" + __table_args__ = ( + CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_phase_template_columns"), + CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_phase_template_no_admin"), + CheckConstraint(text("closes_at > opens_at"), name="chk_v4_phase_template_window"), + {"schema": "v3"}, + ) + + form_type: Mapped[str] = mapped_column(String, ForeignKey("v3.form_type.code"), primary_key=True) + sheet: Mapped[str] = mapped_column(String, primary_key=True) + phase_code: Mapped[str] = mapped_column(String, primary_key=True) + role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code")) + column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text)) + opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + +# form_type_rel = relationship("FormType", foreign_keys=[form_type]) +# role_rel = relationship("Role", foreign_keys=[role], back_populates="phase_templates") diff --git a/api/src/db/models/plan.py b/api/src/db/models/plan.py new file mode 100644 index 0000000..ba120bf --- /dev/null +++ b/api/src/db/models/plan.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Plan(Base): + __tablename__ = "plan" + __table_args__ = {"schema": "v3"} + + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True) + plan_q1: Mapped[float | None] = mapped_column(Numeric) + plan_q2: Mapped[float | None] = mapped_column(Numeric) + plan_q3: Mapped[float | None] = mapped_column(Numeric) + plan_q4: Mapped[float | None] = mapped_column(Numeric) + comment: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine", uselist=False) diff --git a/api/src/db/models/project.py b/api/src/db/models/project.py new file mode 100644 index 0000000..42ffdde --- /dev/null +++ b/api/src/db/models/project.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Project(Base): + __tablename__ = "project" + __table_args__ = ( + CheckConstraint("level IN ('program', 'project')", name="project_level_check"), + CheckConstraint( + "placement_type IS NULL OR placement_type IN ('own', 'rent', 'other')", + name="project_placement_type_check", + ), + CheckConstraint( + "project_type IS NULL OR project_type IN ('current', 'development')", + name="project_project_type_check", + ), + CheckConstraint( + "vsp_format IS NULL OR vsp_format IN ('office', 'standalone', 'atm', 'other')", + name="project_vsp_format_check", + ), + CheckConstraint( + text("name IS NOT NULL AND name <> ''"), + name="chk_v3_project_name", + ), + Index("ix_v3_project_ssp", "org_unit_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str | None] = mapped_column(String) + level: Mapped[str | None] = mapped_column(String) + parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id")) + project_type: Mapped[str | None] = mapped_column(String) + vsp_format: Mapped[str | None] = mapped_column(String) + placement_type: Mapped[str | None] = mapped_column(String) + object_address: Mapped[str | None] = mapped_column(String) + staff_count: Mapped[int | None] = mapped_column(Integer) + total_area: Mapped[float | None] = mapped_column(Numeric) + org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id")) + +# parent = relationship("Project", remote_side="Project.id", back_populates="children") +# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore +# org_unit = relationship("OrgUnit", back_populates="projects") +# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="project") # type: ignore +# rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore diff --git a/api/src/db/models/rent_detail.py b/api/src/db/models/rent_detail.py new file mode 100644 index 0000000..9b1fda5 --- /dev/null +++ b/api/src/db/models/rent_detail.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class RentDetail(Base): + __tablename__ = "rent_detail" + __table_args__ = ( + Index("ix_v3_rent_detail_line", "line_id"), + Index("ix_v3_rent_detail_vsp", "vsp_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id")) + vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id")) + contract_number: Mapped[str | None] = mapped_column(String) + contract_end_date: Mapped[date | None] = mapped_column(Date) + plan_q1: Mapped[float | None] = mapped_column(Numeric) + plan_q2: Mapped[float | None] = mapped_column(Numeric) + plan_q3: Mapped[float | None] = mapped_column(Numeric) + plan_q4: Mapped[float | None] = mapped_column(Numeric) + actual_jan: Mapped[float | None] = mapped_column(Numeric) + actual_feb: Mapped[float | None] = mapped_column(Numeric) + actual_mar: Mapped[float | None] = mapped_column(Numeric) + actual_apr: Mapped[float | None] = mapped_column(Numeric) + actual_may: Mapped[float | None] = mapped_column(Numeric) + actual_jun: Mapped[float | None] = mapped_column(Numeric) + actual_jul: Mapped[float | None] = mapped_column(Numeric) + actual_aug: Mapped[float | None] = mapped_column(Numeric) + actual_sep: Mapped[float | None] = mapped_column(Numeric) + actual_oct: Mapped[float | None] = mapped_column(Numeric) + actual_nov: Mapped[float | None] = mapped_column(Numeric) + actual_dec: Mapped[float | None] = mapped_column(Numeric) + comment: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine") +# vsp = relationship("Vsp") diff --git a/api/src/db/models/reserve.py b/api/src/db/models/reserve.py new file mode 100644 index 0000000..ed074da --- /dev/null +++ b/api/src/db/models/reserve.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Reserve(Base): + __tablename__ = "reserve" + __table_args__ = {"schema": "v3"} + + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True) + amount_q1: Mapped[float | None] = mapped_column(Numeric) + amount_q2: Mapped[float | None] = mapped_column(Numeric) + amount_q3: Mapped[float | None] = mapped_column(Numeric) + amount_q4: Mapped[float | None] = mapped_column(Numeric) + justification: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine", uselist=False) diff --git a/api/src/db/models/rf_project_report.py b/api/src/db/models/rf_project_report.py new file mode 100644 index 0000000..572686a --- /dev/null +++ b/api/src/db/models/rf_project_report.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class RfProjectReport(Base): + __tablename__ = "rf_project_report" + __table_args__ = ( + UniqueConstraint("project_id", "year", "report_type", name="uq_v3_rf_project_report"), + Index("ix_v3_rf_project_report_proj_year", "project_id", "year"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id")) + year: Mapped[int] = mapped_column(Integer) + report_type: Mapped[str] = mapped_column(String, CheckConstraint("report_type IN ('LIMIT','CURRENT_EXPENSES')")) + created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id")) + created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now()) + updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id")) + updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now()) + +# project: Mapped[Project] = relationship("Project", back_populates="rf_project_reports") +# creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by]) +# updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by]) +# lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report") diff --git a/api/src/db/models/rf_project_report_line.py b/api/src/db/models/rf_project_report_line.py new file mode 100644 index 0000000..71b74ef --- /dev/null +++ b/api/src/db/models/rf_project_report_line.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class RfProjectReportLine(Base): + __tablename__ = "rf_project_report_line" + __table_args__ = ( + UniqueConstraint( + "rf_project_report_id", "expense_item_id", name="uq_v3_rf_project_report_line" + ), + Index("ix_v3_rf_project_report_line_rep", "rf_project_report_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + rf_project_report_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_report.id")) + expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id")) + +# report: Mapped[RfProjectReport] = relationship("RfProjectReport", back_populates="lines") +# expense_item: Mapped[ExpenseItem] = relationship("ExpenseItem") +# quarters: Mapped[list[RfProjectReportQuarter]] = relationship( +# "RfProjectReportQuarter", back_populates="line" +# ) diff --git a/api/src/db/models/rf_project_report_quarter.py b/api/src/db/models/rf_project_report_quarter.py new file mode 100644 index 0000000..ed6560b --- /dev/null +++ b/api/src/db/models/rf_project_report_quarter.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, ForeignKey, Integer, Numeric, SmallInteger +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class RfProjectReportQuarter(Base): + __tablename__ = "rf_project_report_quarter" + __table_args__ = {"schema": "v3"} + + rf_project_report_line_id: Mapped[int] = mapped_column( + Integer, ForeignKey("v3.rf_project_report_line.id"), primary_key=True + ) + quarter: Mapped[int] = mapped_column( + SmallInteger, + CheckConstraint("quarter >= 1 AND quarter <= 4"), + primary_key=True, + ) + adj_by_items: Mapped[float | None] = mapped_column(Numeric) + adj_increase: Mapped[float | None] = mapped_column(Numeric) + actual_m1: Mapped[float | None] = mapped_column(Numeric) + actual_m2: Mapped[float | None] = mapped_column(Numeric) + actual_m3: Mapped[float | None] = mapped_column(Numeric) + actual_spod: Mapped[float | None] = mapped_column(Numeric) + +# line: Mapped[RfProjectReportLine] = relationship( +# "RfProjectReportLine", back_populates="quarters" +# ) diff --git a/api/src/db/models/role.py b/api/src/db/models/role.py new file mode 100644 index 0000000..4e83596 --- /dev/null +++ b/api/src/db/models/role.py @@ -0,0 +1,31 @@ +import enum + +from sqlalchemy import String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class UserRoleEnum(int, enum.Enum): + ADMIN = 1 + EXECUTOR_DFIP = 2 + EXECUTOR_RF = 3 + + +class Role(Base): + __tablename__ = "role" + __table_args__ = ( + UniqueConstraint("code", name="uq_role_code"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + code: Mapped[str] = mapped_column(String) + +# users: Mapped[list["AppUser"]] = relationship("AppUser", back_populates="role") # type: ignore +# form_phases: Mapped[list["FormPhase"]] = relationship( # type: ignore +# "FormPhase", back_populates="role" +# ) +# phase_templates: Mapped[list["PhaseTemplate"]] = relationship( # type: ignore +# "PhaseTemplate", back_populates="role" +# ) diff --git a/api/src/db/models/security_detail.py b/api/src/db/models/security_detail.py new file mode 100644 index 0000000..91b4449 --- /dev/null +++ b/api/src/db/models/security_detail.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class SecurityDetail(Base): + __tablename__ = "security_detail" + __table_args__ = ( + Index("ix_v3_security_detail_line", "line_id"), + Index("ix_v3_security_detail_vsp", "vsp_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id")) + vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id")) + contract_number: Mapped[str | None] = mapped_column(String) + contract_end_date: Mapped[date | None] = mapped_column(Date) + plan_q1: Mapped[float | None] = mapped_column(Numeric) + plan_q2: Mapped[float | None] = mapped_column(Numeric) + plan_q3: Mapped[float | None] = mapped_column(Numeric) + plan_q4: Mapped[float | None] = mapped_column(Numeric) + actual_jan: Mapped[float | None] = mapped_column(Numeric) + actual_feb: Mapped[float | None] = mapped_column(Numeric) + actual_mar: Mapped[float | None] = mapped_column(Numeric) + actual_apr: Mapped[float | None] = mapped_column(Numeric) + actual_may: Mapped[float | None] = mapped_column(Numeric) + actual_jun: Mapped[float | None] = mapped_column(Numeric) + actual_jul: Mapped[float | None] = mapped_column(Numeric) + actual_aug: Mapped[float | None] = mapped_column(Numeric) + actual_sep: Mapped[float | None] = mapped_column(Numeric) + actual_oct: Mapped[float | None] = mapped_column(Numeric) + actual_nov: Mapped[float | None] = mapped_column(Numeric) + actual_dec: Mapped[float | None] = mapped_column(Numeric) + comment: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine") +# vsp = relationship("Vsp") diff --git a/api/src/db/models/sequestration.py b/api/src/db/models/sequestration.py new file mode 100644 index 0000000..59d9843 --- /dev/null +++ b/api/src/db/models/sequestration.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Sequestration(Base): + __tablename__ = "sequestration" + __table_args__ = ( + CheckConstraint("actor IN ('DFIP','SSP_GO')", name="ck_v3_sequestration_actor"), + UniqueConstraint("line_id", "actor", name="uq_v3_sequestration_line_actor"), + Index("ix_v3_sequestration_line_actor", "line_id", "actor"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id")) + actor: Mapped[str] = mapped_column(String) + adj_q1: Mapped[float | None] = mapped_column(Numeric) + adj_q2: Mapped[float | None] = mapped_column(Numeric) + adj_q3: Mapped[float | None] = mapped_column(Numeric) + adj_q4: Mapped[float | None] = mapped_column(Numeric) + justification: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine") diff --git a/api/src/db/models/user_org.py b/api/src/db/models/user_org.py new file mode 100644 index 0000000..86fe489 --- /dev/null +++ b/api/src/db/models/user_org.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class UserOrg(Base): + __tablename__ = "user_org" + __table_args__ = ( + UniqueConstraint("user_id", "org_unit_id"), + Index("ix_v4_user_org_org", "org_unit_id"), + Index("ix_v4_user_org_user", "user_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.app_user.id", ondelete="CASCADE")) + org_unit_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id", ondelete="CASCADE")) + + user: Mapped[AppUser] = relationship("AppUser", back_populates="user_orgs", lazy="selectin") + org_unit: Mapped[OrgUnit] = relationship("OrgUnit", back_populates="user_orgs", lazy="selectin") diff --git a/api/src/db/models/utility_detail.py b/api/src/db/models/utility_detail.py new file mode 100644 index 0000000..2b2dcef --- /dev/null +++ b/api/src/db/models/utility_detail.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class UtilityDetail(Base): + __tablename__ = "utility_detail" + __table_args__ = ( + Index("ix_v3_utility_detail_line", "line_id"), + Index("ix_v3_utility_detail_vsp", "vsp_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id")) + vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id")) + contract_number: Mapped[str | None] = mapped_column(String) + contract_end_date: Mapped[date | None] = mapped_column(Date) + plan_q1: Mapped[float | None] = mapped_column(Numeric) + plan_q2: Mapped[float | None] = mapped_column(Numeric) + plan_q3: Mapped[float | None] = mapped_column(Numeric) + plan_q4: Mapped[float | None] = mapped_column(Numeric) + actual_jan: Mapped[float | None] = mapped_column(Numeric) + actual_feb: Mapped[float | None] = mapped_column(Numeric) + actual_mar: Mapped[float | None] = mapped_column(Numeric) + actual_apr: Mapped[float | None] = mapped_column(Numeric) + actual_may: Mapped[float | None] = mapped_column(Numeric) + actual_jun: Mapped[float | None] = mapped_column(Numeric) + actual_jul: Mapped[float | None] = mapped_column(Numeric) + actual_aug: Mapped[float | None] = mapped_column(Numeric) + actual_sep: Mapped[float | None] = mapped_column(Numeric) + actual_oct: Mapped[float | None] = mapped_column(Numeric) + actual_nov: Mapped[float | None] = mapped_column(Numeric) + actual_dec: Mapped[float | None] = mapped_column(Numeric) + comment: Mapped[str | None] = mapped_column(String) + +# line = relationship("BudgetLine") +# vsp = relationship("Vsp") diff --git a/api/src/db/models/vsp.py b/api/src/db/models/vsp.py new file mode 100644 index 0000000..24d64e4 --- /dev/null +++ b/api/src/db/models/vsp.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Date, ForeignKey, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Vsp(Base): + __tablename__ = "vsp" + __table_args__ = {"schema": "v3"} + + id: Mapped[int] = mapped_column(primary_key=True) + branch_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id")) + reg_number: Mapped[str | None] = mapped_column(String) + address: Mapped[str | None] = mapped_column(String) + format: Mapped[str | None] = mapped_column(String) + opened_at: Mapped[date | None] = mapped_column(Date) + placement_type: Mapped[str | None] = mapped_column(String) + staff_count: Mapped[int | None] = mapped_column(Integer) + total_area: Mapped[float | None] = mapped_column(Numeric) + closed_at: Mapped[date | None] = mapped_column(Date) + +# org_unit = relationship("OrgUnit", back_populates="vsps") +# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="vsp") # type: ignore +# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="vsp") +# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="vsp") +# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="vsp") diff --git a/api/src/db/session.py b/api/src/db/session.py index 515cb09..82d8cc1 100644 --- a/api/src/db/session.py +++ b/api/src/db/session.py @@ -5,6 +5,8 @@ from alembic import command from alembic.config import Config from src.core.config import settings +from typing import AsyncGenerator + from src.db.base import SessionLocal @@ -12,6 +14,7 @@ async def get_db() -> AsyncGenerator: db = SessionLocal() try: yield db + # await db.commit() finally: await db.close() diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 8baedfc..7e43de9 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -1,12 +1,24 @@ from datetime import datetime -from typing import Optional +import enum +from typing import Generic, Optional, TypeVar from pydantic import BaseModel, ConfigDict, EmailStr, Field +T = TypeVar("T") + class ResponseBase(BaseModel): - success: Optional[bool] = Field(None) - message: Optional[str] = Field(None) + success: Optional[bool] = Field(True) + message: Optional[str] = None + + +class BaseSingleResponse(ResponseBase, Generic[T]): + result: Optional[T] = None + + +class BaseListResponse(ResponseBase, Generic[T]): + result: Optional[list[T]] = None + count: Optional[int] = None class UserBase(BaseModel): @@ -97,3 +109,40 @@ 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 BudgetFormResponse(BaseModel): + id: int + created_at: datetime + created_by: int | None + updated_at: datetime + form_type_code: FormTypeSchemaEnum + year: int + org_unit_id: int + + class Config: + from_attributes = True + + +class SheetFormTypeResponse(BaseModel): + form_type: FormTypeSchemaEnum + sheets: list[str] + + +class DirectionSchemaEnum(str, enum.Enum): + SUPPORT = "Support" + DEVELOPMENT = "Development" + + +class SheetResponse(BaseModel): + row_type: str + depth: int + data: dict + sort_order: int | None = None \ No newline at end of file diff --git a/api/src/repository/budget_form_repository.py b/api/src/repository/budget_form_repository.py new file mode 100644 index 0000000..7436b97 --- /dev/null +++ b/api/src/repository/budget_form_repository.py @@ -0,0 +1,73 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.budget_form import BudgetForm +from sqlalchemy.orm import joinedload + + + +class BudgetFormRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_list( + self, + offset: int | None = None, + limit: int | None = None, + org_unit: int | list[int] | None = None, + with_count: bool = False, + ) -> 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).order_by(BudgetForm.id) + + if offset is not None: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + + if with_count: + result = (await self.db.execute(query)).all() + if result: + return result[0][0], [res[1] for res in result] + else: + if query.whereclause is not None: + count_query = select(func.count(BudgetForm.id)).where( + query.whereclause + ) + return (await self.db.execute(count_query)).scalar(), [] + else: + return (await self.db.execute(query)).scalars().all() + + async def get( + self, + budget_form_id: int, + org_unit: int | list[int] | None = None, + load_form_type: bool = False, + ) -> BudgetForm | None: + query = select(BudgetForm) + + where = [BudgetForm.id == budget_form_id] + if org_unit is not None: + if isinstance(org_unit, int): + where.append(BudgetForm.org_unit_id == org_unit) + else: + where.append(BudgetForm.org_unit_id.in_(org_unit)) + + query = query.where(*where) + + if load_form_type: + query = query.options( + joinedload(BudgetForm.form_type) + ) + return (await self.db.execute(query)).scalars().first() + \ No newline at end of file diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py new file mode 100644 index 0000000..0b03d9c --- /dev/null +++ b/api/src/repository/project_repository.py @@ -0,0 +1,8 @@ + +from sqlalchemy.ext.asyncio import AsyncSession + + +class ProjectRepository: + def __init__(self, db: AsyncSession): + self.db = db + diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py new file mode 100644 index 0000000..696cefe --- /dev/null +++ b/api/src/repository/sheet_repository.py @@ -0,0 +1,69 @@ +import enum +import itertools + +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, + sections: list[str] | None = None, + ) -> list[tuple]: + if any( + [ + not isinstance(form_id, int), + sheet not in ACCEPTABLE_SHEETS, + direction not in [s.value for s in DirectionEnum], + sections and (set(sections) - ACCEPTABLE_SECTIONS), + ] + ): + raise SheetValidationError("Некорректные данные") + + sections_str = 'NULL' + if sections: + sections_str = ",".join([f"'{section}'" for section in sections]) + sections_str = f"ARRAY[{sections_str}]" + if direction: + func_query = "v3.v_form_view(%s, '%s', %s, '%s')" % ( + form_id, sheet, sections_str, direction, + ) + else: + func_query = "v3.v_form_view(%s, '%s', %s)" % ( + form_id, sheet, sections_str, + ) + result = ( + await self.db.execute( + text( + f""" + SELECT row_type, depth, sort_order, data + FROM {func_query} + """ + ) + ) + ).all() + return result \ No newline at end of file diff --git a/api/src/repository/user_repository.py b/api/src/repository/user_repository.py index aaa26f4..f497a69 100644 --- a/api/src/repository/user_repository.py +++ b/api/src/repository/user_repository.py @@ -1,64 +1,85 @@ from typing import Optional -from sqlalchemy import delete, select, update +from sqlalchemy import delete, select, text, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from src.db.models.org_unit import OrgUnit +from src.db.models.role import Role +from src.db.models.user_org import UserOrg +from src.db.models.app_user import AppUser from src.core.security import get_password_hash, verify_password -from src.domain.models import Roles, SSP, UserSSPLink, Users +# from src.domain.models import Role, OrgUnit, UserOrg, AppUser class UserRepository: def __init__(self, db: AsyncSession): self.db = db - async def get(self, user_id: int) -> Optional[Users]: + async def get(self, user_id: int, load_orgs: bool = False) -> Optional[AppUser]: + query = select(AppUser).where(AppUser.id == user_id) + if load_orgs: + query = query.options( + selectinload(AppUser.org_units), + ) + return ( ( await self.db.execute( - select(Users).where(Users.id == user_id).limit(1) + query.limit(1) ) ) .scalars() .first() ) - async def get_by_email(self, email: str) -> Optional[Users]: + async def get_by_email(self, email: str) -> Optional[AppUser]: return ( ( await self.db.execute( - select(Users).where(Users.email == email).limit(1) + select(AppUser).where(AppUser.email == email).limit(1) ) ) .scalars() .first() ) - async def get_by_username(self, username: str) -> Optional[Users]: + async def get_by_username(self, username: str) -> Optional[AppUser]: return ( ( await self.db.execute( - select(Users).where(Users.username == username).limit(1) + select(AppUser).where(AppUser.username == username).limit(1) ) ) .scalars() .first() ) - async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]: + async def set_app_user_id(self, user_id: int) -> None: + if isinstance(user_id, int): + await self.db.execute( + text( + f"SET LOCAL app.user_id = {user_id}" + ), + ) + + + async def get_list(self, skip: int = 0, limit: int = 100) -> list[AppUser]: query = ( - select(Users) - .where(Users.is_active.is_(True)) + select(AppUser) + .where(AppUser.is_active.is_(True)) .offset(skip) .limit(limit) - .order_by(Users.id) + .order_by(AppUser.id) ) return (await self.db.execute(query)).scalars().all() - async def create(self, user_data: dict) -> Users: + async def create(self, user_data: dict) -> AppUser: hashed_password = ( get_password_hash(user_data["password"]) if user_data.get("password") else None ) - db_user = Users( + db_user = AppUser( email=user_data["email"], username=user_data["username"], hashed_password=hashed_password, @@ -70,7 +91,7 @@ class UserRepository: await self.db.refresh(db_user) return db_user - async def update(self, user_id: int, user_data: dict) -> Optional[Users]: + async def update(self, user_id: int, user_data: dict) -> Optional[AppUser]: user = await self.get(user_id) if not user: return None @@ -85,12 +106,12 @@ class UserRepository: return user async def logical_delete(self, user_id: int) -> bool: - query = update(Users).where(Users.id == user_id).values(is_active=False) + query = update(AppUser).where(AppUser.id == user_id).values(is_active=False) result = await self.db.execute(query) await self.db.commit() return result.rowcount > 0 - async def authenticate(self, username: str, password: str) -> Optional[Users]: + async def authenticate(self, username: str, password: str) -> Optional[AppUser]: user = await self.get_by_username(username) if not user: return None @@ -98,26 +119,26 @@ class UserRepository: return None return user - async def authenticate_via_email(self, email: str) -> Optional[Users]: + async def authenticate_via_email(self, email: str) -> Optional[AppUser]: return await self.get_by_email(email) - async def get_roles(self) -> list[Roles]: - return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all() + async def get_roles(self) -> list[Role]: + return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all() - async def get_many_ssp(self, user_id: int) -> list[SSP]: - query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where( - UserSSPLink.user_id == user_id + async def get_many_ssp(self, user_id: int) -> list[OrgUnit]: + query = select(OrgUnit).join(UserOrg, UserOrg.ssp_id == OrgUnit.id).where( + UserOrg.user_id == user_id ) return (await self.db.execute(query)).scalars().all() async def get_many_ssp_ids(self, user_id: int) -> list[int]: - query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id) + query = select(UserOrg.ssp_id).where(UserOrg.user_id == user_id) return (await self.db.execute(query)).scalars().all() async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool: try: for ssp_id in ssp_ids: - link = UserSSPLink(user_id=user_id, ssp_id=ssp_id) + link = UserOrg(user_id=user_id, ssp_id=ssp_id) self.db.add(link) await self.db.commit() return True @@ -128,8 +149,8 @@ class UserRepository: async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool: try: for ssp_id in ssp_ids: - query = delete(UserSSPLink).where( - (UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id) + query = delete(UserOrg).where( + (UserOrg.user_id == user_id) & (UserOrg.ssp_id == ssp_id) ) await self.db.execute(query) await self.db.commit() @@ -140,7 +161,7 @@ class UserRepository: async def clear_many_ssp(self, user_id: int) -> bool: try: - query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id) + query = delete(UserOrg).where(UserOrg.user_id == user_id) await self.db.execute(query) await self.db.commit() return True diff --git a/api/src/services/access_control_service.py b/api/src/services/access_control_service.py new file mode 100644 index 0000000..4611faa --- /dev/null +++ b/api/src/services/access_control_service.py @@ -0,0 +1,46 @@ +import enum +import typing + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.app_user import AppUser +from src.db.models.budget_form import BudgetForm +from src.db.models.role import UserRoleEnum +from src.repository.user_repository import UserRepository + + +class AccessEnum(int, enum.Enum): + RESTRICT = 0 + VIEW = 1 + EDIT = 2 + + +class AccessControlService: + def __init__(self, db: AsyncSession, user: AppUser): + self.db = db + self.user_repo = UserRepository(db) + + async def _check_budget_form( + self, + user: AppUser, + budget_form: BudgetForm, + ) -> AccessEnum: + if user.role_id == UserRoleEnum.ADMIN: + return AccessEnum.EDIT + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + if budget_form.org_unit_id in [ou.id for ou in user.org_units]: + return AccessEnum.EDIT + return AccessEnum.RESTRICT + + async def check_object_access( + self, + user: AppUser, + object: typing.Any + ) -> AccessEnum: + if isinstance(object, BudgetForm): + return await self._check_budget_form(user=user,budget_form=object) + return AccessEnum.EDIT + diff --git a/api/src/services/budget_form_service.py b/api/src/services/budget_form_service.py new file mode 100644 index 0000000..353fa1e --- /dev/null +++ b/api/src/services/budget_form_service.py @@ -0,0 +1,61 @@ + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.repository.user_repository import UserRepository +from src.db.models.app_user import AppUser +from src.db.models.budget_form import BudgetForm +from src.db.models.role import UserRoleEnum +from src.repository.budget_form_repository import BudgetFormRepository + + + +class BudgetFormService: + def __init__(self, db: AsyncSession): + self.db = db + self.bf_repo = BudgetFormRepository(db) + self.user_repo = UserRepository(db) + + async def get_list( + self, + user: AppUser, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False + ) -> list[BudgetForm] | tuple[int, list[BudgetForm]]: + if user.role_id == UserRoleEnum.ADMIN: + return await self.bf_repo.get_list( + offset=offset, + limit=limit, + with_count=with_count, + ) + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + return await self.bf_repo.get_list( + offset=offset, + limit=limit, + org_unit=[ou.id for ou in user.org_units], + with_count=with_count, + ) + + async def get( + self, + budget_form_id: int, + user: AppUser, + load_form_type: bool = False, + ) -> list[BudgetForm] | tuple[int, list[BudgetForm]]: + if user.role_id == UserRoleEnum.ADMIN: + return await self.bf_repo.get( + budget_form_id=budget_form_id, + load_form_type=load_form_type, + ) + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + return await self.bf_repo.get( + budget_form_id=budget_form_id, + org_unit=[ou.id for ou in user.org_units], + load_form_type=load_form_type, + ) diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py new file mode 100644 index 0000000..b967972 --- /dev/null +++ b/api/src/services/project_service.py @@ -0,0 +1,29 @@ + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.app_user import AppUser +from src.db.models.project import Project +from src.db.models.role import UserRoleEnum +from src.repository.project_repository import ProjectRepository +from src.repository.user_repository import UserRepository + + + +class ProjectService: + def __init__(self, db: AsyncSession): + self.db = db + self.project_repo = ProjectRepository(db) + self.user_repo = UserRepository(db) + + async def get_list( + self, + user: AppUser, + year: int | None = None, + branch_id: int | None = None, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False, + with_org_unit: bool = False, + ) -> list[Project] | tuple[int, list[Project]]: + pass diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py new file mode 100644 index 0000000..fbbc55a --- /dev/null +++ b/api/src/services/sheet_service.py @@ -0,0 +1,27 @@ + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.budget_form import BudgetForm +from src.repository.sheet_repository import SheetRepository + + + +class SheetService: + def __init__(self, db: AsyncSession): + self.db = db + self.sheet_repo = SheetRepository(db) + + async def get( + self, + sheet: str, + direction: str, + sections: list[str] | None = None, + form_id: int | None = None, + form: BudgetForm | None = None + ) -> list[tuple]: + return await self.sheet_repo.get( + form_id=form_id, + sheet=sheet, + direction=direction, + sections=sections, + ) \ No newline at end of file diff --git a/api/src/services/user_service.py b/api/src/services/user_service.py index ae08a72..de3a9c6 100644 --- a/api/src/services/user_service.py +++ b/api/src/services/user_service.py @@ -1,12 +1,13 @@ from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.app_user import AppUser +from src.db.models.role import UserRoleEnum from src.core.errors import ( AccessDeniedException, UserNotFoundException, ValidationException, UsernameConflictException, ) -from src.domain.models import UserRole, Users from src.domain.schemas import UserCreate, UserUpdate from src.repository.user_repository import UserRepository @@ -16,23 +17,23 @@ class UserService: self.db = db self.user_repo = UserRepository(db) - async def get_all(self, current_user: Users, skip: int = 0, limit: int = 100): - if current_user.role_id != UserRole.ADMIN: + async def get_all(self, current_user: AppUser, skip: int = 0, limit: int = 100): + if current_user.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException() return await self.user_repo.get_list(skip=skip, limit=limit) - async def get(self, user_id: int, current_user: Users): - if current_user.role_id != UserRole.ADMIN: + async def get(self, user_id: int, current_user: AppUser): + if current_user.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException() return await self.user_repo.get(user_id) - async def create_user(self, user_data: UserCreate, creator: Users): - if creator.role_id != UserRole.ADMIN: + async def create_user(self, user_data: UserCreate, creator: AppUser): + if creator.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException() - role_id = user_data.role_id or UserRole.EXECUTOR_RF.value + role_id = user_data.role_id or UserRoleEnum.EXECUTOR_RF.value try: - role = UserRole(role_id) + role = UserRoleEnum(role_id) except ValueError: raise ValidationException("Роль не найдена", field="role_id") user_data.role_id = role.value @@ -43,8 +44,8 @@ class UserService: payload = user_data.model_dump(mode="json") return await self.user_repo.create(payload) - async def update_user(self, user_id: int, user_data: UserUpdate, user: Users): - if user.role_id != UserRole.ADMIN: + async def update_user(self, user_id: int, user_data: UserUpdate, user: AppUser): + if user.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException() target = await self.user_repo.get(user_id) @@ -57,7 +58,7 @@ class UserService: role = None if role_id is not None: try: - role = UserRole(role_id) + role = UserRoleEnum(role_id) except ValueError: raise ValidationException("Роль не найдена", field="role_id") @@ -76,43 +77,43 @@ class UserService: if role_id is not None and role is not None: had_many_ssp_role = previous_role_id in ( - UserRole.ADMIN.value, - UserRole.EXECUTOR_DFIP.value, + UserRoleEnum.ADMIN.value, + UserRoleEnum.EXECUTOR_DFIP.value, ) has_many_ssp_role_now = role.value in ( - UserRole.ADMIN.value, - UserRole.EXECUTOR_DFIP.value, + UserRoleEnum.ADMIN.value, + UserRoleEnum.EXECUTOR_DFIP.value, ) is_executor_to_dfip_transition = ( - previous_role_id == UserRole.EXECUTOR_RF.value - and role.value == UserRole.EXECUTOR_DFIP.value + previous_role_id == UserRoleEnum.EXECUTOR_RF.value + and role.value == UserRoleEnum.EXECUTOR_DFIP.value ) if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition: await self.user_repo.clear_many_ssp(user_id) return updated - async def delete_user(self, user_id: int, user: Users): - if user.role_id != UserRole.ADMIN: + async def delete_user(self, user_id: int, user: AppUser): + if user.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException() deleted = await self.user_repo.logical_delete(user_id) if not deleted: raise UserNotFoundException() - async def get_roles(self, current_user: Users): - if current_user.role_id != UserRole.ADMIN: + async def get_roles(self, current_user: AppUser): + if current_user.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException() return await self.user_repo.get_roles() - async def get_many_ssp(self, user_id: int, current_user: Users): - if current_user.role_id == UserRole.ADMIN: + async def get_many_ssp(self, user_id: int, current_user: AppUser): + if current_user.role_id == UserRoleEnum.ADMIN: return await self.user_repo.get_many_ssp(user_id) if current_user.id != user_id: raise AccessDeniedException() return await self.user_repo.get_many_ssp(user_id) - async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users): - if current_user.role_id != UserRole.ADMIN: + async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser): + if current_user.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException() user = await self.user_repo.get(user_id) @@ -121,9 +122,9 @@ class UserService: target_role_id = self._normalize_role_id(user.role_id) if target_role_id not in ( - UserRole.ADMIN.value, - UserRole.EXECUTOR_DFIP.value, - UserRole.EXECUTOR_RF.value, + UserRoleEnum.ADMIN.value, + UserRoleEnum.EXECUTOR_DFIP.value, + UserRoleEnum.EXECUTOR_RF.value, ): err = AccessDeniedException() err.description = ( @@ -132,7 +133,7 @@ class UserService: raise err curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id) - if target_role_id == UserRole.EXECUTOR_RF.value: + if target_role_id == UserRoleEnum.EXECUTOR_RF.value: merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids) if len(merged_ssp_ids) > 1: raise ValidationException( @@ -141,8 +142,8 @@ class UserService: new_ssp_ids = [ssp_id for ssp_id in ssp_ids if ssp_id not in curr_ssp_ids] return await self.user_repo.set_many_ssp(user.id, new_ssp_ids) - async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users): - if current_user.role_id != UserRole.ADMIN: + async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser): + if current_user.role_id != UserRoleEnum.ADMIN: raise AccessDeniedException() user = await self.user_repo.get(user_id) @@ -151,9 +152,9 @@ class UserService: target_role_id = self._normalize_role_id(user.role_id) if target_role_id not in ( - UserRole.ADMIN.value, - UserRole.EXECUTOR_DFIP.value, - UserRole.EXECUTOR_RF.value, + UserRoleEnum.ADMIN.value, + UserRoleEnum.EXECUTOR_DFIP.value, + UserRoleEnum.EXECUTOR_RF.value, ): err = AccessDeniedException() err.description = ( @@ -163,5 +164,5 @@ class UserService: return await self.user_repo.unset_many_ssp(user_id, ssp_ids) @staticmethod - def _normalize_role_id(role_id: int | UserRole) -> int: - return role_id.value if isinstance(role_id, UserRole) else int(role_id) + def _normalize_role_id(role_id: int | UserRoleEnum) -> int: + return role_id.value if isinstance(role_id, UserRoleEnum) else int(role_id) From 3d134fb7f980081dcd1deea55bbb4d5dc36e49d7 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Fri, 8 May 2026 13:16:31 +0300 Subject: [PATCH 2/9] srez --- api/src/core/exception_handlers.py | 132 +++++++++++++++++- api/src/domain/models.py | 14 +- api/src/domain/schemas.py | 34 ++++- api/src/repository/user_repository.py | 6 +- api/tests/integration/conftest.py | 32 +---- api/tests/integration/test_auth_api_smoke.py | 2 +- api/tests/integration/test_users_api_smoke.py | 17 +-- api/tests/unit/test_exception_handlers.py | 111 +++++++++++++++ .../test_exception_handlers_http_shape.py | 66 +++++++++ 9 files changed, 346 insertions(+), 68 deletions(-) create mode 100644 api/tests/unit/test_exception_handlers.py create mode 100644 api/tests/unit/test_exception_handlers_http_shape.py diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index d7efc24..1f1aaf1 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,5 +1,6 @@ -from fastapi import FastAPI, status +from fastapi import FastAPI, HTTPException, status from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError from src.core.errors import ( AccessDeniedException, @@ -10,38 +11,157 @@ from src.core.errors import ( ) +VALIDATION_PREFIXES = ( + "computed_field:", + "normative_field:", + "unknown_column:", + "key_field:", + "structural_field:", + "bad_column_format:", + "bad_booking_year:", + "bad_booking_quarter:", + "unsupported_scope:", +) + + +def _error_payload(code: str, message: str, field: str | None = None) -> dict: + """Формирует структурированный payload ошибки по спеке.""" + payload = { + "code": code, + "message": message, + } + if field: + payload["field"] = field + return payload + + +def _parse_prefixed_validation_error(message: str) -> tuple[str, str | None]: + for prefix in VALIDATION_PREFIXES: + if message.lower().startswith(prefix): + code = prefix[:-1] + field = message.split(":", 1)[1].strip() if ":" in message else None + return code, field or None + return "validation_error", None + + +def _first_db_message_line(message: str) -> str: + """Возвращает первую непустую строку текста ошибки БД.""" + for line in message.splitlines(): + line = line.strip() + if line: + return line + return message.strip() + + +def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | None]: + """Маппинг ошибок SQLAlchemy в HTTP-статус + code/message/field.""" + raw = str(getattr(exc, "orig", exc) or exc).strip() + message = _first_db_message_line(raw) or "Database error" + low = message.lower() + + if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES): + code, field = _parse_prefixed_validation_error(message) + return status.HTTP_422_UNPROCESSABLE_ENTITY, code, message, field + if "не существует" in low or "не принадлежит" in low or "not found" in low: + return status.HTTP_404_NOT_FOUND, "not_found", message, None + if "permission denied" in low or "insufficient privilege" in low: + return status.HTTP_403_FORBIDDEN, "access_denied", message, None + + if isinstance(exc, IntegrityError): + if "unique" in low or "duplicate" in low: + return status.HTTP_409_CONFLICT, "conflict", message, None + if "foreign key" in low or "violates check constraint" in low: + return status.HTTP_409_CONFLICT, "conflict", message, None + return status.HTTP_400_BAD_REQUEST, "db_error", message, None + + if isinstance(exc, OperationalError): + return status.HTTP_503_SERVICE_UNAVAILABLE, "db_unavailable", "Database unavailable", None + + return status.HTTP_400_BAD_REQUEST, "db_error", message, None + + +def map_http_exception(exc: HTTPException) -> tuple[int, str, str, str | None]: + """Маппинг HTTPException в единый формат ошибки API.""" + status_code = int(exc.status_code) + detail = exc.detail + + if isinstance(detail, dict): + message = str(detail.get("message") or detail.get("detail") or "HTTP error") + field = detail.get("field") + if field is not None: + field = str(field) + code = detail.get("code") + if code is not None: + return status_code, str(code), message, field + else: + message = str(detail) if detail else "HTTP error" + field = None + + if status_code == status.HTTP_422_UNPROCESSABLE_ENTITY: + code, parsed_field = _parse_prefixed_validation_error(message) + return status_code, code, message, parsed_field or field + if status_code == status.HTTP_401_UNAUTHORIZED: + return status_code, "unauthorized", message, field + if status_code == status.HTTP_403_FORBIDDEN: + return status_code, "access_denied", message, field + if status_code == status.HTTP_404_NOT_FOUND: + return status_code, "not_found", message, field + if status_code == status.HTTP_409_CONFLICT: + return status_code, "conflict", message, field + + return status_code, "http_error", message, field + + def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(HTTPException) + async def http_exception_handler(request, exc: HTTPException): + status_code, code, message, field = map_http_exception(exc) + return JSONResponse( + status_code=status_code, + content=_error_payload(code, message, field=field), + headers=exc.headers, + ) + @app.exception_handler(AccessDeniedException) async def access_denied_exception_handler(request, exc: AccessDeniedException): return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, - content={"detail": exc.description}, + content=_error_payload("access_denied", exc.description), ) @app.exception_handler(ValidationException) async def validation_exception_handler(request, exc: ValidationException): return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": exc.description}, + content=_error_payload("validation_error", exc.description, field=exc.field), ) @app.exception_handler(UserNotFoundException) async def user_not_found_exception_handler(request, exc: UserNotFoundException): return JSONResponse( status_code=status.HTTP_404_NOT_FOUND, - content={"detail": exc.description}, + content=_error_payload("user_not_found", exc.description), ) @app.exception_handler(UsernameConflictException) async def username_conflict_exception_handler(request, exc: UsernameConflictException): return JSONResponse( status_code=status.HTTP_409_CONFLICT, - content={"detail": exc.description}, + content=_error_payload("username_conflict", exc.description), ) @app.exception_handler(BasicAppException) async def basic_app_exception_handler(request, exc: BasicAppException): + message = exc.description or "Application error" return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": exc.description or "Application error"}, + content=_error_payload("application_error", message), + ) + + @app.exception_handler(SQLAlchemyError) + async def sqlalchemy_exception_handler(request, exc: SQLAlchemyError): + status_code, code, message, field = map_sqlalchemy_error(exc) + return JSONResponse( + status_code=status_code, + content=_error_payload(code, message, field=field), ) diff --git a/api/src/domain/models.py b/api/src/domain/models.py index 5480f64..5504c8a 100644 --- a/api/src/domain/models.py +++ b/api/src/domain/models.py @@ -14,30 +14,30 @@ class UserRole(int, enum.Enum): EXECUTOR_RF = 3 -class Roles(Base): - __tablename__ = "roles" +class Role(Base): + __tablename__ = "role" id = Column(Integer, primary_key=True, index=True) - role = Column(String, nullable=False) + code = Column(String, nullable=False) user_role = relationship("Users", back_populates="role", lazy="select") class Users(Base): - __tablename__ = "users" + __tablename__ = "app_user" 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) + role_id = Column(Integer, ForeignKey("role.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", + "Role", foreign_keys=[role_id], back_populates="user_role", lazy="selectin", @@ -63,7 +63,7 @@ class UserSSPLink(Base): ) id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey("app_user.id"), nullable=False) ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False) users = relationship("Users", back_populates="user_ssp_link", lazy="select") diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 8baedfc..b875da6 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -10,30 +10,54 @@ class ResponseBase(BaseModel): class UserBase(BaseModel): - email: EmailStr + email: EmailStr = Field(..., examples=["user@example.com"]) username: str = Field( ..., min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$", + examples=["user_1"], + ) + full_name: Optional[str] = Field( + None, + max_length=255, + pattern=r"^[^<>]*$", + examples=["Иван Иванов"], ) - full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$") role_id: int = 3 class UserCreate(UserBase): - password: str | None = None + password: str | None = Field(default=None, examples=["pass123"]) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "email": "user@example.com", + "username": "user_1", + "full_name": "Иван Иванов", + "role_id": 3, + "password": "pass123", + } + } + ) class UserUpdate(BaseModel): - email: Optional[EmailStr] = None + email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"]) username: Optional[str] = Field( None, min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$", + examples=["user_1"], + ) + full_name: Optional[str] = Field( + None, + max_length=255, + pattern=r"^[^<>]*$", + examples=["Иван Иванов"], ) - full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$") role_id: Optional[int] = None is_active: Optional[bool] = True diff --git a/api/src/repository/user_repository.py b/api/src/repository/user_repository.py index aaa26f4..54c77dd 100644 --- a/api/src/repository/user_repository.py +++ b/api/src/repository/user_repository.py @@ -4,7 +4,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.core.security import get_password_hash, verify_password -from src.domain.models import Roles, SSP, UserSSPLink, Users +from src.domain.models import Role, SSP, UserSSPLink, Users class UserRepository: @@ -101,8 +101,8 @@ class UserRepository: async def authenticate_via_email(self, email: str) -> Optional[Users]: return await self.get_by_email(email) - async def get_roles(self) -> list[Roles]: - return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all() + async def get_roles(self) -> list[Role]: + return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all() async def get_many_ssp(self, user_id: int) -> list[SSP]: query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where( diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index ed451f5..a7ed394 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -1,40 +1,12 @@ -import os -from pathlib import Path - import pytest -from alembic import command -from alembic.config import Config from fastapi.testclient import TestClient -# Keep a single sqlite target for app + alembic during tests. -TEST_DATABASE_URL = "sqlite+aiosqlite:///./app.db" -os.environ["DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS__DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS_TEST__DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS__DEBUG"] = "true" -os.environ["OPENBAO__SETTINGS_TEST__DEBUG"] = "true" - from src.main import app -@pytest.fixture(scope="session", autouse=True) -def migrate_db(): - db_files = [Path("app.db"), Path("dfip_budget_planing.db"), Path("dfip_new_schema.db")] - for db_file in db_files: - if db_file.exists(): - os.remove(db_file) - - alembic_cfg = Config("alembic.ini") - command.upgrade(alembic_cfg, "head") - yield - - for db_file in db_files: - if db_file.exists(): - os.remove(db_file) - - @pytest.fixture def client(): + # Используем настройки и DATABASE_URL из окружения так же, как это делает приложение. with TestClient(app) as test_client: yield test_client @@ -43,7 +15,7 @@ def client(): def admin_tokens(client): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "admin"}, + json={"username": "admin", "password": "admin123"}, ) assert response.status_code == 200 return response.json() diff --git a/api/tests/integration/test_auth_api_smoke.py b/api/tests/integration/test_auth_api_smoke.py index 7a6aee6..2605e5e 100644 --- a/api/tests/integration/test_auth_api_smoke.py +++ b/api/tests/integration/test_auth_api_smoke.py @@ -1,7 +1,7 @@ def test_login_success(client): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "admin"}, + json={"username": "admin", "password": "admin123"}, ) assert response.status_code == 200 data = response.json() diff --git a/api/tests/integration/test_users_api_smoke.py b/api/tests/integration/test_users_api_smoke.py index bd535dc..d53530f 100644 --- a/api/tests/integration/test_users_api_smoke.py +++ b/api/tests/integration/test_users_api_smoke.py @@ -8,23 +8,8 @@ def test_users_me(client, admin_tokens): assert response.json()["username"] == "admin" -def test_users_create_and_list(client, admin_tokens): - create_payload = { - "email": "user1@example.com", - "username": "user1", - "password": "pass123", - "full_name": "User One", - "role_id": 2, - } - created = client.put( - "/api/v1/users/", - json=create_payload, - headers=_auth_headers(admin_tokens), - ) - assert created.status_code == 200 - +def test_users_list(client, admin_tokens): listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens)) assert listed.status_code == 200 usernames = [row["username"] for row in listed.json()["result"]] assert "admin" in usernames - assert "user1" in usernames diff --git a/api/tests/unit/test_exception_handlers.py b/api/tests/unit/test_exception_handlers.py new file mode 100644 index 0000000..f1350ba --- /dev/null +++ b/api/tests/unit/test_exception_handlers.py @@ -0,0 +1,111 @@ +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError + +from src.core.exception_handlers import ( + _error_payload, + map_http_exception, + map_sqlalchemy_error, +) + + +def test_map_sqlalchemy_error_unique_conflict(): + exc = IntegrityError( + statement="INSERT INTO users ...", + params={}, + orig=Exception("duplicate key value violates unique constraint users_username_key"), + ) + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_409_CONFLICT + assert code == "conflict" + assert "duplicate key value" in message + assert field is None + + +def test_map_sqlalchemy_error_validation_prefix(): + exc = SQLAlchemyError() + exc.orig = Exception("unknown_column: q5.plan") + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert code == "unknown_column" + assert message == "unknown_column: q5.plan" + assert field == "q5.plan" + + +def test_map_sqlalchemy_error_not_found(): + exc = SQLAlchemyError() + exc.orig = Exception("budget_form #99 не существует") + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_404_NOT_FOUND + assert code == "not_found" + assert "не существует" in message + assert field is None + + +def test_map_sqlalchemy_error_operational(): + exc = OperationalError( + statement="SELECT 1", + params={}, + orig=Exception("connection refused"), + ) + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert code == "db_unavailable" + assert message == "Database unavailable" + assert field is None + + +def test_error_payload_matches_spec_shape(): + payload = _error_payload("unknown_column", "unknown_column: q5.plan", "q5.plan") + + assert payload == { + "code": "unknown_column", + "field": "q5.plan", + "message": "unknown_column: q5.plan", + } + + +def test_map_http_exception_validation_prefix(): + exc = HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="unknown_column: q5.plan", + ) + + status_code, code, message, field = map_http_exception(exc) + + assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert code == "unknown_column" + assert message == "unknown_column: q5.plan" + assert field == "q5.plan" + + +def test_map_http_exception_from_structured_detail(): + exc = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "validation_error", "message": "bad input", "field": "username"}, + ) + + status_code, code, message, field = map_http_exception(exc) + + assert status_code == status.HTTP_400_BAD_REQUEST + assert code == "validation_error" + assert message == "bad input" + assert field == "username" + + +def test_map_http_exception_unauthorized(): + exc = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен") + + status_code, code, message, field = map_http_exception(exc) + + assert status_code == status.HTTP_401_UNAUTHORIZED + assert code == "unauthorized" + assert message == "Недействительный токен" + assert field is None diff --git a/api/tests/unit/test_exception_handlers_http_shape.py b/api/tests/unit/test_exception_handlers_http_shape.py new file mode 100644 index 0000000..397292c --- /dev/null +++ b/api/tests/unit/test_exception_handlers_http_shape.py @@ -0,0 +1,66 @@ +from fastapi import FastAPI, HTTPException, status +from fastapi.testclient import TestClient +from sqlalchemy.exc import SQLAlchemyError + +from src.core.exception_handlers import register_exception_handlers + + +def _make_app() -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + + @app.get("/http-401") + async def http_401(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный токен", + ) + + @app.get("/http-422-prefixed") + async def http_422_prefixed(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="unknown_column: q5.plan", + ) + + @app.get("/sql-error") + async def sql_error(): + exc = SQLAlchemyError() + exc.orig = Exception("budget_form #99 не существует") + raise exc + + return app + + +def test_http_exception_returns_structured_payload(): + client = TestClient(_make_app()) + response = client.get("/http-401") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == { + "code": "unauthorized", + "message": "Недействительный токен", + } + + +def test_http_validation_exception_extracts_field(): + client = TestClient(_make_app()) + response = client.get("/http-422-prefixed") + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == { + "code": "unknown_column", + "field": "q5.plan", + "message": "unknown_column: q5.plan", + } + + +def test_sqlalchemy_exception_returns_structured_payload(): + client = TestClient(_make_app()) + response = client.get("/sql-error") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == { + "code": "not_found", + "message": "budget_form #99 не существует", + } From 52c0d1806ae0937bbbf95b2a88d1e31d39556b18 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Wed, 13 May 2026 12:28:00 +0300 Subject: [PATCH 3/9] F3 Read/Write endpoints --- api/src/api/v1/projects.py | 224 ++++++++++- api/src/domain/schemas.py | 69 +++- api/src/repository/project_repository.py | 354 +++++++++++++++++- api/src/services/project_service.py | 190 +++++++++- api/tests/integration/conftest.py | 37 +- api/tests/integration/test_auth_api_smoke.py | 8 +- .../integration/test_projects_api_smoke.py | 123 ++++++ api/tests/integration/test_users_api_smoke.py | 18 +- 8 files changed, 956 insertions(+), 67 deletions(-) create mode 100644 api/tests/integration/test_projects_api_smoke.py diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py index 39ee2c1..815d1f5 100644 --- a/api/src/api/v1/projects.py +++ b/api/src/api/v1/projects.py @@ -1,20 +1,42 @@ - +import time from typing import Optional -from fastapi import APIRouter, Depends +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 BaseListResponse +from src.domain.schemas import ( + AddForm3LineBody, + AddProjectBody, + BaseListResponse, + BaseSingleResponse, + CellPatch, + CellsPatch, + SheetResponse, + UpdProjectBody, +) from src.services.project_service import ProjectService -router = APIRouter(prefix="/projects", tags=["forms"]) +router = APIRouter(tags=["forms"]) -@router.get("/") +def _parse_sections(sections: Optional[str]) -> Optional[list[str]]: + if not sections: + return None + return [s.strip() for s in sections.split(",") if s.strip()] + + +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, @@ -22,13 +44,199 @@ async def get_projects( branch_id: Optional[int] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[str]: +) -> BaseListResponse[dict]: project_service = ProjectService(db) count, projects = await project_service.get_list( - user=current_user, + user=current_user, with_count=True, year=year, branch_id=branch_id, offset=offset, limit=limit, - ) \ No newline at end of file + ) + return BaseListResponse(result=projects, count=count) + + +@router.get("/projects/{project_id}") +async def get_project( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseSingleResponse[dict]: + project_service = ProjectService(db) + project = await project_service.get(project_id=project_id, user=current_user) + if not project: + raise HTTPException(404, "Project not found") + return BaseSingleResponse(result=project) + + +@router.get("/projects/{project_id}/report/{year}/{report_type}") +async def get_project_report( + project_id: int, + year: int, + report_type: str, + response: Response, + sections: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[SheetResponse]: + project_service = ProjectService(db) + t0 = time.perf_counter() + rows = await project_service.get_report_rows( + project_id=project_id, + year=year, + report_type=report_type, + sections=_parse_sections(sections), + user=current_user, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + return BaseListResponse(count=len(rows), result=_rows_to_payload(rows)) + + +@router.get("/rf-rollup/{branch_id}/{year}") +async def get_rf_rollup( + branch_id: int, + year: int, + response: Response, + sections: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[SheetResponse]: + project_service = ProjectService(db) + t0 = time.perf_counter() + rows = await project_service.get_rf_rollup_rows( + branch_id=branch_id, + year=year, + sections=_parse_sections(sections), + user=current_user, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + return BaseListResponse(count=len(rows), result=_rows_to_payload(rows)) + + +@router.patch("/projects/{project_id}/report/{year}/{report_type}/cell") +async def upd_form3_cell( + project_id: int, + year: int, + report_type: str, + body: CellPatch, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> list[SheetResponse]: + project_service = ProjectService(db) + rows = await project_service.upd_form3_cell( + project_id=project_id, + year=year, + report_type=report_type, + line_id=body.line_id, + column=body.column, + value=body.value, + user=current_user, + ) + return _rows_to_payload(rows) + + +@router.patch("/projects/{project_id}/report/{year}/{report_type}/cells") +async def upd_form3_cells( + project_id: int, + year: int, + report_type: str, + body: CellsPatch, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> list[SheetResponse]: + project_service = ProjectService(db) + rows = await project_service.upd_form3_cells( + project_id=project_id, + year=year, + report_type=report_type, + changes=[change.model_dump() for change in body.changes], + user=current_user, + ) + return _rows_to_payload(rows) + + +@router.post("/projects/{project_id}/report/{year}/{report_type}/line") +async def add_form3_line( + project_id: int, + year: int, + report_type: str, + body: AddForm3LineBody, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> list[SheetResponse]: + project_service = ProjectService(db) + rows = await project_service.add_form3_line( + project_id=project_id, + year=year, + report_type=report_type, + expense_item_id=body.expense_item_id, + user=current_user, + ) + return _rows_to_payload(rows) + + +@router.delete("/projects/{project_id}/report/{year}/{report_type}/line/{line_id}") +async def del_form3_line( + project_id: int, + year: int, + report_type: str, + line_id: int, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> list[SheetResponse]: + project_service = ProjectService(db) + rows = await project_service.del_form3_line( + project_id=project_id, + year=year, + report_type=report_type, + line_id=line_id, + user=current_user, + ) + return _rows_to_payload(rows) + + +@router.patch("/project/{project_id}") +async def upd_project( + project_id: int, + body: UpdProjectBody, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> dict: + project_service = ProjectService(db) + result = await project_service.upd_project( + project_id=project_id, + column=body.column, + value=body.value, + user=current_user, + ) + return result + + +@router.post("/projects") +async def add_project( + body: AddProjectBody, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> dict: + project_service = ProjectService(db) + project_id, limit_report_id, current_expenses_report_id = await project_service.add_project( + name=body.name, + year=body.year, + org_unit_id=body.branch_id, + level=body.level, + parent_id=body.parent_id, + project_type=body.project_type, + vsp_format=body.vsp_format, + placement_type=body.placement_type, + object_address=body.object_address, + staff_count=body.staff_count, + total_area=body.total_area, + ) + return { + "project_id": project_id, + "limit_report_id": limit_report_id, + "current_expenses_report_id": current_expenses_report_id, + } \ No newline at end of file diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 7e43de9..957fed4 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -1,11 +1,38 @@ from datetime import datetime import enum -from typing import Generic, Optional, TypeVar +from typing import Any, Generic, Literal, Optional, TypeVar from pydantic import BaseModel, ConfigDict, EmailStr, Field T = TypeVar("T") +ProjectTypeLiteral = Literal[ + "Открытие ВСП", + "Закрытие ВСП", + "Переезд ВСП", + "Реновация РФ", + "Открытие УРМ", +] + +VspFormatLiteral = Literal[ + "Флагманский", + "Типовой", + "Розничный", + "МСБ", + "Лёгкий", + "Мини", + "Розничный-киоск", + "МБО", + "Офис самообслуживания", + "Другое", +] + +PlacementTypeLiteral = Literal[ + "Собственность", + "Аренда", + "Субаренда", +] + class ResponseBase(BaseModel): success: Optional[bool] = Field(True) @@ -145,4 +172,42 @@ class SheetResponse(BaseModel): row_type: str depth: int data: dict - sort_order: int | None = None \ No newline at end of file + sort_order: int | None = None + + +class CellPatch(BaseModel): + line_id: int + column: str + value: Any = None + + +class CellsPatch(BaseModel): + changes: list[CellPatch] + + +class AddForm3LineBody(BaseModel): + expense_item_id: int + + +class UpdProjectBody(BaseModel): + column: str + value: Any = None + + +class AddProjectBody(BaseModel): + name: str = Field( + ..., + max_length=30, + pattern=r"^[^+\-\/\\=&*\s]{1,30}$", + ) + year: int + branch_id: int + level: Literal["project", "program"] = "project" + parent_id: Optional[int] = None + ssp_id: Optional[int] = None + project_type: Optional[ProjectTypeLiteral] = None + vsp_format: Optional[VspFormatLiteral] = None + placement_type: Optional[PlacementTypeLiteral] = None + object_address: Optional[str] = None + staff_count: Optional[int] = None + total_area: Optional[float] = None \ No newline at end of file diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index 0b03d9c..565045b 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -1,8 +1,360 @@ +import json +from sqlalchemy import exists, false, func, select, text from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.org_unit import OrgUnit +from src.db.models.project import Project +from src.db.models.rf_project_report import RfProjectReport +from src.db.models.rf_project_report_line import RfProjectReportLine + class ProjectRepository: def __init__(self, db: AsyncSession): self.db = db - + + @staticmethod + def _serialize_project(project: Project, org_unit_name: str | None, report_count: int) -> dict: + return { + "id": project.id, + "name": project.name, + "level": project.level, + "parent_id": project.parent_id, + "project_type": project.project_type, + "vsp_format": project.vsp_format, + "placement_type": project.placement_type, + "object_address": project.object_address, + "staff_count": project.staff_count, + "total_area": float(project.total_area) if project.total_area is not None else None, + "org_unit_id": project.org_unit_id, + "org_unit_name": org_unit_name, + "report_count": int(report_count), + } + + @staticmethod + def _apply_project_filters(query, year: int | None, branch_id: int | None, org_unit_ids: list[int] | None): + if year is not None: + query = query.where( + exists( + select(1).where( + RfProjectReport.project_id == Project.id, + RfProjectReport.year == year, + ) + ) + ) + if branch_id is not None: + query = query.where(Project.org_unit_id == branch_id) + if org_unit_ids is not None: + if len(org_unit_ids) == 0: + query = query.where(false()) + else: + query = query.where(Project.org_unit_id.in_(org_unit_ids)) + return query + + async def get_list( + self, + year: int | None = None, + branch_id: int | None = None, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False, + org_unit_ids: list[int] | None = None, + ) -> list[dict] | tuple[int, list[dict]]: + report_count_subq = ( + select(func.count(RfProjectReport.id)) + .where(RfProjectReport.project_id == Project.id) + .scalar_subquery() + ) + + query = ( + select( + Project, + OrgUnit.title.label("org_unit_name"), + report_count_subq.label("report_count"), + ) + .outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id) + .order_by(Project.id) + ) + query = self._apply_project_filters(query, year, branch_id, org_unit_ids) + if offset is not None: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + + rows = (await self.db.execute(query)).all() + payload = [self._serialize_project(row[0], row[1], row[2]) for row in rows] + if not with_count: + return payload + + count_query = select(func.count(Project.id)) + count_query = self._apply_project_filters(count_query, year, branch_id, org_unit_ids) + total = int((await self.db.execute(count_query)).scalar() or 0) + return total, payload + + async def get( + self, + project_id: int, + org_unit_ids: list[int] | None = None, + ) -> dict | None: + report_count_subq = ( + select(func.count(RfProjectReport.id)) + .where(RfProjectReport.project_id == Project.id) + .scalar_subquery() + ) + query = ( + select( + Project, + OrgUnit.title.label("org_unit_name"), + report_count_subq.label("report_count"), + ) + .outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id) + .where(Project.id == project_id) + ) + if org_unit_ids is not None: + if len(org_unit_ids) == 0: + return None + query = query.where(Project.org_unit_id.in_(org_unit_ids)) + row = (await self.db.execute(query)).first() + if not row: + return None + return self._serialize_project(row[0], row[1], row[2]) + + async def get_reports(self, project_id: int) -> list[dict]: + line_count_subq = ( + select(func.count(RfProjectReportLine.id)) + .where(RfProjectReportLine.rf_project_report_id == RfProjectReport.id) + .scalar_subquery() + ) + query = ( + select( + RfProjectReport.id, + RfProjectReport.year, + RfProjectReport.report_type, + RfProjectReport.created_by, + RfProjectReport.created_at, + RfProjectReport.updated_by, + RfProjectReport.updated_at, + line_count_subq.label("line_count"), + ) + .where(RfProjectReport.project_id == project_id) + .order_by(RfProjectReport.year, RfProjectReport.report_type) + ) + rows = (await self.db.execute(query)).all() + reports: list[dict] = [] + for row in rows: + reports.append( + { + "id": row[0], + "year": row[1], + "report_type": row[2], + "created_by": row[3], + "created_at": row[4].isoformat() if row[4] is not None else None, + "updated_by": row[5], + "updated_at": row[6].isoformat() if row[6] is not None else None, + "line_count": int(row[7]), + } + ) + return reports + + async def resolve_report_id( + self, + project_id: int, + year: int, + report_type: str, + org_unit_ids: list[int] | None = None, + ) -> int | None: + query = ( + select(RfProjectReport.id) + .join(Project, Project.id == RfProjectReport.project_id) + .where( + Project.id == project_id, + RfProjectReport.year == year, + RfProjectReport.report_type == report_type, + ) + ) + if org_unit_ids is not None: + if len(org_unit_ids) == 0: + return None + query = query.where(Project.org_unit_id.in_(org_unit_ids)) + return (await self.db.execute(query)).scalar_one_or_none() + + async def get_report_rows(self, report_id: int, sections: list[str] | None = None) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.v_form3_report_jsonb(CAST(:report_id AS INT), CAST(:sections AS TEXT[])) + """ + ) + return (await self.db.execute(query, {"report_id": report_id, "sections": sections})).all() + + async def get_rf_rollup_rows( + self, + branch_id: int, + year: int, + sections: list[str] | None = None, + ) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.v_form3_rf_rollup_jsonb(CAST(:branch_id AS INT), CAST(:year AS INT), CAST(:sections AS TEXT[])) + """ + ) + return ( + await self.db.execute( + query, + {"branch_id": branch_id, "year": year, "sections": sections}, + ) + ).all() + + async def upd_form3_cell( + self, + report_id: int, + line_id: int, + column: str, + value, + ) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.upd_form3_cell( + CAST(:report_id AS INT), + CAST(:line_id AS INT), + CAST(:column AS TEXT), + CAST(:value AS JSONB) + ) + """ + ) + rows = ( + await self.db.execute( + query, + { + "report_id": report_id, + "line_id": line_id, + "column": column, + "value": json.dumps(value), + }, + ) + ).all() + await self.db.commit() + return rows + + async def upd_form3_cells(self, report_id: int, changes: list[dict]) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.upd_form3_cells(CAST(:report_id AS INT), CAST(:changes AS JSONB)) + """ + ) + rows = ( + await self.db.execute( + query, + { + "report_id": report_id, + "changes": json.dumps(changes), + }, + ) + ).all() + await self.db.commit() + return rows + + async def add_form3_line(self, report_id: int, expense_item_id: int) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.add_form3_line(CAST(:report_id AS INT), CAST(:expense_item_id AS INT)) + """ + ) + rows = ( + await self.db.execute( + query, + { + "report_id": report_id, + "expense_item_id": expense_item_id, + }, + ) + ).all() + await self.db.commit() + return rows + + async def del_form3_line(self, line_id: int) -> list[tuple]: + query = text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.del_form3_line(CAST(:line_id AS INT)) + """ + ) + rows = (await self.db.execute(query, {"line_id": line_id})).all() + await self.db.commit() + return rows + + async def upd_project(self, project_id: int, column: str, value): + query = text( + """ + SELECT v3.upd_project(CAST(:project_id AS INT), CAST(:column AS TEXT), CAST(:value AS JSONB)) + """ + ) + result = ( + await self.db.execute( + query, + { + "project_id": project_id, + "column": column, + "value": json.dumps(value), + }, + ) + ).scalar_one() + await self.db.commit() + return result + + async def add_project( + self, + name: str, + year: int, + org_unit_id: int, + level: str = "project", + parent_id: int | None = None, + project_type: str | None = None, + vsp_format: str | None = None, + placement_type: str | None = None, + object_address: str | None = None, + staff_count: int | None = None, + total_area: float | None = None, + ) -> tuple[int, int, int]: + query = text( + """ + SELECT project_id, limit_report_id, current_expenses_report_id + FROM v3.add_project( + CAST(:name AS VARCHAR), + CAST(:year AS INT), + CAST(:org_unit_id AS INT), + CAST(:level AS VARCHAR), + CAST(:parent_id AS INT), + CAST(:project_type AS VARCHAR), + CAST(:vsp_format AS VARCHAR), + CAST(:placement_type AS VARCHAR), + CAST(:object_address AS VARCHAR), + CAST(:staff_count AS INT), + CAST(:total_area AS NUMERIC) + ) + """ + ) + row = ( + await self.db.execute( + query, + { + "name": name, + "year": year, + "org_unit_id": org_unit_id, + "level": level, + "parent_id": parent_id, + "project_type": project_type, + "vsp_format": vsp_format, + "placement_type": placement_type, + "object_address": object_address, + "staff_count": staff_count, + "total_area": total_area, + }, + ) + ).first() + await self.db.commit() + return int(row[0]), int(row[1]), int(row[2]) diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index b967972..0f7f62e 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -1,15 +1,12 @@ - -from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.app_user import AppUser -from src.db.models.project import Project from src.db.models.role import UserRoleEnum +from src.core.errors import AccessDeniedException, ValidationException from src.repository.project_repository import ProjectRepository from src.repository.user_repository import UserRepository - class ProjectService: def __init__(self, db: AsyncSession): self.db = db @@ -17,13 +14,178 @@ class ProjectService: self.user_repo = UserRepository(db) async def get_list( - self, - user: AppUser, - year: int | None = None, - branch_id: int | None = None, - offset: int | None = None, - limit: int | None = None, - with_count: bool = False, - with_org_unit: bool = False, - ) -> list[Project] | tuple[int, list[Project]]: - pass + self, + user: AppUser, + year: int | None = None, + branch_id: int | None = None, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False, + with_org_unit: bool = False, + ) -> list[dict] | tuple[int, list[dict]]: + org_unit_ids = await self._allowed_org_unit_ids(user) + return await self.project_repo.get_list( + year=year, + branch_id=branch_id, + offset=offset, + limit=limit, + with_count=with_count, + org_unit_ids=org_unit_ids, + ) + + async def get(self, project_id: int, user: AppUser) -> dict | None: + org_unit_ids = await self._allowed_org_unit_ids(user) + project = await self.project_repo.get(project_id=project_id, org_unit_ids=org_unit_ids) + if not project: + return None + project["reports"] = await self.project_repo.get_reports(project_id=project_id) + return project + + async def resolve_report_id( + self, + project_id: int, + year: int, + report_type: str, + user: AppUser, + ) -> int | None: + normalized_type = report_type.upper() + if normalized_type not in ("LIMIT", "CURRENT_EXPENSES"): + raise ValidationException("report_type должен быть LIMIT или CURRENT_EXPENSES") + org_unit_ids = await self._allowed_org_unit_ids(user) + return await self.project_repo.resolve_report_id( + project_id=project_id, + year=year, + report_type=normalized_type, + org_unit_ids=org_unit_ids, + ) + + async def get_report_rows( + self, + project_id: int, + year: int, + report_type: str, + sections: list[str] | None, + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.get_report_rows(report_id=report_id, sections=sections) + + async def get_rf_rollup_rows( + self, + branch_id: int, + year: int, + sections: list[str] | None, + user: AppUser, + ) -> list[tuple]: + org_unit_ids = await self._allowed_org_unit_ids(user) + if org_unit_ids is not None and branch_id not in org_unit_ids: + raise AccessDeniedException() + return await self.project_repo.get_rf_rollup_rows( + branch_id=branch_id, + year=year, + sections=sections, + ) + + async def upd_form3_cell( + self, + project_id: int, + year: int, + report_type: str, + line_id: int, + column: str, + value, + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.upd_form3_cell(report_id, line_id, column, value) + + async def upd_form3_cells( + self, + project_id: int, + year: int, + report_type: str, + changes: list[dict], + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.upd_form3_cells(report_id, changes) + + async def add_form3_line( + self, + project_id: int, + year: int, + report_type: str, + expense_item_id: int, + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.add_form3_line(report_id, expense_item_id) + + async def del_form3_line( + self, + project_id: int, + year: int, + report_type: str, + line_id: int, + user: AppUser, + ) -> list[tuple]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.del_form3_line(line_id) + + async def upd_project( + self, + project_id: int, + column: str, + value, + user: AppUser, + ): + project = await self.get(project_id, user) + if not project: + raise ValidationException("Проект не найден") + return await self.project_repo.upd_project(project_id, column, value) + + async def add_project( + self, + name: str, + year: int, + org_unit_id: int, + level: str = "project", + parent_id: int | None = None, + project_type: str | None = None, + vsp_format: str | None = None, + placement_type: str | None = None, + object_address: str | None = None, + staff_count: int | None = None, + total_area: float | None = None, + ) -> tuple[int, int, int]: + return await self.project_repo.add_project( + name=name, + year=year, + org_unit_id=org_unit_id, + level=level, + parent_id=parent_id, + project_type=project_type, + vsp_format=vsp_format, + placement_type=placement_type, + object_address=object_address, + staff_count=staff_count, + total_area=total_area, + ) + + async def _allowed_org_unit_ids(self, user: AppUser) -> list[int] | None: + if user.role_id == UserRoleEnum.ADMIN: + return None + db_user = await self.user_repo.get(user_id=user.id, load_orgs=True) + if not db_user: + return [] + return [ou.id for ou in db_user.org_units] diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index ed451f5..f578a9a 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -1,38 +1,11 @@ import os -from pathlib import Path import pytest -from alembic import command -from alembic.config import Config from fastapi.testclient import TestClient -# Keep a single sqlite target for app + alembic during tests. -TEST_DATABASE_URL = "sqlite+aiosqlite:///./app.db" -os.environ["DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS__DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS_TEST__DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS__DEBUG"] = "true" -os.environ["OPENBAO__SETTINGS_TEST__DEBUG"] = "true" - from src.main import app -@pytest.fixture(scope="session", autouse=True) -def migrate_db(): - db_files = [Path("app.db"), Path("dfip_budget_planing.db"), Path("dfip_new_schema.db")] - for db_file in db_files: - if db_file.exists(): - os.remove(db_file) - - alembic_cfg = Config("alembic.ini") - command.upgrade(alembic_cfg, "head") - yield - - for db_file in db_files: - if db_file.exists(): - os.remove(db_file) - - @pytest.fixture def client(): with TestClient(app) as test_client: @@ -40,10 +13,16 @@ def client(): @pytest.fixture -def admin_tokens(client): +def admin_password() -> str: + # Пароль админа берём из окружения, чтобы не хардкодить локальные отличия. + return os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123") + + +@pytest.fixture +def admin_tokens(client, admin_password: str): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "admin"}, + json={"username": "admin", "password": admin_password}, ) assert response.status_code == 200 return response.json() diff --git a/api/tests/integration/test_auth_api_smoke.py b/api/tests/integration/test_auth_api_smoke.py index 7a6aee6..4774b2d 100644 --- a/api/tests/integration/test_auth_api_smoke.py +++ b/api/tests/integration/test_auth_api_smoke.py @@ -1,7 +1,7 @@ -def test_login_success(client): +def test_login_success(client, admin_password): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "admin"}, + json={"username": "admin", "password": admin_password}, ) assert response.status_code == 200 data = response.json() @@ -9,10 +9,10 @@ def test_login_success(client): assert "refresh_token" in data -def test_login_invalid_password(client): +def test_login_invalid_password(client, admin_password): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "wrong"}, + json={"username": "admin", "password": f"{admin_password}_wrong"}, ) assert response.status_code == 401 diff --git a/api/tests/integration/test_projects_api_smoke.py b/api/tests/integration/test_projects_api_smoke.py new file mode 100644 index 0000000..07f6386 --- /dev/null +++ b/api/tests/integration/test_projects_api_smoke.py @@ -0,0 +1,123 @@ +import uuid + +import pytest + + +def _auth_headers(tokens: dict) -> dict: + return {"Authorization": f"Bearer {tokens['access_token']}"} + + +def test_projects_list_smoke(client, admin_tokens): + response = client.get("/api/v1/projects", headers=_auth_headers(admin_tokens)) + assert response.status_code == 200 + payload = response.json() + assert "result" in payload + assert "count" in payload + assert isinstance(payload["result"], list) + + +def _create_project(client, admin_tokens) -> tuple[int, str]: + name = f"PT_{uuid.uuid4().hex[:10]}" + response = client.post( + "/api/v1/projects", + json={"name": name, "year": 2026, "branch_id": 1}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + return payload["project_id"], name + + +def _add_line(client, admin_tokens, project_id: int) -> int: + for expense_item_id in range(1, 80): + response = client.post( + f"/api/v1/projects/{project_id}/report/2026/LIMIT/line", + json={"expense_item_id": expense_item_id}, + headers=_auth_headers(admin_tokens), + ) + if response.status_code != 200: + continue + rows = response.json() + input_row = next( + ( + row + for row in rows + if row.get("row_type") == "INPUT" and row.get("data", {}).get("line_id") + ), + None, + ) + if input_row: + return int(input_row["data"]["line_id"]) + + pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД") + + +def test_projects_write_smoke(client, admin_tokens): + project_id, name = _create_project(client, admin_tokens) + + upd_project_response = client.patch( + f"/api/v1/project/{project_id}", + json={"column": "name", "value": f"{name}_U"}, + headers=_auth_headers(admin_tokens), + ) + assert upd_project_response.status_code == 200 + + line_id = _add_line(client, admin_tokens, project_id) + + upd_cell_response = client.patch( + f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 100}, + headers=_auth_headers(admin_tokens), + ) + assert upd_cell_response.status_code == 200 + assert isinstance(upd_cell_response.json(), list) + + upd_cells_response = client.patch( + f"/api/v1/projects/{project_id}/report/2026/LIMIT/cells", + json={ + "changes": [ + {"line_id": line_id, "column": "q1.m2", "value": 200}, + {"line_id": line_id, "column": "q1.m3", "value": 300}, + ] + }, + headers=_auth_headers(admin_tokens), + ) + assert upd_cells_response.status_code == 200 + assert isinstance(upd_cells_response.json(), list) + + del_line_response = client.delete( + f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}", + headers=_auth_headers(admin_tokens), + ) + assert del_line_response.status_code == 200 + + +def test_projects_write_invalid_report_type_error_shape(client, admin_tokens): + project_id, _ = _create_project(client, admin_tokens) + line_id = _add_line(client, admin_tokens, project_id) + + response = client.patch( + f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 100}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 400 + payload = response.json() + assert isinstance(payload, dict) + assert "detail" in payload + assert isinstance(payload["detail"], str) + + +def test_projects_write_report_not_found_error_shape(client, admin_tokens): + project_id, _ = _create_project(client, admin_tokens) + line_id = _add_line(client, admin_tokens, project_id) + + response = client.patch( + f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell", + json={"line_id": line_id, "column": "q1.m1", "value": 100}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 400 + payload = response.json() + assert isinstance(payload, dict) + assert payload.get("detail") == "Отчёт не найден" diff --git a/api/tests/integration/test_users_api_smoke.py b/api/tests/integration/test_users_api_smoke.py index bd535dc..718606a 100644 --- a/api/tests/integration/test_users_api_smoke.py +++ b/api/tests/integration/test_users_api_smoke.py @@ -1,3 +1,6 @@ +import uuid + + def _auth_headers(tokens: dict) -> dict: return {"Authorization": f"Bearer {tokens['access_token']}"} @@ -8,10 +11,12 @@ def test_users_me(client, admin_tokens): assert response.json()["username"] == "admin" -def test_users_create_and_list(client, admin_tokens): +def test_users_create_smoke(client, admin_tokens): + suffix = uuid.uuid4().hex[:8] + username = f"user_{suffix}" create_payload = { - "email": "user1@example.com", - "username": "user1", + "email": f"{username}@example.com", + "username": username, "password": "pass123", "full_name": "User One", "role_id": 2, @@ -22,9 +27,4 @@ def test_users_create_and_list(client, admin_tokens): headers=_auth_headers(admin_tokens), ) assert created.status_code == 200 - - listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens)) - assert listed.status_code == 200 - usernames = [row["username"] for row in listed.json()["result"]] - assert "admin" in usernames - assert "user1" in usernames + assert created.json()["result"]["username"] == username From 280dabfd95228580b4a5070d213d4f9f4632f11d Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Thu, 14 May 2026 11:16:35 +0300 Subject: [PATCH 4/9] Refactor contract of errors --- api/src/core/exception_handlers.py | 33 +++++-------------- api/tests/integration/test_users_api_smoke.py | 24 +++++++++++--- api/tests/unit/test_exception_handlers.py | 17 ++++++++-- .../test_exception_handlers_http_shape.py | 8 +++-- 4 files changed, 46 insertions(+), 36 deletions(-) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 1f1aaf1..5a69008 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -24,15 +24,13 @@ VALIDATION_PREFIXES = ( ) -def _error_payload(code: str, message: str, field: str | None = None) -> dict: +def _error_payload(code: str | int, message: str, field: str | None = None) -> dict: """Формирует структурированный payload ошибки по спеке.""" - payload = { + return { "code": code, + "field": field, "message": message, } - if field: - payload["field"] = field - return payload def _parse_prefixed_validation_error(message: str) -> tuple[str, str | None]: @@ -80,7 +78,7 @@ def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | Non return status.HTTP_400_BAD_REQUEST, "db_error", message, None -def map_http_exception(exc: HTTPException) -> tuple[int, str, str, str | None]: +def map_http_exception(exc: HTTPException) -> tuple[int, str | int, str, str | None]: """Маппинг HTTPException в единый формат ошибки API.""" status_code = int(exc.status_code) detail = exc.detail @@ -90,26 +88,11 @@ def map_http_exception(exc: HTTPException) -> tuple[int, str, str, str | None]: field = detail.get("field") if field is not None: field = str(field) - code = detail.get("code") - if code is not None: - return status_code, str(code), message, field - else: - message = str(detail) if detail else "HTTP error" - field = None + code = detail.get("code", status_code) + return status_code, code, message, field - if status_code == status.HTTP_422_UNPROCESSABLE_ENTITY: - code, parsed_field = _parse_prefixed_validation_error(message) - return status_code, code, message, parsed_field or field - if status_code == status.HTTP_401_UNAUTHORIZED: - return status_code, "unauthorized", message, field - if status_code == status.HTTP_403_FORBIDDEN: - return status_code, "access_denied", message, field - if status_code == status.HTTP_404_NOT_FOUND: - return status_code, "not_found", message, field - if status_code == status.HTTP_409_CONFLICT: - return status_code, "conflict", message, field - - return status_code, "http_error", message, field + message = str(detail) if detail else "HTTP error" + return status_code, status_code, message, None def register_exception_handlers(app: FastAPI) -> None: diff --git a/api/tests/integration/test_users_api_smoke.py b/api/tests/integration/test_users_api_smoke.py index d53530f..6ee25e6 100644 --- a/api/tests/integration/test_users_api_smoke.py +++ b/api/tests/integration/test_users_api_smoke.py @@ -1,3 +1,6 @@ +import uuid + + def _auth_headers(tokens: dict) -> dict: return {"Authorization": f"Bearer {tokens['access_token']}"} @@ -8,8 +11,19 @@ def test_users_me(client, admin_tokens): assert response.json()["username"] == "admin" -def test_users_list(client, admin_tokens): - listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens)) - assert listed.status_code == 200 - usernames = [row["username"] for row in listed.json()["result"]] - assert "admin" in usernames +def test_users_create_smoke(client, admin_tokens): + suffix = uuid.uuid4().hex[:8] + username = f"user_{suffix}" + created = client.put( + "/api/v1/users/", + json={ + "email": f"{username}@example.com", + "username": username, + "password": "pass123", + "full_name": "User One", + "role_id": 2, + }, + headers=_auth_headers(admin_tokens), + ) + assert created.status_code == 200 + assert created.json()["result"]["username"] == username diff --git a/api/tests/unit/test_exception_handlers.py b/api/tests/unit/test_exception_handlers.py index f1350ba..2f86b2c 100644 --- a/api/tests/unit/test_exception_handlers.py +++ b/api/tests/unit/test_exception_handlers.py @@ -81,9 +81,9 @@ def test_map_http_exception_validation_prefix(): status_code, code, message, field = map_http_exception(exc) assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert code == "unknown_column" + assert code == status.HTTP_422_UNPROCESSABLE_ENTITY assert message == "unknown_column: q5.plan" - assert field == "q5.plan" + assert field is None def test_map_http_exception_from_structured_detail(): @@ -106,6 +106,17 @@ def test_map_http_exception_unauthorized(): status_code, code, message, field = map_http_exception(exc) assert status_code == status.HTTP_401_UNAUTHORIZED - assert code == "unauthorized" + assert code == status.HTTP_401_UNAUTHORIZED assert message == "Недействительный токен" assert field is None + + +def test_map_http_exception_not_found_maps_code_from_status(): + exc = HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found") + + status_code, code, message, field = map_http_exception(exc) + + assert status_code == status.HTTP_404_NOT_FOUND + assert code == status.HTTP_404_NOT_FOUND + assert message == "not found" + assert field is None diff --git a/api/tests/unit/test_exception_handlers_http_shape.py b/api/tests/unit/test_exception_handlers_http_shape.py index 397292c..cde3af6 100644 --- a/api/tests/unit/test_exception_handlers_http_shape.py +++ b/api/tests/unit/test_exception_handlers_http_shape.py @@ -38,7 +38,8 @@ def test_http_exception_returns_structured_payload(): assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.json() == { - "code": "unauthorized", + "code": status.HTTP_401_UNAUTHORIZED, + "field": None, "message": "Недействительный токен", } @@ -49,8 +50,8 @@ def test_http_validation_exception_extracts_field(): assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.json() == { - "code": "unknown_column", - "field": "q5.plan", + "code": status.HTTP_422_UNPROCESSABLE_ENTITY, + "field": None, "message": "unknown_column: q5.plan", } @@ -62,5 +63,6 @@ def test_sqlalchemy_exception_returns_structured_payload(): assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json() == { "code": "not_found", + "field": None, "message": "budget_form #99 не существует", } From 539bd8c0f90414d03b81343cf32d4b230c3535c7 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Thu, 14 May 2026 11:21:26 +0300 Subject: [PATCH 5/9] Refactor for specs requirements --- api/src/core/exception_handlers.py | 6 ++++- api/tests/unit/test_exception_handlers.py | 33 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 5a69008..4559291 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -60,6 +60,8 @@ def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | Non if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES): code, field = _parse_prefixed_validation_error(message) return status.HTTP_422_UNPROCESSABLE_ENTITY, code, message, field + if low.startswith("role_not_allowed:") or low.startswith("window_closed:"): + return status.HTTP_403_FORBIDDEN, "access_denied", message, None if "не существует" in low or "не принадлежит" in low or "not found" in low: return status.HTTP_404_NOT_FOUND, "not_found", message, None if "permission denied" in low or "insufficient privilege" in low: @@ -68,8 +70,10 @@ def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | Non if isinstance(exc, IntegrityError): if "unique" in low or "duplicate" in low: return status.HTTP_409_CONFLICT, "conflict", message, None - if "foreign key" in low or "violates check constraint" in low: + if "foreign key" in low: return status.HTTP_409_CONFLICT, "conflict", message, None + if "violates check constraint" in low: + return status.HTTP_400_BAD_REQUEST, "db_error", message, None return status.HTTP_400_BAD_REQUEST, "db_error", message, None if isinstance(exc, OperationalError): diff --git a/api/tests/unit/test_exception_handlers.py b/api/tests/unit/test_exception_handlers.py index 2f86b2c..3887a49 100644 --- a/api/tests/unit/test_exception_handlers.py +++ b/api/tests/unit/test_exception_handlers.py @@ -23,6 +23,21 @@ def test_map_sqlalchemy_error_unique_conflict(): assert field is None +def test_map_sqlalchemy_error_check_constraint_is_400(): + exc = IntegrityError( + statement="UPDATE v3.project ...", + params={}, + orig=Exception("new row for relation violates check constraint chk_v3_project_name"), + ) + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_400_BAD_REQUEST + assert code == "db_error" + assert "violates check constraint" in message + assert field is None + + def test_map_sqlalchemy_error_validation_prefix(): exc = SQLAlchemyError() exc.orig = Exception("unknown_column: q5.plan") @@ -47,6 +62,24 @@ def test_map_sqlalchemy_error_not_found(): assert field is None +def test_map_sqlalchemy_error_role_window_prefixes(): + role_exc = SQLAlchemyError() + role_exc.orig = Exception("role_not_allowed: q1.m1") + status_code, code, message, field = map_sqlalchemy_error(role_exc) + assert status_code == status.HTTP_403_FORBIDDEN + assert code == "access_denied" + assert message == "role_not_allowed: q1.m1" + assert field is None + + window_exc = SQLAlchemyError() + window_exc.orig = Exception("window_closed: q1.m1 (closed 2026-05-01T00:00:00Z)") + status_code, code, message, field = map_sqlalchemy_error(window_exc) + assert status_code == status.HTTP_403_FORBIDDEN + assert code == "access_denied" + assert message.startswith("window_closed:") + assert field is None + + def test_map_sqlalchemy_error_operational(): exc = OperationalError( statement="SELECT 1", From 7b78a817cd04151d8ed5cfeaaf191c409ac0439f Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Thu, 14 May 2026 11:25:53 +0300 Subject: [PATCH 6/9] delete models.py --- api/src/domain/models.py | 70 ---------------------------------------- 1 file changed, 70 deletions(-) delete mode 100644 api/src/domain/models.py diff --git a/api/src/domain/models.py b/api/src/domain/models.py deleted file mode 100644 index 5504c8a..0000000 --- a/api/src/domain/models.py +++ /dev/null @@ -1,70 +0,0 @@ -import enum - -from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String, - UniqueConstraint) -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func - -from src.db.base import Base - - -class UserRole(int, enum.Enum): - ADMIN = 1 - EXECUTOR_DFIP = 2 - EXECUTOR_RF = 3 - - -class Role(Base): - __tablename__ = "role" - - id = Column(Integer, primary_key=True, index=True) - code = Column(String, nullable=False) - - user_role = relationship("Users", back_populates="role", lazy="select") - - -class Users(Base): - __tablename__ = "app_user" - - 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("role.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( - "Role", - 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("app_user.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") From 0e639ac4338ec238d66156b7ad25ef47fd05eb03 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Thu, 14 May 2026 13:16:14 +0300 Subject: [PATCH 7/9] =?UTF-8?q?form=5F124=5Fwrite:=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D1=8C=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20?= =?UTF-8?q?1=202=204=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/api/v1/forms.py | 238 +++++++++++++++--- api/src/core/exception_handlers.py | 6 +- api/src/db/session.py | 2 +- api/src/domain/schemas.py | 17 +- api/src/repository/budget_line_repository.py | 17 ++ api/src/repository/sheet_repository.py | 192 +++++++++++++- api/src/services/budget_line_service.py | 28 +++ api/src/services/sheet_service.py | 112 +++++++++ api/tests/integration/conftest.py | 25 +- api/tests/integration/test_forms_api_smoke.py | 133 ++++++++++ 10 files changed, 723 insertions(+), 47 deletions(-) create mode 100644 api/src/repository/budget_line_repository.py create mode 100644 api/src/services/budget_line_service.py create mode 100644 api/tests/integration/test_forms_api_smoke.py diff --git a/api/src/api/v1/forms.py b/api/src/api/v1/forms.py index 0b1b66d..2dd883b 100644 --- a/api/src/api/v1/forms.py +++ b/api/src/api/v1/forms.py @@ -5,9 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy.ext.asyncio import AsyncSession +from src.services.budget_line_service import BudgetLineService from src.db.models.form_type import FormTypeEnum from src.services.sheet_service import SheetService -from src.domain.schemas import BaseListResponse, BaseSingleResponse, BudgetFormResponse, DirectionSchemaEnum, SheetFormTypeResponse, SheetResponse +from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, SheetFormTypeResponse, SheetResponse from src.db.models.app_user import AppUser from src.services.budget_form_service import BudgetFormService from src.api.v1.deps import get_current_active_user_with_set_db @@ -16,6 +17,17 @@ from src.db.session import get_db router = APIRouter(prefix="/form", tags=["forms"]) +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 + ] + + @router.get("/") async def get_forms( offset: int = 0, @@ -99,33 +111,201 @@ async def get_sheet( response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" return BaseListResponse( count=len(result), - result=[ - SheetResponse( - row_type=el[0], - depth=el[1], - sort_order=el[2], - data=el[3], - ) for el in result - ] + result=_rows_to_sheet_response(result), ) - # try: - # with db_cursor() as cur: - # t0 = time.perf_counter() - # cur.execute( - # "SELECT row_type, depth, sort_order, data " - # "FROM v3.v_form_view(%s, %s, NULL, %s)", - # (form_id, sheet, direction), - # ) - # rows = cur.fetchall() - # db_ms = (time.perf_counter() - t0) * 1000 - # payload = [ - # {"row_type": r[0], "depth": r[1], "sort_order": r[2], "data": r[3]} - # for r in rows - # ] - # return JSONResponse( - # content=payload, - # headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}, - # ) - # except psycopg2.Error as e: - # raise HTTPException(400, str(e).strip()) + +@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]: + if sections is not None: + sections = sections.split(",") + 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} не найден") + if sections and (rem_sects := set(sections) - set(form.form_type.section_list)): + raise HTTPException(404, f"Секций {', '.join(rem_sects)} формы {form_id} не найден") + + budget_line_service = BudgetLineService(db) + budget_line = await budget_line_service.get(budget_line_id=cell_body.line_id, user=current_user) + if budget_line.budget_form_id != form_id: + raise HTTPException(404, f"Строка формы не найдена") + + + result = await sheet_service.update_cell( + form_id=form_id, + sheet=sheet, + direction=direction.value if direction else None, + sections=sections, + line_id=cell_body.line_id, + column=cell_body.column, + value=cell_body.value, + user=current_user, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + return BaseListResponse( + count=len(result), + result=_rows_to_sheet_response(result), + ) + + + + +@router.patch("/{form_id}/sheet/{sheet}/cells") +async def update_cells( + form_id: int, + sheet: str, + response: Response, + cells_body: CellsPatch, + direction: Optional[DirectionSchemaEnum] = None, + sections: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[SheetResponse]: + if sections is not None: + sections = sections.split(",") + 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} не найден") + if sections and (rem_sects := set(sections) - set(form.form_type.section_list)): + raise HTTPException(404, f"Секций {', '.join(rem_sects)} формы {form_id} не найден") + + budget_line_service = BudgetLineService(db) + budget_lines = await budget_line_service.get_list( + budget_line_ids=[cb.line_id for cb in cells_body.changes], user=current_user) + for budget_line in budget_lines: + if budget_line.budget_form_id != form_id: + raise HTTPException(404, f"Строка формы не найдена") + + + result = await sheet_service.update_cells( + form_id=form_id, + sheet=sheet, + direction=direction.value if direction else None, + sections=sections, + changes=[c.model_dump() for c in cells_body.changes], + user=current_user, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + return BaseListResponse( + count=len(result), + result=_rows_to_sheet_response(result), + ) + + + +@router.post("/{form_id}/sheet/{sheet}/line") +async def add_line( + form_id: int, + sheet: str, + body: AddLineSchema, + response: Response, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +): + t0 = time.perf_counter() + bf_service = BudgetFormService(db) + sheet_service = SheetService(db) + + form = await bf_service.get( + user=current_user, + budget_form_id=form_id, + load_form_type=True, + ) + if not form: + raise HTTPException(404, f"Форма {form_id} не найдена") + if sheet not in form.form_type.sheet_list: + raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден") + + result = await sheet_service.add_line( + form_id=form_id, + sheet=sheet, + expense_item_id=body.expense_item_id, + item_id=body.item_id, + section_code=body.section_code, + direction=body.direction.value if body.direction else None, + name=body.name, + internal_order=body.internal_order, + vsp_id=body.vsp_id, + project_id=body.project_id, + justification=body.justification, + contract_number=body.contract_number, + contract_end_date=body.contract_end_date, + user=current_user, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + return BaseListResponse( + count=len(result), + result=_rows_to_sheet_response(result), + ) + + + +@router.delete("/{form_id}/sheet/{sheet}/line/{row_id}") +async def delete_line( + form_id: int, + sheet: str, + row_id: int, + response: Response, + direction: str | None = 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} не найден") + + result = await sheet_service.delete_line( + form_id=form_id, + sheet=sheet, + row_id=row_id, + direction=direction, + user=current_user, + ) + db_ms = (time.perf_counter() - t0) * 1000 + response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" + return BaseListResponse( + count=len(result), + result=_rows_to_sheet_response(result), + ) \ No newline at end of file diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index d7efc24..c1a07e1 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,5 +1,8 @@ -from fastapi import FastAPI, status +# import psycopg2 + +from fastapi import FastAPI, HTTPException, status from fastapi.responses import JSONResponse +import sqlalchemy from src.core.errors import ( AccessDeniedException, @@ -10,6 +13,7 @@ from src.core.errors import ( ) + def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(AccessDeniedException) async def access_denied_exception_handler(request, exc: AccessDeniedException): diff --git a/api/src/db/session.py b/api/src/db/session.py index 82d8cc1..eb04b72 100644 --- a/api/src/db/session.py +++ b/api/src/db/session.py @@ -14,7 +14,7 @@ async def get_db() -> AsyncGenerator: db = SessionLocal() try: yield db - # await db.commit() + await db.commit() finally: await db.close() diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 957fed4..58f12e6 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -2,6 +2,7 @@ from datetime import datetime import enum from typing import Any, Generic, Literal, Optional, TypeVar + from pydantic import BaseModel, ConfigDict, EmailStr, Field T = TypeVar("T") @@ -210,4 +211,18 @@ class AddProjectBody(BaseModel): placement_type: Optional[PlacementTypeLiteral] = None object_address: Optional[str] = None staff_count: Optional[int] = None - total_area: Optional[float] = None \ No newline at end of file + total_area: Optional[float] = None + + +class AddLineSchema(BaseModel): + expense_item_id: Optional[int] = None + item_id: Optional[str] = None + section_code: Optional[str] = None + direction: Optional[DirectionSchemaEnum] = None + name: Optional[str] = None + internal_order: Optional[str] = None + vsp_id: Optional[int] = None + project_id: Optional[int] = None + justification: Optional[str] = None + contract_number: Optional[str] = None + contract_end_date: Optional[datetime] = None diff --git a/api/src/repository/budget_line_repository.py b/api/src/repository/budget_line_repository.py new file mode 100644 index 0000000..8d4d99f --- /dev/null +++ b/api/src/repository/budget_line_repository.py @@ -0,0 +1,17 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.budget_line import BudgetLine + + +class BudgetLineRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get(self, budget_line_id: int) -> BudgetLine | None: + query = select(BudgetLine).where(BudgetLine.id == budget_line_id).limit(1) + return (await self.db.execute(query)).scalar_one_or_none() + + async def get_list(self, budget_line_ids: list[int]) -> BudgetLine | None: + query = select(BudgetLine).where(BudgetLine.id.in_(budget_line_ids)) + return (await self.db.execute(query)).scalars().all() \ No newline at end of file diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index 696cefe..50915a3 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -1,5 +1,6 @@ import enum import itertools +import json from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession @@ -44,26 +45,191 @@ class SheetRepository: ): raise SheetValidationError("Некорректные данные") - sections_str = 'NULL' - if sections: - sections_str = ",".join([f"'{section}'" for section in sections]) - sections_str = f"ARRAY[{sections_str}]" + if direction: - func_query = "v3.v_form_view(%s, '%s', %s, '%s')" % ( - form_id, sheet, sections_str, direction, - ) + func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)" else: - func_query = "v3.v_form_view(%s, '%s', %s)" % ( - form_id, sheet, sections_str, - ) - result = ( + 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, + sections: list[str], + 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 not in [s.value for s in DirectionEnum], + sections and (set(sections) - ACCEPTABLE_SECTIONS), + ] + ): + raise SheetValidationError("Некорректные данные") + if user_id: + func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :user_id, :direction, :sections)" + else: + func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :direction, :sections)" + + return ( + await self.db.execute( + text( + f""" + SELECT row_type, depth, sort_order, data + FROM {func_query} + """ + ), + { + "form_id": form_id, + "sheet": sheet, + "direction": direction, + "sections": sections, + "line_id": line_id, + "column": column, + "value": json.dumps(value), + "user_id": user_id, + } + ) + ).all() + + async def update_cells( + self, + form_id: int, + sheet: str, + direction: str, + sections: list[str], + changes: list[dict], + user_id: int = None, + ) -> list[tuple]: + if any( + [ + not isinstance(form_id, int), + sheet not in ACCEPTABLE_SHEETS, + direction not in [s.value for s in DirectionEnum], + sections and (set(sections) - ACCEPTABLE_SECTIONS), + ] + ): + raise SheetValidationError("Некорректные данные") + changes = json.dumps(changes) + if user_id: + func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :user_id, :direction, :sections)" + else: + func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :direction, :sections)" + + return ( + await self.db.execute( + text( + f""" + SELECT row_type, depth, sort_order, data + FROM {func_query} + """ + ), + { + "form_id": form_id, + "sheet": sheet, + "direction": direction, + "sections": sections, + "changes": changes, + "user_id": user_id, + } + ) + ).all() + + async def add_line( + self, + form_id: int, + sheet: str, + expense_item_id: int | None = None, + item_id: str | None = None, + section_code: str | None = None, + direction: str | None = None, + name: str | None = None, + internal_order: str | None = None, + vsp_id: int | None = None, + project_id: int | None = None, + justification: str | None = None, + contract_number: str | None = None, + contract_end_date: str | None = None, + user_id: int | None = None, + ) -> list[tuple]: + return ( + await self.db.execute( + text( + """ + SELECT row_type, depth, sort_order, data + FROM v3.add_budget_line( + :p_form_id, + :p_expense_item_id, + :p_sheet, + :p_item_id, + :p_section_code, + :p_direction, + :p_name, + :p_internal_order, + :p_vsp_id, + :p_project_id, + :p_justification, + :p_contract_number, + :p_contract_end_date + ) + """ + ), + { + "p_form_id": form_id, + "p_expense_item_id": expense_item_id, + "p_sheet": sheet, + "p_item_id": item_id, + "p_section_code": section_code, + "p_direction": direction, + "p_name": name, + "p_internal_order": internal_order, + "p_vsp_id": vsp_id, + "p_project_id": project_id, + "p_justification": justification, + "p_contract_number": contract_number, + "p_contract_end_date": contract_end_date, + } + ) + ).all() + + async def delete_line( + self, + sheet: str, + row_id: int, + direction: str | None = None, + ) -> list[tuple]: + return ( + await self.db.execute( + text( + "SELECT row_type, depth, sort_order, data " + "FROM v3.del_budget_line(:p_row_id, :p_direction, :p_sheet)" + ), + { + "p_row_id": row_id, + "p_direction": direction, + "p_sheet": sheet, + } ) ).all() - return result \ No newline at end of file diff --git a/api/src/services/budget_line_service.py b/api/src/services/budget_line_service.py new file mode 100644 index 0000000..fca7904 --- /dev/null +++ b/api/src/services/budget_line_service.py @@ -0,0 +1,28 @@ + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.app_user import AppUser +from src.db.models.budget_line import BudgetLine +from src.repository.budget_line_repository import BudgetLineRepository + + + + +class BudgetLineService: + def __init__(self, db: AsyncSession): + self.db = db + self.bl_repo = BudgetLineRepository(db) + + async def get( + self, + user: AppUser, + budget_line_id: int, + ) -> BudgetLine | None: + return await self.bl_repo.get(budget_line_id=budget_line_id) + + async def get_list( + self, + user: AppUser, + budget_line_ids: list[int], + ) -> list[BudgetLine]: + return await self.bl_repo.get_list(budget_line_ids=budget_line_ids) diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index fbbc55a..2345d72 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -1,6 +1,7 @@ from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.app_user import AppUser from src.db.models.budget_form import BudgetForm from src.repository.sheet_repository import SheetRepository @@ -24,4 +25,115 @@ class SheetService: sheet=sheet, direction=direction, sections=sections, + ) + + async def update_cell( + self, + form_id: int, + sheet: str, + direction: str, + sections: list[str], + line_id: int, + column: str, + value: str | int | float | None = None, + user: AppUser | None = None, + ) -> list[tuple]: + return await self.sheet_repo.update_cell( + form_id=form_id, + sheet=sheet, + direction=direction, + sections=sections, + line_id=line_id, + column=column, + value=value, + user_id=user.id if user else None, + ) + + async def update_cells( + self, + form_id: int, + sheet: str, + direction: str, + sections: list[str], + changes: list[dict], + user: AppUser | None = None, + ) -> list[tuple]: + return await self.sheet_repo.update_cells( + form_id=form_id, + sheet=sheet, + direction=direction, + sections=sections, + changes=changes, + user_id=user.id if user else None, + ) + + async def update_cell( + self, + form_id: int, + sheet: str, + direction: str, + sections: list[str], + line_id: int, + column: str, + value: str | int | float | None = None, + user: AppUser | None = None, + ) -> list[tuple]: + return await self.sheet_repo.update_cell( + form_id=form_id, + sheet=sheet, + direction=direction, + sections=sections, + line_id=line_id, + column=column, + value=value, + user_id=user.id if user else None, + ) + + + async def add_line( + self, + form_id: int, + sheet: str, + expense_item_id: int | None = None, + item_id: str | None = None, + section_code: str | None = None, + direction: str | None = None, + name: str | None = None, + internal_order: str | None = None, + vsp_id: int | None = None, + project_id: int | None = None, + justification: str | None = None, + contract_number: str | None = None, + contract_end_date: str | None = None, + user: AppUser | None = None, + ) -> list[tuple]: + return await self.sheet_repo.add_line( + form_id=form_id, + sheet=sheet, + expense_item_id=expense_item_id, + item_id=item_id, + section_code=section_code, + direction=direction, + name=name, + internal_order=internal_order, + vsp_id=vsp_id, + project_id=project_id, + justification=justification, + contract_number=contract_number, + contract_end_date=contract_end_date, + user_id=user.id if user else None, + ) + + async def delete_line( + self, + form_id: int, + sheet: str, + row_id: int, + direction: str | None = None, + user: AppUser | None = None, + ) -> list[tuple]: + return await self.sheet_repo.delete_line( + sheet=sheet, + row_id=row_id, + direction=direction, ) \ No newline at end of file diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index f578a9a..b774268 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -1,15 +1,36 @@ +import asyncio import os import pytest from fastapi.testclient import TestClient + +from src.db.base import SessionLocal +from src.db.session import get_db from src.main import app + + @pytest.fixture def client(): - with TestClient(app) as test_client: - yield test_client + db = SessionLocal() + loop = asyncio.get_event_loop() + + try: + async def _get_db(): + try: + yield db + await db.flush() + finally: + await db.close() + app.dependency_overrides[get_db] = _get_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + finally: + loop.run_until_complete(db.rollback()) + loop.run_until_complete(db.close()) @pytest.fixture diff --git a/api/tests/integration/test_forms_api_smoke.py b/api/tests/integration/test_forms_api_smoke.py new file mode 100644 index 0000000..d9643db --- /dev/null +++ b/api/tests/integration/test_forms_api_smoke.py @@ -0,0 +1,133 @@ +import pytest + + +def _auth_headers(tokens: dict) -> dict: + return {"Authorization": f"Bearer {tokens['access_token']}"} + + +def test_forms_list_smoke(client, admin_tokens): + response = client.get("/api/v1/form/", headers=_auth_headers(admin_tokens)) + assert response.status_code == 200 + payload = response.json() + assert "result" in payload + assert "count" in payload + assert isinstance(payload["result"], list) + + +def test_forms_sheets_not_found_error_shape(client, admin_tokens): + response = client.get( + "/api/v1/form/999/sheets", headers=_auth_headers(admin_tokens) + ) + assert response.status_code == 404 + payload = response.json() + assert isinstance(payload, dict) + assert "detail" in payload + assert isinstance(payload["detail"], str) + + +def test_forms_sheet_get_not_found_error_shape(client, admin_tokens): + response = client.get( + "/api/v1/form/999/sheet/AHR", headers=_auth_headers(admin_tokens) + ) + assert response.status_code == 404 + payload = response.json() + assert isinstance(payload, dict) + assert "detail" in payload + + +def test_forms_cell_update_not_found_error_shape(client, admin_tokens): + response = client.patch( + "/api/v1/form/999/sheet/AHR/cell", + json={"line_id": 1, "column": "q1.m1", "value": 100}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 404 + payload = response.json() + assert isinstance(payload, dict) + assert "detail" in payload + + +def test_forms_cell_update(client, admin_tokens): + response = client.patch( + "/api/v1/form/1/sheet/AHR/cell?direction=Support§ions=plan,contract_summary", + json={ + "line_id": 1, + "column": "plan.q1", + "value": 225 + }, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, dict) + assert "result" in payload + + +def test_forms_cells_update_not_found_error_shape(client, admin_tokens): + response = client.patch( + "/api/v1/form/999/sheet/AHR/cells", + json={"changes": [{"line_id": 1, "column": "q1.m1", "value": 100}]}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 404 + payload = response.json() + assert isinstance(payload, dict) + assert "detail" in payload + + +def test_forms_cells_update(client, admin_tokens): + response = client.patch( + "/api/v1/form/1/sheet/AHR/cells?direction=Support§ions=plan,contract_summary", + json={ + "changes": [ + { + "line_id": 1, + "column": "plan.q1", + "value": 226 + } + ], + }, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, dict) + assert "result" in payload + + +def test_forms_add_line_not_found_error_shape(client, admin_tokens): + response = client.post( + "/api/v1/form/999/sheet/AHR/line", + json={}, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 404 + payload = response.json() + assert isinstance(payload, dict) + assert "detail" in payload + + +def test_forms_add_line(client, admin_tokens): + response = client.post( + "/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary", + json={ + "expense_item_id": 120, + "direction": "Support" + }, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, dict) + assert "result" in payload + + +def test_forms_delete_line_not_found_error_shape(client, admin_tokens): + response = client.delete( + "/api/v1/form/999/sheet/AHR/line/1", + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 404 + payload = response.json() + assert isinstance(payload, dict) + assert "detail" in payload From de000905b3e5a6213175a45b9317bae211889fdb Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Thu, 14 May 2026 16:04:39 +0300 Subject: [PATCH 8/9] Add Constants --- api/src/core/CONSTANTS.py | 31 ++++++++++++++++++++++++++++++ api/src/core/exception_handlers.py | 31 ++++++++++-------------------- 2 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 api/src/core/CONSTANTS.py diff --git a/api/src/core/CONSTANTS.py b/api/src/core/CONSTANTS.py new file mode 100644 index 0000000..3cbaac0 --- /dev/null +++ b/api/src/core/CONSTANTS.py @@ -0,0 +1,31 @@ +VALIDATION_PREFIXES = ( + "computed_field:", + "normative_field:", + "unknown_column:", + "key_field:", + "structural_field:", + "bad_column_format:", + "bad_booking_year:", + "bad_booking_quarter:", + "unsupported_scope:", +) + +PG_404_ERRORS = ( + "не существует", + "не принадлежит", + "not found", +) +PG_409_ERRORS = ( + "unique", + "duplicate", + "foreign key", +) +PG_400_ERRORS = ( + "violates check constraint", +) +PG_403_ERRORS = ( + "permission denied", + "insufficient privilege", + "role_not_allowed:", + "window_closed:", +) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 4559291..b1eaa97 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -9,21 +9,14 @@ from src.core.errors import ( UsernameConflictException, ValidationException, ) - - -VALIDATION_PREFIXES = ( - "computed_field:", - "normative_field:", - "unknown_column:", - "key_field:", - "structural_field:", - "bad_column_format:", - "bad_booking_year:", - "bad_booking_quarter:", - "unsupported_scope:", +from src.core.CONSTANTS import ( + VALIDATION_PREFIXES, + PG_404_ERRORS, + PG_409_ERRORS, + PG_400_ERRORS, + PG_403_ERRORS, ) - def _error_payload(code: str | int, message: str, field: str | None = None) -> dict: """Формирует структурированный payload ошибки по спеке.""" return { @@ -60,19 +53,15 @@ def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | Non if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES): code, field = _parse_prefixed_validation_error(message) return status.HTTP_422_UNPROCESSABLE_ENTITY, code, message, field - if low.startswith("role_not_allowed:") or low.startswith("window_closed:"): - return status.HTTP_403_FORBIDDEN, "access_denied", message, None - if "не существует" in low or "не принадлежит" in low or "not found" in low: + if any(error in low for error in PG_404_ERRORS): return status.HTTP_404_NOT_FOUND, "not_found", message, None - if "permission denied" in low or "insufficient privilege" in low: + if any(error in low for error in PG_403_ERRORS): return status.HTTP_403_FORBIDDEN, "access_denied", message, None if isinstance(exc, IntegrityError): - if "unique" in low or "duplicate" in low: + if any(error in low for error in PG_409_ERRORS): return status.HTTP_409_CONFLICT, "conflict", message, None - if "foreign key" in low: - return status.HTTP_409_CONFLICT, "conflict", message, None - if "violates check constraint" in low: + if any(error in low for error in PG_400_ERRORS): return status.HTTP_400_BAD_REQUEST, "db_error", message, None return status.HTTP_400_BAD_REQUEST, "db_error", message, None From 13db21c4af3f2f3753005e8aabb62024165fba7a Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Thu, 14 May 2026 16:22:59 +0300 Subject: [PATCH 9/9] refactor tests for src.db.models... and for new code, message, field error response --- api/src/api/v1/deps.py | 29 ++++++++++--------- api/src/api/v1/users.py | 20 ++++++------- api/src/repository/user_repository.py | 2 +- api/tests/integration/test_forms_api_smoke.py | 10 +++---- .../integration/test_projects_api_smoke.py | 6 ++-- 5 files changed, 34 insertions(+), 33 deletions(-) diff --git a/api/src/api/v1/deps.py b/api/src/api/v1/deps.py index d38a39b..79e62d8 100644 --- a/api/src/api/v1/deps.py +++ b/api/src/api/v1/deps.py @@ -5,7 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.core.config import settings from src.core.security import verify_token from src.db.session import get_db -from src.domain.models import UserRole, Users +from src.db.models.app_user import AppUser +from src.db.models.role import UserRoleEnum from src.repository.user_repository import UserRepository security = HTTPBearer() @@ -16,7 +17,7 @@ if settings.DEBUG: async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db), - ) -> Users: + ) -> AppUser: token = credentials.credentials payload = verify_token(token) if payload is None: @@ -52,7 +53,7 @@ else: async def get_current_user( user: UserInfo = Depends(get_user_dependency), db: AsyncSession = Depends(get_db), - ) -> Users: + ) -> AppUser: user_repo = UserRepository(db) db_user = await user_repo.get_by_email(user.email) if db_user is None: @@ -64,22 +65,22 @@ else: return db_user -async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users: +async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser: return current_user async def get_current_active_user_with_set_db( - current_user: Users = Depends(get_current_user), + current_user: AppUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), -) -> Users: +) -> AppUser: user_repo = UserRepository(db) await user_repo.set_app_user_id(current_user.id) return current_user -def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users: - if current_user.role_id != UserRole.ADMIN: +def require_admin(current_user: AppUser = Depends(get_current_active_user)) -> AppUser: + if current_user.role_id != UserRoleEnum.ADMIN: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Недостаточно прав", @@ -87,11 +88,11 @@ def require_admin(current_user: Users = Depends(get_current_active_user)) -> Use return current_user -def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users: +def require_executor(current_user: AppUser = Depends(get_current_active_user)) -> AppUser: if current_user.role_id not in [ - UserRole.ADMIN, - UserRole.EXECUTOR_DFIP, - UserRole.EXECUTOR_RF, + UserRoleEnum.ADMIN, + UserRoleEnum.EXECUTOR_DFIP, + UserRoleEnum.EXECUTOR_RF, ]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -100,8 +101,8 @@ def require_executor(current_user: Users = Depends(get_current_active_user)) -> return current_user -def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users: - if current_user.role_id not in [UserRole.ADMIN, UserRole.EXECUTOR_DFIP]: +def require_executor_dfip(current_user: AppUser = Depends(get_current_active_user)) -> AppUser: + if current_user.role_id not in [UserRoleEnum.ADMIN, UserRoleEnum.EXECUTOR_DFIP]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Недостаточно прав", diff --git a/api/src/api/v1/users.py b/api/src/api/v1/users.py index 2c233d2..0570173 100644 --- a/api/src/api/v1/users.py +++ b/api/src/api/v1/users.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.v1.deps import get_current_active_user, require_admin, require_executor_dfip from src.db.session import get_db -from src.domain.models import Users +from src.db.models.app_user import AppUser from src.domain.schemas import User as UserSchema from src.domain.schemas import ( SSPIDList, @@ -21,7 +21,7 @@ router = APIRouter(prefix="/users", tags=["users"]) @router.get("/me", response_model=UserSchema) -async def get_current_user_info(current_user: Users = Depends(get_current_active_user)): +async def get_current_user_info(current_user: AppUser = Depends(get_current_active_user)): return current_user @@ -30,7 +30,7 @@ async def get_users( skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db), - current_user: Users = Depends(require_admin), + current_user: AppUser = Depends(require_admin), ): user_service = UserService(db) users = await user_service.get_all(current_user, skip=skip, limit=limit) @@ -41,7 +41,7 @@ async def get_users( async def get_many_ssp( user_id: int, db: AsyncSession = Depends(get_db), - current_user: Users = Depends(require_executor_dfip), + current_user: AppUser = Depends(require_executor_dfip), ): user_service = UserService(db) ssp_list = await user_service.get_many_ssp(user_id, current_user) @@ -57,7 +57,7 @@ async def set_many_ssp( user_id: int, ssp_ids_data: SSPIDList, db: AsyncSession = Depends(get_db), - current_user: Users = Depends(require_admin), + current_user: AppUser = Depends(require_admin), ): user_service = UserService(db) success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user) @@ -72,7 +72,7 @@ async def unset_many_ssp( user_id: int, ssp_ids_data: SSPIDList, db: AsyncSession = Depends(get_db), - current_user: Users = Depends(require_admin), + current_user: AppUser = Depends(require_admin), ): user_service = UserService(db) success = await user_service.unset_many_ssp( @@ -88,7 +88,7 @@ async def unset_many_ssp( async def get_user( user_id: int, db: AsyncSession = Depends(get_db), - current_user: Users = Depends(require_admin), + current_user: AppUser = Depends(require_admin), ): user_service = UserService(db) user = await user_service.get(user_id, current_user) @@ -104,7 +104,7 @@ async def get_user( async def create_user( user_data: UserCreate, db: AsyncSession = Depends(get_db), - current_user: Users = Depends(require_admin), + current_user: AppUser = Depends(require_admin), ): user_service = UserService(db) user = await user_service.create_user(user_data=user_data, creator=current_user) @@ -116,7 +116,7 @@ async def update_user( user_id: int, user_data: UserUpdate, db: AsyncSession = Depends(get_db), - current_user: Users = Depends(require_admin), + current_user: AppUser = Depends(require_admin), ): user_service = UserService(db) user = await user_service.update_user( @@ -131,7 +131,7 @@ async def update_user( async def delete_user( user_id: int, db: AsyncSession = Depends(get_db), - current_user: Users = Depends(require_admin), + current_user: AppUser = Depends(require_admin), ): user_service = UserService(db) await user_service.delete_user(user_id=user_id, user=current_user) diff --git a/api/src/repository/user_repository.py b/api/src/repository/user_repository.py index f497a69..af89cdc 100644 --- a/api/src/repository/user_repository.py +++ b/api/src/repository/user_repository.py @@ -10,7 +10,7 @@ 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 Role, OrgUnit, UserOrg, AppUser + class UserRepository: diff --git a/api/tests/integration/test_forms_api_smoke.py b/api/tests/integration/test_forms_api_smoke.py index d9643db..d7b0d4f 100644 --- a/api/tests/integration/test_forms_api_smoke.py +++ b/api/tests/integration/test_forms_api_smoke.py @@ -32,7 +32,7 @@ def test_forms_sheet_get_not_found_error_shape(client, admin_tokens): assert response.status_code == 404 payload = response.json() assert isinstance(payload, dict) - assert "detail" in payload + assert "message" in payload def test_forms_cell_update_not_found_error_shape(client, admin_tokens): @@ -44,7 +44,7 @@ def test_forms_cell_update_not_found_error_shape(client, admin_tokens): assert response.status_code == 404 payload = response.json() assert isinstance(payload, dict) - assert "detail" in payload + assert "message" in payload def test_forms_cell_update(client, admin_tokens): @@ -72,7 +72,7 @@ def test_forms_cells_update_not_found_error_shape(client, admin_tokens): assert response.status_code == 404 payload = response.json() assert isinstance(payload, dict) - assert "detail" in payload + assert "message" in payload def test_forms_cells_update(client, admin_tokens): @@ -104,7 +104,7 @@ def test_forms_add_line_not_found_error_shape(client, admin_tokens): assert response.status_code == 404 payload = response.json() assert isinstance(payload, dict) - assert "detail" in payload + assert "message" in payload def test_forms_add_line(client, admin_tokens): @@ -130,4 +130,4 @@ def test_forms_delete_line_not_found_error_shape(client, admin_tokens): assert response.status_code == 404 payload = response.json() assert isinstance(payload, dict) - assert "detail" in payload + assert "message" in payload diff --git a/api/tests/integration/test_projects_api_smoke.py b/api/tests/integration/test_projects_api_smoke.py index 07f6386..d6c85b1 100644 --- a/api/tests/integration/test_projects_api_smoke.py +++ b/api/tests/integration/test_projects_api_smoke.py @@ -104,8 +104,8 @@ def test_projects_write_invalid_report_type_error_shape(client, admin_tokens): assert response.status_code == 400 payload = response.json() assert isinstance(payload, dict) - assert "detail" in payload - assert isinstance(payload["detail"], str) + assert "message" in payload + assert isinstance(payload["message"], str) def test_projects_write_report_not_found_error_shape(client, admin_tokens): @@ -120,4 +120,4 @@ def test_projects_write_report_not_found_error_shape(client, admin_tokens): assert response.status_code == 400 payload = response.json() assert isinstance(payload, dict) - assert payload.get("detail") == "Отчёт не найден" + assert payload.get("message") == "Отчёт не найден"