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,142 @@
|
|
|
1
|
+
"""Conflict detection analyzers for CUCM-to-Webex migration.
|
|
2
|
+
|
|
3
|
+
Linter-pattern analyzers that sweep the mapped inventory and produce
|
|
4
|
+
Decision objects for conflicts, ambiguities, and incompatibilities.
|
|
5
|
+
|
|
6
|
+
(from 03-conflict-detection-engine.md — linter pattern)
|
|
7
|
+
(from 07-idempotency-resumability.md — fingerprint-based identity)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from wxcli.migration.models import (
|
|
19
|
+
Decision,
|
|
20
|
+
DecisionOption,
|
|
21
|
+
DecisionType,
|
|
22
|
+
)
|
|
23
|
+
from wxcli.migration.store import MigrationStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Analyzer(ABC):
|
|
27
|
+
"""Base class for all conflict/decision analyzers.
|
|
28
|
+
|
|
29
|
+
Each analyzer sweeps the full inventory via MigrationStore and produces
|
|
30
|
+
Decision objects for patterns it detects. Analyzers are independent —
|
|
31
|
+
they don't call each other.
|
|
32
|
+
|
|
33
|
+
(from 03-conflict-detection-engine.md, Analyzer ABC)
|
|
34
|
+
(from 07-idempotency-resumability.md, fingerprint method requirement)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: str = ""
|
|
38
|
+
decision_types: list[DecisionType]
|
|
39
|
+
depends_on: list[str]
|
|
40
|
+
|
|
41
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
42
|
+
super().__init_subclass__(**kwargs)
|
|
43
|
+
# Default mutable class attrs to empty lists if not overridden
|
|
44
|
+
if "decision_types" not in cls.__dict__:
|
|
45
|
+
cls.decision_types = []
|
|
46
|
+
if "depends_on" not in cls.__dict__:
|
|
47
|
+
cls.depends_on = []
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
51
|
+
"""Sweep the inventory, return decisions found."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
56
|
+
"""Compute deterministic fingerprint from causal data.
|
|
57
|
+
|
|
58
|
+
MUST include:
|
|
59
|
+
- The triggering condition (e.g., which DN is shared)
|
|
60
|
+
- The identity of all affected objects (e.g., which owners/devices)
|
|
61
|
+
- Any data whose change would invalidate a previous resolution
|
|
62
|
+
|
|
63
|
+
MUST NOT include:
|
|
64
|
+
- Display-only fields (labels, descriptions)
|
|
65
|
+
- Timestamps, run IDs, or sequential counters
|
|
66
|
+
- Derived fields that could change without the condition changing
|
|
67
|
+
|
|
68
|
+
(from 07-idempotency-resumability.md lines 30-43)
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def _hash_fingerprint(self, data: dict[str, Any]) -> str:
|
|
73
|
+
"""Hash a dict into a 16-char hex fingerprint."""
|
|
74
|
+
canonical = json.dumps(data, sort_keys=True)
|
|
75
|
+
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
|
|
76
|
+
|
|
77
|
+
def _create_decision(
|
|
78
|
+
self,
|
|
79
|
+
store: MigrationStore,
|
|
80
|
+
decision_type: DecisionType,
|
|
81
|
+
severity: str,
|
|
82
|
+
summary: str,
|
|
83
|
+
context: dict[str, Any],
|
|
84
|
+
options: list[DecisionOption],
|
|
85
|
+
affected_objects: list[str],
|
|
86
|
+
) -> Decision:
|
|
87
|
+
"""Helper to create a well-formed Decision with auto-generated ID and fingerprint."""
|
|
88
|
+
ctx = dict(context)
|
|
89
|
+
if affected_objects:
|
|
90
|
+
ctx["_affected_objects"] = affected_objects
|
|
91
|
+
return Decision(
|
|
92
|
+
decision_id=store.next_decision_id(),
|
|
93
|
+
type=decision_type,
|
|
94
|
+
severity=severity,
|
|
95
|
+
summary=summary,
|
|
96
|
+
context=ctx,
|
|
97
|
+
options=options,
|
|
98
|
+
affected_objects=affected_objects,
|
|
99
|
+
fingerprint=self.fingerprint(decision_type, context),
|
|
100
|
+
run_id=store.current_run_id,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _get_existing_decisions_for_type(
|
|
104
|
+
self, store: MigrationStore, decision_type: DecisionType
|
|
105
|
+
) -> dict[str, dict[str, Any]]:
|
|
106
|
+
"""Get existing mapper-produced decisions indexed by affected object canonical_id.
|
|
107
|
+
|
|
108
|
+
Returns {canonical_id: decision_dict} for all decisions of the given type.
|
|
109
|
+
Used by analyzers to skip objects that already have mapper-produced decisions.
|
|
110
|
+
|
|
111
|
+
(from phase-06-analyzers.md, Decision Ownership Table)
|
|
112
|
+
"""
|
|
113
|
+
all_decisions = store.get_all_decisions()
|
|
114
|
+
result: dict[str, dict[str, Any]] = {}
|
|
115
|
+
for dec in all_decisions:
|
|
116
|
+
if dec.get("type") == decision_type.value:
|
|
117
|
+
affected = dec.get("context", {}).get("_affected_objects", [])
|
|
118
|
+
for obj_id in affected:
|
|
119
|
+
result[obj_id] = dec
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class AnalysisResult:
|
|
125
|
+
"""Aggregate result from running all analyzers.
|
|
126
|
+
|
|
127
|
+
(from 03-conflict-detection-engine.md, AnalysisPipeline.run return type)
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
decisions: list[Decision] = field(default_factory=list)
|
|
131
|
+
stats: dict[str, int] = field(default_factory=dict)
|
|
132
|
+
run_id: str = ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Re-export common types for analyzer implementations
|
|
136
|
+
__all__ = [
|
|
137
|
+
"Analyzer",
|
|
138
|
+
"AnalysisResult",
|
|
139
|
+
"Decision",
|
|
140
|
+
"DecisionOption",
|
|
141
|
+
"DecisionType",
|
|
142
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""CSS permission analyzer — finds unclassifiable calling permission patterns.
|
|
2
|
+
|
|
3
|
+
The css_mapper produces CanonicalCallingPermission objects and
|
|
4
|
+
CALLING_PERMISSION_MISMATCH decisions when block patterns can't be cleanly
|
|
5
|
+
classified into Webex call types (INTERNAL, NATIONAL, INTERNATIONAL, etc.).
|
|
6
|
+
This analyzer performs a cross-object sweep to find calling_permission objects
|
|
7
|
+
with incomplete or contradictory entries that the mapper didn't flag.
|
|
8
|
+
|
|
9
|
+
Decision ownership: MAPPER-OWNED (css_mapper produces CALLING_PERMISSION_MISMATCH).
|
|
10
|
+
This analyzer checks for existing mapper decisions before creating new ones.
|
|
11
|
+
|
|
12
|
+
(from 03-conflict-detection-engine.md, CSS permission conflict detection)
|
|
13
|
+
(from 03b-transform-mappers.md, css_mapper calling permissions)
|
|
14
|
+
(from 07-idempotency-resumability.md, fingerprint-based identity)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from wxcli.migration.models import DecisionOption, DecisionType
|
|
22
|
+
from wxcli.migration.store import MigrationStore
|
|
23
|
+
from wxcli.migration.transform.analyzers import Analyzer, Decision
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CSSPermissionAnalyzer(Analyzer):
|
|
27
|
+
"""Finds calling_permission objects with incomplete or contradictory entries.
|
|
28
|
+
|
|
29
|
+
The css_mapper creates CanonicalCallingPermission objects with a
|
|
30
|
+
calling_permissions list of CallingPermissionEntry dicts (call_type,
|
|
31
|
+
action, transfer_enabled). This analyzer detects:
|
|
32
|
+
|
|
33
|
+
- Empty calling_permissions list (no permissions mapped)
|
|
34
|
+
- Permissions that block INTERNAL_CALL (unusual, likely misconfigured)
|
|
35
|
+
- Permission objects that exist but have no assigned users
|
|
36
|
+
|
|
37
|
+
The css_mapper may already have produced CALLING_PERMISSION_MISMATCH
|
|
38
|
+
decisions. This analyzer only creates decisions for issues the mapper
|
|
39
|
+
missed.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name = "css_permission"
|
|
43
|
+
decision_types = [DecisionType.CALLING_PERMISSION_MISMATCH]
|
|
44
|
+
|
|
45
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
46
|
+
"""Sweep all calling_permission objects, flag incomplete or contradictory entries."""
|
|
47
|
+
decisions: list[Decision] = []
|
|
48
|
+
|
|
49
|
+
# Step 1: Get existing mapper-produced CALLING_PERMISSION_MISMATCH decisions
|
|
50
|
+
existing = self._get_existing_decisions_for_type(
|
|
51
|
+
store, DecisionType.CALLING_PERMISSION_MISMATCH
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Step 2: Load all calling_permission objects
|
|
55
|
+
permissions = store.get_objects("calling_permission")
|
|
56
|
+
if not permissions:
|
|
57
|
+
return decisions
|
|
58
|
+
|
|
59
|
+
# Step 3: Load user_has_calling_permission cross-refs to find assigned users
|
|
60
|
+
perm_rows = store.conn.execute(
|
|
61
|
+
"SELECT from_id, to_id FROM cross_refs "
|
|
62
|
+
"WHERE relationship = 'user_has_calling_permission'"
|
|
63
|
+
).fetchall()
|
|
64
|
+
|
|
65
|
+
perm_to_users: dict[str, list[str]] = {}
|
|
66
|
+
for row in perm_rows:
|
|
67
|
+
perm_id = row["to_id"]
|
|
68
|
+
if perm_id not in perm_to_users:
|
|
69
|
+
perm_to_users[perm_id] = []
|
|
70
|
+
perm_to_users[perm_id].append(row["from_id"])
|
|
71
|
+
|
|
72
|
+
# Step 4: Analyze each calling_permission object for issues
|
|
73
|
+
for perm in permissions:
|
|
74
|
+
canonical_id = perm.get("canonical_id", "")
|
|
75
|
+
if not canonical_id:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Skip if mapper already produced a decision for this object
|
|
79
|
+
if canonical_id in existing:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
calling_perms = perm.get("calling_permissions", [])
|
|
83
|
+
assigned_users = perm_to_users.get(canonical_id, [])
|
|
84
|
+
issues: list[str] = []
|
|
85
|
+
|
|
86
|
+
# Issue 1: Empty calling_permissions list
|
|
87
|
+
if not calling_perms:
|
|
88
|
+
issues.append(
|
|
89
|
+
"No calling permissions mapped — all call types will use defaults"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Issue 2: INTERNAL_CALL is blocked
|
|
93
|
+
for entry in calling_perms:
|
|
94
|
+
call_type = entry.get("call_type", "")
|
|
95
|
+
action = entry.get("action", "")
|
|
96
|
+
if call_type == "INTERNAL_CALL" and action.upper() == "BLOCK":
|
|
97
|
+
issues.append(
|
|
98
|
+
"INTERNAL_CALL is blocked — users cannot make internal calls, "
|
|
99
|
+
"likely a misconfiguration"
|
|
100
|
+
)
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
# Note: orphaned permission objects (0 users assigned) are silently
|
|
104
|
+
# dropped by the CSS mapper and never reach the analyzer.
|
|
105
|
+
|
|
106
|
+
if not issues:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Determine severity
|
|
110
|
+
has_internal_block = any(
|
|
111
|
+
"INTERNAL_CALL is blocked" in issue for issue in issues
|
|
112
|
+
)
|
|
113
|
+
severity = "HIGH" if has_internal_block else "MEDIUM"
|
|
114
|
+
|
|
115
|
+
# Build calling_permissions summary for context
|
|
116
|
+
perms_summary = []
|
|
117
|
+
for entry in calling_perms:
|
|
118
|
+
perms_summary.append({
|
|
119
|
+
"call_type": entry.get("call_type", ""),
|
|
120
|
+
"action": entry.get("action", ""),
|
|
121
|
+
"transfer_enabled": entry.get("transfer_enabled", False),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
context = {
|
|
125
|
+
"permission_canonical_id": canonical_id,
|
|
126
|
+
"issues": sorted(issues),
|
|
127
|
+
"calling_permissions": perms_summary,
|
|
128
|
+
"assigned_users_count": len(assigned_users),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
perm_name = perm.get("name", canonical_id)
|
|
132
|
+
|
|
133
|
+
options = [
|
|
134
|
+
DecisionOption(
|
|
135
|
+
id="accept",
|
|
136
|
+
label="Accept Current Mapping",
|
|
137
|
+
impact=(
|
|
138
|
+
f"Keep calling permissions for '{perm_name}' as mapped — "
|
|
139
|
+
f"{len(calling_perms)} entries, {len(assigned_users)} users"
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
DecisionOption(
|
|
143
|
+
id="manual",
|
|
144
|
+
label="Manual Configuration",
|
|
145
|
+
impact=(
|
|
146
|
+
f"Manually configure calling permissions for '{perm_name}' "
|
|
147
|
+
f"in Webex Calling"
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
DecisionOption(
|
|
151
|
+
id="skip",
|
|
152
|
+
label="Skip",
|
|
153
|
+
impact=(
|
|
154
|
+
f"Leave calling permissions for '{perm_name}' unresolved — "
|
|
155
|
+
f"manual intervention required"
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
issue_summary = "; ".join(issues)
|
|
161
|
+
decision = self._create_decision(
|
|
162
|
+
store=store,
|
|
163
|
+
decision_type=DecisionType.CALLING_PERMISSION_MISMATCH,
|
|
164
|
+
severity=severity,
|
|
165
|
+
summary=(
|
|
166
|
+
f"Calling permission '{perm_name}' has issues: {issue_summary}"
|
|
167
|
+
),
|
|
168
|
+
context=context,
|
|
169
|
+
options=options,
|
|
170
|
+
affected_objects=[canonical_id],
|
|
171
|
+
)
|
|
172
|
+
decisions.append(decision)
|
|
173
|
+
|
|
174
|
+
return decisions
|
|
175
|
+
|
|
176
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
177
|
+
"""Deterministic fingerprint from canonical_id and sorted issues.
|
|
178
|
+
|
|
179
|
+
Includes:
|
|
180
|
+
- permission_canonical_id (the object with the problem)
|
|
181
|
+
- sorted list of issues (the triggering conditions)
|
|
182
|
+
|
|
183
|
+
(from 07-idempotency-resumability.md lines 30-43)
|
|
184
|
+
"""
|
|
185
|
+
return self._hash_fingerprint({
|
|
186
|
+
"type": decision_type.value,
|
|
187
|
+
"permission_canonical_id": context.get("permission_canonical_id"),
|
|
188
|
+
"issues": sorted(context.get("issues", [])),
|
|
189
|
+
})
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""CSS routing analyzer — finds overlapping/conflicting dial plan patterns.
|
|
2
|
+
|
|
3
|
+
The css_mapper produces CanonicalDialPlan objects and CSS_ROUTING_MISMATCH
|
|
4
|
+
decisions when it detects routing scope differences between CSSes assigned to
|
|
5
|
+
users. This analyzer performs a cross-object sweep to find routing mismatches
|
|
6
|
+
NOT covered by the mapper — specifically, dial_plan objects that contain
|
|
7
|
+
conflicting or overlapping patterns (same pattern pointing to different routes).
|
|
8
|
+
|
|
9
|
+
Decision ownership: MAPPER-OWNED (css_mapper produces CSS_ROUTING_MISMATCH).
|
|
10
|
+
This analyzer checks for existing mapper decisions before creating new ones.
|
|
11
|
+
|
|
12
|
+
(from 03-conflict-detection-engine.md, CSS routing conflict detection)
|
|
13
|
+
(from 03b-transform-mappers.md, css_mapper dial plan routing)
|
|
14
|
+
(from 07-idempotency-resumability.md, fingerprint-based identity)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from wxcli.migration.models import DecisionOption, DecisionType
|
|
24
|
+
from wxcli.migration.store import MigrationStore
|
|
25
|
+
from wxcli.migration.transform.analyzers import Analyzer, Decision
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CSSRoutingAnalyzer(Analyzer):
|
|
29
|
+
"""Finds conflicting dial plan patterns across CSS routing scopes.
|
|
30
|
+
|
|
31
|
+
CUCM allows the same dial pattern to appear in multiple dial plans
|
|
32
|
+
pointing to different routes (separated by CSS scope). When these
|
|
33
|
+
are flattened for Webex Calling, overlapping patterns with different
|
|
34
|
+
routes create ambiguity.
|
|
35
|
+
|
|
36
|
+
The css_mapper may already have produced CSS_ROUTING_MISMATCH decisions
|
|
37
|
+
for some dial plans. This analyzer only creates decisions for conflicts
|
|
38
|
+
that the mapper missed, acting as a cross-object safety net.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name = "css_routing"
|
|
42
|
+
decision_types = [DecisionType.CSS_ROUTING_MISMATCH]
|
|
43
|
+
|
|
44
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
45
|
+
"""Sweep all dial_plan objects, find overlapping patterns with different routes."""
|
|
46
|
+
decisions: list[Decision] = []
|
|
47
|
+
|
|
48
|
+
# Step 1: Get existing mapper-produced CSS_ROUTING_MISMATCH decisions
|
|
49
|
+
existing = self._get_existing_decisions_for_type(
|
|
50
|
+
store, DecisionType.CSS_ROUTING_MISMATCH
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Step 2: Load all dial_plan objects
|
|
54
|
+
dial_plans = store.get_objects("dial_plan")
|
|
55
|
+
if not dial_plans:
|
|
56
|
+
return decisions
|
|
57
|
+
|
|
58
|
+
# Step 3: Build a map of pattern → [(dial_plan_id, route_id)]
|
|
59
|
+
# CanonicalDialPlan.dial_patterns is list[str] and route_id is a
|
|
60
|
+
# top-level field on the dial plan object. Each dial plan maps all
|
|
61
|
+
# its patterns to the same route.
|
|
62
|
+
# (from models.py: CanonicalDialPlan.dial_patterns: list[str],
|
|
63
|
+
# route_id: str, route_type: str)
|
|
64
|
+
pattern_routes: dict[str, list[dict[str, str]]] = defaultdict(list)
|
|
65
|
+
|
|
66
|
+
dial_plan_by_id: dict[str, dict[str, Any]] = {}
|
|
67
|
+
for dp in dial_plans:
|
|
68
|
+
dp_id = dp.get("canonical_id", "")
|
|
69
|
+
if not dp_id:
|
|
70
|
+
continue
|
|
71
|
+
dial_plan_by_id[dp_id] = dp
|
|
72
|
+
|
|
73
|
+
route_id = dp.get("route_id", "") or ""
|
|
74
|
+
dp_patterns = dp.get("dial_patterns", [])
|
|
75
|
+
for pattern in dp_patterns:
|
|
76
|
+
# dial_patterns is list[str] — each entry is a pattern string
|
|
77
|
+
if isinstance(pattern, str) and pattern:
|
|
78
|
+
pattern_routes[pattern].append({
|
|
79
|
+
"dial_plan_id": dp_id,
|
|
80
|
+
"route_id": route_id,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
# Step 4: Find patterns that appear in multiple dial plans with different routes
|
|
84
|
+
for pattern, entries in sorted(pattern_routes.items()):
|
|
85
|
+
if len(entries) < 2:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Check if routes actually differ
|
|
89
|
+
unique_routes = set(e["route_id"] for e in entries)
|
|
90
|
+
if len(unique_routes) < 2:
|
|
91
|
+
continue # Same route everywhere — no conflict
|
|
92
|
+
|
|
93
|
+
# Collect the dial_plan IDs involved in this conflict
|
|
94
|
+
involved_dp_ids = sorted(set(e["dial_plan_id"] for e in entries))
|
|
95
|
+
|
|
96
|
+
# Skip if ALL involved dial plans already have mapper decisions
|
|
97
|
+
all_covered = all(dp_id in existing for dp_id in involved_dp_ids)
|
|
98
|
+
if all_covered:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# Build scope description from dial plan names
|
|
102
|
+
scope_parts = []
|
|
103
|
+
for dp_id in involved_dp_ids:
|
|
104
|
+
dp_data = dial_plan_by_id.get(dp_id, {})
|
|
105
|
+
dp_name = dp_data.get("name", dp_id)
|
|
106
|
+
route_ids_for_dp = [
|
|
107
|
+
e["route_id"] for e in entries if e["dial_plan_id"] == dp_id
|
|
108
|
+
]
|
|
109
|
+
scope_parts.append(
|
|
110
|
+
f"{dp_name} -> {', '.join(route_ids_for_dp)}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
context = {
|
|
114
|
+
"dial_plan_ids": involved_dp_ids,
|
|
115
|
+
"conflicting_pattern": pattern,
|
|
116
|
+
"route_entries": entries,
|
|
117
|
+
"scope_description": "; ".join(scope_parts),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
options = [
|
|
121
|
+
DecisionOption(
|
|
122
|
+
id="use_union",
|
|
123
|
+
label="Union All Routing Scopes",
|
|
124
|
+
impact=(
|
|
125
|
+
f"Merge routing for pattern '{pattern}' across "
|
|
126
|
+
f"{len(involved_dp_ids)} dial plans — all routes reachable"
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
DecisionOption(
|
|
130
|
+
id="use_intersection",
|
|
131
|
+
label="Most Restrictive Scope",
|
|
132
|
+
impact=(
|
|
133
|
+
f"Keep only routes common to all dial plans for "
|
|
134
|
+
f"pattern '{pattern}' — most restrictive"
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
DecisionOption(
|
|
138
|
+
id="manual",
|
|
139
|
+
label="Manual Resolution",
|
|
140
|
+
impact=(
|
|
141
|
+
f"Manually configure routing for pattern '{pattern}' "
|
|
142
|
+
f"in Webex Calling"
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
DecisionOption(
|
|
146
|
+
id="skip",
|
|
147
|
+
label="Skip",
|
|
148
|
+
impact=(
|
|
149
|
+
f"Leave pattern '{pattern}' unresolved — "
|
|
150
|
+
f"manual intervention required"
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
decision = self._create_decision(
|
|
156
|
+
store=store,
|
|
157
|
+
decision_type=DecisionType.CSS_ROUTING_MISMATCH,
|
|
158
|
+
severity="HIGH",
|
|
159
|
+
summary=(
|
|
160
|
+
f"Pattern '{pattern}' routes to {len(unique_routes)} "
|
|
161
|
+
f"different destinations across {len(involved_dp_ids)} "
|
|
162
|
+
f"dial plans"
|
|
163
|
+
),
|
|
164
|
+
context=context,
|
|
165
|
+
options=options,
|
|
166
|
+
affected_objects=involved_dp_ids,
|
|
167
|
+
)
|
|
168
|
+
decisions.append(decision)
|
|
169
|
+
|
|
170
|
+
# Step 5: Check for user CSS assignments with divergent routing scopes
|
|
171
|
+
# Query cross_refs for user_has_css to find users grouped by CSS
|
|
172
|
+
css_rows = store.conn.execute(
|
|
173
|
+
"SELECT from_id, to_id FROM cross_refs WHERE relationship = 'user_has_css'"
|
|
174
|
+
).fetchall()
|
|
175
|
+
|
|
176
|
+
if css_rows:
|
|
177
|
+
# Group users by CSS
|
|
178
|
+
css_to_users: dict[str, list[str]] = defaultdict(list)
|
|
179
|
+
for row in css_rows:
|
|
180
|
+
css_to_users[row["to_id"]].append(row["from_id"])
|
|
181
|
+
|
|
182
|
+
# For each pair of CSSes, check if they reference dial plans with conflicting routes
|
|
183
|
+
css_ids = sorted(css_to_users.keys())
|
|
184
|
+
css_dial_plans: dict[str, set[str]] = defaultdict(set)
|
|
185
|
+
|
|
186
|
+
# Get CSS → dial_plan cross_refs
|
|
187
|
+
css_dp_rows = store.conn.execute(
|
|
188
|
+
"SELECT from_id, to_id FROM cross_refs WHERE relationship = 'css_has_dial_plan'"
|
|
189
|
+
).fetchall()
|
|
190
|
+
for row in css_dp_rows:
|
|
191
|
+
css_dial_plans[row["from_id"]].add(row["to_id"])
|
|
192
|
+
|
|
193
|
+
# Compare routing scopes between CSSes that have users
|
|
194
|
+
# Two CSSes with different dial plan memberships assigned to users
|
|
195
|
+
# in the same location indicate a routing scope mismatch
|
|
196
|
+
user_objects = store.get_objects("user")
|
|
197
|
+
user_location: dict[str, str] = {}
|
|
198
|
+
for user in user_objects:
|
|
199
|
+
uid = user.get("canonical_id", "")
|
|
200
|
+
loc = user.get("location_id", "") # CanonicalUser.location_id
|
|
201
|
+
if uid and loc:
|
|
202
|
+
user_location[uid] = loc
|
|
203
|
+
|
|
204
|
+
# Group CSSes by location (via their assigned users)
|
|
205
|
+
location_csses: dict[str, set[str]] = defaultdict(set)
|
|
206
|
+
for css_id, user_ids in css_to_users.items():
|
|
207
|
+
for uid in user_ids:
|
|
208
|
+
loc = user_location.get(uid)
|
|
209
|
+
if loc:
|
|
210
|
+
location_csses[loc].add(css_id)
|
|
211
|
+
|
|
212
|
+
# For each location with multiple CSSes, check for dial plan divergence
|
|
213
|
+
for loc_id, loc_css_ids in sorted(location_csses.items()):
|
|
214
|
+
if len(loc_css_ids) < 2:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
loc_css_list = sorted(loc_css_ids)
|
|
218
|
+
# Compute the set of dial plans for each CSS
|
|
219
|
+
dp_sets = {
|
|
220
|
+
css_id: css_dial_plans.get(css_id, set())
|
|
221
|
+
for css_id in loc_css_list
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# Find CSSes with non-identical dial plan sets
|
|
225
|
+
unique_dp_combos = set(
|
|
226
|
+
frozenset(dps) for dps in dp_sets.values()
|
|
227
|
+
)
|
|
228
|
+
if len(unique_dp_combos) < 2:
|
|
229
|
+
continue # All CSSes in this location have the same dial plans
|
|
230
|
+
|
|
231
|
+
all_css_dp_ids = sorted(
|
|
232
|
+
set().union(*(dps for dps in dp_sets.values()))
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Skip if all involved objects already have mapper decisions
|
|
236
|
+
all_covered = all(
|
|
237
|
+
dp_id in existing for dp_id in all_css_dp_ids
|
|
238
|
+
) and all(
|
|
239
|
+
css_id in existing for css_id in loc_css_list
|
|
240
|
+
)
|
|
241
|
+
if all_covered:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
css_scope_desc = []
|
|
245
|
+
for css_id in loc_css_list:
|
|
246
|
+
dps = sorted(dp_sets.get(css_id, set()))
|
|
247
|
+
user_count = len(css_to_users.get(css_id, []))
|
|
248
|
+
css_scope_desc.append(
|
|
249
|
+
f"CSS {css_id} ({user_count} users): "
|
|
250
|
+
f"dial_plans=[{', '.join(dps)}]"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
scope_context = {
|
|
254
|
+
"dial_plan_ids": all_css_dp_ids,
|
|
255
|
+
"css_ids": loc_css_list,
|
|
256
|
+
"location_id": loc_id,
|
|
257
|
+
"scope_description": "; ".join(css_scope_desc),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
scope_options = [
|
|
261
|
+
DecisionOption(
|
|
262
|
+
id="use_union",
|
|
263
|
+
label="Union All Routing Scopes",
|
|
264
|
+
impact=(
|
|
265
|
+
f"Merge all {len(all_css_dp_ids)} dial plans into a "
|
|
266
|
+
f"single routing scope for location {loc_id}"
|
|
267
|
+
),
|
|
268
|
+
),
|
|
269
|
+
DecisionOption(
|
|
270
|
+
id="use_intersection",
|
|
271
|
+
label="Most Restrictive Scope",
|
|
272
|
+
impact=(
|
|
273
|
+
f"Use only dial plans common to all CSSes in "
|
|
274
|
+
f"location {loc_id}"
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
DecisionOption(
|
|
278
|
+
id="manual",
|
|
279
|
+
label="Manual Resolution",
|
|
280
|
+
impact=(
|
|
281
|
+
f"Manually configure routing for users in "
|
|
282
|
+
f"location {loc_id}"
|
|
283
|
+
),
|
|
284
|
+
),
|
|
285
|
+
DecisionOption(
|
|
286
|
+
id="skip",
|
|
287
|
+
label="Skip",
|
|
288
|
+
impact="Leave routing scope divergence unresolved",
|
|
289
|
+
),
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
affected = sorted(set(all_css_dp_ids + loc_css_list))
|
|
293
|
+
|
|
294
|
+
decision = self._create_decision(
|
|
295
|
+
store=store,
|
|
296
|
+
decision_type=DecisionType.CSS_ROUTING_MISMATCH,
|
|
297
|
+
severity="HIGH",
|
|
298
|
+
summary=(
|
|
299
|
+
f"{len(loc_css_list)} CSSes in location {loc_id} have "
|
|
300
|
+
f"divergent routing scopes ({len(all_css_dp_ids)} "
|
|
301
|
+
f"dial plans involved)"
|
|
302
|
+
),
|
|
303
|
+
context=scope_context,
|
|
304
|
+
options=scope_options,
|
|
305
|
+
affected_objects=affected,
|
|
306
|
+
)
|
|
307
|
+
decisions.append(decision)
|
|
308
|
+
|
|
309
|
+
return decisions
|
|
310
|
+
|
|
311
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
312
|
+
"""Deterministic fingerprint from dial plan IDs and conflicting pattern details.
|
|
313
|
+
|
|
314
|
+
Includes:
|
|
315
|
+
- sorted dial_plan canonical_ids
|
|
316
|
+
- conflicting_pattern (if present — pattern-level conflict)
|
|
317
|
+
- css_ids + location_id (if present — scope-level conflict)
|
|
318
|
+
|
|
319
|
+
(from 07-idempotency-resumability.md lines 30-43)
|
|
320
|
+
"""
|
|
321
|
+
data: dict[str, Any] = {
|
|
322
|
+
"type": decision_type.value,
|
|
323
|
+
"dial_plan_ids": sorted(context.get("dial_plan_ids", [])),
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# Pattern-level conflict
|
|
327
|
+
if "conflicting_pattern" in context:
|
|
328
|
+
data["conflicting_pattern"] = context["conflicting_pattern"]
|
|
329
|
+
|
|
330
|
+
# Scope-level conflict (CSS divergence)
|
|
331
|
+
if "css_ids" in context:
|
|
332
|
+
data["css_ids"] = sorted(context["css_ids"])
|
|
333
|
+
data["location_id"] = context.get("location_id")
|
|
334
|
+
|
|
335
|
+
return self._hash_fingerprint(data)
|