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
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
"""Preflight check functions for CUCM-to-Webex migration.
|
|
2
|
+
|
|
3
|
+
Each check reads planned resources from the store and compares them against
|
|
4
|
+
live Webex org state queried via wxcli CLI commands (subprocess).
|
|
5
|
+
|
|
6
|
+
(from 05a-preflight-checks.md — 7 checks + DUPLICATE_USER detection)
|
|
7
|
+
(from phase-10-preflight.md — adapted to use wxcli commands)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from wxcli.migration.models import DecisionType
|
|
16
|
+
from wxcli.migration.preflight import (
|
|
17
|
+
CheckResult,
|
|
18
|
+
CheckStatus,
|
|
19
|
+
PreflightError,
|
|
20
|
+
PreflightIssue,
|
|
21
|
+
_run_wxcli,
|
|
22
|
+
preflight_fingerprint,
|
|
23
|
+
)
|
|
24
|
+
from wxcli.migration.store import MigrationStore
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Known default feature limits (from 05a-preflight-checks.md)
|
|
29
|
+
# Exact per-org limits vary by subscription and are not API-queryable.
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
FEATURE_LIMITS = {
|
|
33
|
+
"auto_attendant": 1000,
|
|
34
|
+
"call_queue": 1000,
|
|
35
|
+
"hunt_group": 1000,
|
|
36
|
+
"paging_group": 100,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Check 1: User Licenses
|
|
42
|
+
# (from 05a-preflight-checks.md Check 1)
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_licenses(store: MigrationStore, licenses: list[dict]) -> CheckResult:
|
|
47
|
+
"""Check if enough Calling Professional licenses are available."""
|
|
48
|
+
# Use plan_operations count (resource_type='user', op_type='create') rather
|
|
49
|
+
# than raw object count so that unanalyzed/skipped users don't inflate the
|
|
50
|
+
# needed count.
|
|
51
|
+
row = store.conn.execute(
|
|
52
|
+
"SELECT COUNT(*) AS cnt FROM plan_operations "
|
|
53
|
+
"WHERE resource_type = 'user' AND op_type = 'create' AND status != 'skipped'"
|
|
54
|
+
).fetchone()
|
|
55
|
+
user_count = row["cnt"] if row else store.count_by_type("user")
|
|
56
|
+
if user_count == 0:
|
|
57
|
+
return CheckResult(
|
|
58
|
+
name="User licenses",
|
|
59
|
+
status=CheckStatus.SKIP,
|
|
60
|
+
detail="No users in migration plan",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
calling_pro = None
|
|
64
|
+
for lic in licenses:
|
|
65
|
+
name_lower = lic.get("name", "").lower()
|
|
66
|
+
if "calling" in name_lower and "professional" in name_lower:
|
|
67
|
+
calling_pro = lic
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
if calling_pro is None:
|
|
71
|
+
return CheckResult(
|
|
72
|
+
name="User licenses",
|
|
73
|
+
status=CheckStatus.FAIL,
|
|
74
|
+
detail="No Webex Calling Professional licenses found in org",
|
|
75
|
+
issues=[PreflightIssue("LICENSE_UNAVAILABLE",
|
|
76
|
+
"No Calling Professional license in org")],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
total = calling_pro.get("totalUnits", 0)
|
|
80
|
+
consumed = calling_pro.get("consumedUnits", 0)
|
|
81
|
+
available = total - consumed
|
|
82
|
+
|
|
83
|
+
if available >= user_count:
|
|
84
|
+
buffer_pct = ((available - user_count) / user_count * 100) if user_count else 100
|
|
85
|
+
detail = f"{user_count} needed, {available} available ({available - user_count} buffer)"
|
|
86
|
+
if buffer_pct < 10:
|
|
87
|
+
return CheckResult(
|
|
88
|
+
name="User licenses",
|
|
89
|
+
status=CheckStatus.WARN,
|
|
90
|
+
detail=detail + f" (only {buffer_pct:.0f}% buffer)",
|
|
91
|
+
data={"needed": user_count, "available": available},
|
|
92
|
+
)
|
|
93
|
+
return CheckResult(
|
|
94
|
+
name="User licenses",
|
|
95
|
+
status=CheckStatus.PASS,
|
|
96
|
+
detail=detail,
|
|
97
|
+
data={"needed": user_count, "available": available},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return CheckResult(
|
|
101
|
+
name="User licenses",
|
|
102
|
+
status=CheckStatus.FAIL,
|
|
103
|
+
detail=f"{user_count} needed, {available} available (SHORT {user_count - available})",
|
|
104
|
+
issues=[PreflightIssue(
|
|
105
|
+
"LICENSE_INSUFFICIENT",
|
|
106
|
+
f"Need {user_count - available} more Calling Professional licenses",
|
|
107
|
+
)],
|
|
108
|
+
data={"needed": user_count, "available": available},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Check 2: Workspace Licenses
|
|
114
|
+
# (from 05a-preflight-checks.md Check 2)
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def check_workspace_licenses(store: MigrationStore, licenses: list[dict]) -> CheckResult:
|
|
119
|
+
"""Check if enough Common Area / workspace licenses are available."""
|
|
120
|
+
# Count workspaces that aren't being skipped via a WORKSPACE_LICENSE_TIER decision.
|
|
121
|
+
all_decisions = store.get_all_decisions()
|
|
122
|
+
skipped_ws_ids: set[str] = set()
|
|
123
|
+
for d in all_decisions:
|
|
124
|
+
if d.get("type") == "WORKSPACE_LICENSE_TIER" and d.get("chosen_option") == "skip":
|
|
125
|
+
# context.canonical_id holds the workspace's canonical ID
|
|
126
|
+
ctx = d.get("context", {})
|
|
127
|
+
ws_id = ctx.get("canonical_id") or ctx.get("object_id")
|
|
128
|
+
if ws_id:
|
|
129
|
+
skipped_ws_ids.add(ws_id)
|
|
130
|
+
|
|
131
|
+
ws_count = store.count_by_type("workspace") - len(skipped_ws_ids)
|
|
132
|
+
if ws_count == 0:
|
|
133
|
+
return CheckResult(
|
|
134
|
+
name="Workspace licenses",
|
|
135
|
+
status=CheckStatus.SKIP,
|
|
136
|
+
detail="No workspaces in migration plan",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
workspace_lic = None
|
|
140
|
+
for lic in licenses:
|
|
141
|
+
name_lower = lic.get("name", "").lower()
|
|
142
|
+
# Real API returns "Webex Calling - Workspaces" (WORKSPACE enum)
|
|
143
|
+
# and potentially "Webex Calling - Professional Workspaces" (PROFESSIONAL_WORKSPACE enum)
|
|
144
|
+
if "calling" in name_lower and "workspace" in name_lower:
|
|
145
|
+
workspace_lic = lic
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
if workspace_lic is None:
|
|
149
|
+
return CheckResult(
|
|
150
|
+
name="Workspace licenses",
|
|
151
|
+
status=CheckStatus.FAIL,
|
|
152
|
+
detail="No Webex Calling Workspace licenses found in org",
|
|
153
|
+
issues=[PreflightIssue("LICENSE_UNAVAILABLE",
|
|
154
|
+
"No Workspace license in org")],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
total = workspace_lic.get("totalUnits", 0)
|
|
158
|
+
consumed = workspace_lic.get("consumedUnits", 0)
|
|
159
|
+
available = total - consumed
|
|
160
|
+
|
|
161
|
+
if available >= ws_count:
|
|
162
|
+
buffer_pct = ((available - ws_count) / ws_count * 100) if ws_count else 100
|
|
163
|
+
detail = f"{ws_count} needed, {available} available ({available - ws_count} buffer)"
|
|
164
|
+
if buffer_pct < 10:
|
|
165
|
+
return CheckResult(
|
|
166
|
+
name="Workspace licenses",
|
|
167
|
+
status=CheckStatus.WARN,
|
|
168
|
+
detail=detail + f" (only {buffer_pct:.0f}% buffer)",
|
|
169
|
+
data={"needed": ws_count, "available": available},
|
|
170
|
+
)
|
|
171
|
+
return CheckResult(
|
|
172
|
+
name="Workspace licenses",
|
|
173
|
+
status=CheckStatus.PASS,
|
|
174
|
+
detail=detail,
|
|
175
|
+
data={"needed": ws_count, "available": available},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return CheckResult(
|
|
179
|
+
name="Workspace licenses",
|
|
180
|
+
status=CheckStatus.FAIL,
|
|
181
|
+
detail=f"{ws_count} needed, {available} available (SHORT {ws_count - available})",
|
|
182
|
+
issues=[PreflightIssue(
|
|
183
|
+
"LICENSE_INSUFFICIENT",
|
|
184
|
+
f"Need {ws_count - available} more Workspace licenses",
|
|
185
|
+
)],
|
|
186
|
+
data={"needed": ws_count, "available": available},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Check 3: Location Existence
|
|
192
|
+
# (from 05a-preflight-checks.md Check 3)
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_locations(
|
|
197
|
+
store: MigrationStore,
|
|
198
|
+
webex_locations: list[dict],
|
|
199
|
+
pstn_connections: dict[str, dict] | None = None,
|
|
200
|
+
) -> CheckResult:
|
|
201
|
+
"""Check if target locations exist in the Webex org and have PSTN configured."""
|
|
202
|
+
planned = store.get_objects("location")
|
|
203
|
+
if not planned:
|
|
204
|
+
return CheckResult(
|
|
205
|
+
name="Locations",
|
|
206
|
+
status=CheckStatus.SKIP,
|
|
207
|
+
detail="No locations in migration plan",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
wx_by_name = {loc.get("name", "").lower(): loc for loc in webex_locations}
|
|
211
|
+
missing: list[str] = []
|
|
212
|
+
found: list[tuple[str, str]] = [] # (name, location_id)
|
|
213
|
+
|
|
214
|
+
for loc in planned:
|
|
215
|
+
name = loc.get("name", "")
|
|
216
|
+
wx_loc = wx_by_name.get(name.lower())
|
|
217
|
+
if wx_loc:
|
|
218
|
+
found.append((name, wx_loc.get("id", "")))
|
|
219
|
+
else:
|
|
220
|
+
missing.append(name)
|
|
221
|
+
|
|
222
|
+
if missing:
|
|
223
|
+
return CheckResult(
|
|
224
|
+
name="Locations",
|
|
225
|
+
status=CheckStatus.FAIL,
|
|
226
|
+
detail=f"{len(missing)} location(s) not found in Webex: {', '.join(missing)}",
|
|
227
|
+
issues=[PreflightIssue("LOCATION_MISSING", f"Location '{n}' not in Webex")
|
|
228
|
+
for n in missing],
|
|
229
|
+
data={"found": len(found), "missing": len(missing), "missing_names": missing},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# PSTN connection sub-check
|
|
233
|
+
if pstn_connections is None:
|
|
234
|
+
return CheckResult(
|
|
235
|
+
name="Locations",
|
|
236
|
+
status=CheckStatus.PASS,
|
|
237
|
+
detail=f"{len(found)}/{len(planned)} target locations exist (PSTN check not available)",
|
|
238
|
+
data={"found": len(found), "total": len(planned)},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
no_pstn: list[str] = []
|
|
242
|
+
for name, loc_id in found:
|
|
243
|
+
if loc_id not in pstn_connections:
|
|
244
|
+
no_pstn.append(name)
|
|
245
|
+
|
|
246
|
+
if no_pstn:
|
|
247
|
+
return CheckResult(
|
|
248
|
+
name="Locations",
|
|
249
|
+
status=CheckStatus.WARN,
|
|
250
|
+
detail=(
|
|
251
|
+
f"{len(found)}/{len(planned)} target locations exist; "
|
|
252
|
+
f"{len(no_pstn)} without PSTN: {', '.join(no_pstn)}"
|
|
253
|
+
),
|
|
254
|
+
issues=[PreflightIssue("PSTN_NOT_CONFIGURED", f"Location '{n}' has no PSTN connection")
|
|
255
|
+
for n in no_pstn],
|
|
256
|
+
data={"found": len(found), "total": len(planned), "no_pstn": no_pstn},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return CheckResult(
|
|
260
|
+
name="Locations",
|
|
261
|
+
status=CheckStatus.PASS,
|
|
262
|
+
detail=f"{len(found)}/{len(planned)} target locations exist, all have PSTN connections",
|
|
263
|
+
data={"found": len(found), "total": len(planned)},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Check 4: Trunk Conflicts
|
|
269
|
+
# (from 05a-preflight-checks.md Check 4)
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def check_trunks(store: MigrationStore, webex_trunks: list[dict]) -> CheckResult:
|
|
274
|
+
"""Check trunk name conflicts and status."""
|
|
275
|
+
planned = store.get_objects("trunk")
|
|
276
|
+
if not planned:
|
|
277
|
+
return CheckResult(
|
|
278
|
+
name="Trunks",
|
|
279
|
+
status=CheckStatus.SKIP,
|
|
280
|
+
detail="No trunks in migration scope",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
wx_by_name = {t.get("name", "").lower(): t for t in webex_trunks}
|
|
284
|
+
conflicts = []
|
|
285
|
+
|
|
286
|
+
for trunk in planned:
|
|
287
|
+
name = trunk.get("name", "")
|
|
288
|
+
if name.lower() in wx_by_name:
|
|
289
|
+
conflicts.append(name)
|
|
290
|
+
|
|
291
|
+
if conflicts:
|
|
292
|
+
return CheckResult(
|
|
293
|
+
name="Trunks",
|
|
294
|
+
status=CheckStatus.WARN,
|
|
295
|
+
detail=f"{len(conflicts)} trunk name(s) already exist: {', '.join(conflicts)}",
|
|
296
|
+
issues=[PreflightIssue("TRUNK_CONFLICT", f"Trunk '{n}' already exists")
|
|
297
|
+
for n in conflicts],
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return CheckResult(
|
|
301
|
+
name="Trunks",
|
|
302
|
+
status=CheckStatus.PASS,
|
|
303
|
+
detail=f"{len(planned)} planned trunk(s), no name conflicts",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Check 5: Feature Entitlements
|
|
309
|
+
# (from 05a-preflight-checks.md Check 5)
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def check_feature_entitlements(
|
|
314
|
+
store: MigrationStore,
|
|
315
|
+
existing_features: dict[str, int],
|
|
316
|
+
) -> CheckResult:
|
|
317
|
+
"""Check if combined existing + planned features approach known limits."""
|
|
318
|
+
feature_types = {
|
|
319
|
+
"auto_attendant": "Auto Attendants",
|
|
320
|
+
"call_queue": "Call Queues",
|
|
321
|
+
"hunt_group": "Hunt Groups",
|
|
322
|
+
"paging_group": "Paging Groups",
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
has_any = False
|
|
326
|
+
warnings = []
|
|
327
|
+
details = []
|
|
328
|
+
|
|
329
|
+
for obj_type, label in feature_types.items():
|
|
330
|
+
planned = store.count_by_type(obj_type)
|
|
331
|
+
existing = existing_features.get(obj_type, 0)
|
|
332
|
+
combined = planned + existing
|
|
333
|
+
limit = FEATURE_LIMITS[obj_type]
|
|
334
|
+
|
|
335
|
+
if planned > 0:
|
|
336
|
+
has_any = True
|
|
337
|
+
details.append(f"{label}({planned}+{existing}={combined})")
|
|
338
|
+
if combined > limit * 0.8:
|
|
339
|
+
warnings.append(
|
|
340
|
+
f"{label}: {existing} existing + {planned} planned = {combined} "
|
|
341
|
+
f"(exceeds 80% of typical {limit} limit)"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if not has_any:
|
|
345
|
+
return CheckResult(
|
|
346
|
+
name="Feature entitlements",
|
|
347
|
+
status=CheckStatus.SKIP,
|
|
348
|
+
detail="No call features in migration plan",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if warnings:
|
|
352
|
+
return CheckResult(
|
|
353
|
+
name="Feature entitlements",
|
|
354
|
+
status=CheckStatus.WARN,
|
|
355
|
+
detail="; ".join(warnings),
|
|
356
|
+
issues=[PreflightIssue("FEATURE_LIMIT", w) for w in warnings],
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return CheckResult(
|
|
360
|
+
name="Feature entitlements",
|
|
361
|
+
status=CheckStatus.PASS,
|
|
362
|
+
detail=", ".join(details) + " within limits",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# Check 6: Number Conflicts
|
|
368
|
+
# (from 05a-preflight-checks.md Check 6)
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def check_number_conflicts(
|
|
373
|
+
store: MigrationStore,
|
|
374
|
+
webex_numbers: list[dict],
|
|
375
|
+
) -> tuple[CheckResult, list[dict]]:
|
|
376
|
+
"""Check for E.164 number collisions between planned and existing.
|
|
377
|
+
|
|
378
|
+
Returns (CheckResult, list of decision dicts for NUMBER_CONFLICT).
|
|
379
|
+
(from 05a-preflight-checks.md Check 6, NUMBER_CONFLICT algorithm)
|
|
380
|
+
"""
|
|
381
|
+
# Build indexes from existing Webex numbers
|
|
382
|
+
wx_by_e164: dict[str, dict] = {}
|
|
383
|
+
wx_by_ext_loc: dict[tuple[str, str], dict] = {}
|
|
384
|
+
|
|
385
|
+
for num in webex_numbers:
|
|
386
|
+
pn = num.get("phoneNumber", "")
|
|
387
|
+
if pn:
|
|
388
|
+
wx_by_e164[pn] = num
|
|
389
|
+
ext = num.get("extension", "")
|
|
390
|
+
loc_id = (num.get("location") or {}).get("id", "")
|
|
391
|
+
if ext and loc_id:
|
|
392
|
+
wx_by_ext_loc[(ext, loc_id)] = num
|
|
393
|
+
|
|
394
|
+
# Gather planned numbers from users, workspaces, virtual lines, lines
|
|
395
|
+
planned_numbers: list[dict] = []
|
|
396
|
+
for obj_type in ("user", "workspace", "virtual_line"):
|
|
397
|
+
for obj in store.get_objects(obj_type):
|
|
398
|
+
entry: dict[str, Any] = {"canonical_id": obj.get("canonical_id", "")}
|
|
399
|
+
# E.164 numbers
|
|
400
|
+
for pn in obj.get("phone_numbers", []):
|
|
401
|
+
if pn.get("value"):
|
|
402
|
+
entry["e164"] = pn["value"]
|
|
403
|
+
if obj.get("phone_number"):
|
|
404
|
+
entry["e164"] = obj["phone_number"]
|
|
405
|
+
if obj.get("e164"):
|
|
406
|
+
entry["e164"] = obj["e164"]
|
|
407
|
+
# Extension
|
|
408
|
+
entry["extension"] = obj.get("extension")
|
|
409
|
+
entry["location_id"] = obj.get("location_id")
|
|
410
|
+
entry["email"] = (obj.get("emails") or [""])[0] if obj.get("emails") else ""
|
|
411
|
+
planned_numbers.append(entry)
|
|
412
|
+
|
|
413
|
+
for obj in store.get_objects("line"):
|
|
414
|
+
if obj.get("e164"):
|
|
415
|
+
planned_numbers.append({
|
|
416
|
+
"canonical_id": obj.get("canonical_id", ""),
|
|
417
|
+
"e164": obj["e164"],
|
|
418
|
+
"extension": obj.get("extension"),
|
|
419
|
+
"location_id": None,
|
|
420
|
+
"email": "",
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
decisions = []
|
|
424
|
+
conflict_count = 0
|
|
425
|
+
|
|
426
|
+
for planned in planned_numbers:
|
|
427
|
+
cid = planned["canonical_id"]
|
|
428
|
+
e164 = planned.get("e164")
|
|
429
|
+
ext = planned.get("extension")
|
|
430
|
+
loc_id = planned.get("location_id")
|
|
431
|
+
|
|
432
|
+
planned_email = planned.get("email", "")
|
|
433
|
+
|
|
434
|
+
# E.164 collision
|
|
435
|
+
if e164 and e164 in wx_by_e164:
|
|
436
|
+
existing = wx_by_e164[e164]
|
|
437
|
+
# Skip if same owner email — handled by DUPLICATE_USER check
|
|
438
|
+
# (from 05a-preflight-checks.md lines 403-409)
|
|
439
|
+
existing_email = _get_owner_email(existing)
|
|
440
|
+
if existing_email and planned_email and existing_email.lower() == planned_email.lower():
|
|
441
|
+
continue
|
|
442
|
+
conflict_count += 1
|
|
443
|
+
decisions.append(_build_number_conflict_decision(
|
|
444
|
+
store, cid, planned_email, e164,
|
|
445
|
+
"E164", existing,
|
|
446
|
+
))
|
|
447
|
+
|
|
448
|
+
# Extension collision (same location)
|
|
449
|
+
if ext and loc_id and (ext, loc_id) in wx_by_ext_loc:
|
|
450
|
+
existing = wx_by_ext_loc[(ext, loc_id)]
|
|
451
|
+
existing_email = _get_owner_email(existing)
|
|
452
|
+
if existing_email and planned_email and existing_email.lower() == planned_email.lower():
|
|
453
|
+
continue
|
|
454
|
+
conflict_count += 1
|
|
455
|
+
decisions.append(_build_number_conflict_decision(
|
|
456
|
+
store, cid, planned_email, f"ext:{ext}@{loc_id}",
|
|
457
|
+
"EXTENSION", existing,
|
|
458
|
+
))
|
|
459
|
+
|
|
460
|
+
if conflict_count == 0:
|
|
461
|
+
result = CheckResult(
|
|
462
|
+
name="Number conflicts",
|
|
463
|
+
status=CheckStatus.PASS,
|
|
464
|
+
detail="No number/extension conflicts with existing Webex assignments",
|
|
465
|
+
)
|
|
466
|
+
else:
|
|
467
|
+
result = CheckResult(
|
|
468
|
+
name="Number conflicts",
|
|
469
|
+
status=CheckStatus.WARN,
|
|
470
|
+
detail=f"{conflict_count} number conflict(s) found",
|
|
471
|
+
issues=[PreflightIssue("NUMBER_CONFLICT", f"{conflict_count} collisions")],
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
return result, decisions
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _get_owner_email(number_record: dict) -> str | None:
|
|
478
|
+
"""Extract owner email from a Webex number record, if available."""
|
|
479
|
+
owner = number_record.get("owner") or {}
|
|
480
|
+
return owner.get("email") or owner.get("firstName") # email preferred
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _build_number_conflict_decision(
|
|
484
|
+
store: MigrationStore,
|
|
485
|
+
planned_cid: str,
|
|
486
|
+
planned_email: str,
|
|
487
|
+
planned_number: str,
|
|
488
|
+
conflict_type: str,
|
|
489
|
+
existing: dict,
|
|
490
|
+
) -> dict:
|
|
491
|
+
"""Build a NUMBER_CONFLICT decision dict.
|
|
492
|
+
(from 05a-preflight-checks.md, NUMBER_CONFLICT decision structure)
|
|
493
|
+
"""
|
|
494
|
+
owner = existing.get("owner") or {}
|
|
495
|
+
existing_owner_id = owner.get("id", "")
|
|
496
|
+
existing_owner_type = owner.get("type", "unknown")
|
|
497
|
+
existing_name = owner.get("lastName", owner.get("firstName", "unknown"))
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
"decision_id": store.next_decision_id(),
|
|
501
|
+
"type": DecisionType.NUMBER_CONFLICT.value,
|
|
502
|
+
"severity": "HIGH",
|
|
503
|
+
"summary": (
|
|
504
|
+
f"{conflict_type} conflict: {planned_number} planned for "
|
|
505
|
+
f"{planned_email or planned_cid} but assigned to "
|
|
506
|
+
f"{existing_name} ({existing_owner_type})"
|
|
507
|
+
),
|
|
508
|
+
"context": {
|
|
509
|
+
"conflict_type": conflict_type,
|
|
510
|
+
"planned_number": planned_number,
|
|
511
|
+
"planned_email": planned_email,
|
|
512
|
+
"planned_canonical_id": planned_cid,
|
|
513
|
+
"existing_owner_id": existing_owner_id,
|
|
514
|
+
"existing_owner_type": existing_owner_type,
|
|
515
|
+
"existing_owner_name": existing_name,
|
|
516
|
+
"existing_location_id": (existing.get("location") or {}).get("id", ""),
|
|
517
|
+
},
|
|
518
|
+
"affected_objects": [planned_cid],
|
|
519
|
+
"options": [
|
|
520
|
+
{"id": "reassign", "label": "Reassign planned number",
|
|
521
|
+
"impact": f"Migrate with a different number. {planned_number} stays with existing owner."},
|
|
522
|
+
{"id": "remove_existing", "label": "Remove existing assignment",
|
|
523
|
+
"impact": f"WARNING: Removes {planned_number} from {existing_name}."},
|
|
524
|
+
{"id": "skip_number", "label": "Skip this number",
|
|
525
|
+
"impact": f"Migrate without {planned_number}."},
|
|
526
|
+
],
|
|
527
|
+
"fingerprint": preflight_fingerprint(
|
|
528
|
+
"NUMBER_CONFLICT", conflict_type, planned_number,
|
|
529
|
+
planned_email, existing_owner_id,
|
|
530
|
+
),
|
|
531
|
+
"run_id": store.current_run_id,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
# Check 7: Duplicate Users
|
|
537
|
+
# (from 05a-preflight-checks.md, DUPLICATE_USER detection)
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def check_duplicate_users(
|
|
542
|
+
store: MigrationStore,
|
|
543
|
+
webex_people: list[dict],
|
|
544
|
+
) -> tuple[CheckResult, list[dict]]:
|
|
545
|
+
"""Check if planned users already exist in Webex.
|
|
546
|
+
|
|
547
|
+
Returns (CheckResult, list of decision dicts for DUPLICATE_USER).
|
|
548
|
+
(from 05a-preflight-checks.md, DUPLICATE_USER algorithm)
|
|
549
|
+
"""
|
|
550
|
+
wx_by_email: dict[str, dict] = {}
|
|
551
|
+
for person in webex_people:
|
|
552
|
+
for email in person.get("emails", []):
|
|
553
|
+
wx_by_email[email.lower()] = person
|
|
554
|
+
|
|
555
|
+
planned_users = store.get_objects("user")
|
|
556
|
+
decisions = []
|
|
557
|
+
|
|
558
|
+
for user in planned_users:
|
|
559
|
+
emails = user.get("emails", [])
|
|
560
|
+
for email in emails:
|
|
561
|
+
existing = wx_by_email.get(email.lower())
|
|
562
|
+
if not existing:
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
existing_location = existing.get("locationId")
|
|
566
|
+
existing_has_calling = existing_location is not None
|
|
567
|
+
planned_location = user.get("location_id")
|
|
568
|
+
|
|
569
|
+
if existing_has_calling:
|
|
570
|
+
location_matches = (existing_location == planned_location)
|
|
571
|
+
scenario = "already_calling"
|
|
572
|
+
severity = "HIGH"
|
|
573
|
+
else:
|
|
574
|
+
location_matches = False
|
|
575
|
+
scenario = "exists_no_calling"
|
|
576
|
+
severity = "MEDIUM"
|
|
577
|
+
|
|
578
|
+
decisions.append({
|
|
579
|
+
"decision_id": store.next_decision_id(),
|
|
580
|
+
"type": DecisionType.DUPLICATE_USER.value,
|
|
581
|
+
"severity": severity,
|
|
582
|
+
"summary": (
|
|
583
|
+
f"{'Calling user' if existing_has_calling else 'User'} "
|
|
584
|
+
f"{email} already exists in Webex"
|
|
585
|
+
f"{' at same location' if location_matches else ''}"
|
|
586
|
+
),
|
|
587
|
+
"context": {
|
|
588
|
+
"email": email,
|
|
589
|
+
"planned_canonical_id": user.get("canonical_id"),
|
|
590
|
+
"existing_person_id": existing.get("id"),
|
|
591
|
+
"existing_display_name": existing.get("displayName"),
|
|
592
|
+
"existing_location_id": existing_location,
|
|
593
|
+
"planned_location_id": planned_location,
|
|
594
|
+
"existing_has_calling": existing_has_calling,
|
|
595
|
+
"location_matches": location_matches,
|
|
596
|
+
"scenario": scenario,
|
|
597
|
+
},
|
|
598
|
+
"affected_objects": [user.get("canonical_id")],
|
|
599
|
+
"options": _duplicate_user_options(scenario, email, location_matches),
|
|
600
|
+
"fingerprint": preflight_fingerprint(
|
|
601
|
+
"DUPLICATE_USER", email.lower(), existing.get("id", ""),
|
|
602
|
+
),
|
|
603
|
+
"run_id": store.current_run_id,
|
|
604
|
+
})
|
|
605
|
+
break # Only check first matching email per user
|
|
606
|
+
|
|
607
|
+
if not decisions:
|
|
608
|
+
result = CheckResult(
|
|
609
|
+
name="Duplicate users",
|
|
610
|
+
status=CheckStatus.PASS,
|
|
611
|
+
detail="No cross-system duplicate users detected",
|
|
612
|
+
)
|
|
613
|
+
else:
|
|
614
|
+
result = CheckResult(
|
|
615
|
+
name="Duplicate users",
|
|
616
|
+
status=CheckStatus.WARN,
|
|
617
|
+
detail=f"{len(decisions)} user(s) already exist in Webex",
|
|
618
|
+
issues=[PreflightIssue("DUPLICATE_USER", f"{len(decisions)} duplicates found")],
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
return result, decisions
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _duplicate_user_options(scenario: str, email: str, location_matches: bool) -> list[dict]:
|
|
625
|
+
"""Build resolution options based on the duplicate scenario.
|
|
626
|
+
(from 05a-preflight-checks.md, _duplicate_user_options)
|
|
627
|
+
"""
|
|
628
|
+
if scenario == "already_calling" and location_matches:
|
|
629
|
+
return [
|
|
630
|
+
{"id": "update_existing", "label": "Update existing person",
|
|
631
|
+
"impact": f"MODIFY {email}'s call settings instead of creating."},
|
|
632
|
+
{"id": "skip", "label": "Skip this user",
|
|
633
|
+
"impact": f"{email} already in Webex with calling at this location."},
|
|
634
|
+
]
|
|
635
|
+
elif scenario == "already_calling":
|
|
636
|
+
return [
|
|
637
|
+
{"id": "update_existing", "label": "Update existing person (settings only)",
|
|
638
|
+
"impact": f"Update call settings for {email}. locationId cannot be changed via API."},
|
|
639
|
+
{"id": "skip", "label": "Skip this user",
|
|
640
|
+
"impact": f"{email} already calling-enabled at a different location."},
|
|
641
|
+
{"id": "fail", "label": "Block migration",
|
|
642
|
+
"impact": "Require manual resolution before proceeding."},
|
|
643
|
+
]
|
|
644
|
+
else:
|
|
645
|
+
return [
|
|
646
|
+
{"id": "update_existing", "label": "Enable calling on existing person",
|
|
647
|
+
"impact": f"Add Calling license and configure {email}."},
|
|
648
|
+
{"id": "skip", "label": "Skip this user",
|
|
649
|
+
"impact": f"{email} exists in Webex without calling."},
|
|
650
|
+
]
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# ---------------------------------------------------------------------------
|
|
654
|
+
# Check 8: Rate Limit Budget
|
|
655
|
+
# (from 05a-preflight-checks.md Check 7)
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def check_rate_limit_budget(store: MigrationStore, config: dict) -> CheckResult:
|
|
660
|
+
"""Estimate total API calls and warn if migration will be long.
|
|
661
|
+
(from 05a-preflight-checks.md Check 7 — local computation only)
|
|
662
|
+
"""
|
|
663
|
+
rate_limit = config.get("rate_limit_per_minute", 100)
|
|
664
|
+
max_hours = config.get("max_migration_hours", 8)
|
|
665
|
+
|
|
666
|
+
row = store.conn.execute(
|
|
667
|
+
"SELECT SUM(api_calls) as total FROM plan_operations WHERE status = 'pending'"
|
|
668
|
+
).fetchone()
|
|
669
|
+
total_api_calls = (row["total"] or 0) if row else 0
|
|
670
|
+
|
|
671
|
+
if total_api_calls == 0:
|
|
672
|
+
return CheckResult(
|
|
673
|
+
name="Rate limit budget",
|
|
674
|
+
status=CheckStatus.SKIP,
|
|
675
|
+
detail="No plan operations found (run 'wxcli cucm plan' first)",
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
estimated_minutes = total_api_calls / rate_limit
|
|
679
|
+
estimated_hours = estimated_minutes / 60
|
|
680
|
+
|
|
681
|
+
detail = (
|
|
682
|
+
f"~{total_api_calls:,} API calls, estimated {estimated_minutes:.0f} minutes "
|
|
683
|
+
f"({estimated_hours:.1f} hours) at {rate_limit} req/min"
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
if estimated_hours >= max_hours:
|
|
687
|
+
return CheckResult(
|
|
688
|
+
name="Rate limit budget",
|
|
689
|
+
status=CheckStatus.WARN,
|
|
690
|
+
detail=detail + ". Consider splitting into site batches.",
|
|
691
|
+
data={"api_calls": total_api_calls, "hours": round(estimated_hours, 1)},
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
return CheckResult(
|
|
695
|
+
name="Rate limit budget",
|
|
696
|
+
status=CheckStatus.PASS,
|
|
697
|
+
detail=detail,
|
|
698
|
+
data={"api_calls": total_api_calls, "hours": round(estimated_hours, 1)},
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# ---------------------------------------------------------------------------
|
|
703
|
+
# Check 9: E911 Readiness
|
|
704
|
+
# (from 2026-04-10-e911-ecbn-execution.md §7)
|
|
705
|
+
# ---------------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def check_e911_readiness(store: MigrationStore) -> CheckResult:
|
|
709
|
+
"""Verify every user/workspace has a resolvable ECBN candidate.
|
|
710
|
+
|
|
711
|
+
Passes if every ecbn_config has a valid selection AND every extension-only
|
|
712
|
+
user's location has a main number. Warns on extension-only users.
|
|
713
|
+
Fails on unresolved E911 decisions or users with no ECBN candidate.
|
|
714
|
+
"""
|
|
715
|
+
users = store.get_objects("user")
|
|
716
|
+
if not users:
|
|
717
|
+
return CheckResult(
|
|
718
|
+
name="E911 readiness",
|
|
719
|
+
status=CheckStatus.SKIP,
|
|
720
|
+
detail="No users in migration plan",
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
location_main: dict[str, str | None] = {}
|
|
724
|
+
for loc in store.get_objects("location"):
|
|
725
|
+
location_main[loc.get("canonical_id", "")] = loc.get("main_number")
|
|
726
|
+
|
|
727
|
+
ecbn_by_entity: dict[str, dict] = {}
|
|
728
|
+
for cfg in store.get_objects("ecbn_config"):
|
|
729
|
+
ecbn_by_entity[cfg.get("entity_canonical_id", "")] = cfg
|
|
730
|
+
|
|
731
|
+
issues: list[PreflightIssue] = []
|
|
732
|
+
warnings: list[str] = []
|
|
733
|
+
extension_only_count = 0
|
|
734
|
+
ok_count = 0
|
|
735
|
+
|
|
736
|
+
for user in users:
|
|
737
|
+
user_cid = user.get("canonical_id", "")
|
|
738
|
+
if user.get("status") in ("skipped", "SKIPPED"):
|
|
739
|
+
continue
|
|
740
|
+
cfg = ecbn_by_entity.get(user_cid)
|
|
741
|
+
if cfg is None:
|
|
742
|
+
issues.append(PreflightIssue(
|
|
743
|
+
"E911_NO_ECBN_CONFIG",
|
|
744
|
+
f"User {user_cid} has no ECBN config — EcbnMapper did not run or user was filtered",
|
|
745
|
+
))
|
|
746
|
+
continue
|
|
747
|
+
|
|
748
|
+
selection = cfg.get("ecbn_selection", "")
|
|
749
|
+
if selection == "DIRECT_LINE" and cfg.get("primary_did"):
|
|
750
|
+
ok_count += 1
|
|
751
|
+
elif selection == "LOCATION_ECBN":
|
|
752
|
+
loc_cid = cfg.get("location_canonical_id") or user.get("location_id")
|
|
753
|
+
main = location_main.get(loc_cid)
|
|
754
|
+
if not main:
|
|
755
|
+
issues.append(PreflightIssue(
|
|
756
|
+
"E911_NO_LOCATION_ECBN",
|
|
757
|
+
f"User {user_cid} at {loc_cid} has no ECBN candidate "
|
|
758
|
+
f"(extension-only, and location has no main number)",
|
|
759
|
+
))
|
|
760
|
+
else:
|
|
761
|
+
extension_only_count += 1
|
|
762
|
+
warnings.append(
|
|
763
|
+
f"User {user_cid} relies on location ECBN {main} at {loc_cid}"
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
unresolved_e911 = 0
|
|
767
|
+
for d in store.get_all_decisions():
|
|
768
|
+
dtype = d.get("type", "")
|
|
769
|
+
if dtype in ("E911_ECBN_AMBIGUOUS", "E911_LOCATION_MISMATCH"):
|
|
770
|
+
if d.get("chosen_option") is None or d.get("chosen_option") == "__stale__":
|
|
771
|
+
unresolved_e911 += 1
|
|
772
|
+
issues.append(PreflightIssue(
|
|
773
|
+
"E911_UNRESOLVED_DECISION",
|
|
774
|
+
f"Unresolved {dtype}: {d.get('summary', '')}",
|
|
775
|
+
))
|
|
776
|
+
|
|
777
|
+
if issues:
|
|
778
|
+
return CheckResult(
|
|
779
|
+
name="E911 readiness",
|
|
780
|
+
status=CheckStatus.FAIL,
|
|
781
|
+
detail=(
|
|
782
|
+
f"{len(issues)} E911 blocker(s): "
|
|
783
|
+
f"{unresolved_e911} unresolved decision(s), "
|
|
784
|
+
f"{len(issues) - unresolved_e911} users without ECBN candidates"
|
|
785
|
+
),
|
|
786
|
+
issues=issues,
|
|
787
|
+
data={
|
|
788
|
+
"ok_count": ok_count,
|
|
789
|
+
"extension_only_count": extension_only_count,
|
|
790
|
+
"unresolved_decisions": unresolved_e911,
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
if warnings:
|
|
795
|
+
return CheckResult(
|
|
796
|
+
name="E911 readiness",
|
|
797
|
+
status=CheckStatus.WARN,
|
|
798
|
+
detail=f"{ok_count} users with DIDs, {extension_only_count} extension-only users rely on location ECBN",
|
|
799
|
+
data={"ok_count": ok_count, "extension_only_count": extension_only_count},
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
return CheckResult(
|
|
803
|
+
name="E911 readiness",
|
|
804
|
+
status=CheckStatus.PASS,
|
|
805
|
+
detail=f"{ok_count} users have resolvable ECBN candidates",
|
|
806
|
+
data={"ok_count": ok_count},
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
# ---------------------------------------------------------------------------
|
|
811
|
+
# Check 10: Bulk Device Job Support (Issue #9, Wave 4)
|
|
812
|
+
# (from docs/superpowers/specs/2026-04-16-silent-failure-hardening-design.md
|
|
813
|
+
# Group 6, Issue #9)
|
|
814
|
+
# ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def check_bulk_device_job_support(
|
|
818
|
+
store: MigrationStore,
|
|
819
|
+
probe_fn: Callable[[], tuple[int, str]] | None = None,
|
|
820
|
+
) -> CheckResult:
|
|
821
|
+
"""Verify the Webex org supports bulk device job endpoints.
|
|
822
|
+
|
|
823
|
+
Webex for Government (FedRAMP) tenants do not support bulk device job
|
|
824
|
+
endpoints (e.g. ``rebuildPhones``); calls return 404 or 403. The planner's
|
|
825
|
+
``_optimize_for_bulk()`` pass replaces per-device ops with bulk job ops
|
|
826
|
+
once ``bulk_device_threshold`` is crossed, so without this preflight the
|
|
827
|
+
operator only discovers incompatibility at execution time.
|
|
828
|
+
|
|
829
|
+
Behavior on FAIL (Finding #11 clarification): the check **does NOT**
|
|
830
|
+
auto-reshape the plan by deleting the bulk ops and re-emitting per-device
|
|
831
|
+
ops. The spec (Group 6, Issue #9) says:
|
|
832
|
+
"If not supported, the planner should emit per-device ops instead."
|
|
833
|
+
We chose a simpler / safer interpretation: fail loudly with the
|
|
834
|
+
operator-actionable remedy baked into the error message
|
|
835
|
+
(``set bulk_device_threshold=999999 in config.json to disable bulk
|
|
836
|
+
ops``). Auto-reshaping the plan mid-preflight was deemed too invasive —
|
|
837
|
+
the operator may want to investigate *why* the bulk endpoint is
|
|
838
|
+
unavailable (FedRAMP? token scope? partner token pointing at wrong org?)
|
|
839
|
+
before mechanically falling back to the per-device path, which at 1000+
|
|
840
|
+
devices can take hours. Failing closed with a one-line remedy preserves
|
|
841
|
+
operator choice.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
store: Migration store with plan_operations populated.
|
|
845
|
+
probe_fn: Zero-arg callable that probes a bulk job list endpoint and
|
|
846
|
+
returns ``(status_code, error_message)``. When ``None`` the check
|
|
847
|
+
cannot verify and returns SKIP. The runner wires this via the
|
|
848
|
+
authenticated ``WebexSimpleApi`` session (see
|
|
849
|
+
``runner._build_bulk_job_probe`` for the subprocess-vs-direct-HTTP
|
|
850
|
+
decision).
|
|
851
|
+
"""
|
|
852
|
+
row = store.conn.execute(
|
|
853
|
+
"SELECT COUNT(*) AS cnt FROM plan_operations "
|
|
854
|
+
"WHERE resource_type LIKE 'bulk\\_%%' ESCAPE '\\'"
|
|
855
|
+
).fetchone()
|
|
856
|
+
bulk_op_count = row["cnt"] if row else 0
|
|
857
|
+
|
|
858
|
+
if bulk_op_count == 0:
|
|
859
|
+
return CheckResult(
|
|
860
|
+
name="Bulk device job support",
|
|
861
|
+
status=CheckStatus.SKIP,
|
|
862
|
+
detail="No bulk device job ops in plan (per-device path will be used)",
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
if probe_fn is None:
|
|
866
|
+
return CheckResult(
|
|
867
|
+
name="Bulk device job support",
|
|
868
|
+
status=CheckStatus.SKIP,
|
|
869
|
+
detail=f"{bulk_op_count} bulk op(s) in plan; probe not available (skipped)",
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
try:
|
|
873
|
+
status, err = probe_fn()
|
|
874
|
+
except Exception as exc: # noqa: BLE001 — probe is best-effort
|
|
875
|
+
return CheckResult(
|
|
876
|
+
name="Bulk device job support",
|
|
877
|
+
status=CheckStatus.WARN,
|
|
878
|
+
detail=f"Bulk device job probe failed: {exc}",
|
|
879
|
+
issues=[PreflightIssue("BULK_JOB_PROBE_ERROR", str(exc))],
|
|
880
|
+
data={"bulk_op_count": bulk_op_count},
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
if status == 200:
|
|
884
|
+
return CheckResult(
|
|
885
|
+
name="Bulk device job support",
|
|
886
|
+
status=CheckStatus.PASS,
|
|
887
|
+
detail=f"Bulk device jobs supported ({bulk_op_count} bulk op(s) in plan)",
|
|
888
|
+
data={"bulk_op_count": bulk_op_count},
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
if status in (403, 404):
|
|
892
|
+
msg = (
|
|
893
|
+
f"Bulk device jobs not supported for this org (HTTP {status}) — "
|
|
894
|
+
f"set bulk_device_threshold=999999 in config.json to disable bulk ops"
|
|
895
|
+
)
|
|
896
|
+
return CheckResult(
|
|
897
|
+
name="Bulk device job support",
|
|
898
|
+
status=CheckStatus.FAIL,
|
|
899
|
+
detail=msg,
|
|
900
|
+
issues=[PreflightIssue("BULK_JOB_UNSUPPORTED", msg)],
|
|
901
|
+
data={"bulk_op_count": bulk_op_count, "probe_status": status},
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
return CheckResult(
|
|
905
|
+
name="Bulk device job support",
|
|
906
|
+
status=CheckStatus.WARN,
|
|
907
|
+
detail=f"Bulk device job probe returned HTTP {status}: {err}",
|
|
908
|
+
issues=[PreflightIssue("BULK_JOB_PROBE_UNEXPECTED",
|
|
909
|
+
f"HTTP {status}: {err}")],
|
|
910
|
+
data={"bulk_op_count": bulk_op_count, "probe_status": status},
|
|
911
|
+
)
|