修复迁移幂等并补部署检查
This commit is contained in:
@@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# 添加 is_locked 字段到 studies 表
|
bind = op.get_bind()
|
||||||
op.add_column('studies', sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false'))
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("studies")}
|
||||||
|
|
||||||
|
if "is_locked" not in columns:
|
||||||
|
op.add_column("studies", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default="false"))
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# 删除 is_locked 字段
|
bind = op.get_bind()
|
||||||
op.drop_column('studies', 'is_locked')
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("studies")}
|
||||||
|
|
||||||
|
if "is_locked" in columns:
|
||||||
|
op.drop_column("studies", "is_locked")
|
||||||
|
|||||||
@@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# 添加 enrollment_target 字段到 sites 表
|
bind = op.get_bind()
|
||||||
op.add_column('sites', sa.Column('enrollment_target', sa.Integer(), nullable=True))
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||||
|
|
||||||
|
if "enrollment_target" not in columns:
|
||||||
|
op.add_column("sites", sa.Column("enrollment_target", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# 删除 enrollment_target 字段
|
bind = op.get_bind()
|
||||||
op.drop_column('sites', 'enrollment_target')
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||||
|
|
||||||
|
if "enrollment_target" in columns:
|
||||||
|
op.drop_column("sites", "enrollment_target")
|
||||||
|
|||||||
@@ -19,18 +19,31 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("adverse_events")}
|
||||||
|
|
||||||
|
if "is_sae" not in columns:
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"adverse_events",
|
"adverse_events",
|
||||||
sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()),
|
sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
)
|
)
|
||||||
|
if "is_susar" not in columns:
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"adverse_events",
|
"adverse_events",
|
||||||
sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()),
|
sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
)
|
)
|
||||||
|
if "is_sae" in columns or "is_susar" in columns:
|
||||||
op.alter_column("adverse_events", "is_sae", server_default=None)
|
op.alter_column("adverse_events", "is_sae", server_default=None)
|
||||||
op.alter_column("adverse_events", "is_susar", server_default=None)
|
op.alter_column("adverse_events", "is_susar", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("adverse_events")}
|
||||||
|
|
||||||
|
if "is_susar" in columns:
|
||||||
op.drop_column("adverse_events", "is_susar")
|
op.drop_column("adverse_events", "is_susar")
|
||||||
|
if "is_sae" in columns:
|
||||||
op.drop_column("adverse_events", "is_sae")
|
op.drop_column("adverse_events", "is_sae")
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
CRITICAL_TABLE_COLUMNS: dict[str, set[str]] = {
|
||||||
|
"studies": {
|
||||||
|
"is_locked",
|
||||||
|
"visit_schedule",
|
||||||
|
"enrollment_monthly_goal_note",
|
||||||
|
"enrollment_stage_breakdown",
|
||||||
|
},
|
||||||
|
"subjects": {
|
||||||
|
"baseline_date",
|
||||||
|
"actual_medication_count",
|
||||||
|
},
|
||||||
|
"monitoring_visit_issues": {
|
||||||
|
"site_id",
|
||||||
|
"severity",
|
||||||
|
"mark",
|
||||||
|
"visit_cycle",
|
||||||
|
"center_query",
|
||||||
|
"center_latest_reply",
|
||||||
|
"rectification_completed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def check() -> int:
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL)
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
def inspect_schema(sync_conn):
|
||||||
|
inspector = inspect(sync_conn)
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if not inspector.has_table("alembic_version"):
|
||||||
|
errors.append("missing alembic_version table")
|
||||||
|
else:
|
||||||
|
version_rows = sync_conn.execute(text("select version_num from alembic_version")).fetchall()
|
||||||
|
if not version_rows:
|
||||||
|
errors.append("alembic_version table is empty")
|
||||||
|
|
||||||
|
for table_name, expected_columns in CRITICAL_TABLE_COLUMNS.items():
|
||||||
|
if not inspector.has_table(table_name):
|
||||||
|
errors.append(f"missing table: {table_name}")
|
||||||
|
continue
|
||||||
|
existing = {col["name"] for col in inspector.get_columns(table_name)}
|
||||||
|
missing = sorted(expected_columns - existing)
|
||||||
|
if missing:
|
||||||
|
errors.append(f"{table_name} missing columns: {', '.join(missing)}")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
errors = await conn.run_sync(inspect_schema)
|
||||||
|
if errors:
|
||||||
|
for item in errors:
|
||||||
|
print(f"ERROR: {item}")
|
||||||
|
return 1
|
||||||
|
print("migration state OK")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
raise SystemExit(asyncio.run(check()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_locked_migration_is_idempotent():
|
||||||
|
source = Path("alembic/versions/20260116_01_add_is_locked_to_studies.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'if "is_locked" not in columns:' in source
|
||||||
|
assert 'if "is_locked" in columns:' in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_state_check_script_exists():
|
||||||
|
source = Path("scripts/check_migration_state.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "missing alembic_version table" in source
|
||||||
|
assert "studies" in source
|
||||||
|
assert "subjects" in source
|
||||||
|
assert "monitoring_visit_issues" in source
|
||||||
Reference in New Issue
Block a user