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,821 @@
|
|
|
1
|
+
"""Routing mapper: CUCM routing infrastructure -> Webex Calling routing.
|
|
2
|
+
|
|
3
|
+
Maps CUCM SIP Trunks/Gateways, Route Groups, Route Patterns (-> Dial Plans),
|
|
4
|
+
and Translation Patterns to Webex Calling routing objects.
|
|
5
|
+
|
|
6
|
+
(from 03b-transform-mappers.md §6, routing_mapper)
|
|
7
|
+
|
|
8
|
+
Cross-ref reads:
|
|
9
|
+
route_pattern_uses_gateway (#11) — MAY be empty (listRoutePattern limitation)
|
|
10
|
+
route_pattern_uses_route_list (#12) — MAY be empty (listRoutePattern limitation)
|
|
11
|
+
route_group_has_trunk (#13) — route group membership
|
|
12
|
+
trunk_at_location (#14) — trunk -> device pool for location
|
|
13
|
+
translation_pattern_in_partition (#15) — translation pattern scoping
|
|
14
|
+
|
|
15
|
+
Decisions generated:
|
|
16
|
+
FEATURE_APPROXIMATION — @ macro in route pattern
|
|
17
|
+
LOCATION_AMBIGUOUS — trunk has no device pool; multiple locations available
|
|
18
|
+
MISSING_DATA — trunk password, missing address, partition scope issues
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import secrets
|
|
25
|
+
import string
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from wxcli.migration.models import (
|
|
30
|
+
CanonicalDialPlan,
|
|
31
|
+
CanonicalRouteGroup,
|
|
32
|
+
CanonicalRouteList,
|
|
33
|
+
CanonicalTranslationPattern,
|
|
34
|
+
CanonicalTrunk,
|
|
35
|
+
DecisionOption,
|
|
36
|
+
DecisionType,
|
|
37
|
+
MapperResult,
|
|
38
|
+
MigrationStatus,
|
|
39
|
+
Provenance,
|
|
40
|
+
TrunkGatewayRef,
|
|
41
|
+
)
|
|
42
|
+
from wxcli.migration.store import MigrationStore
|
|
43
|
+
from wxcli.migration.transform.mappers.base import (
|
|
44
|
+
Mapper,
|
|
45
|
+
accept_option,
|
|
46
|
+
decision_to_store_dict,
|
|
47
|
+
extract_provenance,
|
|
48
|
+
hash_id,
|
|
49
|
+
manual_option,
|
|
50
|
+
skip_option,
|
|
51
|
+
)
|
|
52
|
+
from wxcli.migration.transform.pattern_converter import cucm_to_webex_pattern
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# Max trunks per Webex route group
|
|
57
|
+
# (from 03b-transform-mappers.md §6: "Max 10 trunks per route group in Webex")
|
|
58
|
+
_MAX_TRUNKS_PER_ROUTE_GROUP = 10
|
|
59
|
+
|
|
60
|
+
# Temporary password length for trunk authentication
|
|
61
|
+
_TRUNK_PASSWORD_LENGTH = 16
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _bool_field(val: Any) -> bool | None:
|
|
65
|
+
"""Coerce an AXL field to bool or None."""
|
|
66
|
+
if val is None:
|
|
67
|
+
return None
|
|
68
|
+
if isinstance(val, bool):
|
|
69
|
+
return val
|
|
70
|
+
s = str(val).lower()
|
|
71
|
+
if s in ("true", "t", "1"):
|
|
72
|
+
return True
|
|
73
|
+
if s in ("false", "f", "0"):
|
|
74
|
+
return False
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _generate_temp_password() -> str:
|
|
79
|
+
"""Generate a temporary password for trunk authentication.
|
|
80
|
+
|
|
81
|
+
(from 03b-transform-mappers.md §6: "mapper generates a temporary password")
|
|
82
|
+
"""
|
|
83
|
+
alphabet = string.ascii_letters + string.digits + "!@#$%"
|
|
84
|
+
return "".join(secrets.choice(alphabet) for _ in range(_TRUNK_PASSWORD_LENGTH))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RoutingMapper(Mapper):
|
|
88
|
+
"""Maps CUCM routing infrastructure to Webex Calling routing objects.
|
|
89
|
+
|
|
90
|
+
Produces: CanonicalTrunk, CanonicalRouteGroup, CanonicalDialPlan,
|
|
91
|
+
CanonicalTranslationPattern.
|
|
92
|
+
|
|
93
|
+
(from 03b-transform-mappers.md §6)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
name = "routing_mapper"
|
|
97
|
+
depends_on = ["location_mapper"]
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
country_code: str = "+1",
|
|
102
|
+
outside_dial_digit: str = "9",
|
|
103
|
+
) -> None:
|
|
104
|
+
self.country_code = country_code
|
|
105
|
+
self.outside_dial_digit = outside_dial_digit
|
|
106
|
+
|
|
107
|
+
def map(self, store: MigrationStore) -> MapperResult:
|
|
108
|
+
"""Read routing objects and produce Webex routing canonical objects."""
|
|
109
|
+
result = MapperResult()
|
|
110
|
+
|
|
111
|
+
self._map_trunks(store, result)
|
|
112
|
+
self._map_route_groups(store, result)
|
|
113
|
+
self._map_route_lists(store, result)
|
|
114
|
+
self._map_dial_plans(store, result)
|
|
115
|
+
self._map_translation_patterns(store, result)
|
|
116
|
+
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Trunks: CUCM SIP Trunk / Gateway -> CanonicalTrunk
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _map_trunks(self, store: MigrationStore, result: MapperResult) -> None:
|
|
124
|
+
"""Map CUCM SIP trunks and gateways to CanonicalTrunk objects.
|
|
125
|
+
|
|
126
|
+
(from 03b-transform-mappers.md §6: trunk field mapping table)
|
|
127
|
+
"""
|
|
128
|
+
for trunk_type in ("sip_trunk", "gateway"):
|
|
129
|
+
for trunk_data in store.get_objects(trunk_type):
|
|
130
|
+
trunk_id = trunk_data["canonical_id"]
|
|
131
|
+
state = trunk_data.get("pre_migration_state") or {}
|
|
132
|
+
|
|
133
|
+
name = state.get("name") or trunk_id.split(":", 1)[-1]
|
|
134
|
+
|
|
135
|
+
# --- Location resolution ---
|
|
136
|
+
# Chain: trunk -> trunk_at_location -> device_pool -> device_pool_to_location
|
|
137
|
+
# (from 03b-transform-mappers.md §6: "trunk_at_location cross-ref")
|
|
138
|
+
location_id = store.resolve_chain(
|
|
139
|
+
trunk_id, "trunk_at_location", "device_pool_to_location"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# --- Trunk type inference ---
|
|
143
|
+
# (from 03b-transform-mappers.md §6: "SIP profile + security profile -> type")
|
|
144
|
+
trunk_type_val = _infer_trunk_type(state)
|
|
145
|
+
|
|
146
|
+
# --- Destination address ---
|
|
147
|
+
# Phase 04 correction: destinations is a LIST of [{address, port, sort_order}]
|
|
148
|
+
# Use primary destination (sort_order 1 or first in list)
|
|
149
|
+
address = None
|
|
150
|
+
port = None
|
|
151
|
+
destinations = state.get("destinations", [])
|
|
152
|
+
if isinstance(destinations, list) and destinations:
|
|
153
|
+
# Find primary destination (sort_order 1 or first)
|
|
154
|
+
primary = destinations[0] or {}
|
|
155
|
+
for dest in destinations:
|
|
156
|
+
if not dest:
|
|
157
|
+
continue
|
|
158
|
+
if dest.get("sort_order") == 1:
|
|
159
|
+
primary = dest
|
|
160
|
+
break
|
|
161
|
+
address = primary.get("address")
|
|
162
|
+
port = primary.get("port")
|
|
163
|
+
elif isinstance(destinations, dict):
|
|
164
|
+
# Legacy flat format fallback
|
|
165
|
+
address = destinations.get("address") or state.get("destination_address")
|
|
166
|
+
port = destinations.get("port") or state.get("destination_port")
|
|
167
|
+
else:
|
|
168
|
+
# Try flat fields as last resort
|
|
169
|
+
address = state.get("destination_address")
|
|
170
|
+
port = state.get("destination_port")
|
|
171
|
+
|
|
172
|
+
if port is not None:
|
|
173
|
+
try:
|
|
174
|
+
port = int(port)
|
|
175
|
+
except (ValueError, TypeError):
|
|
176
|
+
port = None
|
|
177
|
+
|
|
178
|
+
# --- Password generation ---
|
|
179
|
+
# (from 03b-transform-mappers.md §6: "mapper generates temporary password")
|
|
180
|
+
password = _generate_temp_password()
|
|
181
|
+
|
|
182
|
+
# --- Max concurrent calls ---
|
|
183
|
+
max_concurrent_calls = state.get("max_calls") or state.get("maxCallsPerSIPTrunk")
|
|
184
|
+
if max_concurrent_calls is not None:
|
|
185
|
+
try:
|
|
186
|
+
max_concurrent_calls = int(max_concurrent_calls)
|
|
187
|
+
except (ValueError, TypeError):
|
|
188
|
+
max_concurrent_calls = None
|
|
189
|
+
|
|
190
|
+
# --- Other fields ---
|
|
191
|
+
dual_identity = state.get("dualIdentitySupportEnabled")
|
|
192
|
+
device_type = state.get("device_type")
|
|
193
|
+
p_charge_info = state.get("pChargeInfoSupportPolicy")
|
|
194
|
+
|
|
195
|
+
# Fix 10: Resolve location when cross-ref chain breaks (org-wide trunks)
|
|
196
|
+
if not location_id:
|
|
197
|
+
locations = store.get_objects("location")
|
|
198
|
+
if len(locations) == 1:
|
|
199
|
+
# Single location — auto-assign (no decision needed)
|
|
200
|
+
location_id = locations[0]["canonical_id"]
|
|
201
|
+
logger.info(
|
|
202
|
+
"Trunk '%s' auto-assigned to only location '%s'",
|
|
203
|
+
name, location_id,
|
|
204
|
+
)
|
|
205
|
+
elif len(locations) > 1:
|
|
206
|
+
# Multiple locations — present as selectable options
|
|
207
|
+
loc_options = [
|
|
208
|
+
DecisionOption(
|
|
209
|
+
id=loc["canonical_id"],
|
|
210
|
+
label=f"Assign to {(loc.get('pre_migration_state') or {}).get('name', loc['canonical_id'])}",
|
|
211
|
+
impact=f"Trunk created in {(loc.get('pre_migration_state') or {}).get('name', loc['canonical_id'])}",
|
|
212
|
+
)
|
|
213
|
+
for loc in locations
|
|
214
|
+
]
|
|
215
|
+
loc_options.append(skip_option("Trunk not migrated"))
|
|
216
|
+
decision = self._create_decision(
|
|
217
|
+
store=store,
|
|
218
|
+
decision_type=DecisionType.LOCATION_AMBIGUOUS,
|
|
219
|
+
severity="HIGH",
|
|
220
|
+
summary=(
|
|
221
|
+
f"Trunk '{name}' has no device pool — "
|
|
222
|
+
f"choose which of {len(locations)} locations to assign it to"
|
|
223
|
+
),
|
|
224
|
+
context={
|
|
225
|
+
"trunk_id": f"trunk:{name}",
|
|
226
|
+
"trunk_name": name,
|
|
227
|
+
"reason": "no_location",
|
|
228
|
+
"available_locations": [
|
|
229
|
+
loc["canonical_id"] for loc in locations
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
options=loc_options,
|
|
233
|
+
affected_objects=[f"trunk:{name}"],
|
|
234
|
+
)
|
|
235
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
236
|
+
result.decisions.append(decision)
|
|
237
|
+
else:
|
|
238
|
+
# No locations at all — MISSING_DATA
|
|
239
|
+
decision = self._create_decision(
|
|
240
|
+
store=store,
|
|
241
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
242
|
+
severity="HIGH",
|
|
243
|
+
summary=(
|
|
244
|
+
f"Trunk '{name}' has no resolvable location and "
|
|
245
|
+
f"no locations exist in migration — "
|
|
246
|
+
f"locationId required for Webex trunk creation"
|
|
247
|
+
),
|
|
248
|
+
context={
|
|
249
|
+
"trunk_id": f"trunk:{name}",
|
|
250
|
+
"trunk_name": name,
|
|
251
|
+
"reason": "no_location",
|
|
252
|
+
},
|
|
253
|
+
options=[
|
|
254
|
+
manual_option("Admin assigns trunk to a Webex location"),
|
|
255
|
+
skip_option("Trunk not migrated"),
|
|
256
|
+
],
|
|
257
|
+
affected_objects=[f"trunk:{name}"],
|
|
258
|
+
)
|
|
259
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
260
|
+
result.decisions.append(decision)
|
|
261
|
+
|
|
262
|
+
trunk = CanonicalTrunk(
|
|
263
|
+
canonical_id=f"trunk:{name}",
|
|
264
|
+
provenance=extract_provenance(trunk_data),
|
|
265
|
+
status=MigrationStatus.ANALYZED,
|
|
266
|
+
name=name,
|
|
267
|
+
location_id=location_id,
|
|
268
|
+
trunk_type=trunk_type_val,
|
|
269
|
+
address=address,
|
|
270
|
+
domain=state.get("domain"),
|
|
271
|
+
port=port,
|
|
272
|
+
max_concurrent_calls=max_concurrent_calls,
|
|
273
|
+
password=password,
|
|
274
|
+
dual_identity_support_enabled=dual_identity,
|
|
275
|
+
device_type=device_type,
|
|
276
|
+
p_charge_info_support_policy=p_charge_info,
|
|
277
|
+
# Tier 2 §2.8: SIP/security profile detail
|
|
278
|
+
sip_profile_early_offer=_bool_field(state.get("sip_profile_early_offer")),
|
|
279
|
+
sip_profile_srtp_fallback=_bool_field(state.get("sip_profile_srtp_fallback")),
|
|
280
|
+
security_mode=state.get("security_mode"),
|
|
281
|
+
security_digest_auth=_bool_field(state.get("security_digest_auth")),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
store.upsert_object(trunk)
|
|
285
|
+
result.objects_created += 1
|
|
286
|
+
|
|
287
|
+
# --- MISSING_DATA decision for password ---
|
|
288
|
+
# (from 03b-transform-mappers.md §6: "trunk password can't be extracted")
|
|
289
|
+
decision = self._create_decision(
|
|
290
|
+
store=store,
|
|
291
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
292
|
+
severity="MEDIUM",
|
|
293
|
+
summary=(
|
|
294
|
+
f"Trunk '{name}' password cannot be extracted from CUCM — "
|
|
295
|
+
f"temporary password generated"
|
|
296
|
+
),
|
|
297
|
+
context={
|
|
298
|
+
"trunk_id": trunk.canonical_id,
|
|
299
|
+
"trunk_name": name,
|
|
300
|
+
"reason": "password_not_extractable",
|
|
301
|
+
},
|
|
302
|
+
options=[
|
|
303
|
+
manual_option(
|
|
304
|
+
"Admin configures matching passwords on gateway and in Webex"
|
|
305
|
+
),
|
|
306
|
+
accept_option(
|
|
307
|
+
"Accept generated password and update gateway configuration"
|
|
308
|
+
),
|
|
309
|
+
],
|
|
310
|
+
affected_objects=[trunk.canonical_id],
|
|
311
|
+
)
|
|
312
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
313
|
+
result.decisions.append(decision)
|
|
314
|
+
|
|
315
|
+
# --- MISSING_DATA decision for missing address ---
|
|
316
|
+
if not address and trunk_type_val == "CERTIFICATE_BASED":
|
|
317
|
+
decision = self._create_decision(
|
|
318
|
+
store=store,
|
|
319
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
320
|
+
severity="HIGH",
|
|
321
|
+
summary=(
|
|
322
|
+
f"Trunk '{name}' has no destination address — "
|
|
323
|
+
f"required for CERTIFICATE_BASED trunk type"
|
|
324
|
+
),
|
|
325
|
+
context={
|
|
326
|
+
"trunk_id": trunk.canonical_id,
|
|
327
|
+
"trunk_name": name,
|
|
328
|
+
"reason": "missing_address",
|
|
329
|
+
},
|
|
330
|
+
options=[
|
|
331
|
+
manual_option("Admin provides SBC/gateway FQDN or SRV record"),
|
|
332
|
+
skip_option("Skip trunk — routing through this trunk won't be migrated"),
|
|
333
|
+
],
|
|
334
|
+
affected_objects=[trunk.canonical_id],
|
|
335
|
+
)
|
|
336
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
337
|
+
result.decisions.append(decision)
|
|
338
|
+
|
|
339
|
+
# ------------------------------------------------------------------
|
|
340
|
+
# Route Groups: CUCM Route Group -> CanonicalRouteGroup
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def _map_route_groups(self, store: MigrationStore, result: MapperResult) -> None:
|
|
344
|
+
"""Map CUCM route groups to CanonicalRouteGroup objects.
|
|
345
|
+
|
|
346
|
+
Splits groups exceeding 10-trunk limit into multiple Webex route groups.
|
|
347
|
+
|
|
348
|
+
(from 03b-transform-mappers.md §6: route group field mapping table)
|
|
349
|
+
"""
|
|
350
|
+
for rg_data in store.get_objects("route_group"):
|
|
351
|
+
rg_id = rg_data["canonical_id"]
|
|
352
|
+
state = rg_data.get("pre_migration_state") or {}
|
|
353
|
+
|
|
354
|
+
rg_name = state.get("name") or rg_id.split(":", 1)[-1]
|
|
355
|
+
|
|
356
|
+
# --- Resolve trunks via cross-ref ---
|
|
357
|
+
# (from 03b-transform-mappers.md §6: "route_group_has_trunk")
|
|
358
|
+
trunk_refs = store.find_cross_refs(rg_id, "route_group_has_trunk")
|
|
359
|
+
|
|
360
|
+
# Build trunk gateway refs with priority from pre_migration_state
|
|
361
|
+
trunks_with_priority = []
|
|
362
|
+
state_trunks = state.get("trunks") or []
|
|
363
|
+
for i, trunk_ref_id in enumerate(trunk_refs):
|
|
364
|
+
# Try to find priority from state data
|
|
365
|
+
priority = i + 1
|
|
366
|
+
trunk_name = trunk_ref_id.split(":", 1)[-1] if ":" in trunk_ref_id else trunk_ref_id
|
|
367
|
+
for st in state_trunks:
|
|
368
|
+
if not st:
|
|
369
|
+
continue
|
|
370
|
+
if st.get("trunk_name") == trunk_name:
|
|
371
|
+
priority = st.get("priority", i + 1)
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
# Resolve to the canonical trunk ID (trunk:name format)
|
|
375
|
+
canonical_trunk_id = f"trunk:{trunk_name}"
|
|
376
|
+
trunks_with_priority.append(
|
|
377
|
+
TrunkGatewayRef(trunk_canonical_id=canonical_trunk_id, priority=priority)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Fix 11: MISSING_DATA decision if no gateways resolved
|
|
381
|
+
if not trunks_with_priority:
|
|
382
|
+
decision = self._create_decision(
|
|
383
|
+
store=store,
|
|
384
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
385
|
+
severity="HIGH",
|
|
386
|
+
summary=(
|
|
387
|
+
f"Route group '{rg_name}' has no resolvable gateway members — "
|
|
388
|
+
f"at least one localGateway required"
|
|
389
|
+
),
|
|
390
|
+
context={
|
|
391
|
+
"route_group_id": f"route_group:{rg_name}",
|
|
392
|
+
"route_group_name": rg_name,
|
|
393
|
+
"reason": "no_gateways",
|
|
394
|
+
},
|
|
395
|
+
options=[
|
|
396
|
+
manual_option("Admin assigns trunk/gateway members"),
|
|
397
|
+
skip_option("Route group not migrated"),
|
|
398
|
+
],
|
|
399
|
+
affected_objects=[f"route_group:{rg_name}"],
|
|
400
|
+
)
|
|
401
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
402
|
+
result.decisions.append(decision)
|
|
403
|
+
|
|
404
|
+
# --- Split if exceeding max trunks ---
|
|
405
|
+
# (from 03b-transform-mappers.md §6: "Max 10 trunks per route group")
|
|
406
|
+
if len(trunks_with_priority) <= _MAX_TRUNKS_PER_ROUTE_GROUP:
|
|
407
|
+
rg = CanonicalRouteGroup(
|
|
408
|
+
canonical_id=f"route_group:{rg_name}",
|
|
409
|
+
provenance=extract_provenance(rg_data),
|
|
410
|
+
status=MigrationStatus.ANALYZED,
|
|
411
|
+
name=rg_name,
|
|
412
|
+
local_gateways=trunks_with_priority,
|
|
413
|
+
)
|
|
414
|
+
store.upsert_object(rg)
|
|
415
|
+
result.objects_created += 1
|
|
416
|
+
else:
|
|
417
|
+
# Split into chunks of max 10
|
|
418
|
+
for chunk_idx, start in enumerate(
|
|
419
|
+
range(0, len(trunks_with_priority), _MAX_TRUNKS_PER_ROUTE_GROUP)
|
|
420
|
+
):
|
|
421
|
+
chunk = trunks_with_priority[start:start + _MAX_TRUNKS_PER_ROUTE_GROUP]
|
|
422
|
+
split_name = f"{rg_name}-{chunk_idx + 1}"
|
|
423
|
+
rg = CanonicalRouteGroup(
|
|
424
|
+
canonical_id=f"route_group:{split_name}",
|
|
425
|
+
provenance=extract_provenance(rg_data),
|
|
426
|
+
status=MigrationStatus.ANALYZED,
|
|
427
|
+
name=split_name,
|
|
428
|
+
local_gateways=chunk,
|
|
429
|
+
)
|
|
430
|
+
store.upsert_object(rg)
|
|
431
|
+
result.objects_created += 1
|
|
432
|
+
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
# Route Lists: CUCM Route List -> CanonicalRouteList
|
|
435
|
+
# ------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def _map_route_lists(self, store: MigrationStore, result: MapperResult) -> None:
|
|
438
|
+
"""Map CUCM route lists to CanonicalRouteList objects.
|
|
439
|
+
|
|
440
|
+
Webex route lists bind to exactly ONE route group. CUCM route lists with
|
|
441
|
+
multiple route group members produce a FEATURE_APPROXIMATION decision.
|
|
442
|
+
Route lists are standalone resources — dial plans point to route groups,
|
|
443
|
+
not route lists (RouteType enum = ROUTE_GROUP | TRUNK only).
|
|
444
|
+
"""
|
|
445
|
+
for rl_data in store.get_objects("route_list"):
|
|
446
|
+
if rl_data.get("status") == "analyzed":
|
|
447
|
+
continue # Already mapped (e.g., re-run)
|
|
448
|
+
|
|
449
|
+
rl_id = rl_data["canonical_id"]
|
|
450
|
+
state = rl_data.get("pre_migration_state") or {}
|
|
451
|
+
rl_name = state.get("route_list_name") or rl_id.split(":", 1)[-1]
|
|
452
|
+
|
|
453
|
+
# Skip disabled route lists
|
|
454
|
+
enabled = state.get("routeListEnabled", "true")
|
|
455
|
+
if str(enabled).lower() == "false":
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
route_groups = state.get("route_groups") or []
|
|
459
|
+
|
|
460
|
+
# No route group members -> MISSING_DATA
|
|
461
|
+
if not route_groups:
|
|
462
|
+
decision = self._create_decision(
|
|
463
|
+
store=store,
|
|
464
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
465
|
+
severity="HIGH",
|
|
466
|
+
summary=(
|
|
467
|
+
f"Route list '{rl_name}' has no route group members — "
|
|
468
|
+
f"at least one route group required"
|
|
469
|
+
),
|
|
470
|
+
context={
|
|
471
|
+
"route_list_id": rl_id,
|
|
472
|
+
"route_list_name": rl_name,
|
|
473
|
+
"reason": "route_list_no_members",
|
|
474
|
+
},
|
|
475
|
+
options=[
|
|
476
|
+
manual_option("Admin assigns route group member"),
|
|
477
|
+
skip_option("Route list not migrated"),
|
|
478
|
+
],
|
|
479
|
+
affected_objects=[rl_id],
|
|
480
|
+
)
|
|
481
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
482
|
+
result.decisions.append(decision)
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
# Single route group -> direct mapping
|
|
486
|
+
if len(route_groups) == 1:
|
|
487
|
+
rg_name = route_groups[0]
|
|
488
|
+
rl = CanonicalRouteList(
|
|
489
|
+
canonical_id=rl_id,
|
|
490
|
+
provenance=extract_provenance(rl_data),
|
|
491
|
+
status=MigrationStatus.ANALYZED,
|
|
492
|
+
name=rl_name,
|
|
493
|
+
route_group_id=f"route_group:{rg_name}",
|
|
494
|
+
cucm_route_list_name=rl_name,
|
|
495
|
+
cucm_route_groups=route_groups,
|
|
496
|
+
)
|
|
497
|
+
store.upsert_object(rl)
|
|
498
|
+
result.objects_created += 1
|
|
499
|
+
else:
|
|
500
|
+
# Multiple route groups -> FEATURE_APPROXIMATION decision
|
|
501
|
+
# Webex route lists bind to exactly ONE route group
|
|
502
|
+
decision = self._create_decision(
|
|
503
|
+
store=store,
|
|
504
|
+
decision_type=DecisionType.FEATURE_APPROXIMATION,
|
|
505
|
+
severity="MEDIUM",
|
|
506
|
+
summary=(
|
|
507
|
+
f"Route list '{rl_name}' has {len(route_groups)} route group members "
|
|
508
|
+
f"({', '.join(route_groups)}). Webex route lists bind to exactly one "
|
|
509
|
+
f"route group — choose how to handle the extra members."
|
|
510
|
+
),
|
|
511
|
+
context={
|
|
512
|
+
"route_list_id": rl_id,
|
|
513
|
+
"route_list_name": rl_name,
|
|
514
|
+
"route_groups": route_groups,
|
|
515
|
+
"reason": "multi_route_group_route_list",
|
|
516
|
+
},
|
|
517
|
+
options=[
|
|
518
|
+
accept_option(
|
|
519
|
+
f"Split into {len(route_groups)} route lists "
|
|
520
|
+
f"(one per route group)"
|
|
521
|
+
),
|
|
522
|
+
accept_option(
|
|
523
|
+
f"Use first route group only ({route_groups[0]}) — "
|
|
524
|
+
f"loses failover to other members"
|
|
525
|
+
),
|
|
526
|
+
skip_option("Route list not migrated"),
|
|
527
|
+
],
|
|
528
|
+
affected_objects=[rl_id],
|
|
529
|
+
)
|
|
530
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
531
|
+
result.decisions.append(decision)
|
|
532
|
+
|
|
533
|
+
# Default: create with first route group (can be overridden by decision)
|
|
534
|
+
rl = CanonicalRouteList(
|
|
535
|
+
canonical_id=rl_id,
|
|
536
|
+
provenance=extract_provenance(rl_data),
|
|
537
|
+
status=MigrationStatus.ANALYZED,
|
|
538
|
+
name=rl_name,
|
|
539
|
+
route_group_id=f"route_group:{route_groups[0]}",
|
|
540
|
+
cucm_route_list_name=rl_name,
|
|
541
|
+
cucm_route_groups=route_groups,
|
|
542
|
+
)
|
|
543
|
+
store.upsert_object(rl)
|
|
544
|
+
result.objects_created += 1
|
|
545
|
+
|
|
546
|
+
# ------------------------------------------------------------------
|
|
547
|
+
# Dial Plans: CUCM Route Patterns -> CanonicalDialPlan
|
|
548
|
+
# ------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
def _map_dial_plans(self, store: MigrationStore, result: MapperResult) -> None:
|
|
551
|
+
"""Map CUCM route patterns to CanonicalDialPlan objects.
|
|
552
|
+
|
|
553
|
+
Groups route patterns by their routing target (gateway/trunk or route group)
|
|
554
|
+
into consolidated dial plans.
|
|
555
|
+
|
|
556
|
+
Phase 04 correction: route_pattern_uses_gateway (#11) and
|
|
557
|
+
route_pattern_uses_route_list (#12) cross-refs may be EMPTY because
|
|
558
|
+
listRoutePattern doesn't return destination data. Must fall back to
|
|
559
|
+
pre_migration_state if available.
|
|
560
|
+
|
|
561
|
+
(from 03b-transform-mappers.md §6: dial plan field mapping table)
|
|
562
|
+
"""
|
|
563
|
+
# Group patterns by target for consolidation
|
|
564
|
+
# Key = target canonical_id (trunk:X or route_group:X), Value = list of converted patterns
|
|
565
|
+
target_groups: dict[str, list[str]] = {}
|
|
566
|
+
# Track route pattern names for dial plan naming
|
|
567
|
+
target_names: dict[str, str] = {}
|
|
568
|
+
# Track route types per target
|
|
569
|
+
target_route_types: dict[str, str] = {}
|
|
570
|
+
|
|
571
|
+
rp_count = 0
|
|
572
|
+
skipped_at_macro = 0
|
|
573
|
+
skipped_no_target = 0
|
|
574
|
+
|
|
575
|
+
for rp_data in store.get_objects("route_pattern"):
|
|
576
|
+
rp_id = rp_data["canonical_id"]
|
|
577
|
+
state = rp_data.get("pre_migration_state") or {}
|
|
578
|
+
|
|
579
|
+
cucm_pattern = state.get("pattern", "")
|
|
580
|
+
|
|
581
|
+
# --- @ macro detection ---
|
|
582
|
+
# (from 03b-transform-mappers.md §6: "@ macro requires expansion")
|
|
583
|
+
if "@" in cucm_pattern:
|
|
584
|
+
decision = self._create_decision(
|
|
585
|
+
store=store,
|
|
586
|
+
decision_type=DecisionType.FEATURE_APPROXIMATION,
|
|
587
|
+
severity="MEDIUM",
|
|
588
|
+
summary=(
|
|
589
|
+
f"Route pattern '{cucm_pattern}' uses @ macro (national numbering "
|
|
590
|
+
f"plan) which has no single Webex equivalent"
|
|
591
|
+
),
|
|
592
|
+
context={
|
|
593
|
+
"route_pattern_id": rp_id,
|
|
594
|
+
"cucm_pattern": cucm_pattern,
|
|
595
|
+
"reason": "at_macro",
|
|
596
|
+
},
|
|
597
|
+
options=[
|
|
598
|
+
accept_option("Expand to explicit pattern set from national plan"),
|
|
599
|
+
accept_option("Use broad wildcard +! as approximation"),
|
|
600
|
+
manual_option("Admin defines explicit patterns manually"),
|
|
601
|
+
],
|
|
602
|
+
affected_objects=[rp_id],
|
|
603
|
+
)
|
|
604
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
605
|
+
result.decisions.append(decision)
|
|
606
|
+
# Skip this pattern — can't convert automatically
|
|
607
|
+
skipped_at_macro += 1
|
|
608
|
+
continue
|
|
609
|
+
|
|
610
|
+
# --- Convert pattern ---
|
|
611
|
+
webex_pattern = cucm_to_webex_pattern(
|
|
612
|
+
cucm_pattern, self.country_code, self.outside_dial_digit
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# --- Resolve routing target ---
|
|
616
|
+
# Try cross-refs first, then fall back to pre_migration_state
|
|
617
|
+
# Phase 04 correction: cross-refs may be empty
|
|
618
|
+
target_id = None
|
|
619
|
+
route_type = None
|
|
620
|
+
|
|
621
|
+
# Try gateway cross-ref (#11)
|
|
622
|
+
gw_refs = store.find_cross_refs(rp_id, "route_pattern_uses_gateway")
|
|
623
|
+
if gw_refs:
|
|
624
|
+
# Gateway -> trunk mapping
|
|
625
|
+
gw_id = gw_refs[0]
|
|
626
|
+
gw_name = gw_id.split(":", 1)[-1] if ":" in gw_id else gw_id
|
|
627
|
+
target_id = f"trunk:{gw_name}"
|
|
628
|
+
route_type = "TRUNK"
|
|
629
|
+
else:
|
|
630
|
+
# Try route list cross-ref (#12)
|
|
631
|
+
rl_refs = store.find_cross_refs(rp_id, "route_pattern_uses_route_list")
|
|
632
|
+
if rl_refs:
|
|
633
|
+
# Webex dial plans only support RouteType=ROUTE_GROUP|TRUNK,
|
|
634
|
+
# so we resolve through the mapped CanonicalRouteList to get
|
|
635
|
+
# its underlying route_group_id. The route list itself is
|
|
636
|
+
# created as a parallel standalone resource.
|
|
637
|
+
rl_id = rl_refs[0]
|
|
638
|
+
rl_obj = store.get_object(rl_id)
|
|
639
|
+
if rl_obj and rl_obj.get("route_group_id"):
|
|
640
|
+
target_id = rl_obj["route_group_id"]
|
|
641
|
+
route_type = "ROUTE_GROUP"
|
|
642
|
+
|
|
643
|
+
# Fall back to pre_migration_state if cross-refs were empty
|
|
644
|
+
if not target_id:
|
|
645
|
+
target_type = state.get("target_type")
|
|
646
|
+
target_name = state.get("target_name")
|
|
647
|
+
if target_name and target_type:
|
|
648
|
+
if target_type in ("gateway", "trunk"):
|
|
649
|
+
target_id = f"trunk:{target_name}"
|
|
650
|
+
route_type = "TRUNK"
|
|
651
|
+
elif target_type in ("route_list", "routeList", "route_group"):
|
|
652
|
+
target_id = f"route_group:{target_name}"
|
|
653
|
+
route_type = "ROUTE_GROUP"
|
|
654
|
+
|
|
655
|
+
if not target_id:
|
|
656
|
+
# No target resolved — create MISSING_DATA decision
|
|
657
|
+
decision = self._create_decision(
|
|
658
|
+
store=store,
|
|
659
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
660
|
+
severity="MEDIUM",
|
|
661
|
+
summary=(
|
|
662
|
+
f"Route pattern '{cucm_pattern}' has no resolvable routing "
|
|
663
|
+
f"target — cross-refs and pre_migration_state both empty"
|
|
664
|
+
),
|
|
665
|
+
context={
|
|
666
|
+
"route_pattern_id": rp_id,
|
|
667
|
+
"cucm_pattern": cucm_pattern,
|
|
668
|
+
"reason": "no_routing_target",
|
|
669
|
+
},
|
|
670
|
+
options=[
|
|
671
|
+
manual_option("Admin assigns routing target manually"),
|
|
672
|
+
skip_option("Pattern not migrated"),
|
|
673
|
+
],
|
|
674
|
+
affected_objects=[rp_id],
|
|
675
|
+
)
|
|
676
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
677
|
+
result.decisions.append(decision)
|
|
678
|
+
skipped_no_target += 1
|
|
679
|
+
continue
|
|
680
|
+
|
|
681
|
+
# Accumulate pattern into target group
|
|
682
|
+
target_groups.setdefault(target_id, []).append(webex_pattern)
|
|
683
|
+
if target_id not in target_names:
|
|
684
|
+
target_names[target_id] = target_id.split(":", 1)[-1]
|
|
685
|
+
target_route_types[target_id] = route_type
|
|
686
|
+
rp_count += 1
|
|
687
|
+
|
|
688
|
+
logger.info(
|
|
689
|
+
"Dial plan mapping: %d route patterns processed, %d skipped (@ macro), "
|
|
690
|
+
"%d skipped (no target), %d dial plans created",
|
|
691
|
+
rp_count, skipped_at_macro, skipped_no_target, len(target_groups),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
# --- Create CanonicalDialPlan per target ---
|
|
695
|
+
for target_id, patterns in target_groups.items():
|
|
696
|
+
target_name = target_names[target_id]
|
|
697
|
+
route_type = target_route_types[target_id]
|
|
698
|
+
|
|
699
|
+
dp_name = f"Migration-DialPlan-{target_name}"
|
|
700
|
+
|
|
701
|
+
dial_plan = CanonicalDialPlan(
|
|
702
|
+
canonical_id=f"dial_plan:{hash_id(dp_name)}",
|
|
703
|
+
provenance=Provenance(
|
|
704
|
+
source_system="cucm",
|
|
705
|
+
source_id=target_id,
|
|
706
|
+
source_name=dp_name,
|
|
707
|
+
extracted_at=datetime.now(timezone.utc),
|
|
708
|
+
),
|
|
709
|
+
status=MigrationStatus.ANALYZED,
|
|
710
|
+
name=dp_name,
|
|
711
|
+
dial_patterns=sorted(set(patterns)),
|
|
712
|
+
route_id=target_id,
|
|
713
|
+
route_type=route_type,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
store.upsert_object(dial_plan)
|
|
717
|
+
result.objects_created += 1
|
|
718
|
+
|
|
719
|
+
# ------------------------------------------------------------------
|
|
720
|
+
# Translation Patterns
|
|
721
|
+
# ------------------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
def _map_translation_patterns(
|
|
724
|
+
self, store: MigrationStore, result: MapperResult
|
|
725
|
+
) -> None:
|
|
726
|
+
"""Map CUCM translation patterns to CanonicalTranslationPattern objects.
|
|
727
|
+
|
|
728
|
+
(from 03b-transform-mappers.md §6: translation pattern field mapping table)
|
|
729
|
+
"""
|
|
730
|
+
tp_objects = list(store.get_objects("translation_pattern"))
|
|
731
|
+
logger.info("Translation pattern mapping: %d source objects found", len(tp_objects))
|
|
732
|
+
|
|
733
|
+
for tp_data in tp_objects:
|
|
734
|
+
tp_id = tp_data["canonical_id"]
|
|
735
|
+
state = tp_data.get("pre_migration_state") or {}
|
|
736
|
+
|
|
737
|
+
cucm_pattern = state.get("pattern", "")
|
|
738
|
+
|
|
739
|
+
# Fix 6: Skip translation patterns with empty/None pattern
|
|
740
|
+
if not cucm_pattern:
|
|
741
|
+
continue
|
|
742
|
+
replacement = state.get("calledPartyTransformationMask", "")
|
|
743
|
+
description = state.get("description") or cucm_pattern
|
|
744
|
+
|
|
745
|
+
# --- Convert matching pattern ---
|
|
746
|
+
matching_pattern = cucm_to_webex_pattern(
|
|
747
|
+
cucm_pattern, self.country_code, self.outside_dial_digit
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
# --- Validate replacement pattern ---
|
|
751
|
+
# (from 03b-transform-mappers.md §6: "E.164 replacementPattern cannot contain X wildcards")
|
|
752
|
+
replacement_pattern = replacement
|
|
753
|
+
if replacement_pattern and replacement_pattern.startswith("+") and "X" in replacement_pattern.upper():
|
|
754
|
+
decision = self._create_decision(
|
|
755
|
+
store=store,
|
|
756
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
757
|
+
severity="MEDIUM",
|
|
758
|
+
summary=(
|
|
759
|
+
f"Translation pattern '{cucm_pattern}' has E.164 replacement "
|
|
760
|
+
f"'{replacement}' with X wildcards — invalid in Webex"
|
|
761
|
+
),
|
|
762
|
+
context={
|
|
763
|
+
"translation_pattern_id": tp_id,
|
|
764
|
+
"cucm_pattern": cucm_pattern,
|
|
765
|
+
"replacement_pattern": replacement,
|
|
766
|
+
"reason": "e164_replacement_has_wildcards",
|
|
767
|
+
},
|
|
768
|
+
options=[
|
|
769
|
+
manual_option("Admin provides valid replacement pattern without wildcards"),
|
|
770
|
+
skip_option("Translation pattern not migrated"),
|
|
771
|
+
],
|
|
772
|
+
affected_objects=[tp_id],
|
|
773
|
+
)
|
|
774
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
775
|
+
result.decisions.append(decision)
|
|
776
|
+
|
|
777
|
+
tp_obj = CanonicalTranslationPattern(
|
|
778
|
+
canonical_id=f"translation_pattern:{hash_id(cucm_pattern)}",
|
|
779
|
+
provenance=extract_provenance(tp_data),
|
|
780
|
+
status=MigrationStatus.ANALYZED,
|
|
781
|
+
name=description,
|
|
782
|
+
matching_pattern=matching_pattern,
|
|
783
|
+
replacement_pattern=replacement_pattern,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
store.upsert_object(tp_obj)
|
|
787
|
+
result.objects_created += 1
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# ---------------------------------------------------------------------------
|
|
791
|
+
# Helpers
|
|
792
|
+
# ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _infer_trunk_type(state: dict[str, Any]) -> str:
|
|
796
|
+
"""Infer Webex trunk type from CUCM SIP trunk settings.
|
|
797
|
+
|
|
798
|
+
(from 03b-transform-mappers.md §6: "REGISTERING or CERTIFICATE_BASED")
|
|
799
|
+
Tier 2: Prefer explicit security_mode from profile detail when available.
|
|
800
|
+
"""
|
|
801
|
+
# Tier 2: Prefer explicit security_mode from profile detail
|
|
802
|
+
security_mode = (state.get("security_mode") or "").upper()
|
|
803
|
+
if security_mode == "TLS":
|
|
804
|
+
return "CERTIFICATE_BASED"
|
|
805
|
+
if security_mode == "IPSEC":
|
|
806
|
+
return "CERTIFICATE_BASED" # Closest Webex equivalent
|
|
807
|
+
if security_mode == "NON_SECURE":
|
|
808
|
+
return "REGISTERING"
|
|
809
|
+
|
|
810
|
+
# Fallback: pattern-match on security profile name
|
|
811
|
+
security_profile = (state.get("security_profile") or state.get("securityProfileName") or "").lower()
|
|
812
|
+
if "tls" in security_profile or "certificate" in security_profile:
|
|
813
|
+
return "CERTIFICATE_BASED"
|
|
814
|
+
|
|
815
|
+
trunk_type = state.get("trunk_type", "").upper()
|
|
816
|
+
if trunk_type == "CERTIFICATE_BASED":
|
|
817
|
+
return "CERTIFICATE_BASED"
|
|
818
|
+
|
|
819
|
+
return "REGISTERING"
|
|
820
|
+
|
|
821
|
+
|