migrations: alembic миграции, пофиксил ошибки получения списков при пустых таблицах, добавил флаг --init_db для возможности заведения отдельной базы для тестов
This commit is contained in:
parent
361eeb24bb
commit
2581589756
@ -1,9 +1,12 @@
|
|||||||
|
import asyncio
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config
|
|
||||||
from sqlalchemy import pool
|
|
||||||
|
|
||||||
from alembic import context
|
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
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# 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:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
# add your model's MetaData object here
|
target_metadata = Base.metadata
|
||||||
# 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:
|
def run_migrations_offline() -> None:
|
||||||
"""Run migrations in 'offline' mode.
|
url = settings.DATABASE_URL
|
||||||
|
|
||||||
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(
|
context.configure(
|
||||||
url=url,
|
url=url,
|
||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
literal_binds=True,
|
literal_binds=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
include_schemas=True,
|
||||||
|
version_table_schema="v3",
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
def do_run_migrations(connection):
|
||||||
"""Run migrations in 'online' mode.
|
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.
|
|
||||||
|
|
||||||
"""
|
async def run_async_migrations() -> None:
|
||||||
connectable = engine_from_config(
|
connectable = create_async_engine(
|
||||||
config.get_section(config.config_ini_section, {}),
|
settings.DATABASE_URL,
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
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():
|
def run_migrations_online() -> None:
|
||||||
context.run_migrations()
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
if context.is_offline_mode():
|
||||||
|
|||||||
1038
api/alembic/versions/0001_initial_schema.py
Normal file
1038
api/alembic/versions/0001_initial_schema.py
Normal file
File diff suppressed because it is too large
Load Diff
79
api/alembic/versions/0002_functions.py
Normal file
79
api/alembic/versions/0002_functions.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0002"
|
||||||
|
down_revision = "0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_dollar_tag(line: str) -> Optional[str]:
|
||||||
|
m = _DOLLAR_TAG_RE.search(line.strip())
|
||||||
|
return m.group(0) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _split_statements(sql: str) -> List[str]:
|
||||||
|
statements: List[str] = []
|
||||||
|
current: List[str] = []
|
||||||
|
in_dollar = False
|
||||||
|
dollar_tag: Optional[str] = None
|
||||||
|
|
||||||
|
for line in sql.split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if not in_dollar:
|
||||||
|
tag = _find_dollar_tag(stripped)
|
||||||
|
if tag and tag.endswith("$") and tag.startswith("$"):
|
||||||
|
dollar_tag = tag
|
||||||
|
in_dollar = True
|
||||||
|
current.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
|
||||||
|
after = stripped[len(dollar_tag):].strip()
|
||||||
|
if after == ";" or after == "":
|
||||||
|
in_dollar = False
|
||||||
|
dollar_tag = None
|
||||||
|
if after == ";":
|
||||||
|
current.append(line)
|
||||||
|
statements.append("\n".join(current))
|
||||||
|
current = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not in_dollar and stripped.rstrip().endswith(";"):
|
||||||
|
current.append(line)
|
||||||
|
statements.append("\n".join(current))
|
||||||
|
current = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
current.append(line)
|
||||||
|
|
||||||
|
remaining = "\n".join(current).strip()
|
||||||
|
if remaining:
|
||||||
|
statements.append(remaining)
|
||||||
|
|
||||||
|
return statements
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0002_functions.sql")
|
||||||
|
with open(ddl_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
statements = _split_statements(content)
|
||||||
|
for stmt in statements:
|
||||||
|
stripped = stmt.strip().rstrip(";").strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
|
||||||
|
continue
|
||||||
|
op.execute(stripped)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
30
api/alembic/versions/0003_initial_data.py
Normal file
30
api/alembic/versions/0003_initial_data.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0003"
|
||||||
|
down_revision = "0002"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
has_data = conn.execute(sa.text("SELECT 1 FROM v3.role LIMIT 1")).scalar()
|
||||||
|
|
||||||
|
if has_data:
|
||||||
|
return
|
||||||
|
sql_path = os.path.join(os.path.dirname(__file__), "sql", "0003_initial_data.sql")
|
||||||
|
with open(sql_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
for stmt in content.split(";\n"):
|
||||||
|
stripped = stmt.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
op.execute(stripped)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
7793
api/alembic/versions/sql/0002_functions.sql
Normal file
7793
api/alembic/versions/sql/0002_functions.sql
Normal file
File diff suppressed because one or more lines are too long
4253
api/alembic/versions/sql/0003_initial_data.sql
Normal file
4253
api/alembic/versions/sql/0003_initial_data.sql
Normal file
File diff suppressed because it is too large
Load Diff
@ -190,7 +190,7 @@ async def update_cell(
|
|||||||
|
|
||||||
budget_line_service = BudgetLineService(db)
|
budget_line_service = BudgetLineService(db)
|
||||||
budget_line = await budget_line_service.get(budget_line_id=cell_body.line_id, user=current_user)
|
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"Строка формы не найдена")
|
raise HTTPException(404, f"Строка формы не найдена")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,11 @@ class BudgetForm(Base):
|
|||||||
updated_by: Mapped[int | None] = mapped_column(
|
updated_by: Mapped[int | None] = mapped_column(
|
||||||
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_updated_by")
|
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(
|
org_unit_id: Mapped[int | None] = mapped_column(
|
||||||
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
|
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -47,11 +47,12 @@ class BudgetFormRepository:
|
|||||||
if result:
|
if result:
|
||||||
return result[0][0], [res[1] for res in result]
|
return result[0][0], [res[1] for res in result]
|
||||||
else:
|
else:
|
||||||
|
count_query = select(func.count(BudgetForm.id))
|
||||||
if query.whereclause is not None:
|
if query.whereclause is not None:
|
||||||
count_query = select(func.count(BudgetForm.id)).where(
|
count_query = count_query.where(
|
||||||
query.whereclause
|
query.whereclause
|
||||||
)
|
)
|
||||||
return (await self.db.execute(count_query)).scalar(), []
|
return (await self.db.execute(count_query)).scalar(), []
|
||||||
else:
|
else:
|
||||||
return (await self.db.execute(query)).scalars().all()
|
return (await self.db.execute(query)).scalars().all()
|
||||||
|
|
||||||
|
|||||||
@ -57,11 +57,12 @@ class OrgUnitRepository:
|
|||||||
if result:
|
if result:
|
||||||
return result[0][0], [res[1] for res in result]
|
return result[0][0], [res[1] for res in result]
|
||||||
else:
|
else:
|
||||||
|
count_query = select(func.count(OrgUnit.id))
|
||||||
if query.whereclause is not None:
|
if query.whereclause is not None:
|
||||||
count_query = select(func.count(OrgUnit.id)).where(
|
count_query = count_query.where(
|
||||||
query.whereclause
|
query.whereclause
|
||||||
)
|
)
|
||||||
return (await self.db.execute(count_query)).scalar(), []
|
return (await self.db.execute(count_query)).scalar(), []
|
||||||
else:
|
else:
|
||||||
return (await self.db.execute(query)).scalars().unique().all()
|
return (await self.db.execute(query)).scalars().unique().all()
|
||||||
|
|
||||||
|
|||||||
@ -99,11 +99,12 @@ class UserRepository:
|
|||||||
if result:
|
if result:
|
||||||
return result[0][0], [res[1] for res in result]
|
return result[0][0], [res[1] for res in result]
|
||||||
else:
|
else:
|
||||||
|
count_query = select(func.count(AppUser.id))
|
||||||
if query.whereclause is not None:
|
if query.whereclause is not None:
|
||||||
count_query = select(func.count(AppUser.id)).where(
|
count_query = count_query.where(
|
||||||
query.whereclause
|
query.whereclause
|
||||||
)
|
)
|
||||||
return (await self.db.execute(count_query)).scalar(), []
|
return (await self.db.execute(count_query)).scalar(), []
|
||||||
else:
|
else:
|
||||||
return (await self.db.execute(query)).scalars().unique().all()
|
return (await self.db.execute(query)).scalars().unique().all()
|
||||||
|
|
||||||
|
|||||||
@ -104,11 +104,12 @@ class VSPRepository:
|
|||||||
if result:
|
if result:
|
||||||
return result[0][0], [res[1] for res in result]
|
return result[0][0], [res[1] for res in result]
|
||||||
else:
|
else:
|
||||||
|
count_query = select(func.count(Vsp.id))
|
||||||
if query.whereclause is not None:
|
if query.whereclause is not None:
|
||||||
count_query = select(func.count(Vsp.id)).where(
|
count_query = count_query.where(
|
||||||
query.whereclause
|
query.whereclause
|
||||||
)
|
)
|
||||||
return (await self.db.execute(count_query)).scalar(), []
|
return (await self.db.execute(count_query)).scalar(), []
|
||||||
else:
|
else:
|
||||||
return (await self.db.execute(query)).scalars().all()
|
return (await self.db.execute(query)).scalars().all()
|
||||||
return (await self.db.execute(query.order_by(Vsp.id))).scalars().all()
|
return (await self.db.execute(query.order_by(Vsp.id))).scalars().all()
|
||||||
|
|||||||
76
api/tests/conftest.py
Normal file
76
api/tests/conftest.py
Normal 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()
|
||||||
30
api/tests/sqls/fixture.sql
Normal file
30
api/tests/sqls/fixture.sql
Normal 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);
|
||||||
Loading…
x
Reference in New Issue
Block a user