fix(migrations): adopt existing scheduling schema

This commit is contained in:
2026-07-20 19:25:32 +02:00
parent 2570192e06
commit 4ddac65367
4 changed files with 520 additions and 0 deletions

View File

@@ -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),

View File

@@ -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="[]"))

View File

@@ -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(