fix(migrations): adopt existing scheduling schema
This commit is contained in:
@@ -16,7 +16,171 @@ branch_labels = None
|
|||||||
depends_on = "2a3b4c5d6e7f"
|
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:
|
def upgrade() -> None:
|
||||||
|
if _adopt_existing_baseline():
|
||||||
|
return
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"scheduling_requests",
|
"scheduling_requests",
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
|||||||
@@ -16,7 +16,128 @@ branch_labels = None
|
|||||||
depends_on = 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:
|
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_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_status", sa.String(length=40), nullable=True))
|
||||||
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]"))
|
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]"))
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> 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(
|
op.add_column(
|
||||||
"scheduling_requests",
|
"scheduling_requests",
|
||||||
sa.Column(
|
sa.Column(
|
||||||
|
|||||||
224
tests/test_migrations.py
Normal file
224
tests/test_migrations.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user