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,868 @@
|
|
|
1
|
+
"""CSS decomposition mapper: CUCM CSS -> Webex Dial Plans + Calling Permissions.
|
|
2
|
+
|
|
3
|
+
Decomposes CUCM Calling Search Spaces into:
|
|
4
|
+
- Webex Calling Dial Plans (org-wide routing)
|
|
5
|
+
- Per-user Outgoing Calling Permissions (category-based blocking)
|
|
6
|
+
|
|
7
|
+
Implements the 5-step algorithm from 04-css-decomposition.md.
|
|
8
|
+
|
|
9
|
+
(from 03b-transform-mappers.md §7, css_mapper)
|
|
10
|
+
(from 04-css-decomposition.md, full algorithm)
|
|
11
|
+
|
|
12
|
+
Cross-ref reads:
|
|
13
|
+
css_contains_partition (#16) — ordered partitions in a CSS
|
|
14
|
+
partition_has_pattern (#17) — route patterns in a partition
|
|
15
|
+
user_has_css (#18) — user -> CSS assignment
|
|
16
|
+
device_has_css (#19) — device -> CSS assignment
|
|
17
|
+
line_has_css (#20) — line -> CSS assignment
|
|
18
|
+
|
|
19
|
+
Decisions generated:
|
|
20
|
+
CSS_ROUTING_MISMATCH — routing scope differences, MIXED partitions, ordering conflicts
|
|
21
|
+
CALLING_PERMISSION_MISMATCH — unclassifiable block patterns
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from collections import defaultdict
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from wxcli.migration.models import (
|
|
32
|
+
CallingPermissionEntry,
|
|
33
|
+
CanonicalCallingPermission,
|
|
34
|
+
CanonicalDialPlan,
|
|
35
|
+
DecisionType,
|
|
36
|
+
MapperResult,
|
|
37
|
+
MigrationStatus,
|
|
38
|
+
Provenance,
|
|
39
|
+
)
|
|
40
|
+
from wxcli.migration.store import MigrationStore
|
|
41
|
+
from wxcli.migration.transform.cucm_pattern import (
|
|
42
|
+
classify_block_pattern,
|
|
43
|
+
cucm_patterns_overlap,
|
|
44
|
+
)
|
|
45
|
+
from wxcli.migration.transform.mappers.base import (
|
|
46
|
+
Mapper,
|
|
47
|
+
accept_option,
|
|
48
|
+
decision_to_store_dict,
|
|
49
|
+
hash_id,
|
|
50
|
+
manual_option,
|
|
51
|
+
skip_option,
|
|
52
|
+
)
|
|
53
|
+
from wxcli.migration.transform.pattern_converter import cucm_to_webex_pattern
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# Webex OutgoingPermissionCallType values
|
|
58
|
+
# (from docs/reference/person-call-settings-permissions.md)
|
|
59
|
+
WEBEX_CALL_TYPES = [
|
|
60
|
+
"INTERNAL_CALL",
|
|
61
|
+
"TOLL_FREE",
|
|
62
|
+
"NATIONAL",
|
|
63
|
+
"INTERNATIONAL",
|
|
64
|
+
"OPERATOR_ASSISTED",
|
|
65
|
+
"CHARGEABLE_DIRECTORY_ASSISTED",
|
|
66
|
+
"SPECIAL_SERVICES_I",
|
|
67
|
+
"SPECIAL_SERVICES_II",
|
|
68
|
+
"PREMIUM_SERVICES_I",
|
|
69
|
+
"PREMIUM_SERVICES_II",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# Partition classification labels
|
|
73
|
+
# (from 04-css-decomposition.md Step 2)
|
|
74
|
+
DIRECTORY = "DIRECTORY"
|
|
75
|
+
ROUTING = "ROUTING"
|
|
76
|
+
BLOCKING = "BLOCKING"
|
|
77
|
+
MIXED = "MIXED"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CSSMapper(Mapper):
|
|
81
|
+
"""Decomposes CUCM CSSes into Webex Dial Plans and Calling Permissions.
|
|
82
|
+
|
|
83
|
+
Implements the 5-step CSS decomposition algorithm:
|
|
84
|
+
1. Build CSS->Partition->Pattern graph
|
|
85
|
+
2. Classify partitions (DIRECTORY/ROUTING/BLOCKING/MIXED)
|
|
86
|
+
3. Compute routing scope per CSS, intersection baseline
|
|
87
|
+
4. Compute restriction profiles, group users by profile
|
|
88
|
+
5. Detect ordering conflicts via cucm_patterns_overlap()
|
|
89
|
+
|
|
90
|
+
(from 03b-transform-mappers.md §7, 04-css-decomposition.md)
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
name = "css_mapper"
|
|
94
|
+
depends_on = ["routing_mapper", "user_mapper", "line_mapper", "device_mapper"]
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
category_rules: list[dict[str, str]] | None = None,
|
|
99
|
+
country_code: str = "+1",
|
|
100
|
+
outside_dial_digit: str = "9",
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Initialize the CSS mapper.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
category_rules: Configurable mapping from CUCM block patterns to
|
|
106
|
+
Webex permission categories. Each rule is a dict with
|
|
107
|
+
'cucm_pattern' and 'webex_category' keys.
|
|
108
|
+
country_code: E.164 country prefix for pattern conversion.
|
|
109
|
+
outside_dial_digit: CUCM outside access digit.
|
|
110
|
+
"""
|
|
111
|
+
self.category_rules = category_rules or []
|
|
112
|
+
self.country_code = country_code
|
|
113
|
+
self.outside_dial_digit = outside_dial_digit
|
|
114
|
+
|
|
115
|
+
def map(self, store: MigrationStore) -> MapperResult:
|
|
116
|
+
"""Execute the CSS decomposition algorithm."""
|
|
117
|
+
result = MapperResult()
|
|
118
|
+
|
|
119
|
+
# Get all CSS objects
|
|
120
|
+
css_objects = store.get_objects("css")
|
|
121
|
+
if not css_objects:
|
|
122
|
+
logger.info("No CSS objects found — skipping CSS decomposition")
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
# Step 1+2: Build CSS graph and classify partitions
|
|
126
|
+
css_graphs = {}
|
|
127
|
+
for css_data in css_objects:
|
|
128
|
+
css_id = css_data["canonical_id"]
|
|
129
|
+
graph = self._build_css_graph(store, css_id, result)
|
|
130
|
+
if graph:
|
|
131
|
+
css_graphs[css_id] = graph
|
|
132
|
+
|
|
133
|
+
if not css_graphs:
|
|
134
|
+
logger.info("No CSS objects with partitions found")
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
# Build combined CSS for users (Line CSS + Device CSS)
|
|
138
|
+
user_effective_css = self._build_user_effective_css(store, css_graphs)
|
|
139
|
+
|
|
140
|
+
# Step 3: Compute routing scope per effective CSS, group by scope
|
|
141
|
+
self._compute_routing_scopes(store, css_graphs, user_effective_css, result)
|
|
142
|
+
|
|
143
|
+
# Step 4: Compute restriction profiles and create CallingPermissions
|
|
144
|
+
self._compute_restriction_profiles(
|
|
145
|
+
store, css_graphs, user_effective_css, result
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Step 5: Detect ordering conflicts
|
|
149
|
+
self._detect_ordering_conflicts(store, css_graphs, result)
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Step 1+2: Build CSS→Partition→Pattern graph and classify
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def _build_css_graph(
|
|
158
|
+
self,
|
|
159
|
+
store: MigrationStore,
|
|
160
|
+
css_id: str,
|
|
161
|
+
result: MapperResult,
|
|
162
|
+
) -> dict[str, Any] | None:
|
|
163
|
+
"""Build the partition→pattern graph for a CSS, classify partitions.
|
|
164
|
+
|
|
165
|
+
Returns a dict with:
|
|
166
|
+
partitions: list of {partition_id, ordinal, classification, patterns}
|
|
167
|
+
patterns is list of {pattern_id, pattern, action}
|
|
168
|
+
|
|
169
|
+
(from 04-css-decomposition.md Steps 1-2)
|
|
170
|
+
"""
|
|
171
|
+
# Get ordered partitions via css_contains_partition with ordinal
|
|
172
|
+
partition_refs = store.get_cross_refs(
|
|
173
|
+
from_id=css_id, relationship="css_contains_partition"
|
|
174
|
+
)
|
|
175
|
+
if not partition_refs:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
# Sort by ordinal to preserve CUCM priority order
|
|
179
|
+
partition_refs.sort(key=lambda r: r.get("ordinal") or 0)
|
|
180
|
+
|
|
181
|
+
partitions = []
|
|
182
|
+
for ref in partition_refs:
|
|
183
|
+
partition_id = ref["to_id"]
|
|
184
|
+
ordinal = ref.get("ordinal") or 0
|
|
185
|
+
|
|
186
|
+
# Get patterns in this partition
|
|
187
|
+
pattern_refs = store.get_cross_refs(
|
|
188
|
+
from_id=partition_id, relationship="partition_has_pattern"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
patterns = []
|
|
192
|
+
for pref in pattern_refs:
|
|
193
|
+
pattern_id = pref["to_id"]
|
|
194
|
+
pat_data = store.get_object(pattern_id)
|
|
195
|
+
if pat_data is None:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
state = pat_data.get("pre_migration_state") or {}
|
|
199
|
+
pattern_str = state.get("pattern", "")
|
|
200
|
+
action = state.get("action", "")
|
|
201
|
+
patterns.append({
|
|
202
|
+
"pattern_id": pattern_id,
|
|
203
|
+
"pattern": pattern_str,
|
|
204
|
+
"action": action.upper() if action else "",
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
# Classify partition (from 04-css-decomposition.md Step 2)
|
|
208
|
+
classification = self._classify_partition(patterns)
|
|
209
|
+
|
|
210
|
+
# Handle MIXED partition — split into virtual ROUTING + BLOCKING
|
|
211
|
+
if classification == MIXED:
|
|
212
|
+
route_patterns = [p for p in patterns if p["action"] == "ROUTE"]
|
|
213
|
+
block_patterns = [p for p in patterns if p["action"] == "BLOCK"]
|
|
214
|
+
|
|
215
|
+
# Create virtual ROUTING partition
|
|
216
|
+
partitions.append({
|
|
217
|
+
"partition_id": f"{partition_id}__virtual_routing",
|
|
218
|
+
"ordinal": ordinal,
|
|
219
|
+
"classification": ROUTING,
|
|
220
|
+
"patterns": route_patterns,
|
|
221
|
+
"is_virtual": True,
|
|
222
|
+
"source_partition": partition_id,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
# Create virtual BLOCKING partition
|
|
226
|
+
partitions.append({
|
|
227
|
+
"partition_id": f"{partition_id}__virtual_blocking",
|
|
228
|
+
"ordinal": ordinal,
|
|
229
|
+
"classification": BLOCKING,
|
|
230
|
+
"patterns": block_patterns,
|
|
231
|
+
"is_virtual": True,
|
|
232
|
+
"source_partition": partition_id,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
# Generate CSS_ROUTING_MISMATCH decision for MIXED partition
|
|
236
|
+
decision = self._create_decision(
|
|
237
|
+
store=store,
|
|
238
|
+
decision_type=DecisionType.CSS_ROUTING_MISMATCH,
|
|
239
|
+
severity="MEDIUM",
|
|
240
|
+
summary=(
|
|
241
|
+
f"MIXED partition '{partition_id}' in CSS '{css_id}' "
|
|
242
|
+
f"contains both ROUTE and BLOCK patterns — "
|
|
243
|
+
f"split into virtual ROUTING + BLOCKING partitions"
|
|
244
|
+
),
|
|
245
|
+
context={
|
|
246
|
+
"css_id": css_id,
|
|
247
|
+
"partition_id": partition_id,
|
|
248
|
+
"reason": "mixed_partition",
|
|
249
|
+
"route_count": len(route_patterns),
|
|
250
|
+
"block_count": len(block_patterns),
|
|
251
|
+
},
|
|
252
|
+
options=[
|
|
253
|
+
accept_option(
|
|
254
|
+
"Accept virtual split into ROUTING + BLOCKING partitions"
|
|
255
|
+
),
|
|
256
|
+
manual_option("Admin manually classifies partition patterns"),
|
|
257
|
+
],
|
|
258
|
+
affected_objects=[css_id, partition_id],
|
|
259
|
+
)
|
|
260
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
261
|
+
result.decisions.append(decision)
|
|
262
|
+
else:
|
|
263
|
+
partitions.append({
|
|
264
|
+
"partition_id": partition_id,
|
|
265
|
+
"ordinal": ordinal,
|
|
266
|
+
"classification": classification,
|
|
267
|
+
"patterns": patterns,
|
|
268
|
+
"is_virtual": False,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
return {"partitions": partitions}
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _classify_partition(patterns: list[dict[str, Any]]) -> str:
|
|
275
|
+
"""Classify a partition based on its patterns.
|
|
276
|
+
|
|
277
|
+
(from 04-css-decomposition.md Step 2)
|
|
278
|
+
"""
|
|
279
|
+
if not patterns:
|
|
280
|
+
return DIRECTORY
|
|
281
|
+
|
|
282
|
+
has_route = any(p["action"] == "ROUTE" for p in patterns)
|
|
283
|
+
has_block = any(p["action"] == "BLOCK" for p in patterns)
|
|
284
|
+
|
|
285
|
+
# Patterns with no action (DNs only) → directory
|
|
286
|
+
has_any_action = has_route or has_block
|
|
287
|
+
|
|
288
|
+
if not has_any_action:
|
|
289
|
+
return DIRECTORY
|
|
290
|
+
if has_route and has_block:
|
|
291
|
+
return MIXED
|
|
292
|
+
if has_route:
|
|
293
|
+
return ROUTING
|
|
294
|
+
if has_block:
|
|
295
|
+
return BLOCKING
|
|
296
|
+
|
|
297
|
+
return DIRECTORY
|
|
298
|
+
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
# Combined CSS: Line CSS (higher priority) + Device CSS (lower priority)
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def _build_user_effective_css(
|
|
304
|
+
self,
|
|
305
|
+
store: MigrationStore,
|
|
306
|
+
css_graphs: dict[str, dict[str, Any]],
|
|
307
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
308
|
+
"""Build the effective combined CSS for each user.
|
|
309
|
+
|
|
310
|
+
The effective CSS is Line CSS partitions (higher priority) followed by
|
|
311
|
+
Device CSS partitions (lower priority).
|
|
312
|
+
|
|
313
|
+
(from 03b-transform-mappers.md §7: CSS on devices vs. users)
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
dict mapping user_canonical_id -> combined partition list
|
|
317
|
+
"""
|
|
318
|
+
# Gather user→CSS assignments
|
|
319
|
+
user_css_refs = store.get_cross_refs(relationship="user_has_css")
|
|
320
|
+
line_css_refs = store.get_cross_refs(relationship="line_has_css")
|
|
321
|
+
device_css_refs = store.get_cross_refs(relationship="device_has_css")
|
|
322
|
+
|
|
323
|
+
# Build index: user_id → css_ids (direct)
|
|
324
|
+
user_css_map: dict[str, list[str]] = defaultdict(list)
|
|
325
|
+
for ref in user_css_refs:
|
|
326
|
+
user_css_map[ref["from_id"]].append(ref["to_id"])
|
|
327
|
+
|
|
328
|
+
# Build index: line_id → css_id
|
|
329
|
+
line_css_map: dict[str, str] = {}
|
|
330
|
+
for ref in line_css_refs:
|
|
331
|
+
line_css_map[ref["from_id"]] = ref["to_id"]
|
|
332
|
+
|
|
333
|
+
# Build index: device_id → css_id
|
|
334
|
+
device_css_map: dict[str, str] = {}
|
|
335
|
+
for ref in device_css_refs:
|
|
336
|
+
device_css_map[ref["from_id"]] = ref["to_id"]
|
|
337
|
+
|
|
338
|
+
# For users with direct CSS assignment, use that directly
|
|
339
|
+
user_effective: dict[str, list[dict[str, Any]]] = {}
|
|
340
|
+
|
|
341
|
+
for user_id, css_ids in user_css_map.items():
|
|
342
|
+
combined_partitions = []
|
|
343
|
+
for css_id in css_ids:
|
|
344
|
+
graph = css_graphs.get(css_id)
|
|
345
|
+
if graph:
|
|
346
|
+
combined_partitions.extend(graph["partitions"])
|
|
347
|
+
if combined_partitions:
|
|
348
|
+
user_effective[user_id] = combined_partitions
|
|
349
|
+
|
|
350
|
+
# For users without direct CSS, resolve from line CSS (higher
|
|
351
|
+
# priority) then device CSS (lower priority). Trace:
|
|
352
|
+
# user → user_has_device → device → device_has_dn → line
|
|
353
|
+
# Most CUCM deployments assign CSS on the line, not the user.
|
|
354
|
+
users = store.query_by_type("user")
|
|
355
|
+
for user_obj in users:
|
|
356
|
+
uid = user_obj.canonical_id
|
|
357
|
+
if uid in user_effective:
|
|
358
|
+
continue # already has direct CSS
|
|
359
|
+
|
|
360
|
+
combined_partitions: list[dict[str, Any]] = []
|
|
361
|
+
seen_css: set[str] = set()
|
|
362
|
+
|
|
363
|
+
# Get user's devices
|
|
364
|
+
device_ids = store.find_cross_refs(uid, "user_has_device")
|
|
365
|
+
|
|
366
|
+
for dev_id in device_ids:
|
|
367
|
+
# Line CSS (higher priority) — check DNs on this device
|
|
368
|
+
dn_ids = store.find_cross_refs(dev_id, "device_has_dn")
|
|
369
|
+
for dn_id in dn_ids:
|
|
370
|
+
css_id = line_css_map.get(dn_id)
|
|
371
|
+
if css_id and css_id not in seen_css:
|
|
372
|
+
seen_css.add(css_id)
|
|
373
|
+
graph = css_graphs.get(css_id)
|
|
374
|
+
if graph:
|
|
375
|
+
combined_partitions.extend(graph["partitions"])
|
|
376
|
+
|
|
377
|
+
# Device CSS (lower priority)
|
|
378
|
+
css_id = device_css_map.get(dev_id)
|
|
379
|
+
if css_id and css_id not in seen_css:
|
|
380
|
+
seen_css.add(css_id)
|
|
381
|
+
graph = css_graphs.get(css_id)
|
|
382
|
+
if graph:
|
|
383
|
+
combined_partitions.extend(graph["partitions"])
|
|
384
|
+
|
|
385
|
+
if combined_partitions:
|
|
386
|
+
user_effective[uid] = combined_partitions
|
|
387
|
+
|
|
388
|
+
return user_effective
|
|
389
|
+
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
# Step 3: Compute routing scope per CSS
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def _compute_routing_scopes(
|
|
395
|
+
self,
|
|
396
|
+
store: MigrationStore,
|
|
397
|
+
css_graphs: dict[str, dict[str, Any]],
|
|
398
|
+
user_effective_css: dict[str, list[dict[str, Any]]],
|
|
399
|
+
result: MapperResult,
|
|
400
|
+
) -> None:
|
|
401
|
+
"""Compute effective routing scope per CSS, create dial plans.
|
|
402
|
+
|
|
403
|
+
Groups CSSes by identical routing scope. If single group, creates
|
|
404
|
+
org-wide dial plans directly. If multiple groups, uses intersection
|
|
405
|
+
as baseline and flags deltas as CSS_ROUTING_MISMATCH decisions.
|
|
406
|
+
|
|
407
|
+
(from 04-css-decomposition.md Step 3)
|
|
408
|
+
"""
|
|
409
|
+
# Compute routing scope per CSS
|
|
410
|
+
css_routing_scopes: dict[str, frozenset[str]] = {}
|
|
411
|
+
# Track pattern→target mapping for dial plan creation
|
|
412
|
+
pattern_targets: dict[str, tuple[str | None, str | None]] = {}
|
|
413
|
+
|
|
414
|
+
for css_id, graph in css_graphs.items():
|
|
415
|
+
route_patterns = set()
|
|
416
|
+
for partition in graph["partitions"]:
|
|
417
|
+
if partition["classification"] == ROUTING:
|
|
418
|
+
for pat in partition["patterns"]:
|
|
419
|
+
if pat["action"] == "ROUTE":
|
|
420
|
+
route_patterns.add(pat["pattern"])
|
|
421
|
+
# Track the target if available from pre_migration_state
|
|
422
|
+
pat_data = store.get_object(pat["pattern_id"])
|
|
423
|
+
if pat_data:
|
|
424
|
+
state = pat_data.get("pre_migration_state") or {}
|
|
425
|
+
target_type = state.get("target_type")
|
|
426
|
+
target_name = state.get("target_name")
|
|
427
|
+
if target_name and target_type:
|
|
428
|
+
if target_type in ("gateway", "trunk"):
|
|
429
|
+
pattern_targets[pat["pattern"]] = (
|
|
430
|
+
f"trunk:{target_name}",
|
|
431
|
+
"TRUNK",
|
|
432
|
+
)
|
|
433
|
+
elif target_type in (
|
|
434
|
+
"route_list",
|
|
435
|
+
"routeList",
|
|
436
|
+
"route_group",
|
|
437
|
+
):
|
|
438
|
+
pattern_targets[pat["pattern"]] = (
|
|
439
|
+
f"route_group:{target_name}",
|
|
440
|
+
"ROUTE_GROUP",
|
|
441
|
+
)
|
|
442
|
+
css_routing_scopes[css_id] = frozenset(route_patterns)
|
|
443
|
+
|
|
444
|
+
# Group CSSes by identical routing scope
|
|
445
|
+
scope_groups: dict[frozenset[str], list[str]] = defaultdict(list)
|
|
446
|
+
for css_id, scope in css_routing_scopes.items():
|
|
447
|
+
if scope: # Skip empty routing scopes
|
|
448
|
+
scope_groups[scope].append(css_id)
|
|
449
|
+
|
|
450
|
+
if not scope_groups:
|
|
451
|
+
logger.info("No routing patterns found in any CSS")
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
if len(scope_groups) == 1:
|
|
455
|
+
# Happy path: all CSSes have the same routing scope
|
|
456
|
+
# Create org-wide dial plans
|
|
457
|
+
baseline_patterns = next(iter(scope_groups))
|
|
458
|
+
self._create_dial_plans(
|
|
459
|
+
store, baseline_patterns, pattern_targets, result
|
|
460
|
+
)
|
|
461
|
+
else:
|
|
462
|
+
# Multiple routing scopes — use intersection as baseline
|
|
463
|
+
all_scopes = list(scope_groups.keys())
|
|
464
|
+
intersection = all_scopes[0]
|
|
465
|
+
for scope in all_scopes[1:]:
|
|
466
|
+
intersection = intersection & scope
|
|
467
|
+
|
|
468
|
+
# Create dial plans for intersection baseline
|
|
469
|
+
if intersection:
|
|
470
|
+
self._create_dial_plans(
|
|
471
|
+
store, intersection, pattern_targets, result
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# Generate CSS_ROUTING_MISMATCH decisions for delta patterns
|
|
475
|
+
for scope, css_ids in scope_groups.items():
|
|
476
|
+
delta = scope - intersection
|
|
477
|
+
if delta:
|
|
478
|
+
decision = self._create_decision(
|
|
479
|
+
store=store,
|
|
480
|
+
decision_type=DecisionType.CSS_ROUTING_MISMATCH,
|
|
481
|
+
severity="HIGH",
|
|
482
|
+
summary=(
|
|
483
|
+
f"{len(delta)} route pattern(s) in CSSes "
|
|
484
|
+
f"{css_ids} are NOT in the org-wide intersection "
|
|
485
|
+
f"baseline — these users have broader routing than "
|
|
486
|
+
f"the common baseline"
|
|
487
|
+
),
|
|
488
|
+
context={
|
|
489
|
+
"css_ids": css_ids,
|
|
490
|
+
"reason": "routing_scope_mismatch",
|
|
491
|
+
"delta_patterns": sorted(delta),
|
|
492
|
+
"baseline_pattern_count": len(intersection),
|
|
493
|
+
},
|
|
494
|
+
options=[
|
|
495
|
+
accept_option(
|
|
496
|
+
"Include delta patterns in org-wide dial plan "
|
|
497
|
+
"(all users gain access; rely on permissions to "
|
|
498
|
+
"restrict)"
|
|
499
|
+
),
|
|
500
|
+
accept_option(
|
|
501
|
+
"Exclude delta patterns (affected users lose "
|
|
502
|
+
"these routes)"
|
|
503
|
+
),
|
|
504
|
+
manual_option("Admin reviews and decides per pattern"),
|
|
505
|
+
],
|
|
506
|
+
affected_objects=css_ids,
|
|
507
|
+
)
|
|
508
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
509
|
+
result.decisions.append(decision)
|
|
510
|
+
|
|
511
|
+
def _create_dial_plans(
|
|
512
|
+
self,
|
|
513
|
+
store: MigrationStore,
|
|
514
|
+
patterns: frozenset[str],
|
|
515
|
+
pattern_targets: dict[str, tuple[str | None, str | None]],
|
|
516
|
+
result: MapperResult,
|
|
517
|
+
) -> None:
|
|
518
|
+
"""Create CanonicalDialPlan objects from a set of route patterns.
|
|
519
|
+
|
|
520
|
+
Groups patterns by their routing target (trunk/route group) and creates
|
|
521
|
+
one dial plan per target. Patterns without a known target are grouped
|
|
522
|
+
into a single 'unresolved' dial plan.
|
|
523
|
+
|
|
524
|
+
(from 03b-transform-mappers.md §7: dial plan field mapping)
|
|
525
|
+
"""
|
|
526
|
+
# Group patterns by target
|
|
527
|
+
target_patterns: dict[tuple[str | None, str | None], list[str]] = defaultdict(
|
|
528
|
+
list
|
|
529
|
+
)
|
|
530
|
+
for pattern in patterns:
|
|
531
|
+
target = pattern_targets.get(pattern, (None, None))
|
|
532
|
+
target_patterns[target].append(pattern)
|
|
533
|
+
|
|
534
|
+
for (route_id, route_type), pat_list in target_patterns.items():
|
|
535
|
+
# Convert CUCM patterns to Webex format
|
|
536
|
+
webex_patterns = []
|
|
537
|
+
for pat in pat_list:
|
|
538
|
+
try:
|
|
539
|
+
webex_pat = cucm_to_webex_pattern(
|
|
540
|
+
pat, self.country_code, self.outside_dial_digit
|
|
541
|
+
)
|
|
542
|
+
webex_patterns.append(webex_pat)
|
|
543
|
+
except (ValueError, Exception) as e:
|
|
544
|
+
logger.warning(
|
|
545
|
+
"Failed to convert pattern '%s' to Webex format: %s",
|
|
546
|
+
pat,
|
|
547
|
+
e,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if not webex_patterns:
|
|
551
|
+
continue
|
|
552
|
+
|
|
553
|
+
# Generate dial plan name
|
|
554
|
+
if route_id:
|
|
555
|
+
target_name = route_id.split(":", 1)[-1] if ":" in route_id else route_id
|
|
556
|
+
dp_name = f"CSS-Migration-DialPlan-{target_name}"
|
|
557
|
+
else:
|
|
558
|
+
dp_name = "CSS-Migration-DialPlan-Unresolved"
|
|
559
|
+
|
|
560
|
+
dp_hash = hash_id(dp_name + "|" + ",".join(sorted(webex_patterns)))
|
|
561
|
+
|
|
562
|
+
dial_plan = CanonicalDialPlan(
|
|
563
|
+
canonical_id=f"dial_plan:css_{dp_hash}",
|
|
564
|
+
provenance=Provenance(
|
|
565
|
+
source_system="cucm",
|
|
566
|
+
source_id=f"css_routing_scope",
|
|
567
|
+
source_name=dp_name,
|
|
568
|
+
extracted_at=datetime.now(timezone.utc),
|
|
569
|
+
),
|
|
570
|
+
status=MigrationStatus.ANALYZED,
|
|
571
|
+
name=dp_name,
|
|
572
|
+
dial_patterns=sorted(set(webex_patterns)),
|
|
573
|
+
route_id=route_id,
|
|
574
|
+
route_type=route_type,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
store.upsert_object(dial_plan)
|
|
578
|
+
result.objects_created += 1
|
|
579
|
+
|
|
580
|
+
# ------------------------------------------------------------------
|
|
581
|
+
# Step 4: Compute restriction profiles
|
|
582
|
+
# ------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
def _compute_restriction_profiles(
|
|
585
|
+
self,
|
|
586
|
+
store: MigrationStore,
|
|
587
|
+
css_graphs: dict[str, dict[str, Any]],
|
|
588
|
+
user_effective_css: dict[str, list[dict[str, Any]]],
|
|
589
|
+
result: MapperResult,
|
|
590
|
+
) -> None:
|
|
591
|
+
"""Compute restriction profiles and create CallingPermission objects.
|
|
592
|
+
|
|
593
|
+
For each CSS, classify block patterns into Webex categories.
|
|
594
|
+
Group users by identical restriction profile (frozenset of blocked categories).
|
|
595
|
+
Create one CanonicalCallingPermission per unique profile.
|
|
596
|
+
|
|
597
|
+
(from 04-css-decomposition.md Step 4)
|
|
598
|
+
"""
|
|
599
|
+
# Compute restriction profile per CSS
|
|
600
|
+
css_restriction_profiles: dict[str, frozenset[str]] = {}
|
|
601
|
+
css_unclassified: dict[str, list[str]] = {}
|
|
602
|
+
|
|
603
|
+
for css_id, graph in css_graphs.items():
|
|
604
|
+
blocked_categories: set[str] = set()
|
|
605
|
+
unclassified_patterns: list[str] = []
|
|
606
|
+
|
|
607
|
+
for partition in graph["partitions"]:
|
|
608
|
+
if partition["classification"] == BLOCKING:
|
|
609
|
+
for pat in partition["patterns"]:
|
|
610
|
+
if pat["action"] == "BLOCK":
|
|
611
|
+
category = classify_block_pattern(
|
|
612
|
+
pat["pattern"], self.category_rules
|
|
613
|
+
)
|
|
614
|
+
if category:
|
|
615
|
+
blocked_categories.add(category)
|
|
616
|
+
else:
|
|
617
|
+
unclassified_patterns.append(pat["pattern"])
|
|
618
|
+
|
|
619
|
+
css_restriction_profiles[css_id] = frozenset(blocked_categories)
|
|
620
|
+
if unclassified_patterns:
|
|
621
|
+
css_unclassified[css_id] = unclassified_patterns
|
|
622
|
+
|
|
623
|
+
# Generate CALLING_PERMISSION_MISMATCH decisions for unclassifiable patterns
|
|
624
|
+
for css_id, patterns in css_unclassified.items():
|
|
625
|
+
decision = self._create_decision(
|
|
626
|
+
store=store,
|
|
627
|
+
decision_type=DecisionType.CALLING_PERMISSION_MISMATCH,
|
|
628
|
+
severity="MEDIUM",
|
|
629
|
+
summary=(
|
|
630
|
+
f"CSS '{css_id}' has {len(patterns)} block pattern(s) "
|
|
631
|
+
f"that cannot be classified into Webex permission categories: "
|
|
632
|
+
f"{patterns}"
|
|
633
|
+
),
|
|
634
|
+
context={
|
|
635
|
+
"css_id": css_id,
|
|
636
|
+
"reason": "unclassifiable_block_patterns",
|
|
637
|
+
"unclassified_patterns": patterns,
|
|
638
|
+
},
|
|
639
|
+
options=[
|
|
640
|
+
accept_option(
|
|
641
|
+
"Accept that these blocking restrictions are lost in migration"
|
|
642
|
+
),
|
|
643
|
+
manual_option(
|
|
644
|
+
"Admin maps patterns to Webex categories or implements "
|
|
645
|
+
"custom blocking dial plan routes"
|
|
646
|
+
),
|
|
647
|
+
skip_option(
|
|
648
|
+
"Skip restriction — affected users get unrestricted access "
|
|
649
|
+
"for these call types"
|
|
650
|
+
),
|
|
651
|
+
],
|
|
652
|
+
affected_objects=[css_id],
|
|
653
|
+
)
|
|
654
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
655
|
+
result.decisions.append(decision)
|
|
656
|
+
|
|
657
|
+
# Group users by identical restriction profile.
|
|
658
|
+
# Use user_effective_css (includes line/device CSS fallback users),
|
|
659
|
+
# not just user_has_css cross-refs, so that users inheriting CSS
|
|
660
|
+
# via line or device also get calling permissions assigned.
|
|
661
|
+
profile_users: dict[frozenset[str], list[str]] = defaultdict(list)
|
|
662
|
+
for user_id, partitions in user_effective_css.items():
|
|
663
|
+
# Compute this user's restriction profile from their effective
|
|
664
|
+
# blocking partitions (same logic as per-CSS, but user-specific)
|
|
665
|
+
user_blocked: set[str] = set()
|
|
666
|
+
for pt_info in partitions:
|
|
667
|
+
if pt_info.get("classification") != "BLOCKING":
|
|
668
|
+
continue
|
|
669
|
+
for pat in pt_info.get("patterns", []):
|
|
670
|
+
cat = classify_block_pattern(
|
|
671
|
+
pat["pattern"], self.category_rules
|
|
672
|
+
)
|
|
673
|
+
if cat:
|
|
674
|
+
user_blocked.add(cat)
|
|
675
|
+
profile = frozenset(user_blocked)
|
|
676
|
+
profile_users[profile].append(user_id)
|
|
677
|
+
|
|
678
|
+
# Also add users from CSSes that have no users mapped via cross-refs
|
|
679
|
+
# (the profile still needs to be created)
|
|
680
|
+
|
|
681
|
+
# Create one CanonicalCallingPermission per unique restriction profile
|
|
682
|
+
for profile_idx, (blocked_categories, users) in enumerate(
|
|
683
|
+
profile_users.items()
|
|
684
|
+
):
|
|
685
|
+
# Deduplicate users
|
|
686
|
+
unique_users = sorted(set(users))
|
|
687
|
+
|
|
688
|
+
# Skip orphaned profiles — no users reference this CSS
|
|
689
|
+
if not unique_users:
|
|
690
|
+
logger.debug(
|
|
691
|
+
"Skipping orphaned calling permission profile "
|
|
692
|
+
"(categories: %s) — 0 users assigned",
|
|
693
|
+
",".join(sorted(blocked_categories)) if blocked_categories else "allow_all",
|
|
694
|
+
)
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
# Build the callingPermissions array
|
|
698
|
+
# All Webex call types get an entry; blocked categories get BLOCK action
|
|
699
|
+
permissions = []
|
|
700
|
+
for call_type in WEBEX_CALL_TYPES:
|
|
701
|
+
# Map our category names to Webex call type names
|
|
702
|
+
is_blocked = self._category_matches_call_type(
|
|
703
|
+
blocked_categories, call_type
|
|
704
|
+
)
|
|
705
|
+
permissions.append(
|
|
706
|
+
CallingPermissionEntry(
|
|
707
|
+
call_type=call_type,
|
|
708
|
+
action="BLOCK" if is_blocked else "ALLOW",
|
|
709
|
+
transfer_enabled=not is_blocked,
|
|
710
|
+
)
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
profile_hash = hash_id(
|
|
714
|
+
",".join(sorted(blocked_categories)) if blocked_categories else "allow_all"
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
permission = CanonicalCallingPermission(
|
|
718
|
+
canonical_id=f"calling_permission:{profile_hash}",
|
|
719
|
+
provenance=Provenance(
|
|
720
|
+
source_system="cucm",
|
|
721
|
+
source_id=f"css_restriction_profile_{profile_idx}",
|
|
722
|
+
source_name=(
|
|
723
|
+
f"Restriction-{','.join(sorted(blocked_categories))}"
|
|
724
|
+
if blocked_categories
|
|
725
|
+
else "Allow-All"
|
|
726
|
+
),
|
|
727
|
+
extracted_at=datetime.now(timezone.utc),
|
|
728
|
+
),
|
|
729
|
+
status=MigrationStatus.ANALYZED,
|
|
730
|
+
calling_permissions=permissions,
|
|
731
|
+
assigned_users=unique_users,
|
|
732
|
+
use_custom_enabled=bool(blocked_categories),
|
|
733
|
+
use_custom_permissions=bool(blocked_categories),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
store.upsert_object(permission)
|
|
737
|
+
result.objects_created += 1
|
|
738
|
+
|
|
739
|
+
@staticmethod
|
|
740
|
+
def _category_matches_call_type(
|
|
741
|
+
blocked_categories: frozenset[str],
|
|
742
|
+
call_type: str,
|
|
743
|
+
) -> bool:
|
|
744
|
+
"""Check if a Webex call type should be blocked based on blocked categories.
|
|
745
|
+
|
|
746
|
+
Maps the configurable category names (from category_rules) to Webex
|
|
747
|
+
OutgoingPermissionCallType enum values.
|
|
748
|
+
|
|
749
|
+
(from 03b-transform-mappers.md §7: callingPermissions field mapping)
|
|
750
|
+
"""
|
|
751
|
+
# Mapping from our category names to Webex call type names
|
|
752
|
+
# This is configurable via category_rules — the webex_category values
|
|
753
|
+
# in the rules should match these keys.
|
|
754
|
+
category_to_call_type: dict[str, list[str]] = {
|
|
755
|
+
"international": ["INTERNATIONAL"],
|
|
756
|
+
"premium": ["PREMIUM_SERVICES_I", "PREMIUM_SERVICES_II"],
|
|
757
|
+
"operator": ["OPERATOR_ASSISTED"],
|
|
758
|
+
"directory_assistance": ["CHARGEABLE_DIRECTORY_ASSISTED"],
|
|
759
|
+
"toll_free": ["TOLL_FREE"],
|
|
760
|
+
"national": ["NATIONAL"],
|
|
761
|
+
"special_services": ["SPECIAL_SERVICES_I", "SPECIAL_SERVICES_II"],
|
|
762
|
+
"internal": ["INTERNAL_CALL"],
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
for category in blocked_categories:
|
|
766
|
+
mapped_types = category_to_call_type.get(category, [])
|
|
767
|
+
if call_type in mapped_types:
|
|
768
|
+
return True
|
|
769
|
+
|
|
770
|
+
return False
|
|
771
|
+
|
|
772
|
+
# ------------------------------------------------------------------
|
|
773
|
+
# Step 5: Detect ordering conflicts
|
|
774
|
+
# ------------------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
def _detect_ordering_conflicts(
|
|
777
|
+
self,
|
|
778
|
+
store: MigrationStore,
|
|
779
|
+
css_graphs: dict[str, dict[str, Any]],
|
|
780
|
+
result: MapperResult,
|
|
781
|
+
) -> None:
|
|
782
|
+
"""Detect cases where CUCM partition ordering produces different behavior
|
|
783
|
+
than Webex's independent routing+permissions model.
|
|
784
|
+
|
|
785
|
+
(from 04-css-decomposition.md Step 5)
|
|
786
|
+
"""
|
|
787
|
+
for css_id, graph in css_graphs.items():
|
|
788
|
+
# Collect all patterns with their action and ordinal, sorted by ordinal
|
|
789
|
+
all_patterns: list[tuple[str, str, int]] = []
|
|
790
|
+
for partition in graph["partitions"]:
|
|
791
|
+
for pat in partition["patterns"]:
|
|
792
|
+
if pat["action"] in ("ROUTE", "BLOCK"):
|
|
793
|
+
all_patterns.append(
|
|
794
|
+
(pat["pattern"], pat["action"], partition["ordinal"])
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# Sort by ordinal (priority order)
|
|
798
|
+
all_patterns.sort(key=lambda x: x[2])
|
|
799
|
+
|
|
800
|
+
# Check each pair where a higher-priority pattern shadows a lower one
|
|
801
|
+
for i, (pat_a, action_a, prio_a) in enumerate(all_patterns):
|
|
802
|
+
for pat_b, action_b, prio_b in all_patterns[i + 1 :]:
|
|
803
|
+
if action_a == action_b:
|
|
804
|
+
continue # Same action, no conflict
|
|
805
|
+
if not cucm_patterns_overlap(pat_a, pat_b):
|
|
806
|
+
continue # No overlap, no shadowing
|
|
807
|
+
|
|
808
|
+
# Determine risk
|
|
809
|
+
if action_a == "ROUTE" and action_b == "BLOCK":
|
|
810
|
+
risk = "Webex MORE restrictive than CUCM"
|
|
811
|
+
cucm_behavior = (
|
|
812
|
+
f"ALLOWED (route '{pat_a}' at priority {prio_a} "
|
|
813
|
+
f"shadows block '{pat_b}' at priority {prio_b})"
|
|
814
|
+
)
|
|
815
|
+
webex_behavior = (
|
|
816
|
+
"BLOCKED (permissions enforced independently of routing)"
|
|
817
|
+
)
|
|
818
|
+
severity = "MEDIUM"
|
|
819
|
+
elif action_a == "BLOCK" and action_b == "ROUTE":
|
|
820
|
+
risk = "Webex LESS restrictive than CUCM"
|
|
821
|
+
cucm_behavior = (
|
|
822
|
+
f"BLOCKED (block '{pat_a}' at priority {prio_a} "
|
|
823
|
+
f"shadows route '{pat_b}' at priority {prio_b})"
|
|
824
|
+
)
|
|
825
|
+
webex_behavior = (
|
|
826
|
+
"ALLOWED (dial plan routes, no matching permission block)"
|
|
827
|
+
)
|
|
828
|
+
severity = "HIGH" # LESS restrictive is riskier
|
|
829
|
+
else:
|
|
830
|
+
continue
|
|
831
|
+
|
|
832
|
+
decision = self._create_decision(
|
|
833
|
+
store=store,
|
|
834
|
+
decision_type=DecisionType.CSS_ROUTING_MISMATCH,
|
|
835
|
+
severity=severity,
|
|
836
|
+
summary=(
|
|
837
|
+
f"Ordering conflict in CSS '{css_id}': "
|
|
838
|
+
f"{risk}. CUCM: {cucm_behavior}. "
|
|
839
|
+
f"Webex: {webex_behavior}."
|
|
840
|
+
),
|
|
841
|
+
context={
|
|
842
|
+
"css_id": css_id,
|
|
843
|
+
"reason": "ordering_conflict",
|
|
844
|
+
"shadowing_pattern": pat_a,
|
|
845
|
+
"shadowed_pattern": pat_b,
|
|
846
|
+
"shadowing_action": action_a,
|
|
847
|
+
"shadowed_action": action_b,
|
|
848
|
+
"shadowing_priority": prio_a,
|
|
849
|
+
"shadowed_priority": prio_b,
|
|
850
|
+
"risk": risk,
|
|
851
|
+
"cucm_behavior": cucm_behavior,
|
|
852
|
+
"webex_behavior": webex_behavior,
|
|
853
|
+
},
|
|
854
|
+
options=[
|
|
855
|
+
accept_option(
|
|
856
|
+
f"Accept Webex behavior ({risk})"
|
|
857
|
+
),
|
|
858
|
+
manual_option(
|
|
859
|
+
"Admin adjusts permissions to compensate "
|
|
860
|
+
"for ordering difference"
|
|
861
|
+
),
|
|
862
|
+
],
|
|
863
|
+
affected_objects=[css_id],
|
|
864
|
+
)
|
|
865
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
866
|
+
result.decisions.append(decision)
|
|
867
|
+
|
|
868
|
+
|