wxcli 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wxcli/__init__.py +4 -0
- wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
- wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
- wxcli/_playbook/.claude/rules/cleanup.md +29 -0
- wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
- wxcli/_playbook/.claude/rules/org-health.md +14 -0
- wxcli/_playbook/.claude/settings.json +8 -0
- wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
- wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
- wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
- wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
- wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
- wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
- wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
- wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
- wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
- wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
- wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
- wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
- wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
- wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
- wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
- wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
- wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
- wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
- wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
- wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
- wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
- wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
- wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
- wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
- wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
- wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
- wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
- wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
- wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
- wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
- wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
- wxcli/_playbook/CLAUDE.md +373 -0
- wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
- wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
- wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
- wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
- wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
- wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
- wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
- wxcli/_playbook/docs/reference/admin-partner.md +357 -0
- wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
- wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
- wxcli/_playbook/docs/reference/authentication.md +965 -0
- wxcli/_playbook/docs/reference/call-control.md +1205 -0
- wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
- wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
- wxcli/_playbook/docs/reference/call-routing.md +2304 -0
- wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
- wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
- wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
- wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
- wxcli/_playbook/docs/reference/devices-core.md +1869 -0
- wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
- wxcli/_playbook/docs/reference/devices-platform.md +467 -0
- wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
- wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
- wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
- wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
- wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
- wxcli/_playbook/docs/reference/meetings-content.md +473 -0
- wxcli/_playbook/docs/reference/meetings-core.md +903 -0
- wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
- wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
- wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
- wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
- wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
- wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
- wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
- wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
- wxcli/_playbook/docs/reference/provisioning.md +1366 -0
- wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
- wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
- wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
- wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
- wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
- wxcli/_version.py +24 -0
- wxcli/auth.py +137 -0
- wxcli/commands/__init__.py +0 -0
- wxcli/commands/_registry.py +181 -0
- wxcli/commands/activation_email.py +103 -0
- wxcli/commands/admin_recordings.py +585 -0
- wxcli/commands/analytics.py +105 -0
- wxcli/commands/announcement_playlists.py +219 -0
- wxcli/commands/announcements.py +519 -0
- wxcli/commands/archive_users.py +36 -0
- wxcli/commands/attachment_actions.py +60 -0
- wxcli/commands/audit_events.py +83 -0
- wxcli/commands/authorizations.py +113 -0
- wxcli/commands/auto_attendant.py +642 -0
- wxcli/commands/broadworks_billing_reports.py +126 -0
- wxcli/commands/broadworks_enterprises.py +160 -0
- wxcli/commands/broadworks_subscribers.py +319 -0
- wxcli/commands/broadworks_workspaces.py +108 -0
- wxcli/commands/call_controls.py +1304 -0
- wxcli/commands/call_park.py +494 -0
- wxcli/commands/call_pickup.py +229 -0
- wxcli/commands/call_queue.py +1878 -0
- wxcli/commands/call_recording.py +707 -0
- wxcli/commands/call_routing.py +1610 -0
- wxcli/commands/call_settings_for_me_phase_5.py +212 -0
- wxcli/commands/caller_reputation.py +169 -0
- wxcli/commands/calling_service.py +305 -0
- wxcli/commands/cc_address_book.py +770 -0
- wxcli/commands/cc_agent_greetings.py +242 -0
- wxcli/commands/cc_agent_summaries.py +39 -0
- wxcli/commands/cc_agent_wellbeing.py +216 -0
- wxcli/commands/cc_agents.py +425 -0
- wxcli/commands/cc_ai_assistant.py +55 -0
- wxcli/commands/cc_ai_feature.py +294 -0
- wxcli/commands/cc_audio_files.py +149 -0
- wxcli/commands/cc_auto_csat.py +311 -0
- wxcli/commands/cc_aux_code.py +458 -0
- wxcli/commands/cc_business_hour.py +353 -0
- wxcli/commands/cc_call_monitoring.py +228 -0
- wxcli/commands/cc_callbacks.py +194 -0
- wxcli/commands/cc_campaign.py +212 -0
- wxcli/commands/cc_captures.py +39 -0
- wxcli/commands/cc_contact_list.py +169 -0
- wxcli/commands/cc_contact_number.py +295 -0
- wxcli/commands/cc_data_sources.py +235 -0
- wxcli/commands/cc_desktop_layout.py +380 -0
- wxcli/commands/cc_desktop_profile.py +322 -0
- wxcli/commands/cc_dial_number.py +511 -0
- wxcli/commands/cc_dial_plan.py +347 -0
- wxcli/commands/cc_dnc.py +101 -0
- wxcli/commands/cc_entry_point.py +545 -0
- wxcli/commands/cc_ewt.py +51 -0
- wxcli/commands/cc_flow.py +477 -0
- wxcli/commands/cc_global_vars.py +430 -0
- wxcli/commands/cc_holiday_list.py +335 -0
- wxcli/commands/cc_journey.py +1261 -0
- wxcli/commands/cc_legacy_flows.py +49 -0
- wxcli/commands/cc_multimedia_profile.py +436 -0
- wxcli/commands/cc_notification.py +51 -0
- wxcli/commands/cc_outdial_ani.py +604 -0
- wxcli/commands/cc_overrides.py +341 -0
- wxcli/commands/cc_queue.py +1083 -0
- wxcli/commands/cc_queue_stats.py +55 -0
- wxcli/commands/cc_realtime.py +51 -0
- wxcli/commands/cc_resource_collection.py +312 -0
- wxcli/commands/cc_search.py +46 -0
- wxcli/commands/cc_site.py +400 -0
- wxcli/commands/cc_skill.py +350 -0
- wxcli/commands/cc_skill_profile.py +289 -0
- wxcli/commands/cc_subscriptions.py +426 -0
- wxcli/commands/cc_summaries.py +130 -0
- wxcli/commands/cc_tasks.py +844 -0
- wxcli/commands/cc_team.py +325 -0
- wxcli/commands/cc_user_profiles.py +349 -0
- wxcli/commands/cc_users.py +701 -0
- wxcli/commands/cc_work_types.py +363 -0
- wxcli/commands/cdr.py +89 -0
- wxcli/commands/classifications.py +38 -0
- wxcli/commands/cleanup.py +1427 -0
- wxcli/commands/client_settings.py +72 -0
- wxcli/commands/conference.py +328 -0
- wxcli/commands/configure.py +72 -0
- wxcli/commands/converged_recordings.py +347 -0
- wxcli/commands/converged_recordings_export.py +310 -0
- wxcli/commands/cq_playlists.py +42 -0
- wxcli/commands/cucm.py +2794 -0
- wxcli/commands/cucm_config.py +129 -0
- wxcli/commands/customer_assist.py +373 -0
- wxcli/commands/data_sources.py +227 -0
- wxcli/commands/dect_devices.py +787 -0
- wxcli/commands/device_configurations.py +77 -0
- wxcli/commands/device_dynamic_settings.py +320 -0
- wxcli/commands/device_settings.py +1489 -0
- wxcli/commands/devices.py +280 -0
- wxcli/commands/domains.py +142 -0
- wxcli/commands/ecm.py +178 -0
- wxcli/commands/emergency_services.py +827 -0
- wxcli/commands/events.py +83 -0
- wxcli/commands/external_voicemail.py +52 -0
- wxcli/commands/groups.py +206 -0
- wxcli/commands/guest_management.py +76 -0
- wxcli/commands/hds.py +340 -0
- wxcli/commands/hot_desk.py +67 -0
- wxcli/commands/hot_desking_members.py +118 -0
- wxcli/commands/hot_desking_portal.py +124 -0
- wxcli/commands/hunt_group.py +577 -0
- wxcli/commands/hybrid_clusters.py +71 -0
- wxcli/commands/hybrid_connectors.py +67 -0
- wxcli/commands/identity_org.py +159 -0
- wxcli/commands/init_playbook.py +155 -0
- wxcli/commands/licenses.py +106 -0
- wxcli/commands/live_monitoring.py +40 -0
- wxcli/commands/location_call_handling.py +577 -0
- wxcli/commands/location_schedules.py +341 -0
- wxcli/commands/location_settings.py +1297 -0
- wxcli/commands/location_voicemail.py +494 -0
- wxcli/commands/locations.py +336 -0
- wxcli/commands/meeting_captions.py +110 -0
- wxcli/commands/meeting_chats.py +63 -0
- wxcli/commands/meeting_invitees.py +234 -0
- wxcli/commands/meeting_messages.py +28 -0
- wxcli/commands/meeting_participants.py +278 -0
- wxcli/commands/meeting_polls.py +111 -0
- wxcli/commands/meeting_preferences.py +515 -0
- wxcli/commands/meeting_qa.py +78 -0
- wxcli/commands/meeting_qualities.py +43 -0
- wxcli/commands/meeting_reports.py +101 -0
- wxcli/commands/meeting_session_types.py +105 -0
- wxcli/commands/meeting_site.py +57 -0
- wxcli/commands/meeting_slido.py +44 -0
- wxcli/commands/meeting_summaries.py +92 -0
- wxcli/commands/meeting_tracking_codes.py +233 -0
- wxcli/commands/meeting_transcripts.py +230 -0
- wxcli/commands/meetings.py +1997 -0
- wxcli/commands/memberships.py +164 -0
- wxcli/commands/messages.py +213 -0
- wxcli/commands/mode_management.py +244 -0
- wxcli/commands/my_call_settings.py +3577 -0
- wxcli/commands/numbers.py +405 -0
- wxcli/commands/operating_modes.py +410 -0
- wxcli/commands/org_contacts.py +284 -0
- wxcli/commands/org_health_cli.py +55 -0
- wxcli/commands/org_settings.py +71 -0
- wxcli/commands/organizations.py +83 -0
- wxcli/commands/paging_group.py +257 -0
- wxcli/commands/partner_admins.py +141 -0
- wxcli/commands/partner_reports.py +188 -0
- wxcli/commands/partner_tags.py +216 -0
- wxcli/commands/people.py +279 -0
- wxcli/commands/person_call_settings.py +71 -0
- wxcli/commands/pstn.py +280 -0
- wxcli/commands/recording_report.py +154 -0
- wxcli/commands/report_templates.py +38 -0
- wxcli/commands/reports.py +144 -0
- wxcli/commands/resource_group_memberships.py +160 -0
- wxcli/commands/resource_groups.py +67 -0
- wxcli/commands/roles.py +63 -0
- wxcli/commands/room_tabs.py +160 -0
- wxcli/commands/rooms.py +225 -0
- wxcli/commands/scim_bulk.py +46 -0
- wxcli/commands/scim_groups.py +246 -0
- wxcli/commands/scim_schemas.py +82 -0
- wxcli/commands/scim_users.py +283 -0
- wxcli/commands/security_audit.py +54 -0
- wxcli/commands/service_apps.py +51 -0
- wxcli/commands/single_number_reach.py +209 -0
- wxcli/commands/team_memberships.py +155 -0
- wxcli/commands/teams.py +153 -0
- wxcli/commands/ucm_profile.py +42 -0
- wxcli/commands/update.py +219 -0
- wxcli/commands/user_settings.py +5116 -0
- wxcli/commands/video_mesh.py +1090 -0
- wxcli/commands/virtual_extensions.py +502 -0
- wxcli/commands/virtual_line_settings.py +2072 -0
- wxcli/commands/webhooks.py +176 -0
- wxcli/commands/wholesale_billing_reports.py +147 -0
- wxcli/commands/wholesale_provisioning.py +523 -0
- wxcli/commands/workspace_locations.py +339 -0
- wxcli/commands/workspace_metrics.py +103 -0
- wxcli/commands/workspace_personalization.py +70 -0
- wxcli/commands/workspace_settings.py +3347 -0
- wxcli/commands/workspaces.py +279 -0
- wxcli/commands/xapi.py +114 -0
- wxcli/config.py +139 -0
- wxcli/errors.py +77 -0
- wxcli/main.py +210 -0
- wxcli/migration/CLAUDE.md +106 -0
- wxcli/migration/__init__.py +1 -0
- wxcli/migration/advisory/CLAUDE.md +196 -0
- wxcli/migration/advisory/__init__.py +46 -0
- wxcli/migration/advisory/advisor.py +107 -0
- wxcli/migration/advisory/advisory_patterns.py +2552 -0
- wxcli/migration/advisory/recommendation_rules.py +835 -0
- wxcli/migration/cucm/CLAUDE.md +74 -0
- wxcli/migration/cucm/__init__.py +5 -0
- wxcli/migration/cucm/connection.py +265 -0
- wxcli/migration/cucm/discovery.py +225 -0
- wxcli/migration/cucm/extractors/__init__.py +1 -0
- wxcli/migration/cucm/extractors/announcements.py +92 -0
- wxcli/migration/cucm/extractors/base.py +90 -0
- wxcli/migration/cucm/extractors/device_profiles.py +98 -0
- wxcli/migration/cucm/extractors/devices.py +193 -0
- wxcli/migration/cucm/extractors/e911.py +103 -0
- wxcli/migration/cucm/extractors/features.py +309 -0
- wxcli/migration/cucm/extractors/helpers.py +73 -0
- wxcli/migration/cucm/extractors/informational.py +295 -0
- wxcli/migration/cucm/extractors/locations.py +221 -0
- wxcli/migration/cucm/extractors/moh.py +100 -0
- wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
- wxcli/migration/cucm/extractors/routing.py +493 -0
- wxcli/migration/cucm/extractors/shared_lines.py +146 -0
- wxcli/migration/cucm/extractors/templates.py +229 -0
- wxcli/migration/cucm/extractors/tier4.py +173 -0
- wxcli/migration/cucm/extractors/users.py +240 -0
- wxcli/migration/cucm/extractors/voicemail.py +243 -0
- wxcli/migration/cucm/extractors/workspaces.py +88 -0
- wxcli/migration/cucm/unity_connection.py +304 -0
- wxcli/migration/execute/CLAUDE.md +372 -0
- wxcli/migration/execute/__init__.py +303 -0
- wxcli/migration/execute/batch.py +277 -0
- wxcli/migration/execute/dependency.py +483 -0
- wxcli/migration/execute/engine.py +964 -0
- wxcli/migration/execute/handlers.py +2232 -0
- wxcli/migration/execute/planner.py +1939 -0
- wxcli/migration/execute/runtime.py +571 -0
- wxcli/migration/export/CLAUDE.md +49 -0
- wxcli/migration/export/__init__.py +8 -0
- wxcli/migration/export/csv_export.py +126 -0
- wxcli/migration/export/deployment_plan.py +479 -0
- wxcli/migration/export/json_export.py +60 -0
- wxcli/migration/models.py +941 -0
- wxcli/migration/phone_models.py +50 -0
- wxcli/migration/preflight/CLAUDE.md +52 -0
- wxcli/migration/preflight/__init__.py +89 -0
- wxcli/migration/preflight/checks.py +911 -0
- wxcli/migration/preflight/runner.py +293 -0
- wxcli/migration/rate_limiter.py +144 -0
- wxcli/migration/report/CLAUDE.md +245 -0
- wxcli/migration/report/__init__.py +7 -0
- wxcli/migration/report/appendix.py +2437 -0
- wxcli/migration/report/assembler.py +183 -0
- wxcli/migration/report/charts.py +263 -0
- wxcli/migration/report/executive.py +776 -0
- wxcli/migration/report/explainer.py +704 -0
- wxcli/migration/report/helpers.py +74 -0
- wxcli/migration/report/ingest.py +152 -0
- wxcli/migration/report/notice_templates.py +192 -0
- wxcli/migration/report/score.py +375 -0
- wxcli/migration/report/styles.py +1052 -0
- wxcli/migration/report/user_diff.py +1052 -0
- wxcli/migration/report/user_notice.py +425 -0
- wxcli/migration/state.py +139 -0
- wxcli/migration/store.py +860 -0
- wxcli/migration/transform/CLAUDE.md +107 -0
- wxcli/migration/transform/__init__.py +12 -0
- wxcli/migration/transform/analysis_pipeline.py +453 -0
- wxcli/migration/transform/analyzers/__init__.py +142 -0
- wxcli/migration/transform/analyzers/css_permission.py +189 -0
- wxcli/migration/transform/analyzers/css_routing.py +335 -0
- wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
- wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
- wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
- wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
- wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
- wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
- wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
- wxcli/migration/transform/analyzers/missing_data.py +300 -0
- wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
- wxcli/migration/transform/analyzers/shared_line.py +170 -0
- wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
- wxcli/migration/transform/analyzers/workspace_license.py +212 -0
- wxcli/migration/transform/cross_reference.py +1136 -0
- wxcli/migration/transform/cucm_pattern.py +246 -0
- wxcli/migration/transform/decisions.py +301 -0
- wxcli/migration/transform/e164.py +145 -0
- wxcli/migration/transform/engine.py +256 -0
- wxcli/migration/transform/mappers/CLAUDE.md +123 -0
- wxcli/migration/transform/mappers/__init__.py +76 -0
- wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
- wxcli/migration/transform/mappers/base.py +190 -0
- wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
- wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
- wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
- wxcli/migration/transform/mappers/css_mapper.py +868 -0
- wxcli/migration/transform/mappers/dect_mapper.py +367 -0
- wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
- wxcli/migration/transform/mappers/device_mapper.py +227 -0
- wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
- wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
- wxcli/migration/transform/mappers/e911_mapper.py +129 -0
- wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
- wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
- wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
- wxcli/migration/transform/mappers/line_mapper.py +310 -0
- wxcli/migration/transform/mappers/location_mapper.py +235 -0
- wxcli/migration/transform/mappers/moh_mapper.py +114 -0
- wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
- wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
- wxcli/migration/transform/mappers/routing_mapper.py +821 -0
- wxcli/migration/transform/mappers/snr_mapper.py +201 -0
- wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
- wxcli/migration/transform/mappers/user_mapper.py +264 -0
- wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
- wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
- wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
- wxcli/migration/transform/normalizers.py +2008 -0
- wxcli/migration/transform/pattern_converter.py +106 -0
- wxcli/migration/transform/pipeline.py +271 -0
- wxcli/migration/transform/rules.py +255 -0
- wxcli/org_health/CLAUDE.md +63 -0
- wxcli/org_health/__init__.py +7 -0
- wxcli/org_health/analyze.py +93 -0
- wxcli/org_health/checks.py +458 -0
- wxcli/org_health/collector.py +62 -0
- wxcli/org_health/models.py +78 -0
- wxcli/org_health/report.py +300 -0
- wxcli/output.py +101 -0
- wxcli-1.2.0.dist-info/METADATA +505 -0
- wxcli-1.2.0.dist-info/RECORD +413 -0
- wxcli-1.2.0.dist-info/WHEEL +5 -0
- wxcli-1.2.0.dist-info/entry_points.txt +2 -0
- wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
- wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
- wxcli-1.2.0.dist-info/scm_version.json +8 -0
- wxcli-1.2.0.dist-info/top_level.txt +1 -0
wxcli/migration/store.py
ADDED
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
"""SQLite-backed migration store.
|
|
2
|
+
|
|
3
|
+
Thin wrapper over sqlite3 for migration persistence. Uses WAL mode for
|
|
4
|
+
concurrent reads and foreign keys for referential integrity.
|
|
5
|
+
|
|
6
|
+
Schema from: 01-data-representation.md (objects, cross_refs, decisions, journal)
|
|
7
|
+
07-idempotency-resumability.md (merge_log)
|
|
8
|
+
05-dependency-graph.md (plan_operations, plan_edges)
|
|
9
|
+
Query helpers from: 03b-transform-mappers.md shared patterns section
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sqlite3
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from wxcli.migration.models import (
|
|
22
|
+
CANONICAL_CLASS_TO_TYPE,
|
|
23
|
+
CANONICAL_TYPE_REGISTRY,
|
|
24
|
+
MigrationObject,
|
|
25
|
+
MigrationStatus,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _now() -> str:
|
|
30
|
+
return datetime.now(timezone.utc).isoformat()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MigrationStore:
|
|
34
|
+
"""SQLite-backed store for migration objects, cross-refs, and decisions.
|
|
35
|
+
|
|
36
|
+
(from 01-data-representation.md lines 112-136,
|
|
37
|
+
03b-transform-mappers.md shared patterns lines 999-1031)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, db_path: Path | str) -> None:
|
|
41
|
+
self.db_path = Path(db_path)
|
|
42
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
self.conn = sqlite3.connect(str(self.db_path))
|
|
44
|
+
self.conn.row_factory = sqlite3.Row
|
|
45
|
+
# WAL mode for concurrent reads (from 01-data-representation.md line 118)
|
|
46
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
47
|
+
# Foreign keys (from 01-data-representation.md line 119)
|
|
48
|
+
self.conn.execute("PRAGMA foreign_keys=ON")
|
|
49
|
+
self._run_id: str | None = None
|
|
50
|
+
self._ensure_schema()
|
|
51
|
+
self._decision_counter: int = self._max_decision_number()
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
self.conn.close()
|
|
55
|
+
|
|
56
|
+
def __enter__(self) -> MigrationStore:
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, *args: object) -> None:
|
|
60
|
+
self.close()
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Schema
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def _ensure_schema(self) -> None:
|
|
67
|
+
"""Create all tables and indexes if they don't exist.
|
|
68
|
+
(from 01-data-representation.md lines 30-91,
|
|
69
|
+
07-idempotency-resumability.md lines 209-218,
|
|
70
|
+
05-dependency-graph.md lines 304-325)
|
|
71
|
+
"""
|
|
72
|
+
self.conn.executescript("""
|
|
73
|
+
-- Core object store (from 01-data-representation.md lines 34-48)
|
|
74
|
+
CREATE TABLE IF NOT EXISTS objects (
|
|
75
|
+
canonical_id TEXT PRIMARY KEY,
|
|
76
|
+
object_type TEXT NOT NULL,
|
|
77
|
+
status TEXT NOT NULL DEFAULT 'discovered',
|
|
78
|
+
location_id TEXT,
|
|
79
|
+
webex_id TEXT,
|
|
80
|
+
batch TEXT,
|
|
81
|
+
tier INTEGER,
|
|
82
|
+
data TEXT NOT NULL,
|
|
83
|
+
created_at TEXT NOT NULL,
|
|
84
|
+
updated_at TEXT NOT NULL
|
|
85
|
+
);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_type_status ON objects(object_type, status);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_location ON objects(location_id);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_batch_tier ON objects(batch, tier);
|
|
89
|
+
|
|
90
|
+
-- Cross-references (from 01-data-representation.md lines 50-61)
|
|
91
|
+
CREATE TABLE IF NOT EXISTS cross_refs (
|
|
92
|
+
from_id TEXT NOT NULL,
|
|
93
|
+
to_id TEXT NOT NULL,
|
|
94
|
+
relationship TEXT NOT NULL,
|
|
95
|
+
ordinal INTEGER,
|
|
96
|
+
PRIMARY KEY (from_id, to_id, relationship),
|
|
97
|
+
FOREIGN KEY (from_id) REFERENCES objects(canonical_id),
|
|
98
|
+
FOREIGN KEY (to_id) REFERENCES objects(canonical_id)
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_xref_to ON cross_refs(to_id, relationship);
|
|
101
|
+
|
|
102
|
+
-- Decisions (from 01-data-representation.md lines 63-77)
|
|
103
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
104
|
+
decision_id TEXT PRIMARY KEY,
|
|
105
|
+
type TEXT NOT NULL,
|
|
106
|
+
severity TEXT NOT NULL,
|
|
107
|
+
summary TEXT NOT NULL,
|
|
108
|
+
context TEXT NOT NULL,
|
|
109
|
+
options TEXT NOT NULL,
|
|
110
|
+
chosen_option TEXT,
|
|
111
|
+
resolved_at TEXT,
|
|
112
|
+
resolved_by TEXT,
|
|
113
|
+
fingerprint TEXT NOT NULL,
|
|
114
|
+
run_id TEXT NOT NULL,
|
|
115
|
+
UNIQUE(fingerprint)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
-- Execution journal (from 01-data-representation.md lines 79-90)
|
|
119
|
+
CREATE TABLE IF NOT EXISTS journal (
|
|
120
|
+
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
121
|
+
timestamp TEXT NOT NULL,
|
|
122
|
+
entry_type TEXT NOT NULL,
|
|
123
|
+
canonical_id TEXT NOT NULL,
|
|
124
|
+
resource_type TEXT NOT NULL,
|
|
125
|
+
request TEXT NOT NULL,
|
|
126
|
+
response TEXT,
|
|
127
|
+
pre_state TEXT,
|
|
128
|
+
FOREIGN KEY (canonical_id) REFERENCES objects(canonical_id)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
-- Merge log (from 07-idempotency-resumability.md lines 209-218)
|
|
132
|
+
CREATE TABLE IF NOT EXISTS merge_log (
|
|
133
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
134
|
+
timestamp TEXT NOT NULL,
|
|
135
|
+
stage TEXT NOT NULL,
|
|
136
|
+
run_id TEXT NOT NULL,
|
|
137
|
+
action TEXT NOT NULL,
|
|
138
|
+
target_type TEXT NOT NULL,
|
|
139
|
+
target_id TEXT NOT NULL,
|
|
140
|
+
details TEXT
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
-- Execution plan DAG (from 05-dependency-graph.md lines 304-325)
|
|
144
|
+
CREATE TABLE IF NOT EXISTS plan_operations (
|
|
145
|
+
node_id TEXT PRIMARY KEY,
|
|
146
|
+
canonical_id TEXT NOT NULL,
|
|
147
|
+
op_type TEXT NOT NULL,
|
|
148
|
+
resource_type TEXT NOT NULL,
|
|
149
|
+
tier INTEGER NOT NULL,
|
|
150
|
+
batch TEXT,
|
|
151
|
+
api_calls INTEGER DEFAULT 1,
|
|
152
|
+
description TEXT,
|
|
153
|
+
status TEXT DEFAULT 'pending',
|
|
154
|
+
-- Phase 12b execution tracking
|
|
155
|
+
webex_id TEXT,
|
|
156
|
+
error_message TEXT,
|
|
157
|
+
completed_at TEXT,
|
|
158
|
+
attempts INTEGER DEFAULT 0,
|
|
159
|
+
FOREIGN KEY (canonical_id) REFERENCES objects(canonical_id)
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
CREATE TABLE IF NOT EXISTS plan_edges (
|
|
163
|
+
from_node TEXT NOT NULL,
|
|
164
|
+
to_node TEXT NOT NULL,
|
|
165
|
+
dep_type TEXT NOT NULL,
|
|
166
|
+
broken INTEGER DEFAULT 0,
|
|
167
|
+
PRIMARY KEY (from_node, to_node),
|
|
168
|
+
FOREIGN KEY (from_node) REFERENCES plan_operations(node_id),
|
|
169
|
+
FOREIGN KEY (to_node) REFERENCES plan_operations(node_id)
|
|
170
|
+
);
|
|
171
|
+
""")
|
|
172
|
+
self.conn.commit()
|
|
173
|
+
|
|
174
|
+
# Schema migration for Phase 12b execution tracking
|
|
175
|
+
for col, col_def in [
|
|
176
|
+
("webex_id", "TEXT"),
|
|
177
|
+
("error_message", "TEXT"),
|
|
178
|
+
("completed_at", "TEXT"),
|
|
179
|
+
("attempts", "INTEGER DEFAULT 0"),
|
|
180
|
+
]:
|
|
181
|
+
try:
|
|
182
|
+
self.conn.execute(
|
|
183
|
+
f"ALTER TABLE plan_operations ADD COLUMN {col} {col_def}"
|
|
184
|
+
)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass # Column already exists
|
|
187
|
+
|
|
188
|
+
# Bulk operations payload column (Phase: bulk-operations)
|
|
189
|
+
try:
|
|
190
|
+
self.conn.execute("ALTER TABLE plan_operations ADD COLUMN data_json TEXT")
|
|
191
|
+
except Exception:
|
|
192
|
+
pass # Column already exists
|
|
193
|
+
|
|
194
|
+
# Advisory system schema migration (Phase 13a)
|
|
195
|
+
for col in ["recommendation", "recommendation_reasoning"]:
|
|
196
|
+
try:
|
|
197
|
+
self.conn.execute(f"ALTER TABLE decisions ADD COLUMN {col} TEXT")
|
|
198
|
+
except Exception:
|
|
199
|
+
pass # Column already exists
|
|
200
|
+
self.conn.commit()
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# Run ID (from 03b-transform-mappers.md line 1025-1026)
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def current_run_id(self) -> str:
|
|
208
|
+
if self._run_id is None:
|
|
209
|
+
self._run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S-") + uuid.uuid4().hex[:8]
|
|
210
|
+
return self._run_id
|
|
211
|
+
|
|
212
|
+
def set_run_id(self, run_id: str) -> None:
|
|
213
|
+
self._run_id = run_id
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
# Decision ID (from 03b-transform-mappers.md line 1029-1030)
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def next_decision_id(self) -> str:
|
|
220
|
+
self._decision_counter += 1
|
|
221
|
+
return f"D{self._decision_counter:04d}"
|
|
222
|
+
|
|
223
|
+
def _max_decision_number(self) -> int:
|
|
224
|
+
"""Read the highest existing decision number from the DB so new IDs
|
|
225
|
+
don't collide with decisions from a previous session."""
|
|
226
|
+
row = self.conn.execute(
|
|
227
|
+
"SELECT MAX(CAST(SUBSTR(decision_id, 2) AS INTEGER)) AS n FROM decisions"
|
|
228
|
+
).fetchone()
|
|
229
|
+
return row["n"] or 0
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# Object CRUD
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def upsert_object(self, obj: MigrationObject) -> None:
|
|
236
|
+
"""Insert or update a canonical migration object.
|
|
237
|
+
(from 01-data-representation.md lines 122-128)
|
|
238
|
+
"""
|
|
239
|
+
now = _now()
|
|
240
|
+
data_json = obj.model_dump_json()
|
|
241
|
+
object_type = self._object_type_for(obj)
|
|
242
|
+
location_id = getattr(obj, "location_id", None)
|
|
243
|
+
self.conn.execute(
|
|
244
|
+
"""
|
|
245
|
+
INSERT INTO objects (canonical_id, object_type, status, location_id,
|
|
246
|
+
webex_id, batch, tier, data, created_at, updated_at)
|
|
247
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
248
|
+
ON CONFLICT(canonical_id) DO UPDATE SET
|
|
249
|
+
object_type = excluded.object_type,
|
|
250
|
+
status = excluded.status,
|
|
251
|
+
location_id = excluded.location_id,
|
|
252
|
+
webex_id = excluded.webex_id,
|
|
253
|
+
batch = excluded.batch,
|
|
254
|
+
data = excluded.data,
|
|
255
|
+
updated_at = excluded.updated_at
|
|
256
|
+
""",
|
|
257
|
+
(
|
|
258
|
+
obj.canonical_id,
|
|
259
|
+
object_type,
|
|
260
|
+
obj.status.value,
|
|
261
|
+
location_id,
|
|
262
|
+
obj.webex_id,
|
|
263
|
+
obj.batch,
|
|
264
|
+
None, # tier set by planner
|
|
265
|
+
data_json,
|
|
266
|
+
now,
|
|
267
|
+
now,
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
self.conn.commit()
|
|
271
|
+
|
|
272
|
+
def get_object(self, canonical_id: str) -> dict[str, Any] | None:
|
|
273
|
+
"""Get a single object by canonical_id.
|
|
274
|
+
(from 03b-transform-mappers.md line 1008-1009)
|
|
275
|
+
"""
|
|
276
|
+
row = self.conn.execute(
|
|
277
|
+
"SELECT data FROM objects WHERE canonical_id = ?", (canonical_id,)
|
|
278
|
+
).fetchone()
|
|
279
|
+
if row is None:
|
|
280
|
+
return None
|
|
281
|
+
return json.loads(row["data"])
|
|
282
|
+
|
|
283
|
+
def get_object_typed(self, canonical_id: str) -> MigrationObject | None:
|
|
284
|
+
"""Get a single object deserialized into its Pydantic type."""
|
|
285
|
+
row = self.conn.execute(
|
|
286
|
+
"SELECT object_type, data FROM objects WHERE canonical_id = ?",
|
|
287
|
+
(canonical_id,),
|
|
288
|
+
).fetchone()
|
|
289
|
+
if row is None:
|
|
290
|
+
return None
|
|
291
|
+
return self._deserialize(row["object_type"], row["data"])
|
|
292
|
+
|
|
293
|
+
def get_objects(self, object_type: str) -> list[dict[str, Any]]:
|
|
294
|
+
"""Get all objects of a given type as dicts.
|
|
295
|
+
(from 03b-transform-mappers.md line 1005-1006)
|
|
296
|
+
"""
|
|
297
|
+
rows = self.conn.execute(
|
|
298
|
+
"SELECT data FROM objects WHERE object_type = ?", (object_type,)
|
|
299
|
+
).fetchall()
|
|
300
|
+
return [json.loads(r["data"]) for r in rows]
|
|
301
|
+
|
|
302
|
+
def query_by_type(self, object_type: str, status: str | None = None) -> list[MigrationObject]:
|
|
303
|
+
"""Query objects by type, optionally filtered by status.
|
|
304
|
+
(from 01-data-representation.md line 130-131)
|
|
305
|
+
"""
|
|
306
|
+
if status:
|
|
307
|
+
rows = self.conn.execute(
|
|
308
|
+
"SELECT object_type, data FROM objects WHERE object_type = ? AND status = ?",
|
|
309
|
+
(object_type, status),
|
|
310
|
+
).fetchall()
|
|
311
|
+
else:
|
|
312
|
+
rows = self.conn.execute(
|
|
313
|
+
"SELECT object_type, data FROM objects WHERE object_type = ?",
|
|
314
|
+
(object_type,),
|
|
315
|
+
).fetchall()
|
|
316
|
+
return [self._deserialize(r["object_type"], r["data"]) for r in rows]
|
|
317
|
+
|
|
318
|
+
# Synthetic bulk-op placeholder types stored in the objects table as FK anchors.
|
|
319
|
+
# These have minimal JSON (no canonical_id/provenance) and cannot be deserialized
|
|
320
|
+
# as MigrationObject subclasses. The planner handles them via _optimize_for_bulk()
|
|
321
|
+
# after expand_to_operations(), so they must be excluded from query_by_status().
|
|
322
|
+
_BULK_PLACEHOLDER_TYPES: frozenset[str] = frozenset({
|
|
323
|
+
"bulk_device_settings",
|
|
324
|
+
"bulk_line_key_template",
|
|
325
|
+
"bulk_rebuild_phones",
|
|
326
|
+
"bulk_dynamic_settings",
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
def query_by_status(self, status: str) -> list[MigrationObject]:
|
|
330
|
+
"""Query all objects with a given status.
|
|
331
|
+
|
|
332
|
+
Excludes synthetic bulk-op placeholder rows (_BULK_PLACEHOLDER_TYPES)
|
|
333
|
+
which are stored in the objects table as FK anchors but do not
|
|
334
|
+
deserialize as MigrationObject subclasses.
|
|
335
|
+
"""
|
|
336
|
+
placeholders = ",".join("?" * len(self._BULK_PLACEHOLDER_TYPES))
|
|
337
|
+
rows = self.conn.execute(
|
|
338
|
+
f"SELECT canonical_id, object_type, data FROM objects "
|
|
339
|
+
f"WHERE status = ? AND object_type NOT IN ({placeholders})",
|
|
340
|
+
(status, *self._BULK_PLACEHOLDER_TYPES),
|
|
341
|
+
).fetchall()
|
|
342
|
+
return [self._deserialize(r["object_type"], r["data"]) for r in rows]
|
|
343
|
+
|
|
344
|
+
def count_by_type(self, object_type: str) -> int:
|
|
345
|
+
row = self.conn.execute(
|
|
346
|
+
"SELECT COUNT(*) as cnt FROM objects WHERE object_type = ?", (object_type,)
|
|
347
|
+
).fetchone()
|
|
348
|
+
return row["cnt"]
|
|
349
|
+
|
|
350
|
+
# ------------------------------------------------------------------
|
|
351
|
+
# Cross-references
|
|
352
|
+
# ------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def add_cross_ref(
|
|
355
|
+
self,
|
|
356
|
+
from_id: str,
|
|
357
|
+
to_id: str,
|
|
358
|
+
relationship: str,
|
|
359
|
+
ordinal: int | None = None,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Add a cross-reference between two objects.
|
|
362
|
+
(from 01-data-representation.md lines 50-61)
|
|
363
|
+
"""
|
|
364
|
+
self.conn.execute(
|
|
365
|
+
"""
|
|
366
|
+
INSERT OR REPLACE INTO cross_refs (from_id, to_id, relationship, ordinal)
|
|
367
|
+
VALUES (?, ?, ?, ?)
|
|
368
|
+
""",
|
|
369
|
+
(from_id, to_id, relationship, ordinal),
|
|
370
|
+
)
|
|
371
|
+
self.conn.commit()
|
|
372
|
+
|
|
373
|
+
def find_cross_refs(self, canonical_id: str, relationship: str) -> list[str]:
|
|
374
|
+
"""Find all target IDs for a source + relationship.
|
|
375
|
+
(from 01-data-representation.md lines 133-135)
|
|
376
|
+
"""
|
|
377
|
+
rows = self.conn.execute(
|
|
378
|
+
"SELECT to_id FROM cross_refs WHERE from_id = ? AND relationship = ?",
|
|
379
|
+
(canonical_id, relationship),
|
|
380
|
+
).fetchall()
|
|
381
|
+
return [r["to_id"] for r in rows]
|
|
382
|
+
|
|
383
|
+
def get_cross_refs(
|
|
384
|
+
self,
|
|
385
|
+
from_id: str | None = None,
|
|
386
|
+
to_id: str | None = None,
|
|
387
|
+
relationship: str | None = None,
|
|
388
|
+
) -> list[dict[str, Any]]:
|
|
389
|
+
"""Flexible cross-ref query with optional filters.
|
|
390
|
+
(from 03b-transform-mappers.md lines 1014-1016)
|
|
391
|
+
"""
|
|
392
|
+
clauses: list[str] = []
|
|
393
|
+
params: list[Any] = []
|
|
394
|
+
if from_id is not None:
|
|
395
|
+
clauses.append("from_id = ?")
|
|
396
|
+
params.append(from_id)
|
|
397
|
+
if to_id is not None:
|
|
398
|
+
clauses.append("to_id = ?")
|
|
399
|
+
params.append(to_id)
|
|
400
|
+
if relationship is not None:
|
|
401
|
+
clauses.append("relationship = ?")
|
|
402
|
+
params.append(relationship)
|
|
403
|
+
where = " AND ".join(clauses) if clauses else "1=1"
|
|
404
|
+
rows = self.conn.execute(
|
|
405
|
+
f"SELECT from_id, to_id, relationship, ordinal FROM cross_refs WHERE {where}",
|
|
406
|
+
params,
|
|
407
|
+
).fetchall()
|
|
408
|
+
return [dict(r) for r in rows]
|
|
409
|
+
|
|
410
|
+
def get_cross_ref_targets(self, from_id: str, relationship: str) -> list[str]:
|
|
411
|
+
"""Get all target IDs for a given source and relationship type.
|
|
412
|
+
(from 03b-transform-mappers.md lines 1018-1019)
|
|
413
|
+
"""
|
|
414
|
+
return self.find_cross_refs(from_id, relationship)
|
|
415
|
+
|
|
416
|
+
def resolve_chain(self, start_id: str, *relationships: str) -> str | None:
|
|
417
|
+
"""Follow a chain of cross-refs to reach a final target.
|
|
418
|
+
(from 03b-transform-mappers.md lines 1021-1023)
|
|
419
|
+
|
|
420
|
+
Example: resolve_chain(dn_id, 'device_has_dn', 'device_in_pool', 'device_pool_to_location')
|
|
421
|
+
"""
|
|
422
|
+
current = start_id
|
|
423
|
+
for rel in relationships:
|
|
424
|
+
targets = self.find_cross_refs(current, rel)
|
|
425
|
+
if not targets:
|
|
426
|
+
return None
|
|
427
|
+
current = targets[0]
|
|
428
|
+
return current
|
|
429
|
+
|
|
430
|
+
def clear_cross_refs(self) -> None:
|
|
431
|
+
"""Delete all cross-refs for rebuild during normalization pass 2.
|
|
432
|
+
(from 07-idempotency-resumability.md lines 183-187)
|
|
433
|
+
"""
|
|
434
|
+
self.conn.execute("DELETE FROM cross_refs")
|
|
435
|
+
self.conn.commit()
|
|
436
|
+
|
|
437
|
+
# ------------------------------------------------------------------
|
|
438
|
+
# Cleanup (for pipeline re-run safety)
|
|
439
|
+
# ------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
def clear_all(self) -> dict[str, int]:
|
|
442
|
+
"""Delete all data from every table in FK-safe order. Returns counts per table."""
|
|
443
|
+
counts: dict[str, int] = {}
|
|
444
|
+
for table in [
|
|
445
|
+
"plan_edges", "plan_operations", "merge_log",
|
|
446
|
+
"decisions", "journal", "cross_refs", "objects",
|
|
447
|
+
]:
|
|
448
|
+
cursor = self.conn.execute(f"DELETE FROM {table}") # noqa: S608
|
|
449
|
+
counts[table] = cursor.rowcount
|
|
450
|
+
self.conn.commit()
|
|
451
|
+
return counts
|
|
452
|
+
|
|
453
|
+
def clear_journal(self) -> int:
|
|
454
|
+
"""Delete all journal entries. Returns count deleted."""
|
|
455
|
+
cursor = self.conn.execute("DELETE FROM journal")
|
|
456
|
+
self.conn.commit()
|
|
457
|
+
return cursor.rowcount
|
|
458
|
+
|
|
459
|
+
def clear_objects(self) -> int:
|
|
460
|
+
"""Delete all objects. Must clear cross_refs and journal first (FK constraints)."""
|
|
461
|
+
cursor = self.conn.execute("DELETE FROM objects")
|
|
462
|
+
self.conn.commit()
|
|
463
|
+
return cursor.rowcount
|
|
464
|
+
|
|
465
|
+
def clear_objects_by_types(self, types: list[str]) -> int:
|
|
466
|
+
"""Delete objects of specific resource types. Returns count deleted."""
|
|
467
|
+
if not types:
|
|
468
|
+
return 0
|
|
469
|
+
placeholders = ",".join("?" for _ in types)
|
|
470
|
+
cursor = self.conn.execute(
|
|
471
|
+
f"DELETE FROM objects WHERE resource_type IN ({placeholders})", types
|
|
472
|
+
)
|
|
473
|
+
self.conn.commit()
|
|
474
|
+
return cursor.rowcount
|
|
475
|
+
|
|
476
|
+
def clear_decisions_by_source(self, source: str) -> int:
|
|
477
|
+
"""Delete decisions from a specific source (e.g. 'mapper', 'analyzer'). Returns count deleted."""
|
|
478
|
+
cursor = self.conn.execute("DELETE FROM decisions WHERE source = ?", (source,))
|
|
479
|
+
self.conn.commit()
|
|
480
|
+
return cursor.rowcount
|
|
481
|
+
|
|
482
|
+
# ------------------------------------------------------------------
|
|
483
|
+
# Decisions
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
def save_decision(self, decision: dict[str, Any]) -> None:
|
|
487
|
+
"""Insert or update a decision (upsert by fingerprint).
|
|
488
|
+
(from 01-data-representation.md lines 63-77)
|
|
489
|
+
|
|
490
|
+
On fingerprint conflict the existing ``decision_id`` is preserved
|
|
491
|
+
and the incoming ID is discarded. This matches the merge algorithm
|
|
492
|
+
in 07-idempotency-resumability.md: existing decisions keep their
|
|
493
|
+
identity across re-analysis runs.
|
|
494
|
+
"""
|
|
495
|
+
self.conn.execute(
|
|
496
|
+
"""
|
|
497
|
+
INSERT INTO decisions (decision_id, type, severity, summary, context,
|
|
498
|
+
options, chosen_option, resolved_at, resolved_by,
|
|
499
|
+
fingerprint, run_id,
|
|
500
|
+
recommendation, recommendation_reasoning)
|
|
501
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
502
|
+
ON CONFLICT(fingerprint) DO UPDATE SET
|
|
503
|
+
type = excluded.type,
|
|
504
|
+
severity = excluded.severity,
|
|
505
|
+
summary = excluded.summary,
|
|
506
|
+
context = excluded.context,
|
|
507
|
+
options = excluded.options,
|
|
508
|
+
run_id = excluded.run_id,
|
|
509
|
+
recommendation = excluded.recommendation,
|
|
510
|
+
recommendation_reasoning = excluded.recommendation_reasoning
|
|
511
|
+
""",
|
|
512
|
+
(
|
|
513
|
+
decision["decision_id"],
|
|
514
|
+
decision["type"],
|
|
515
|
+
decision["severity"],
|
|
516
|
+
decision["summary"],
|
|
517
|
+
json.dumps(decision.get("context", {})),
|
|
518
|
+
json.dumps(decision.get("options", [])),
|
|
519
|
+
decision.get("chosen_option"),
|
|
520
|
+
decision.get("resolved_at"),
|
|
521
|
+
decision.get("resolved_by"),
|
|
522
|
+
decision["fingerprint"],
|
|
523
|
+
decision.get("run_id", self.current_run_id),
|
|
524
|
+
decision.get("recommendation"),
|
|
525
|
+
decision.get("recommendation_reasoning"),
|
|
526
|
+
),
|
|
527
|
+
)
|
|
528
|
+
self.conn.commit()
|
|
529
|
+
|
|
530
|
+
def update_decision_context(
|
|
531
|
+
self,
|
|
532
|
+
decision_id: str,
|
|
533
|
+
context: dict[str, Any],
|
|
534
|
+
) -> None:
|
|
535
|
+
"""Patch only the ``context`` column of an existing decision row.
|
|
536
|
+
|
|
537
|
+
Used by ``enrich_cross_decision_context()`` to write cross-decision
|
|
538
|
+
fields (e.g., ``is_on_incompatible_device``) into a MISSING_DATA
|
|
539
|
+
decision without re-fingerprinting or touching other columns.
|
|
540
|
+
|
|
541
|
+
Raises ``KeyError`` if the decision_id does not exist.
|
|
542
|
+
"""
|
|
543
|
+
cursor = self.conn.execute(
|
|
544
|
+
"UPDATE decisions SET context = ? WHERE decision_id = ?",
|
|
545
|
+
(json.dumps(context), decision_id),
|
|
546
|
+
)
|
|
547
|
+
if cursor.rowcount == 0:
|
|
548
|
+
raise KeyError(f"decision_id not found: {decision_id}")
|
|
549
|
+
self.conn.commit()
|
|
550
|
+
|
|
551
|
+
def get_decision(self, decision_id: str) -> dict[str, Any] | None:
|
|
552
|
+
row = self.conn.execute(
|
|
553
|
+
"SELECT * FROM decisions WHERE decision_id = ?", (decision_id,)
|
|
554
|
+
).fetchone()
|
|
555
|
+
if row is None:
|
|
556
|
+
return None
|
|
557
|
+
return self._decision_row_to_dict(row)
|
|
558
|
+
|
|
559
|
+
def get_all_decisions(self) -> list[dict[str, Any]]:
|
|
560
|
+
rows = self.conn.execute("SELECT * FROM decisions").fetchall()
|
|
561
|
+
return [self._decision_row_to_dict(r) for r in rows]
|
|
562
|
+
|
|
563
|
+
def resolve_decision(
|
|
564
|
+
self, decision_id: str, chosen_option: str, resolved_by: str = "user"
|
|
565
|
+
) -> None:
|
|
566
|
+
self.conn.execute(
|
|
567
|
+
"""
|
|
568
|
+
UPDATE decisions SET chosen_option = ?, resolved_at = ?, resolved_by = ?
|
|
569
|
+
WHERE decision_id = ?
|
|
570
|
+
""",
|
|
571
|
+
(chosen_option, _now(), resolved_by, decision_id),
|
|
572
|
+
)
|
|
573
|
+
self.conn.commit()
|
|
574
|
+
|
|
575
|
+
def update_recommendation(self, decision_id: str, recommendation: str | None,
|
|
576
|
+
recommendation_reasoning: str | None) -> None:
|
|
577
|
+
"""Update recommendation fields on an existing decision."""
|
|
578
|
+
self.conn.execute(
|
|
579
|
+
"UPDATE decisions SET recommendation = ?, recommendation_reasoning = ? "
|
|
580
|
+
"WHERE decision_id = ?",
|
|
581
|
+
(recommendation, recommendation_reasoning, decision_id),
|
|
582
|
+
)
|
|
583
|
+
self.conn.commit()
|
|
584
|
+
|
|
585
|
+
# ------------------------------------------------------------------
|
|
586
|
+
# Decision merge (from 07-idempotency-resumability.md lines 66-120)
|
|
587
|
+
# ------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
def merge_decisions(
|
|
590
|
+
self, new_decisions: list[dict[str, Any]],
|
|
591
|
+
decision_types: list[str] | None = None,
|
|
592
|
+
stage: str = "analyze",
|
|
593
|
+
) -> dict[str, int]:
|
|
594
|
+
"""Three-way merge of new analysis results with existing decisions.
|
|
595
|
+
|
|
596
|
+
On re-analysis, matches new decisions against existing by fingerprint:
|
|
597
|
+
- Fingerprint in both: keep existing (preserve resolution if resolved)
|
|
598
|
+
- Fingerprint only in new: insert as new decision
|
|
599
|
+
- Fingerprint only in old: mark stale
|
|
600
|
+
|
|
601
|
+
If ``decision_types`` is provided, only existing decisions of those
|
|
602
|
+
types are considered for the merge. This prevents preflight from
|
|
603
|
+
stale-marking analyzer decisions (from 05a-preflight-checks.md
|
|
604
|
+
lines 920-929).
|
|
605
|
+
|
|
606
|
+
Returns dict with counts: kept, updated, new, stale, invalidated.
|
|
607
|
+
Logs each action to the merge_log table.
|
|
608
|
+
|
|
609
|
+
(from 07-idempotency-resumability.md lines 66-120)
|
|
610
|
+
"""
|
|
611
|
+
existing_rows = self.get_all_decisions()
|
|
612
|
+
existing_by_fp: dict[str, dict[str, Any]] = {}
|
|
613
|
+
for d in existing_rows:
|
|
614
|
+
fp = d.get("fingerprint", "")
|
|
615
|
+
# Skip already-stale decisions
|
|
616
|
+
if d.get("chosen_option") == "__stale__":
|
|
617
|
+
continue
|
|
618
|
+
# Scope to specific decision types if provided
|
|
619
|
+
if decision_types is not None and d.get("type") not in decision_types:
|
|
620
|
+
continue
|
|
621
|
+
existing_by_fp[fp] = d
|
|
622
|
+
|
|
623
|
+
new_by_fp: dict[str, dict[str, Any]] = {}
|
|
624
|
+
for d in new_decisions:
|
|
625
|
+
fp = d.get("fingerprint", "")
|
|
626
|
+
new_by_fp[fp] = d
|
|
627
|
+
|
|
628
|
+
result = {"kept": 0, "updated": 0, "new": 0, "stale": 0, "invalidated": 0}
|
|
629
|
+
|
|
630
|
+
existing_fps = set(existing_by_fp.keys())
|
|
631
|
+
new_fps = set(new_by_fp.keys())
|
|
632
|
+
|
|
633
|
+
# Case 1: Fingerprint in both old and new — condition still exists
|
|
634
|
+
for fp in existing_fps & new_fps:
|
|
635
|
+
old = existing_by_fp[fp]
|
|
636
|
+
new = new_by_fp[fp]
|
|
637
|
+
if old.get("chosen_option") is not None:
|
|
638
|
+
# Resolved decision, same fingerprint → keep resolution
|
|
639
|
+
# Update display-only fields (summary, options, context)
|
|
640
|
+
# while preserving chosen_option, resolved_at, resolved_by
|
|
641
|
+
self.conn.execute(
|
|
642
|
+
"""UPDATE decisions SET context = ?, summary = ?,
|
|
643
|
+
options = ?, run_id = ?,
|
|
644
|
+
recommendation = ?, recommendation_reasoning = ?
|
|
645
|
+
WHERE decision_id = ?""",
|
|
646
|
+
(
|
|
647
|
+
json.dumps(new.get("context", {})),
|
|
648
|
+
new.get("summary", old.get("summary", "")),
|
|
649
|
+
json.dumps(new.get("options", [])),
|
|
650
|
+
new.get("run_id", self.current_run_id),
|
|
651
|
+
new.get("recommendation"),
|
|
652
|
+
new.get("recommendation_reasoning"),
|
|
653
|
+
old["decision_id"],
|
|
654
|
+
),
|
|
655
|
+
)
|
|
656
|
+
self.add_merge_log(
|
|
657
|
+
stage, "kept", "decision", old["decision_id"],
|
|
658
|
+
{"reason": "resolved, fingerprint unchanged"},
|
|
659
|
+
)
|
|
660
|
+
result["kept"] += 1
|
|
661
|
+
else:
|
|
662
|
+
# Pending decision, same fingerprint → update context
|
|
663
|
+
self.conn.execute(
|
|
664
|
+
"""UPDATE decisions SET context = ?, summary = ?,
|
|
665
|
+
severity = ?, options = ?, run_id = ?,
|
|
666
|
+
recommendation = ?, recommendation_reasoning = ?
|
|
667
|
+
WHERE decision_id = ?""",
|
|
668
|
+
(
|
|
669
|
+
json.dumps(new.get("context", {})),
|
|
670
|
+
new.get("summary", old.get("summary", "")),
|
|
671
|
+
new.get("severity", old.get("severity", "")),
|
|
672
|
+
json.dumps(new.get("options", [])),
|
|
673
|
+
new.get("run_id", self.current_run_id),
|
|
674
|
+
new.get("recommendation"),
|
|
675
|
+
new.get("recommendation_reasoning"),
|
|
676
|
+
old["decision_id"],
|
|
677
|
+
),
|
|
678
|
+
)
|
|
679
|
+
self.add_merge_log(
|
|
680
|
+
stage, "updated", "decision", old["decision_id"],
|
|
681
|
+
{"reason": "pending, context updated"},
|
|
682
|
+
)
|
|
683
|
+
result["updated"] += 1
|
|
684
|
+
|
|
685
|
+
# Case 2: Fingerprint only in new — new decision
|
|
686
|
+
for fp in new_fps - existing_fps:
|
|
687
|
+
self.save_decision(new_by_fp[fp])
|
|
688
|
+
new_id = new_by_fp[fp].get("decision_id", "?")
|
|
689
|
+
self.add_merge_log(
|
|
690
|
+
stage, "new", "decision", new_id,
|
|
691
|
+
{"reason": "new conflict detected"},
|
|
692
|
+
)
|
|
693
|
+
result["new"] += 1
|
|
694
|
+
|
|
695
|
+
# Case 3: Fingerprint only in old — condition gone
|
|
696
|
+
for fp in existing_fps - new_fps:
|
|
697
|
+
old = existing_by_fp[fp]
|
|
698
|
+
old_id = old["decision_id"]
|
|
699
|
+
if old.get("chosen_option") is not None:
|
|
700
|
+
# Was resolved, now irrelevant
|
|
701
|
+
self.conn.execute(
|
|
702
|
+
"""UPDATE decisions SET chosen_option = '__stale__',
|
|
703
|
+
resolved_by = 'stale' WHERE decision_id = ?""",
|
|
704
|
+
(old_id,),
|
|
705
|
+
)
|
|
706
|
+
self.add_merge_log(
|
|
707
|
+
stage, "invalidated", "decision", old_id,
|
|
708
|
+
{"reason": "resolved decision no longer relevant"},
|
|
709
|
+
)
|
|
710
|
+
result["invalidated"] += 1
|
|
711
|
+
else:
|
|
712
|
+
self.conn.execute(
|
|
713
|
+
"""UPDATE decisions SET chosen_option = '__stale__',
|
|
714
|
+
resolved_by = 'stale' WHERE decision_id = ?""",
|
|
715
|
+
(old_id,),
|
|
716
|
+
)
|
|
717
|
+
self.add_merge_log(
|
|
718
|
+
stage, "stale", "decision", old_id,
|
|
719
|
+
{"reason": "condition no longer exists"},
|
|
720
|
+
)
|
|
721
|
+
result["stale"] += 1
|
|
722
|
+
|
|
723
|
+
self.conn.commit()
|
|
724
|
+
return result
|
|
725
|
+
|
|
726
|
+
# ------------------------------------------------------------------
|
|
727
|
+
# Journal
|
|
728
|
+
# ------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
def add_journal_entry(
|
|
731
|
+
self,
|
|
732
|
+
entry_type: str,
|
|
733
|
+
canonical_id: str,
|
|
734
|
+
resource_type: str,
|
|
735
|
+
request: dict[str, Any],
|
|
736
|
+
response: dict[str, Any] | None = None,
|
|
737
|
+
pre_state: dict[str, Any] | None = None,
|
|
738
|
+
) -> None:
|
|
739
|
+
"""Append to the execution journal.
|
|
740
|
+
(from 01-data-representation.md lines 79-90)
|
|
741
|
+
"""
|
|
742
|
+
self.conn.execute(
|
|
743
|
+
"""
|
|
744
|
+
INSERT INTO journal (timestamp, entry_type, canonical_id, resource_type,
|
|
745
|
+
request, response, pre_state)
|
|
746
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
747
|
+
""",
|
|
748
|
+
(
|
|
749
|
+
_now(),
|
|
750
|
+
entry_type,
|
|
751
|
+
canonical_id,
|
|
752
|
+
resource_type,
|
|
753
|
+
json.dumps(request),
|
|
754
|
+
json.dumps(response) if response else None,
|
|
755
|
+
json.dumps(pre_state) if pre_state else None,
|
|
756
|
+
),
|
|
757
|
+
)
|
|
758
|
+
self.conn.commit()
|
|
759
|
+
|
|
760
|
+
def get_journal_count(self, entry_type: str | None = None) -> int:
|
|
761
|
+
"""Count journal entries, optionally filtered by entry_type."""
|
|
762
|
+
if entry_type:
|
|
763
|
+
row = self.conn.execute(
|
|
764
|
+
"SELECT COUNT(*) as cnt FROM journal WHERE entry_type = ?",
|
|
765
|
+
(entry_type,),
|
|
766
|
+
).fetchone()
|
|
767
|
+
else:
|
|
768
|
+
row = self.conn.execute(
|
|
769
|
+
"SELECT COUNT(*) as cnt FROM journal"
|
|
770
|
+
).fetchone()
|
|
771
|
+
return row["cnt"]
|
|
772
|
+
|
|
773
|
+
def get_journal_entries(
|
|
774
|
+
self,
|
|
775
|
+
entry_type: str | None = None,
|
|
776
|
+
limit: int | None = None,
|
|
777
|
+
) -> list[dict[str, Any]]:
|
|
778
|
+
"""Get journal entries, optionally filtered by entry_type.
|
|
779
|
+
|
|
780
|
+
Returns list of dicts with keys: entry_id, timestamp, entry_type,
|
|
781
|
+
canonical_id, resource_type, request, response, pre_state.
|
|
782
|
+
"""
|
|
783
|
+
if entry_type:
|
|
784
|
+
sql = "SELECT * FROM journal WHERE entry_type = ?"
|
|
785
|
+
params: tuple[Any, ...] = (entry_type,)
|
|
786
|
+
else:
|
|
787
|
+
sql = "SELECT * FROM journal"
|
|
788
|
+
params = ()
|
|
789
|
+
if limit:
|
|
790
|
+
sql += " LIMIT ?"
|
|
791
|
+
params = (*params, limit)
|
|
792
|
+
rows = self.conn.execute(sql, params).fetchall()
|
|
793
|
+
return [dict(r) for r in rows]
|
|
794
|
+
|
|
795
|
+
# ------------------------------------------------------------------
|
|
796
|
+
# Merge log
|
|
797
|
+
# ------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
def add_merge_log(
|
|
800
|
+
self,
|
|
801
|
+
stage: str,
|
|
802
|
+
action: str,
|
|
803
|
+
target_type: str,
|
|
804
|
+
target_id: str,
|
|
805
|
+
details: dict[str, Any] | None = None,
|
|
806
|
+
) -> None:
|
|
807
|
+
"""Log a merge operation.
|
|
808
|
+
(from 07-idempotency-resumability.md lines 209-218)
|
|
809
|
+
"""
|
|
810
|
+
self.conn.execute(
|
|
811
|
+
"""
|
|
812
|
+
INSERT INTO merge_log (timestamp, stage, run_id, action, target_type,
|
|
813
|
+
target_id, details)
|
|
814
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
815
|
+
""",
|
|
816
|
+
(
|
|
817
|
+
_now(),
|
|
818
|
+
stage,
|
|
819
|
+
self.current_run_id,
|
|
820
|
+
action,
|
|
821
|
+
target_type,
|
|
822
|
+
target_id,
|
|
823
|
+
json.dumps(details) if details else None,
|
|
824
|
+
),
|
|
825
|
+
)
|
|
826
|
+
self.conn.commit()
|
|
827
|
+
|
|
828
|
+
# ------------------------------------------------------------------
|
|
829
|
+
# Helpers
|
|
830
|
+
# ------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
@staticmethod
|
|
833
|
+
def _object_type_for(obj: MigrationObject) -> str:
|
|
834
|
+
"""Derive the object_type string from a Pydantic model class (O(1) lookup).
|
|
835
|
+
|
|
836
|
+
For canonical subtypes (CanonicalUser, CanonicalDevice, etc.), uses the
|
|
837
|
+
class→type registry. For base MigrationObject instances (intermediate types
|
|
838
|
+
like device_pool, css, partition), extracts the type from the canonical_id
|
|
839
|
+
prefix (e.g., "device_pool:DP-HQ" → "device_pool").
|
|
840
|
+
"""
|
|
841
|
+
registered = CANONICAL_CLASS_TO_TYPE.get(type(obj))
|
|
842
|
+
if registered is not None:
|
|
843
|
+
return registered
|
|
844
|
+
# Intermediate types use canonical_id prefix convention: "type:name"
|
|
845
|
+
if type(obj) is MigrationObject and ":" in obj.canonical_id:
|
|
846
|
+
return obj.canonical_id.split(":")[0]
|
|
847
|
+
return obj.__class__.__name__.lower()
|
|
848
|
+
|
|
849
|
+
@staticmethod
|
|
850
|
+
def _deserialize(object_type: str, data_json: str) -> MigrationObject:
|
|
851
|
+
"""Deserialize JSON data into the correct Pydantic type."""
|
|
852
|
+
cls = CANONICAL_TYPE_REGISTRY.get(object_type, MigrationObject)
|
|
853
|
+
return cls.model_validate_json(data_json)
|
|
854
|
+
|
|
855
|
+
@staticmethod
|
|
856
|
+
def _decision_row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
|
|
857
|
+
d = dict(row)
|
|
858
|
+
d["context"] = json.loads(d["context"]) if d["context"] else {}
|
|
859
|
+
d["options"] = json.loads(d["options"]) if d["options"] else []
|
|
860
|
+
return d
|