WIP: READ ручки для форм 1 2 4

This commit is contained in:
tsygankoviva 2026-05-08 12:33:05 +03:00
parent e267d2d631
commit c67c94ba1f
50 changed files with 1945 additions and 68 deletions

141
api/alembic.ini Normal file
View File

@ -0,0 +1,141 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
api/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

78
api/alembic/env.py Normal file
View File

@ -0,0 +1,78 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -68,6 +68,16 @@ async def get_current_active_user(current_user: Users = Depends(get_current_user
return 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: def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users:
if current_user.role_id != UserRole.ADMIN: if current_user.role_id != UserRole.ADMIN:
raise HTTPException( raise HTTPException(

131
api/src/api/v1/forms.py Normal file
View File

@ -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())

View File

@ -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,
)

View File

@ -1,7 +1,11 @@
from fastapi import APIRouter from fastapi import APIRouter
from src.api.v1 import projects
from src.api.v1 import forms
from src.api.v1 import auth, users from src.api.v1 import auth, users
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(forms.router)
api_router.include_router(projects.router)

View File

@ -7,12 +7,15 @@ connect_args = {}
if settings.DATABASE_SCHEMA: if settings.DATABASE_SCHEMA:
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA} connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
if "sqlite" in settings.DATABASE_URL: if "sqlite" in settings.DATABASE_URL:
connect_args["check_same_thread"] = False connect_args["check_same_thread"] = False
engine = create_async_engine( engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
pool_pre_ping=True, pool_pre_ping=True,
pool_size=5,
max_overflow=10,
connect_args=connect_args, connect_args=connect_args,
) )

View File

@ -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

View File

@ -0,0 +1,19 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Allocation(Base):
__tablename__ = "allocation"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
internal_order: Mapped[str | None] = mapped_column(String)
property_object: Mapped[str | None] = mapped_column(String)
contract_ref: Mapped[str | None] = mapped_column(String)
allocation_purpose: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,56 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class AppUser(Base):
__tablename__ = "app_user"
__table_args__ = (
Index(
"ix_v4_app_user_active",
"is_active",
postgresql_where="is_active",
),
Index("ix_v4_app_user_role", "role_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String)
username: Mapped[str] = mapped_column(String)
hashed_password: Mapped[str] = mapped_column(String)
full_name: Mapped[str | None] = mapped_column(String)
role_id: Mapped[int] = mapped_column(ForeignKey("v3.role.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# role: Mapped["Role"] = relationship("Role", back_populates="users") # type: ignore
# created_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="creator", foreign_keys="BudgetForm.created_by"
# )
# updated_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="updater", foreign_keys="BudgetForm.updated_by"
# )
# created_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
# "RfProjectReport",
# back_populates="creator",
# foreign_keys="RfProjectReport.created_by",
# )
# updated_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
# "RfProjectReport",
# back_populates="updater",
# foreign_keys="RfProjectReport.updated_by",
# )
# audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore
# "AuditLog", back_populates="user"
# )
user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="user")
org_units: Mapped[list["OrgUnit"]] = relationship("OrgUnit", secondary="v3.user_org", viewonly=True)

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
JSON,
String,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class AuditLog(Base):
__tablename__ = "audit_log"
__table_args__ = (
Index("ix_v3_audit_log_dt", text("event_dt DESC")),
Index("ix_v3_audit_log_event", "event"),
Index(
"ix_v3_audit_log_form",
"form_id",
postgresql_where="form_id IS NOT NULL",
),
Index("ix_v3_audit_log_user", "user_id"),
Index("ix_v4_audit_log_dt", text("event_dt DESC")),
Index("ix_v4_audit_log_event", "event"),
Index(
"ix_v4_audit_log_form",
"form_id",
postgresql_where="form_id IS NOT NULL",
),
Index("ix_v4_audit_log_user", "user_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("v3.app_user.id", ondelete="SET NULL")
)
org_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("v3.org_unit.id", ondelete="SET NULL")
)
task_id: Mapped[int | None] = mapped_column()
form_id: Mapped[int | None] = mapped_column()
event_dt: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
event: Mapped[str] = mapped_column(String)
event_type: Mapped[str] = mapped_column(String)
event_data: Mapped[dict | None] = mapped_column(JSON)
# user: Mapped["AppUser | None"] = relationship(
# "AppUser", back_populates="audit_logs"
# )
# org_unit: Mapped["OrgUnit | None"] = relationship(
# "OrgUnit", back_populates="audit_logs"
# )

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Booking(Base):
__tablename__ = "booking"
__table_args__ = (
CheckConstraint("quarter BETWEEN 1 AND 4", name="ck_v3_booking_quarter"),
CheckConstraint("source IN ('CKK','CONTRACT')", name="ck_v3_booking_source"),
Index("ix_v3_booking_line", "line_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
quarter: Mapped[int] = mapped_column(SmallInteger)
source: Mapped[str] = mapped_column(String)
booked_amount_ckk: Mapped[float | None] = mapped_column(Numeric)
booked_amount_contract: Mapped[float | None] = mapped_column(Numeric)
# line = relationship("BudgetLine")

View File

@ -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"
# )

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class BudgetLine(Base):
__tablename__ = "budget_line"
__table_args__ = (
Index("ix_v3_budget_line_form_item", "budget_form_id", "expense_item_id"),
Index("ix_v3_budget_line_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id"))
expense_item_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
name: Mapped[str | None] = mapped_column(String)
internal_order: Mapped[str | None] = mapped_column(String)
vsp_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
project_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
justification: Mapped[str | None] = mapped_column(String)
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
direction: Mapped[str | None] = mapped_column(String)
# budget_form = relationship("BudgetForm", back_populates="budget_lines")
# expense_item = relationship("ExpenseItem", back_populates="budget_lines")
# vsp = relationship("Vsp", back_populates="budget_lines")
# project = relationship("Project", back_populates="budget_lines")
# ckk = relationship("Ckk", uselist=False)
# collegial_approval = relationship("CollegialApproval", uselist=False)
# contract_detail = relationship("ContractDetail", uselist=False)
# contract_summary = relationship("ContractSummary", uselist=False)
# plan = relationship("Plan", uselist=False)
# reserve = relationship("Reserve", uselist=False)
# allocation = relationship("Allocation", uselist=False)
# quarters: Mapped[list["BudgetLineQuarter"]] = relationship("BudgetLineQuarter", back_populates="line") # type: ignore
# sequestration_records: Mapped[list["Sequestration"]] = relationship("Sequestration", back_populates="budget_line") # type: ignore
# booking_records: Mapped[list["Booking"]] = relationship("Booking", back_populates="budget_line") # type: ignore
# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="budget_line") # type: ignore
# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="budget_line") # type: ignore
# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="budget_line") # type: ignore

View File

@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class BudgetLineQuarter(Base):
__tablename__ = "budget_line_quarter"
__table_args__ = (
CheckConstraint("quarter >= 1 AND quarter <= 4", name="chk_budget_line_quarter_quarter"),
{"schema": "v3"},
)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
quarter: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
adj_current: Mapped[float | None] = mapped_column(Numeric)
adj_ssp: Mapped[float | None] = mapped_column(Numeric)
adj_rf: Mapped[float | None] = mapped_column(Numeric)
adj_reserve: Mapped[float | None] = mapped_column(Numeric)
adj_comment: Mapped[str | None] = mapped_column(String)
target_change: Mapped[float | None] = mapped_column(Numeric)
base_plan_correction: Mapped[float | None] = mapped_column(Numeric)
base_plan_correction_comment: Mapped[str | None] = mapped_column(String)
payment_date: Mapped[date | None] = mapped_column(Date)
payment_amount: Mapped[float | None] = mapped_column(Numeric)
payment_amount_ho: Mapped[float | None] = mapped_column(Numeric)
payment_amount_rf: Mapped[float | None] = mapped_column(Numeric)
payment_comment: Mapped[str | None] = mapped_column(String)
payment_act: Mapped[str | None] = mapped_column(String)
actual_m1: Mapped[float | None] = mapped_column(Numeric)
actual_m2: Mapped[float | None] = mapped_column(Numeric)
actual_m3: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q2: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q3: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q4: Mapped[float | None] = mapped_column(Numeric)
transfer_to_economy: Mapped[float | None] = mapped_column(Numeric)
transfer_delay_acts: Mapped[float | None] = mapped_column(Numeric)
transfer_delay_procurement: Mapped[float | None] = mapped_column(Numeric)
transfer_economy_rf: Mapped[float | None] = mapped_column(Numeric)
transfer_next_comment: Mapped[str | None] = mapped_column(String)
transfer_far_comment: Mapped[str | None] = mapped_column(String)
booking_amount: Mapped[float | None] = mapped_column(Numeric)
plan_revision_eco_change: Mapped[float | None] = mapped_column(Numeric)
plan_revision_item_adj: Mapped[float | None] = mapped_column(Numeric)
plan_revision_increase: Mapped[float | None] = mapped_column(Numeric)
plan_revision_sequester: Mapped[float | None] = mapped_column(Numeric)
plan_revision_comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", back_populates="quarters")

29
api/src/db/models/ckk.py Normal file
View File

@ -0,0 +1,29 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Ckk(Base):
__tablename__ = "ckk"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q4: Mapped[float | None] = mapped_column(Numeric)
rf_schedule: Mapped[str | None] = mapped_column(String)
delivery_deadline: Mapped[str | None] = mapped_column(String)
procurement_plan: Mapped[str | None] = mapped_column(String)
procurement_method: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,18 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class CollegialApproval(Base):
__tablename__ = "collegial_approval"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
approved_amount: Mapped[float | None] = mapped_column(Numeric)
protocol_reference: Mapped[str | None] = mapped_column(String)
note: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,34 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class ContractDetail(Base):
__tablename__ = "contract_detail"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
counterparty: Mapped[str | None] = mapped_column(String)
reference: Mapped[str | None] = mapped_column(String)
addenda: Mapped[str | None] = mapped_column(String)
subject: Mapped[str | None] = mapped_column(String)
currency: Mapped[str | None] = mapped_column(String)
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
rf_schedule: Mapped[str | None] = mapped_column(String)
vat_rate: Mapped[str | None] = mapped_column(String)
exchange_rate: Mapped[float | None] = mapped_column(Numeric)
amount_foreign: Mapped[float | None] = mapped_column(Numeric)
deadline: Mapped[str | None] = mapped_column(String)
payment_scheme: Mapped[str | None] = mapped_column(String)
act: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
contract_date: Mapped[str | None] = mapped_column(Date)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class ContractSummary(Base):
__tablename__ = "contract_summary"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
total_amount: Mapped[float | None] = mapped_column(Numeric)
counterparty: Mapped[str | None] = mapped_column(String)
reference: Mapped[str | None] = mapped_column(String)
deadline: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
future_payments_y1: Mapped[float | None] = mapped_column(Numeric)
future_payments_y2: Mapped[float | None] = mapped_column(Numeric)
other_ssp_amount: Mapped[float | None] = mapped_column(Numeric)
centralized_flag: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class FormLimit(Base):
__tablename__ = "form_limit"
__table_args__ = (
Index("ix_v3_form_limit_form", "budget_form_id"),
{"schema": "v3"},
)
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True)
template_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.limit_template.id", ondelete="CASCADE"), primary_key=True)
qty_q1: Mapped[int | None] = mapped_column(Integer)
qty_q2: Mapped[int | None] = mapped_column(Integer)
qty_q3: Mapped[int | None] = mapped_column(Integer)
qty_q4: Mapped[int | None] = mapped_column(Integer)
comment: Mapped[str | None] = mapped_column(String)
# budget_form = relationship("BudgetForm")
# template = relationship("LimitTemplate")

View File

@ -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"
# )

View File

@ -0,0 +1,33 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class LimitTemplate(Base):
__tablename__ = "limit_template"
__table_args__ = (
CheckConstraint(
"row_type IN ('SECTION','GROUP','LEAF')",
name="ck_v3_limit_template_row_type",
),
Index("ix_v3_limit_template_parent", "parent_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.limit_template.id"))
sort_order: Mapped[int] = mapped_column(Integer)
row_type: Mapped[str] = mapped_column(String)
section_no: Mapped[str | None] = mapped_column(String)
expense_item_code: Mapped[str | None] = mapped_column(String)
name: Mapped[str] = mapped_column(String)
unit: Mapped[str | None] = mapped_column(String)
limit_with_vat: Mapped[float | None] = mapped_column(Numeric)
limit_without_vat: Mapped[float | None] = mapped_column(Numeric)
# parent = relationship("LimitTemplate", remote_side="LimitTemplate.id")
# children: Mapped[list[LimitTemplate]] = relationship("LimitTemplate")
# form_limits: Mapped[list[FormLimit]] = relationship("FormLimit")

View File

@ -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")

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class PhaseTemplate(Base):
__tablename__ = "phase_template"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_phase_template_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_phase_template_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_phase_template_window"),
{"schema": "v3"},
)
form_type: Mapped[str] = mapped_column(String, ForeignKey("v3.form_type.code"), primary_key=True)
sheet: Mapped[str] = mapped_column(String, primary_key=True)
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
# form_type_rel = relationship("FormType", foreign_keys=[form_type])
# role_rel = relationship("Role", foreign_keys=[role], back_populates="phase_templates")

20
api/src/db/models/plan.py Normal file
View File

@ -0,0 +1,20 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Plan(Base):
__tablename__ = "plan"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,49 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Project(Base):
__tablename__ = "project"
__table_args__ = (
CheckConstraint("level IN ('program', 'project')", name="project_level_check"),
CheckConstraint(
"placement_type IS NULL OR placement_type IN ('own', 'rent', 'other')",
name="project_placement_type_check",
),
CheckConstraint(
"project_type IS NULL OR project_type IN ('current', 'development')",
name="project_project_type_check",
),
CheckConstraint(
"vsp_format IS NULL OR vsp_format IN ('office', 'standalone', 'atm', 'other')",
name="project_vsp_format_check",
),
CheckConstraint(
text("name IS NOT NULL AND name <> ''"),
name="chk_v3_project_name",
),
Index("ix_v3_project_ssp", "org_unit_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str | None] = mapped_column(String)
level: Mapped[str | None] = mapped_column(String)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
project_type: Mapped[str | None] = mapped_column(String)
vsp_format: Mapped[str | None] = mapped_column(String)
placement_type: Mapped[str | None] = mapped_column(String)
object_address: Mapped[str | None] = mapped_column(String)
staff_count: Mapped[int | None] = mapped_column(Integer)
total_area: Mapped[float | None] = mapped_column(Numeric)
org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
# parent = relationship("Project", remote_side="Project.id", back_populates="children")
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore
# org_unit = relationship("OrgUnit", back_populates="projects")
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="project") # type: ignore
# rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RentDetail(Base):
__tablename__ = "rent_detail"
__table_args__ = (
Index("ix_v3_rent_detail_line", "line_id"),
Index("ix_v3_rent_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

View File

@ -0,0 +1,20 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Reserve(Base):
__tablename__ = "reserve"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
amount_q1: Mapped[float | None] = mapped_column(Numeric)
amount_q2: Mapped[float | None] = mapped_column(Numeric)
amount_q3: Mapped[float | None] = mapped_column(Numeric)
amount_q4: Mapped[float | None] = mapped_column(Numeric)
justification: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReport(Base):
__tablename__ = "rf_project_report"
__table_args__ = (
UniqueConstraint("project_id", "year", "report_type", name="uq_v3_rf_project_report"),
Index("ix_v3_rf_project_report_proj_year", "project_id", "year"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id"))
year: Mapped[int] = mapped_column(Integer)
report_type: Mapped[str] = mapped_column(String, CheckConstraint("report_type IN ('LIMIT','CURRENT_EXPENSES')"))
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
# project: Mapped[Project] = relationship("Project", back_populates="rf_project_reports")
# creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by])
# updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by])
# lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report")

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReportLine(Base):
__tablename__ = "rf_project_report_line"
__table_args__ = (
UniqueConstraint(
"rf_project_report_id", "expense_item_id", name="uq_v3_rf_project_report_line"
),
Index("ix_v3_rf_project_report_line_rep", "rf_project_report_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rf_project_report_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_report.id"))
expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
# report: Mapped[RfProjectReport] = relationship("RfProjectReport", back_populates="lines")
# expense_item: Mapped[ExpenseItem] = relationship("ExpenseItem")
# quarters: Mapped[list[RfProjectReportQuarter]] = relationship(
# "RfProjectReportQuarter", back_populates="line"
# )

View File

@ -0,0 +1,30 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Numeric, SmallInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReportQuarter(Base):
__tablename__ = "rf_project_report_quarter"
__table_args__ = {"schema": "v3"}
rf_project_report_line_id: Mapped[int] = mapped_column(
Integer, ForeignKey("v3.rf_project_report_line.id"), primary_key=True
)
quarter: Mapped[int] = mapped_column(
SmallInteger,
CheckConstraint("quarter >= 1 AND quarter <= 4"),
primary_key=True,
)
adj_by_items: Mapped[float | None] = mapped_column(Numeric)
adj_increase: Mapped[float | None] = mapped_column(Numeric)
actual_m1: Mapped[float | None] = mapped_column(Numeric)
actual_m2: Mapped[float | None] = mapped_column(Numeric)
actual_m3: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
# line: Mapped[RfProjectReportLine] = relationship(
# "RfProjectReportLine", back_populates="quarters"
# )

31
api/src/db/models/role.py Normal file
View File

@ -0,0 +1,31 @@
import enum
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UserRoleEnum(int, enum.Enum):
ADMIN = 1
EXECUTOR_DFIP = 2
EXECUTOR_RF = 3
class Role(Base):
__tablename__ = "role"
__table_args__ = (
UniqueConstraint("code", name="uq_role_code"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String)
# users: Mapped[list["AppUser"]] = relationship("AppUser", back_populates="role") # type: ignore
# form_phases: Mapped[list["FormPhase"]] = relationship( # type: ignore
# "FormPhase", back_populates="role"
# )
# phase_templates: Mapped[list["PhaseTemplate"]] = relationship( # type: ignore
# "PhaseTemplate", back_populates="role"
# )

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class SecurityDetail(Base):
__tablename__ = "security_detail"
__table_args__ = (
Index("ix_v3_security_detail_line", "line_id"),
Index("ix_v3_security_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Sequestration(Base):
__tablename__ = "sequestration"
__table_args__ = (
CheckConstraint("actor IN ('DFIP','SSP_GO')", name="ck_v3_sequestration_actor"),
UniqueConstraint("line_id", "actor", name="uq_v3_sequestration_line_actor"),
Index("ix_v3_sequestration_line_actor", "line_id", "actor"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
actor: Mapped[str] = mapped_column(String)
adj_q1: Mapped[float | None] = mapped_column(Numeric)
adj_q2: Mapped[float | None] = mapped_column(Numeric)
adj_q3: Mapped[float | None] = mapped_column(Numeric)
adj_q4: Mapped[float | None] = mapped_column(Numeric)
justification: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UserOrg(Base):
__tablename__ = "user_org"
__table_args__ = (
UniqueConstraint("user_id", "org_unit_id"),
Index("ix_v4_user_org_org", "org_unit_id"),
Index("ix_v4_user_org_user", "user_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.app_user.id", ondelete="CASCADE"))
org_unit_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id", ondelete="CASCADE"))
user: Mapped[AppUser] = relationship("AppUser", back_populates="user_orgs", lazy="selectin")
org_unit: Mapped[OrgUnit] = relationship("OrgUnit", back_populates="user_orgs", lazy="selectin")

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UtilityDetail(Base):
__tablename__ = "utility_detail"
__table_args__ = (
Index("ix_v3_utility_detail_line", "line_id"),
Index("ix_v3_utility_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

30
api/src/db/models/vsp.py Normal file
View File

@ -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")

View File

@ -5,6 +5,8 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from src.core.config import settings from src.core.config import settings
from typing import AsyncGenerator
from src.db.base import SessionLocal from src.db.base import SessionLocal
@ -12,6 +14,7 @@ async def get_db() -> AsyncGenerator:
db = SessionLocal() db = SessionLocal()
try: try:
yield db yield db
# await db.commit()
finally: finally:
await db.close() await db.close()

View File

@ -1,12 +1,24 @@
from datetime import datetime from datetime import datetime
from typing import Optional import enum
from typing import Generic, Optional, TypeVar
from pydantic import BaseModel, ConfigDict, EmailStr, Field from pydantic import BaseModel, ConfigDict, EmailStr, Field
T = TypeVar("T")
class ResponseBase(BaseModel): class ResponseBase(BaseModel):
success: Optional[bool] = Field(None) success: Optional[bool] = Field(True)
message: Optional[str] = Field(None) message: Optional[str] = None
class BaseSingleResponse(ResponseBase, Generic[T]):
result: Optional[T] = None
class BaseListResponse(ResponseBase, Generic[T]):
result: Optional[list[T]] = None
count: Optional[int] = None
class UserBase(BaseModel): class UserBase(BaseModel):
@ -97,3 +109,40 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str
class FormTypeSchemaEnum(str, enum.Enum):
FORM_1 = "FORM_1"
FORM_2 = "FORM_2"
FORM_3 = "FORM_3"
FORM_4 = "FORM_4"
class 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

View File

@ -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()

View File

@ -0,0 +1,8 @@
from sqlalchemy.ext.asyncio import AsyncSession
class ProjectRepository:
def __init__(self, db: AsyncSession):
self.db = db

View File

@ -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

View File

@ -1,64 +1,85 @@
from typing import Optional 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.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.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: class UserRepository:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
async def get(self, user_id: int) -> Optional[Users]: async def get(self, user_id: int, load_orgs: bool = False) -> Optional[AppUser]:
query = select(AppUser).where(AppUser.id == user_id)
if load_orgs:
query = query.options(
selectinload(AppUser.org_units),
)
return ( return (
( (
await self.db.execute( await self.db.execute(
select(Users).where(Users.id == user_id).limit(1) query.limit(1)
) )
) )
.scalars() .scalars()
.first() .first()
) )
async def get_by_email(self, email: str) -> Optional[Users]: async def get_by_email(self, email: str) -> Optional[AppUser]:
return ( return (
( (
await self.db.execute( await self.db.execute(
select(Users).where(Users.email == email).limit(1) select(AppUser).where(AppUser.email == email).limit(1)
) )
) )
.scalars() .scalars()
.first() .first()
) )
async def get_by_username(self, username: str) -> Optional[Users]: async def get_by_username(self, username: str) -> Optional[AppUser]:
return ( return (
( (
await self.db.execute( await self.db.execute(
select(Users).where(Users.username == username).limit(1) select(AppUser).where(AppUser.username == username).limit(1)
) )
) )
.scalars() .scalars()
.first() .first()
) )
async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]: async def set_app_user_id(self, user_id: int) -> None:
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 = ( query = (
select(Users) select(AppUser)
.where(Users.is_active.is_(True)) .where(AppUser.is_active.is_(True))
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)
.order_by(Users.id) .order_by(AppUser.id)
) )
return (await self.db.execute(query)).scalars().all() 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 = ( hashed_password = (
get_password_hash(user_data["password"]) if user_data.get("password") else None get_password_hash(user_data["password"]) if user_data.get("password") else None
) )
db_user = Users( db_user = AppUser(
email=user_data["email"], email=user_data["email"],
username=user_data["username"], username=user_data["username"],
hashed_password=hashed_password, hashed_password=hashed_password,
@ -70,7 +91,7 @@ class UserRepository:
await self.db.refresh(db_user) await self.db.refresh(db_user)
return 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) user = await self.get(user_id)
if not user: if not user:
return None return None
@ -85,12 +106,12 @@ class UserRepository:
return user return user
async def logical_delete(self, user_id: int) -> bool: async def logical_delete(self, user_id: int) -> bool:
query = update(Users).where(Users.id == user_id).values(is_active=False) query = update(AppUser).where(AppUser.id == user_id).values(is_active=False)
result = await self.db.execute(query) result = await self.db.execute(query)
await self.db.commit() await self.db.commit()
return result.rowcount > 0 return result.rowcount > 0
async def authenticate(self, username: str, password: str) -> Optional[Users]: async def authenticate(self, username: str, password: str) -> Optional[AppUser]:
user = await self.get_by_username(username) user = await self.get_by_username(username)
if not user: if not user:
return None return None
@ -98,26 +119,26 @@ class UserRepository:
return None return None
return user return user
async def authenticate_via_email(self, email: str) -> Optional[Users]: async def authenticate_via_email(self, email: str) -> Optional[AppUser]:
return await self.get_by_email(email) return await self.get_by_email(email)
async def get_roles(self) -> list[Roles]: async def get_roles(self) -> list[Role]:
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all() return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
async def get_many_ssp(self, user_id: int) -> list[SSP]: async def get_many_ssp(self, user_id: int) -> list[OrgUnit]:
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where( query = select(OrgUnit).join(UserOrg, UserOrg.ssp_id == OrgUnit.id).where(
UserSSPLink.user_id == user_id UserOrg.user_id == user_id
) )
return (await self.db.execute(query)).scalars().all() return (await self.db.execute(query)).scalars().all()
async def get_many_ssp_ids(self, user_id: int) -> list[int]: async def get_many_ssp_ids(self, user_id: int) -> list[int]:
query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id) query = select(UserOrg.ssp_id).where(UserOrg.user_id == user_id)
return (await self.db.execute(query)).scalars().all() return (await self.db.execute(query)).scalars().all()
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool: async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try: try:
for ssp_id in ssp_ids: 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) self.db.add(link)
await self.db.commit() await self.db.commit()
return True return True
@ -128,8 +149,8 @@ class UserRepository:
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool: async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try: try:
for ssp_id in ssp_ids: for ssp_id in ssp_ids:
query = delete(UserSSPLink).where( query = delete(UserOrg).where(
(UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id) (UserOrg.user_id == user_id) & (UserOrg.ssp_id == ssp_id)
) )
await self.db.execute(query) await self.db.execute(query)
await self.db.commit() await self.db.commit()
@ -140,7 +161,7 @@ class UserRepository:
async def clear_many_ssp(self, user_id: int) -> bool: async def clear_many_ssp(self, user_id: int) -> bool:
try: 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.execute(query)
await self.db.commit() await self.db.commit()
return True return True

View File

@ -0,0 +1,46 @@
import enum
import typing
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.user_repository import UserRepository
class AccessEnum(int, enum.Enum):
RESTRICT = 0
VIEW = 1
EDIT = 2
class AccessControlService:
def __init__(self, db: AsyncSession, user: AppUser):
self.db = db
self.user_repo = UserRepository(db)
async def _check_budget_form(
self,
user: AppUser,
budget_form: BudgetForm,
) -> AccessEnum:
if user.role_id == UserRoleEnum.ADMIN:
return AccessEnum.EDIT
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
if budget_form.org_unit_id in [ou.id for ou in user.org_units]:
return AccessEnum.EDIT
return AccessEnum.RESTRICT
async def check_object_access(
self,
user: AppUser,
object: typing.Any
) -> AccessEnum:
if isinstance(object, BudgetForm):
return await self._check_budget_form(user=user,budget_form=object)
return AccessEnum.EDIT

View File

@ -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,
)

View File

@ -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

View File

@ -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,
)

View File

@ -1,12 +1,13 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.role import UserRoleEnum
from src.core.errors import ( from src.core.errors import (
AccessDeniedException, AccessDeniedException,
UserNotFoundException, UserNotFoundException,
ValidationException, ValidationException,
UsernameConflictException, UsernameConflictException,
) )
from src.domain.models import UserRole, Users
from src.domain.schemas import UserCreate, UserUpdate from src.domain.schemas import UserCreate, UserUpdate
from src.repository.user_repository import UserRepository from src.repository.user_repository import UserRepository
@ -16,23 +17,23 @@ class UserService:
self.db = db self.db = db
self.user_repo = UserRepository(db) self.user_repo = UserRepository(db)
async def get_all(self, current_user: Users, skip: int = 0, limit: int = 100): async def get_all(self, current_user: AppUser, skip: int = 0, limit: int = 100):
if current_user.role_id != UserRole.ADMIN: if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get_list(skip=skip, limit=limit) return await self.user_repo.get_list(skip=skip, limit=limit)
async def get(self, user_id: int, current_user: Users): async def get(self, user_id: int, current_user: AppUser):
if current_user.role_id != UserRole.ADMIN: if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get(user_id) return await self.user_repo.get(user_id)
async def create_user(self, user_data: UserCreate, creator: Users): async def create_user(self, user_data: UserCreate, creator: AppUser):
if creator.role_id != UserRole.ADMIN: if creator.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
role_id = user_data.role_id or UserRole.EXECUTOR_RF.value role_id = user_data.role_id or UserRoleEnum.EXECUTOR_RF.value
try: try:
role = UserRole(role_id) role = UserRoleEnum(role_id)
except ValueError: except ValueError:
raise ValidationException("Роль не найдена", field="role_id") raise ValidationException("Роль не найдена", field="role_id")
user_data.role_id = role.value user_data.role_id = role.value
@ -43,8 +44,8 @@ class UserService:
payload = user_data.model_dump(mode="json") payload = user_data.model_dump(mode="json")
return await self.user_repo.create(payload) return await self.user_repo.create(payload)
async def update_user(self, user_id: int, user_data: UserUpdate, user: Users): async def update_user(self, user_id: int, user_data: UserUpdate, user: AppUser):
if user.role_id != UserRole.ADMIN: if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
target = await self.user_repo.get(user_id) target = await self.user_repo.get(user_id)
@ -57,7 +58,7 @@ class UserService:
role = None role = None
if role_id is not None: if role_id is not None:
try: try:
role = UserRole(role_id) role = UserRoleEnum(role_id)
except ValueError: except ValueError:
raise ValidationException("Роль не найдена", field="role_id") raise ValidationException("Роль не найдена", field="role_id")
@ -76,43 +77,43 @@ class UserService:
if role_id is not None and role is not None: if role_id is not None and role is not None:
had_many_ssp_role = previous_role_id in ( had_many_ssp_role = previous_role_id in (
UserRole.ADMIN.value, UserRoleEnum.ADMIN.value,
UserRole.EXECUTOR_DFIP.value, UserRoleEnum.EXECUTOR_DFIP.value,
) )
has_many_ssp_role_now = role.value in ( has_many_ssp_role_now = role.value in (
UserRole.ADMIN.value, UserRoleEnum.ADMIN.value,
UserRole.EXECUTOR_DFIP.value, UserRoleEnum.EXECUTOR_DFIP.value,
) )
is_executor_to_dfip_transition = ( is_executor_to_dfip_transition = (
previous_role_id == UserRole.EXECUTOR_RF.value previous_role_id == UserRoleEnum.EXECUTOR_RF.value
and role.value == UserRole.EXECUTOR_DFIP.value and role.value == UserRoleEnum.EXECUTOR_DFIP.value
) )
if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition: if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition:
await self.user_repo.clear_many_ssp(user_id) await self.user_repo.clear_many_ssp(user_id)
return updated return updated
async def delete_user(self, user_id: int, user: Users): async def delete_user(self, user_id: int, user: AppUser):
if user.role_id != UserRole.ADMIN: if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
deleted = await self.user_repo.logical_delete(user_id) deleted = await self.user_repo.logical_delete(user_id)
if not deleted: if not deleted:
raise UserNotFoundException() raise UserNotFoundException()
async def get_roles(self, current_user: Users): async def get_roles(self, current_user: AppUser):
if current_user.role_id != UserRole.ADMIN: if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get_roles() return await self.user_repo.get_roles()
async def get_many_ssp(self, user_id: int, current_user: Users): async def get_many_ssp(self, user_id: int, current_user: AppUser):
if current_user.role_id == UserRole.ADMIN: if current_user.role_id == UserRoleEnum.ADMIN:
return await self.user_repo.get_many_ssp(user_id) return await self.user_repo.get_many_ssp(user_id)
if current_user.id != user_id: if current_user.id != user_id:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get_many_ssp(user_id) return await self.user_repo.get_many_ssp(user_id)
async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users): async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser):
if current_user.role_id != UserRole.ADMIN: if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
user = await self.user_repo.get(user_id) user = await self.user_repo.get(user_id)
@ -121,9 +122,9 @@ class UserService:
target_role_id = self._normalize_role_id(user.role_id) target_role_id = self._normalize_role_id(user.role_id)
if target_role_id not in ( if target_role_id not in (
UserRole.ADMIN.value, UserRoleEnum.ADMIN.value,
UserRole.EXECUTOR_DFIP.value, UserRoleEnum.EXECUTOR_DFIP.value,
UserRole.EXECUTOR_RF.value, UserRoleEnum.EXECUTOR_RF.value,
): ):
err = AccessDeniedException() err = AccessDeniedException()
err.description = ( err.description = (
@ -132,7 +133,7 @@ class UserService:
raise err raise err
curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id) curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id)
if target_role_id == UserRole.EXECUTOR_RF.value: if target_role_id == UserRoleEnum.EXECUTOR_RF.value:
merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids) merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids)
if len(merged_ssp_ids) > 1: if len(merged_ssp_ids) > 1:
raise ValidationException( raise ValidationException(
@ -141,8 +142,8 @@ class UserService:
new_ssp_ids = [ssp_id for ssp_id in ssp_ids if ssp_id not in curr_ssp_ids] new_ssp_ids = [ssp_id for ssp_id in ssp_ids if ssp_id not in curr_ssp_ids]
return await self.user_repo.set_many_ssp(user.id, new_ssp_ids) return await self.user_repo.set_many_ssp(user.id, new_ssp_ids)
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users): async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser):
if current_user.role_id != UserRole.ADMIN: if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
user = await self.user_repo.get(user_id) user = await self.user_repo.get(user_id)
@ -151,9 +152,9 @@ class UserService:
target_role_id = self._normalize_role_id(user.role_id) target_role_id = self._normalize_role_id(user.role_id)
if target_role_id not in ( if target_role_id not in (
UserRole.ADMIN.value, UserRoleEnum.ADMIN.value,
UserRole.EXECUTOR_DFIP.value, UserRoleEnum.EXECUTOR_DFIP.value,
UserRole.EXECUTOR_RF.value, UserRoleEnum.EXECUTOR_RF.value,
): ):
err = AccessDeniedException() err = AccessDeniedException()
err.description = ( err.description = (
@ -163,5 +164,5 @@ class UserService:
return await self.user_repo.unset_many_ssp(user_id, ssp_ids) return await self.user_repo.unset_many_ssp(user_id, ssp_ids)
@staticmethod @staticmethod
def _normalize_role_id(role_id: int | UserRole) -> int: def _normalize_role_id(role_id: int | UserRoleEnum) -> int:
return role_id.value if isinstance(role_id, UserRole) else int(role_id) return role_id.value if isinstance(role_id, UserRoleEnum) else int(role_id)