From 95928e058577688063289da2a2f9aaf286afadd8 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 01:48:24 +0200 Subject: [PATCH] fix(scheduling): repair missing participation gateway column --- ...scheduling_participation_gateway_repair.py | 65 +++++++++++++++++++ tests/test_migrations.py | 60 ++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/govoplan_scheduling/backend/migrations/versions/be8f4d2c1a70_v0110_scheduling_participation_gateway_repair.py diff --git a/src/govoplan_scheduling/backend/migrations/versions/be8f4d2c1a70_v0110_scheduling_participation_gateway_repair.py b/src/govoplan_scheduling/backend/migrations/versions/be8f4d2c1a70_v0110_scheduling_participation_gateway_repair.py new file mode 100644 index 0000000..72a389e --- /dev/null +++ b/src/govoplan_scheduling/backend/migrations/versions/be8f4d2c1a70_v0110_scheduling_participation_gateway_repair.py @@ -0,0 +1,65 @@ +"""v0.1.10 scheduling participation gateway schema repair + +Revision ID: be8f4d2c1a70 +Revises: ad7e3c9b2f10 +Create Date: 2026-07-22 00:00:00.000000 + +Some development databases were stamped at ``ad7e3c9b2f10`` before the +``scheduling_participants.participation_gateway`` column was present. A +forward repair is required because Alembic correctly does not replay an +already-recorded revision. The column is nullable, so adding it preserves all +existing participant rows without inventing gateway ownership for legacy +invitations. +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "be8f4d2c1a70" +down_revision = "ad7e3c9b2f10" +branch_labels = None +depends_on = None + + +def _require_compatible_column(column: dict[str, object]) -> None: + column_type = column["type"] + if ( + not column.get("nullable") + or not isinstance(column_type, sa.String) + or column_type.length != 40 + ): + raise RuntimeError( + "Cannot adopt scheduling_participants.participation_gateway " + "because its schema is unexpected" + ) + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "scheduling_participants" not in inspector.get_table_names(): + raise RuntimeError( + "Cannot repair Scheduling participation gateways because " + "scheduling_participants is missing" + ) + + columns = { + item["name"]: item + for item in inspector.get_columns("scheduling_participants") + } + existing = columns.get("participation_gateway") + if existing is not None: + _require_compatible_column(existing) + return + + op.add_column( + "scheduling_participants", + sa.Column("participation_gateway", sa.String(length=40), nullable=True), + ) + + +def downgrade() -> None: + # ad7e3c9b2f10 already owns this column in the canonical schema. The repair + # revision must therefore not remove it when returning to that revision. + pass diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 86ad9b9..19c199f 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -20,7 +20,8 @@ from govoplan_scheduling.backend.db.models import ( from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest -_SCHEDULING_HEAD = "ad7e3c9b2f10" +_SCHEDULING_HEAD = "be8f4d2c1a70" +_SCHEDULING_RESPONSE_SETTINGS_REVISION = "ad7e3c9b2f10" _ENABLED_MODULES = ("poll", "scheduling") _MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest) @@ -306,6 +307,63 @@ class SchedulingMigrationTests(unittest.TestCase): finally: engine.dispose() + def test_repairs_missing_participation_gateway_after_previous_head_was_stamped(self) -> None: + with tempfile.TemporaryDirectory( + prefix="govoplan-scheduling-migration-" + ) as directory: + url = f"sqlite:///{Path(directory) / 'scheduling.db'}" + self._migrate(url) + engine = create_engine(url) + try: + self._seed_scheduling_rows(engine) + with engine.begin() as connection: + connection.execute( + text( + "DELETE FROM alembic_version WHERE version_num = :revision" + ), + {"revision": _SCHEDULING_HEAD}, + ) + connection.execute( + text( + "INSERT INTO alembic_version (version_num) VALUES (:revision)" + ), + {"revision": _SCHEDULING_RESPONSE_SETTINGS_REVISION}, + ) + connection.execute( + text( + "ALTER TABLE scheduling_participants " + "DROP COLUMN participation_gateway" + ) + ) + + self._migrate(url) + + with engine.connect() as connection: + heads = set( + MigrationContext.configure(connection).get_current_heads() + ) + participant_columns = { + item["name"] + for item in inspect(connection).get_columns( + "scheduling_participants" + ) + } + participant = connection.execute( + text( + "SELECT display_name, email, participation_gateway " + "FROM scheduling_participants WHERE id = 'participant-1'" + ) + ).one() + + self.assertIn(_SCHEDULING_HEAD, heads) + self.assertIn("participation_gateway", participant_columns) + self.assertEqual( + tuple(participant), + ("Ada", "ada@example.test", None), + ) + finally: + engine.dispose() + if __name__ == "__main__": unittest.main()