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 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():
|
||||
|
||||
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 = 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"Строка формы не найдена")
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
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