From 4592ff375de22a846270f1dea80e6aaf1504aefd Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 11 May 2026 08:45:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=81=E7=A7=BB=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E5=B9=B6=E8=A1=A5=E9=83=A8=E7=BD=B2=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260116_01_add_is_locked_to_studies.py | 16 +++- ...60116_02_add_enrollment_target_to_sites.py | 16 +++- .../20260206_01_add_ae_sae_susar_flags.py | 37 ++++++--- backend/scripts/check_migration_state.py | 76 +++++++++++++++++++ backend/tests/test_migration_safety.py | 17 +++++ 5 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 backend/scripts/check_migration_state.py create mode 100644 backend/tests/test_migration_safety.py diff --git a/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py b/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py index 13b0e724..275d7bec 100644 --- a/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py +++ b/backend/alembic/versions/20260116_01_add_is_locked_to_studies.py @@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # 添加 is_locked 字段到 studies 表 - op.add_column('studies', sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false')) + bind = op.get_bind() + 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: - # 删除 is_locked 字段 - op.drop_column('studies', 'is_locked') + bind = op.get_bind() + 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") diff --git a/backend/alembic/versions/20260116_02_add_enrollment_target_to_sites.py b/backend/alembic/versions/20260116_02_add_enrollment_target_to_sites.py index d8987dad..1a5cf900 100644 --- a/backend/alembic/versions/20260116_02_add_enrollment_target_to_sites.py +++ b/backend/alembic/versions/20260116_02_add_enrollment_target_to_sites.py @@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # 添加 enrollment_target 字段到 sites 表 - op.add_column('sites', sa.Column('enrollment_target', sa.Integer(), nullable=True)) + bind = op.get_bind() + 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: - # 删除 enrollment_target 字段 - op.drop_column('sites', 'enrollment_target') + bind = op.get_bind() + 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") diff --git a/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py b/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py index 3809dfb2..33b486ff 100644 --- a/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py +++ b/backend/alembic/versions/20260206_01_add_ae_sae_susar_flags.py @@ -19,18 +19,31 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.add_column( - "adverse_events", - sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()), - ) - op.add_column( - "adverse_events", - sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()), - ) - op.alter_column("adverse_events", "is_sae", server_default=None) - op.alter_column("adverse_events", "is_susar", server_default=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( + "adverse_events", + sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + if "is_susar" not in columns: + op.add_column( + "adverse_events", + 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_susar", server_default=None) def downgrade() -> None: - op.drop_column("adverse_events", "is_susar") - op.drop_column("adverse_events", "is_sae") + 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") + if "is_sae" in columns: + op.drop_column("adverse_events", "is_sae") diff --git a/backend/scripts/check_migration_state.py b/backend/scripts/check_migration_state.py new file mode 100644 index 00000000..9aa431f4 --- /dev/null +++ b/backend/scripts/check_migration_state.py @@ -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() diff --git a/backend/tests/test_migration_safety.py b/backend/tests/test_migration_safety.py new file mode 100644 index 00000000..0ff5545a --- /dev/null +++ b/backend/tests/test_migration_safety.py @@ -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