Merge pull request 'migrations: alembic миграции, пофиксил ошибки получения списков при пустых таблицах, добавил флаг --init_db для возможности заведения отдельной базы …' (#69) from migrations into test

Reviewed-on: #69
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
This commit is contained in:
tsygankoviva 2026-06-24 10:50:51 +03:00
commit fbf02d80d9
14 changed files with 13345 additions and 50 deletions

View File

@ -1,9 +1,12 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from src.core.config import settings
from src.db.base import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@ -14,62 +17,47 @@ config = context.config
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.
target_metadata = Base.metadata
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")
url = settings.DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
include_schemas=True,
version_table_schema="v3",
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
include_schemas=True,
version_table_schema="v3",
)
with context.begin_transaction():
context.execute(f"CREATE SCHEMA IF NOT EXISTS v3")
context.run_migrations()
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.",
async def run_async_migrations() -> None:
connectable = create_async_engine(
settings.DATABASE_URL,
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
import os
import re
from typing import List, Optional
from alembic import op
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
def _find_dollar_tag(line: str) -> Optional[str]:
m = _DOLLAR_TAG_RE.search(line.strip())
return m.group(0) if m else None
def _split_statements(sql: str) -> List[str]:
statements: List[str] = []
current: List[str] = []
in_dollar = False
dollar_tag: Optional[str] = None
for line in sql.split("\n"):
stripped = line.strip()
if not in_dollar:
tag = _find_dollar_tag(stripped)
if tag and tag.endswith("$") and tag.startswith("$"):
dollar_tag = tag
in_dollar = True
current.append(line)
continue
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
after = stripped[len(dollar_tag):].strip()
if after == ";" or after == "":
in_dollar = False
dollar_tag = None
if after == ";":
current.append(line)
statements.append("\n".join(current))
current = []
continue
if not in_dollar and stripped.rstrip().endswith(";"):
current.append(line)
statements.append("\n".join(current))
current = []
continue
current.append(line)
remaining = "\n".join(current).strip()
if remaining:
statements.append(remaining)
return statements
def upgrade() -> None:
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0002_functions.sql")
with open(ddl_path) as f:
content = f.read()
statements = _split_statements(content)
for stmt in statements:
stripped = stmt.strip().rstrip(";").strip()
if not stripped:
continue
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
continue
op.execute(stripped)
def downgrade() -> None:
pass

View File

@ -0,0 +1,30 @@
import os
from alembic import op
import sqlalchemy as sa
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
has_data = conn.execute(sa.text("SELECT 1 FROM v3.role LIMIT 1")).scalar()
if has_data:
return
sql_path = os.path.join(os.path.dirname(__file__), "sql", "0003_initial_data.sql")
with open(sql_path) as f:
content = f.read()
for stmt in content.split(";\n"):
stripped = stmt.strip()
if not stripped:
continue
op.execute(stripped)
def downgrade() -> None:
pass

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -190,7 +190,7 @@ async def update_cell(
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:
if not budget_line or budget_line.budget_form_id != form_id:
raise HTTPException(404, f"Строка формы не найдена")

View File

@ -26,7 +26,11 @@ class BudgetForm(Base):
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)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime,
default=func.now(),
onupdate=func.now(),
)
org_unit_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
)

View File

@ -47,11 +47,12 @@ class BudgetFormRepository:
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(BudgetForm.id))
if query.whereclause is not None:
count_query = select(func.count(BudgetForm.id)).where(
count_query = count_query.where(
query.whereclause
)
return (await self.db.execute(count_query)).scalar(), []
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().all()

View File

@ -57,11 +57,12 @@ class OrgUnitRepository:
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(OrgUnit.id))
if query.whereclause is not None:
count_query = select(func.count(OrgUnit.id)).where(
count_query = count_query.where(
query.whereclause
)
return (await self.db.execute(count_query)).scalar(), []
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().unique().all()

View File

@ -99,11 +99,12 @@ class UserRepository:
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(AppUser.id))
if query.whereclause is not None:
count_query = select(func.count(AppUser.id)).where(
count_query = count_query.where(
query.whereclause
)
return (await self.db.execute(count_query)).scalar(), []
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().unique().all()

View File

@ -104,11 +104,12 @@ class VSPRepository:
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(Vsp.id))
if query.whereclause is not None:
count_query = select(func.count(Vsp.id)).where(
count_query = count_query.where(
query.whereclause
)
return (await self.db.execute(count_query)).scalar(), []
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().all()
return (await self.db.execute(query.order_by(Vsp.id))).scalars().all()

76
api/tests/conftest.py Normal file
View File

@ -0,0 +1,76 @@
import os
from argon2 import PasswordHasher
import pytest
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from alembic.config import Config
from alembic import command
from src.core.config import settings
hasher = PasswordHasher()
ADMIN_PASSWORD = hasher.hash(os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123"))
ISP_PASSWORD = hasher.hash(os.getenv("OPENBAO__TEST_EXECUTOR_PASSWORD", "admin123"))
def pytest_addoption(parser):
parser.addoption(
"--init_db",
action="store_true",
default=False,
help="Initialize database with migrations and seed data"
)
@pytest.fixture(scope="session")
def init_db_flag(request):
return request.config.getoption("--init_db")
@pytest.fixture(scope="session", autouse=True)
def setup_database(init_db_flag):
if not init_db_flag:
return
asyncio.run(run_migrations_async())
asyncio.run(
run_seed_script_async(
admin_password=ADMIN_PASSWORD,
isp_password=ISP_PASSWORD,
)
)
async def run_migrations_async():
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, run_migrations_sync)
def run_migrations_sync():
alembic_cfg = Config("alembic.ini")
command.upgrade(alembic_cfg, "head")
async def run_seed_script_async(admin_password, isp_password):
sql_script_path = os.path.join(os.path.dirname(__file__), "sqls", "fixture.sql")
with open(sql_script_path, "r", encoding="utf-8") as f:
sql_script = f.read()
engine = create_async_engine(settings.DATABASE_URL, echo=False)
try:
async with engine.connect() as conn:
for statement in sql_script.split(";"):
statement = statement.strip().format(
admin_password=admin_password,
isp_password=isp_password,
)
if statement:
await conn.execute(text(statement))
await conn.commit()
finally:
await engine.dispose()

View File

@ -0,0 +1,30 @@
INSERT INTO v3.app_user (id,email,username,hashed_password,full_name,role_id,is_active,created_at,updated_at) VALUES
(1,'admin@rshb.ru','admin','{admin_password}','admin',1,true,'2026-05-20 11:09:16.987331+03',NULL),
(2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03');
SELECT setval('v3.app_user_id_seq', 2);
INSERT INTO v3.org_unit (id,title,is_active,is_ssp) VALUES
(1,'Test_SSP',true,true),
(2,'Test РФ',true,false);
SELECT setval('v3.org_unit_id_seq', 2);
INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES
(1,1,2),
(2,1,1),
(3,2,2);
SELECT setval('v3.user_org_id_seq', 3);
INSERT INTO v3.budget_form (id,form_type_code,"year",created_by,created_at,updated_by,updated_at,org_unit_id) VALUES
(1,'FORM_1',2027,NULL,'2026-05-06 16:57:38.066371',NULL,'2026-06-11 14:35:53.783017',1);
SELECT setval('v3.budget_form_id_seq', 1);
INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_order,vsp_id,project_id,justification,created_by,created_at,updated_by,updated_at,direction) VALUES
(1,1,88,'1234',NULL,NULL,NULL,NULL,NULL,'2026-05-06 16:58:05.793083',NULL,'2026-06-22 17:41:54.255313','Support');
SELECT setval('v3.budget_line_id_seq', 1);
INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at) VALUES
(1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03');
INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,location_form,numbers,rent_contract_num,rent_end_date) VALUES
(1,2,'Тестовый всп 1','Тестовая 12','укукк','2026-05-07','ывс',2026,230,'2026-06-16',false,'2026-06-18 15:46:38.611903',false,'2026-06-04 14:36:59.737727',1,'1233',91,'','','встроенное помещение',NULL,'',NULL);
SELECT setval('v3.vsp_id_seq', 1);