diff --git a/src/govoplan_scheduling/backend/migrations/versions/3c4d5e6f7a8b_v018_scheduling_baseline.py b/src/govoplan_scheduling/backend/migrations/versions/3c4d5e6f7a8b_v018_scheduling_baseline.py index bab41c3..6b51d0e 100644 --- a/src/govoplan_scheduling/backend/migrations/versions/3c4d5e6f7a8b_v018_scheduling_baseline.py +++ b/src/govoplan_scheduling/backend/migrations/versions/3c4d5e6f7a8b_v018_scheduling_baseline.py @@ -16,7 +16,171 @@ branch_labels = None depends_on = "2a3b4c5d6e7f" +_BASELINE_COLUMNS = { + "scheduling_requests": { + "id", + "tenant_id", + "title", + "description", + "location", + "timezone", + "status", + "poll_id", + "selected_slot_id", + "organizer_user_id", + "deadline_at", + "allow_external_participants", + "allow_participant_updates", + "result_visibility", + "calendar_integration_enabled", + "calendar_id", + "calendar_freebusy_enabled", + "calendar_hold_enabled", + "create_calendar_event_on_decision", + "calendar_event_id", + "handed_off_at", + "cancelled_at", + "deleted_at", + "metadata", + "created_at", + "updated_at", + }, + "scheduling_candidate_slots": { + "id", + "tenant_id", + "request_id", + "poll_option_id", + "label", + "description", + "start_at", + "end_at", + "timezone", + "location", + "position", + "deleted_at", + "metadata", + "created_at", + "updated_at", + }, + "scheduling_participants": { + "id", + "tenant_id", + "request_id", + "respondent_id", + "display_name", + "email", + "participant_type", + "required", + "status", + "poll_invitation_id", + "last_invited_at", + "responded_at", + "deleted_at", + "metadata", + "created_at", + "updated_at", + }, +} + +_BASELINE_INDEXES = { + "scheduling_requests": { + "ix_scheduling_requests_calendar_event_id", + "ix_scheduling_requests_calendar_id", + "ix_scheduling_requests_deadline", + "ix_scheduling_requests_deadline_at", + "ix_scheduling_requests_deleted_at", + "ix_scheduling_requests_organizer_user_id", + "ix_scheduling_requests_poll_id", + "ix_scheduling_requests_selected_slot_id", + "ix_scheduling_requests_status", + "ix_scheduling_requests_tenant_id", + "ix_scheduling_requests_tenant_poll", + "ix_scheduling_requests_tenant_status", + }, + "scheduling_candidate_slots": { + "ix_scheduling_candidate_slots_deleted_at", + "ix_scheduling_candidate_slots_end_at", + "ix_scheduling_candidate_slots_poll_option_id", + "ix_scheduling_candidate_slots_range", + "ix_scheduling_candidate_slots_request_id", + "ix_scheduling_candidate_slots_request_position", + "ix_scheduling_candidate_slots_start_at", + "ix_scheduling_candidate_slots_tenant_id", + }, + "scheduling_participants": { + "ix_scheduling_participants_deleted_at", + "ix_scheduling_participants_email", + "ix_scheduling_participants_last_invited_at", + "ix_scheduling_participants_participant_type", + "ix_scheduling_participants_poll_invitation_id", + "ix_scheduling_participants_request", + "ix_scheduling_participants_request_email", + "ix_scheduling_participants_request_id", + "ix_scheduling_participants_request_respondent", + "ix_scheduling_participants_responded_at", + "ix_scheduling_participants_respondent_id", + "ix_scheduling_participants_status", + "ix_scheduling_participants_tenant_id", + }, +} + + +def _request_foreign_key_exists(inspector: sa.Inspector, table_name: str) -> bool: + return any( + tuple(item.get("constrained_columns") or ()) == ("request_id",) + and item.get("referred_table") == "scheduling_requests" + and tuple(item.get("referred_columns") or ()) == ("id",) + and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE" + for item in inspector.get_foreign_keys(table_name) + ) + + +def _adopt_existing_baseline() -> bool: + """Adopt the complete unversioned Scheduling baseline, never a partial one.""" + + inspector = sa.inspect(op.get_bind()) + expected_tables = set(_BASELINE_COLUMNS) + present_tables = expected_tables.intersection(inspector.get_table_names()) + if not present_tables: + return False + + problems: list[str] = [] + missing_tables = expected_tables - present_tables + if missing_tables: + problems.append(f"missing tables: {', '.join(sorted(missing_tables))}") + + for table_name in sorted(present_tables): + columns = {item["name"] for item in inspector.get_columns(table_name)} + missing_columns = _BASELINE_COLUMNS[table_name] - columns + if missing_columns: + problems.append(f"{table_name} missing columns: {', '.join(sorted(missing_columns))}") + + indexes = {item["name"] for item in inspector.get_indexes(table_name)} + missing_indexes = _BASELINE_INDEXES[table_name] - indexes + if missing_indexes: + problems.append(f"{table_name} missing indexes: {', '.join(sorted(missing_indexes))}") + + primary_key = tuple(inspector.get_pk_constraint(table_name).get("constrained_columns") or ()) + if primary_key != ("id",): + problems.append(f"{table_name} has unexpected primary key: {primary_key!r}") + + for table_name in ("scheduling_candidate_slots", "scheduling_participants"): + if table_name in present_tables and not _request_foreign_key_exists(inspector, table_name): + problems.append(f"{table_name} is missing its cascading request_id foreign key") + + if problems: + detail = "; ".join(problems) + raise RuntimeError( + "Cannot adopt the existing Scheduling v0.1.8 baseline because it is incomplete or ambiguous: " + f"{detail}" + ) + return True + + def upgrade() -> None: + if _adopt_existing_baseline(): + return + op.create_table( "scheduling_requests", sa.Column("id", sa.String(length=36), nullable=False), diff --git a/src/govoplan_scheduling/backend/migrations/versions/4d5e6f7a8b9c_v018_scheduling_calendar_notifications.py b/src/govoplan_scheduling/backend/migrations/versions/4d5e6f7a8b9c_v018_scheduling_calendar_notifications.py index 53928e6..9b4df07 100644 --- a/src/govoplan_scheduling/backend/migrations/versions/4d5e6f7a8b9c_v018_scheduling_calendar_notifications.py +++ b/src/govoplan_scheduling/backend/migrations/versions/4d5e6f7a8b9c_v018_scheduling_calendar_notifications.py @@ -16,7 +16,128 @@ branch_labels = None depends_on = None +_CALENDAR_SLOT_COLUMNS = { + "freebusy_checked_at", + "freebusy_status", + "freebusy_conflicts", + "tentative_hold_event_id", +} +_CALENDAR_SLOT_INDEXES = { + "ix_scheduling_candidate_slots_freebusy_status", + "ix_scheduling_candidate_slots_tentative_hold_event_id", +} +_NOTIFICATION_COLUMNS = { + "id", + "tenant_id", + "request_id", + "participant_id", + "event_kind", + "channel", + "recipient", + "status", + "payload", + "error", + "sent_at", + "metadata", + "created_at", + "updated_at", +} +_NOTIFICATION_INDEXES = { + "ix_scheduling_notifications_channel", + "ix_scheduling_notifications_event_kind", + "ix_scheduling_notifications_participant_id", + "ix_scheduling_notifications_recipient", + "ix_scheduling_notifications_request_id", + "ix_scheduling_notifications_request_status", + "ix_scheduling_notifications_status", + "ix_scheduling_notifications_tenant_id", + "ix_scheduling_notifications_tenant_status", +} + + +def _adopt_existing_calendar_notifications() -> bool: + """Adopt only the complete calendar/notification extension.""" + + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "scheduling_candidate_slots" not in tables: + raise RuntimeError( + "Cannot apply the Scheduling calendar migration because scheduling_candidate_slots is missing" + ) + + slot_columns = {item["name"] for item in inspector.get_columns("scheduling_candidate_slots")} + present_slot_columns = _CALENDAR_SLOT_COLUMNS.intersection(slot_columns) + notification_exists = "scheduling_notifications" in tables + if not present_slot_columns and not notification_exists: + return False + + problems: list[str] = [] + missing_slot_columns = _CALENDAR_SLOT_COLUMNS - slot_columns + if missing_slot_columns: + problems.append( + "scheduling_candidate_slots missing columns: " + f"{', '.join(sorted(missing_slot_columns))}" + ) + slot_indexes = {item["name"] for item in inspector.get_indexes("scheduling_candidate_slots")} + missing_slot_indexes = _CALENDAR_SLOT_INDEXES - slot_indexes + if missing_slot_indexes: + problems.append( + "scheduling_candidate_slots missing indexes: " + f"{', '.join(sorted(missing_slot_indexes))}" + ) + + if not notification_exists: + problems.append("missing table: scheduling_notifications") + else: + notification_columns = { + item["name"] for item in inspector.get_columns("scheduling_notifications") + } + missing_notification_columns = _NOTIFICATION_COLUMNS - notification_columns + if missing_notification_columns: + problems.append( + "scheduling_notifications missing columns: " + f"{', '.join(sorted(missing_notification_columns))}" + ) + + notification_indexes = { + item["name"] for item in inspector.get_indexes("scheduling_notifications") + } + missing_notification_indexes = _NOTIFICATION_INDEXES - notification_indexes + if missing_notification_indexes: + problems.append( + "scheduling_notifications missing indexes: " + f"{', '.join(sorted(missing_notification_indexes))}" + ) + + primary_key = tuple( + inspector.get_pk_constraint("scheduling_notifications").get("constrained_columns") or () + ) + if primary_key != ("id",): + problems.append(f"scheduling_notifications has unexpected primary key: {primary_key!r}") + + request_foreign_key_exists = any( + tuple(item.get("constrained_columns") or ()) == ("request_id",) + and item.get("referred_table") == "scheduling_requests" + and tuple(item.get("referred_columns") or ()) == ("id",) + and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE" + for item in inspector.get_foreign_keys("scheduling_notifications") + ) + if not request_foreign_key_exists: + problems.append("scheduling_notifications is missing its cascading request_id foreign key") + + if problems: + detail = "; ".join(problems) + raise RuntimeError( + "Cannot adopt the existing Scheduling v0.1.8 calendar/notification schema because it is " + f"incomplete or ambiguous: {detail}" + ) + return True + + def upgrade() -> None: + if _adopt_existing_calendar_notifications(): + return + op.add_column("scheduling_candidate_slots", sa.Column("freebusy_checked_at", sa.DateTime(timezone=True), nullable=True)) op.add_column("scheduling_candidate_slots", sa.Column("freebusy_status", sa.String(length=40), nullable=True)) op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]")) diff --git a/src/govoplan_scheduling/backend/migrations/versions/9c2f4a7d1e6b_v019_scheduling_participant_visibility.py b/src/govoplan_scheduling/backend/migrations/versions/9c2f4a7d1e6b_v019_scheduling_participant_visibility.py index 060759e..49d6d7d 100644 --- a/src/govoplan_scheduling/backend/migrations/versions/9c2f4a7d1e6b_v019_scheduling_participant_visibility.py +++ b/src/govoplan_scheduling/backend/migrations/versions/9c2f4a7d1e6b_v019_scheduling_participant_visibility.py @@ -17,6 +17,17 @@ depends_on = None def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + columns = {item["name"]: item for item in inspector.get_columns("scheduling_requests")} + existing = columns.get("participant_visibility") + if existing is not None: + column_type = existing["type"] + if existing.get("nullable") or not isinstance(column_type, sa.String) or column_type.length != 32: + raise RuntimeError( + "Cannot adopt scheduling_requests.participant_visibility because its schema is unexpected" + ) + return + op.add_column( "scheduling_requests", sa.Column( diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..8cdd798 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import tempfile +import unittest +from datetime import datetime +from pathlib import Path + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, func, inspect, select, text +from sqlalchemy.orm import Session + +from govoplan_core.db.migrations import migrate_database +from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest +from govoplan_scheduling.backend.db.models import ( + SchedulingCandidateSlot, + SchedulingNotification, + SchedulingParticipant, + SchedulingRequest, +) +from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest + + +_SCHEDULING_HEAD = "9c2f4a7d1e6b" +_ENABLED_MODULES = ("poll", "scheduling") +_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest) + + +class SchedulingMigrationTests(unittest.TestCase): + def _migrate(self, url: str) -> None: + migrate_database( + database_url=url, + enabled_modules=_ENABLED_MODULES, + manifest_factories=_MANIFEST_FACTORIES, + ) + + @staticmethod + def _remove_scheduling_revision( + connection, *, remove_privacy_column: bool = True + ) -> None: + connection.execute( + text("DELETE FROM alembic_version WHERE version_num = :revision"), + {"revision": _SCHEDULING_HEAD}, + ) + if remove_privacy_column: + connection.execute( + text( + "ALTER TABLE scheduling_requests DROP COLUMN participant_visibility" + ) + ) + + @staticmethod + def _seed_scheduling_rows(engine) -> None: + with Session(engine) as session: + request = SchedulingRequest( + id="request-1", + tenant_id="tenant-1", + title="Migration adoption probe", + ) + request.slots.append( + SchedulingCandidateSlot( + id="slot-1", + tenant_id="tenant-1", + label="First slot", + start_at=datetime(2026, 7, 21, 8, 0), + end_at=datetime(2026, 7, 21, 9, 0), + ) + ) + request.participants.append( + SchedulingParticipant( + id="participant-1", + tenant_id="tenant-1", + display_name="Ada", + email="ada@example.test", + ) + ) + session.add(request) + session.flush() + session.add( + SchedulingNotification( + id="notification-1", + tenant_id="tenant-1", + request_id=request.id, + event_kind="invitation", + recipient="ada@example.test", + ) + ) + session.commit() + + def test_adopts_complete_unversioned_v018_schema_and_preserves_rows(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: + self._remove_scheduling_revision(connection) + + self._migrate(url) + + with engine.connect() as connection: + heads = set( + MigrationContext.configure(connection).get_current_heads() + ) + columns = { + item["name"] + for item in inspect(connection).get_columns( + "scheduling_requests" + ) + } + visibility = connection.execute( + text( + "SELECT participant_visibility FROM scheduling_requests " + "WHERE id = 'request-1'" + ) + ).scalar_one() + counts = { + model.__tablename__: connection.execute( + select(func.count()).select_from(model) + ).scalar_one() + for model in ( + SchedulingRequest, + SchedulingCandidateSlot, + SchedulingParticipant, + SchedulingNotification, + ) + } + + self.assertIn(_SCHEDULING_HEAD, heads) + self.assertIn("participant_visibility", columns) + self.assertEqual(visibility, "aggregates_only") + self.assertEqual(set(counts.values()), {1}) + finally: + engine.dispose() + + def test_adopts_already_present_compatible_privacy_column(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: + with engine.begin() as connection: + self._remove_scheduling_revision( + connection, remove_privacy_column=False + ) + + self._migrate(url) + + with engine.connect() as connection: + heads = set( + MigrationContext.configure(connection).get_current_heads() + ) + matching_columns = [ + item + for item in inspect(connection).get_columns( + "scheduling_requests" + ) + if item["name"] == "participant_visibility" + ] + + self.assertIn(_SCHEDULING_HEAD, heads) + self.assertEqual(len(matching_columns), 1) + finally: + engine.dispose() + + def test_rejects_incomplete_existing_baseline(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: + with engine.begin() as connection: + self._remove_scheduling_revision(connection) + connection.execute(text("DROP INDEX ix_scheduling_requests_status")) + + with self.assertRaisesRegex( + RuntimeError, + "scheduling_requests missing indexes: ix_scheduling_requests_status", + ): + self._migrate(url) + + with engine.connect() as connection: + heads = set( + MigrationContext.configure(connection).get_current_heads() + ) + self.assertNotIn(_SCHEDULING_HEAD, heads) + finally: + engine.dispose() + + def test_rejects_partial_existing_calendar_extension(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: + with engine.begin() as connection: + self._remove_scheduling_revision(connection) + connection.execute(text("DROP TABLE scheduling_notifications")) + + with self.assertRaisesRegex( + RuntimeError, + "missing table: scheduling_notifications", + ): + self._migrate(url) + + with engine.connect() as connection: + heads = set( + MigrationContext.configure(connection).get_current_heads() + ) + self.assertNotIn(_SCHEDULING_HEAD, heads) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main()