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,300 @@
|
|
|
1
|
+
"""Missing data analyzer for CUCM-to-Webex migration.
|
|
2
|
+
|
|
3
|
+
Sweeps the inventory for objects with null/empty required fields
|
|
4
|
+
and produces MISSING_DATA decisions so users can supply the data
|
|
5
|
+
or choose to skip the object.
|
|
6
|
+
|
|
7
|
+
MISSING_DATA is a mapper-owned decision type — multiple mappers
|
|
8
|
+
can produce these decisions. This analyzer checks for existing
|
|
9
|
+
mapper decisions before creating new ones.
|
|
10
|
+
|
|
11
|
+
(from 03-conflict-detection-engine.md, linter pattern)
|
|
12
|
+
(from phase-06-analyzers.md, Decision Ownership Table — MISSING_DATA is mapper-owned)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from wxcli.migration.models import DecisionOption, DecisionType
|
|
20
|
+
from wxcli.migration.store import MigrationStore
|
|
21
|
+
from wxcli.migration.transform.analyzers import Analyzer, Decision
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Required-field definitions per object type
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
# Each entry: (field_name, check_fn, severity, description)
|
|
29
|
+
# check_fn receives the field value and returns True if the value is MISSING.
|
|
30
|
+
|
|
31
|
+
def _is_empty_list(val: Any) -> bool:
|
|
32
|
+
return val is None or (isinstance(val, list) and len(val) == 0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_none(val: Any) -> bool:
|
|
36
|
+
return val is None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_none_or_blank(val: Any) -> bool:
|
|
40
|
+
return val is None or (isinstance(val, str) and val.strip() == "")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_nested(obj: dict, dotted_key: str) -> Any:
|
|
44
|
+
"""Retrieve a value from a dict using dot-notation (e.g., 'address.country')."""
|
|
45
|
+
parts = dotted_key.split(".")
|
|
46
|
+
val = obj
|
|
47
|
+
for p in parts:
|
|
48
|
+
if isinstance(val, dict):
|
|
49
|
+
val = val.get(p)
|
|
50
|
+
else:
|
|
51
|
+
return None
|
|
52
|
+
return val
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_REQUIRED_FIELDS: dict[str, list[tuple[str, Any, str, str]]] = {
|
|
56
|
+
"user": [
|
|
57
|
+
("emails", _is_empty_list, "HIGH", "Email address required for Webex identity"),
|
|
58
|
+
("first_name", _is_none_or_blank, "MEDIUM", "First or last name required"),
|
|
59
|
+
("last_name", _is_none_or_blank, "MEDIUM", "First or last name required"),
|
|
60
|
+
# Fix 5: location_id required for Webex Calling provisioning
|
|
61
|
+
("location_id", _is_none_or_blank, "HIGH", "Location assignment required for Webex Calling"),
|
|
62
|
+
],
|
|
63
|
+
"device": [
|
|
64
|
+
("mac", _is_none_or_blank, "MEDIUM", "MAC address required for device migration"),
|
|
65
|
+
("owner_canonical_id", _is_none_or_blank, "MEDIUM", "Device owner required for assignment"),
|
|
66
|
+
],
|
|
67
|
+
"trunk": [
|
|
68
|
+
("address", _is_none_or_blank, "MEDIUM", "FQDN/SRV address required for trunk registration"),
|
|
69
|
+
# password NOT checked — Webex Calling trunks use certificate-based TLS,
|
|
70
|
+
# not SIP password auth. CUCM never exposes passwords via AXL anyway.
|
|
71
|
+
],
|
|
72
|
+
"location": [
|
|
73
|
+
("name", _is_none_or_blank, "MEDIUM", "Location name required"),
|
|
74
|
+
# Fix 4: Address fields — E911 compliance
|
|
75
|
+
("address.country", _is_none_or_blank, "HIGH", "Country required for E911 compliance"),
|
|
76
|
+
("address.address1", _is_none_or_blank, "HIGH", "Street address required for E911 compliance"),
|
|
77
|
+
("address.city", _is_none_or_blank, "HIGH", "City required for E911 compliance"),
|
|
78
|
+
("address.state", _is_none_or_blank, "HIGH", "State/province required for E911 compliance"),
|
|
79
|
+
("address.postal_code", _is_none_or_blank, "MEDIUM", "Postal code required for full address"),
|
|
80
|
+
],
|
|
81
|
+
# Fix 6: Translation pattern required fields
|
|
82
|
+
"translation_pattern": [
|
|
83
|
+
("matching_pattern", _is_none_or_blank, "HIGH", "Pattern required for translation"),
|
|
84
|
+
("name", _is_none_or_blank, "MEDIUM", "Name required for identification"),
|
|
85
|
+
],
|
|
86
|
+
# Fix 11: Route group required fields
|
|
87
|
+
"route_group": [
|
|
88
|
+
("local_gateways", _is_empty_list, "HIGH", "At least one gateway required"),
|
|
89
|
+
],
|
|
90
|
+
# Lines handled separately in _check_lines() — extension is only
|
|
91
|
+
# required for EXTENSION-classified lines (special logic).
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MissingDataAnalyzer(Analyzer):
|
|
96
|
+
"""Finds objects where required fields are null or empty.
|
|
97
|
+
|
|
98
|
+
For users: checks emails (HIGH — blocks identity provisioning), first_name/last_name.
|
|
99
|
+
For users with name fields: only flags if BOTH first_name AND last_name are missing
|
|
100
|
+
(having either one is acceptable).
|
|
101
|
+
|
|
102
|
+
For devices: checks mac and owner_canonical_id.
|
|
103
|
+
For trunks: checks address and password.
|
|
104
|
+
For locations: checks name.
|
|
105
|
+
For lines: checks extension (only when classification == EXTENSION).
|
|
106
|
+
|
|
107
|
+
(from 03-conflict-detection-engine.md, MissingDataAnalyzer)
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
name = "missing_data"
|
|
111
|
+
decision_types = [DecisionType.MISSING_DATA]
|
|
112
|
+
|
|
113
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
114
|
+
"""Sweep all object types for missing required fields."""
|
|
115
|
+
# Collect existing mapper-produced MISSING_DATA decisions so we skip
|
|
116
|
+
# objects that already have one.
|
|
117
|
+
existing = self._get_existing_decisions_for_type(store, DecisionType.MISSING_DATA)
|
|
118
|
+
|
|
119
|
+
decisions: list[Decision] = []
|
|
120
|
+
|
|
121
|
+
for object_type, field_defs in _REQUIRED_FIELDS.items():
|
|
122
|
+
objects = store.get_objects(object_type)
|
|
123
|
+
for obj in objects:
|
|
124
|
+
canonical_id = obj.get("canonical_id", "")
|
|
125
|
+
|
|
126
|
+
# Skip if mapper already produced a MISSING_DATA decision
|
|
127
|
+
if canonical_id in existing:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Webex App devices (Jabber, CSF, Dual Mode, IP Communicator)
|
|
131
|
+
# and infrastructure devices (CTI, CER, gateways) don't need
|
|
132
|
+
# a MAC address or owner — they aren't migrated as Webex devices.
|
|
133
|
+
if object_type == "device" and obj.get("compatibility_tier") in ("webex_app", "infrastructure", "dect"):
|
|
134
|
+
continue # Skip all missing-data checks for non-migratable devices
|
|
135
|
+
else:
|
|
136
|
+
active_fields = field_defs
|
|
137
|
+
|
|
138
|
+
missing = self._check_object(object_type, obj, active_fields)
|
|
139
|
+
if not missing:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
# Determine severity — take the highest among missing fields
|
|
143
|
+
severity = self._highest_severity(missing)
|
|
144
|
+
missing_names = sorted([m[0] for m in missing])
|
|
145
|
+
|
|
146
|
+
summary = (
|
|
147
|
+
f"{object_type} '{canonical_id}' missing required fields: "
|
|
148
|
+
f"{', '.join(missing_names)}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
context = {
|
|
152
|
+
"object_type": object_type,
|
|
153
|
+
"canonical_id": canonical_id,
|
|
154
|
+
"missing_fields": missing_names,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
options = [
|
|
158
|
+
DecisionOption(
|
|
159
|
+
id="provide_data",
|
|
160
|
+
label="Provide missing data",
|
|
161
|
+
impact=f"User will supply {len(missing_names)} missing field(s)",
|
|
162
|
+
),
|
|
163
|
+
DecisionOption(
|
|
164
|
+
id="skip",
|
|
165
|
+
label="Skip this object",
|
|
166
|
+
impact=f"1 {object_type} excluded from migration",
|
|
167
|
+
),
|
|
168
|
+
DecisionOption(
|
|
169
|
+
id="manual",
|
|
170
|
+
label="Handle manually post-migration",
|
|
171
|
+
impact=f"Object migrated without {', '.join(missing_names)}; fix manually",
|
|
172
|
+
),
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
decision = self._create_decision(
|
|
176
|
+
store=store,
|
|
177
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
178
|
+
severity=severity,
|
|
179
|
+
summary=summary,
|
|
180
|
+
context=context,
|
|
181
|
+
options=options,
|
|
182
|
+
affected_objects=[canonical_id],
|
|
183
|
+
)
|
|
184
|
+
decisions.append(decision)
|
|
185
|
+
|
|
186
|
+
# Handle lines separately (extension-only check for EXTENSION lines)
|
|
187
|
+
decisions.extend(self._check_lines(store, existing))
|
|
188
|
+
|
|
189
|
+
return decisions
|
|
190
|
+
|
|
191
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
192
|
+
"""Deterministic fingerprint from canonical_id + sorted missing fields."""
|
|
193
|
+
return self._hash_fingerprint({
|
|
194
|
+
"type": decision_type.value,
|
|
195
|
+
"canonical_id": context.get("canonical_id", ""),
|
|
196
|
+
"missing_fields": sorted(context.get("missing_fields", [])),
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Internal helpers
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _check_object(
|
|
205
|
+
object_type: str,
|
|
206
|
+
obj: dict[str, Any],
|
|
207
|
+
field_defs: list[tuple[str, Any, str, str]],
|
|
208
|
+
) -> list[tuple[str, str, str]]:
|
|
209
|
+
"""Return list of (field_name, severity, description) for missing fields.
|
|
210
|
+
|
|
211
|
+
Special case for user: first_name/last_name only flagged when BOTH missing.
|
|
212
|
+
Supports dot-notation for nested fields (e.g., 'address.country').
|
|
213
|
+
"""
|
|
214
|
+
missing: list[tuple[str, str, str]] = []
|
|
215
|
+
|
|
216
|
+
if object_type == "user":
|
|
217
|
+
# Email and location_id are always required
|
|
218
|
+
for field_name, check_fn, severity, desc in field_defs:
|
|
219
|
+
if field_name in ("emails", "location_id"):
|
|
220
|
+
val = _get_nested(obj, field_name) if "." in field_name else obj.get(field_name)
|
|
221
|
+
if check_fn(val):
|
|
222
|
+
missing.append((field_name, severity, desc))
|
|
223
|
+
# Name: flag only if both first_name AND last_name are missing
|
|
224
|
+
first = obj.get("first_name")
|
|
225
|
+
last = obj.get("last_name")
|
|
226
|
+
if _is_none_or_blank(first) and _is_none_or_blank(last):
|
|
227
|
+
missing.append(("first_name/last_name", "MEDIUM", "First or last name required"))
|
|
228
|
+
else:
|
|
229
|
+
for field_name, check_fn, severity, desc in field_defs:
|
|
230
|
+
# Support dot-notation for nested fields (Fix 4)
|
|
231
|
+
val = _get_nested(obj, field_name) if "." in field_name else obj.get(field_name)
|
|
232
|
+
if check_fn(val):
|
|
233
|
+
missing.append((field_name, severity, desc))
|
|
234
|
+
|
|
235
|
+
return missing
|
|
236
|
+
|
|
237
|
+
def _check_lines(
|
|
238
|
+
self,
|
|
239
|
+
store: MigrationStore,
|
|
240
|
+
existing: dict[str, dict[str, Any]],
|
|
241
|
+
) -> list[Decision]:
|
|
242
|
+
"""Check lines: extension is required only for EXTENSION-classified lines."""
|
|
243
|
+
decisions: list[Decision] = []
|
|
244
|
+
lines = store.get_objects("line")
|
|
245
|
+
|
|
246
|
+
for line in lines:
|
|
247
|
+
canonical_id = line.get("canonical_id", "")
|
|
248
|
+
if canonical_id in existing:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
classification = line.get("classification")
|
|
252
|
+
extension = line.get("extension")
|
|
253
|
+
|
|
254
|
+
# Only flag missing extension for EXTENSION-classified lines
|
|
255
|
+
if classification == "EXTENSION" and _is_none_or_blank(extension):
|
|
256
|
+
context = {
|
|
257
|
+
"object_type": "line",
|
|
258
|
+
"canonical_id": canonical_id,
|
|
259
|
+
"missing_fields": ["extension"],
|
|
260
|
+
}
|
|
261
|
+
decision = self._create_decision(
|
|
262
|
+
store=store,
|
|
263
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
264
|
+
severity="MEDIUM",
|
|
265
|
+
summary=(
|
|
266
|
+
f"line '{canonical_id}' classified as EXTENSION but extension is empty"
|
|
267
|
+
),
|
|
268
|
+
context=context,
|
|
269
|
+
options=[
|
|
270
|
+
DecisionOption(
|
|
271
|
+
id="provide_data",
|
|
272
|
+
label="Provide extension number",
|
|
273
|
+
impact="User will supply the extension value",
|
|
274
|
+
),
|
|
275
|
+
DecisionOption(
|
|
276
|
+
id="skip",
|
|
277
|
+
label="Skip this line",
|
|
278
|
+
impact="1 line excluded from migration",
|
|
279
|
+
),
|
|
280
|
+
DecisionOption(
|
|
281
|
+
id="manual",
|
|
282
|
+
label="Handle manually post-migration",
|
|
283
|
+
impact="Line migrated without extension; fix manually",
|
|
284
|
+
),
|
|
285
|
+
],
|
|
286
|
+
affected_objects=[canonical_id],
|
|
287
|
+
)
|
|
288
|
+
decisions.append(decision)
|
|
289
|
+
|
|
290
|
+
return decisions
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _highest_severity(missing: list[tuple[str, str, str]]) -> str:
|
|
294
|
+
"""Return the highest severity among the missing fields."""
|
|
295
|
+
order = {"HIGH": 3, "MEDIUM": 2, "LOW": 1}
|
|
296
|
+
best = "LOW"
|
|
297
|
+
for _, sev, _ in missing:
|
|
298
|
+
if order.get(sev, 0) > order.get(best, 0):
|
|
299
|
+
best = sev
|
|
300
|
+
return best
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Selective call handling analyzer for CUCM-to-Webex migration.
|
|
2
|
+
|
|
3
|
+
Detects CUCM CSS/partition patterns that imply per-caller routing differences
|
|
4
|
+
and produces FEATURE_APPROXIMATION advisory decisions recommending Webex
|
|
5
|
+
selective call handling features (Selective Forward / Accept / Reject /
|
|
6
|
+
Priority Alert).
|
|
7
|
+
|
|
8
|
+
Three heuristics:
|
|
9
|
+
1. Multi-partition DN — same DN in 2+ partitions reachable via different
|
|
10
|
+
user CSSes (internal vs external routing split).
|
|
11
|
+
2. Low-membership partition — partition with few DNs that appears in only
|
|
12
|
+
a subset of CSSes (VIP/priority bypass pattern).
|
|
13
|
+
3. Naming convention — partition name contains VIP/executive/priority
|
|
14
|
+
keywords (weak signal, only LOW severity unless paired with structural
|
|
15
|
+
signal).
|
|
16
|
+
|
|
17
|
+
This analyzer reuses DecisionType.FEATURE_APPROXIMATION with a distinguishing
|
|
18
|
+
context key `selective_call_handling_pattern`. The recommendation rule and
|
|
19
|
+
report renderer key off this context key.
|
|
20
|
+
|
|
21
|
+
Spec: docs/superpowers/specs/2026-04-10-selective-call-forwarding.md
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections import defaultdict
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from wxcli.migration.models import DecisionOption, DecisionType
|
|
30
|
+
from wxcli.migration.store import MigrationStore
|
|
31
|
+
from wxcli.migration.transform.analyzers import Analyzer, Decision
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_VIP_KEYWORDS = (
|
|
35
|
+
"vip",
|
|
36
|
+
"executive",
|
|
37
|
+
"exec",
|
|
38
|
+
"priority",
|
|
39
|
+
"direct",
|
|
40
|
+
"bypass",
|
|
41
|
+
"afterhours",
|
|
42
|
+
"after_hours",
|
|
43
|
+
"after-hours",
|
|
44
|
+
"emergency",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SelectiveCallHandlingAnalyzer(Analyzer):
|
|
49
|
+
"""Detects CSS/partition patterns suggesting selective call handling.
|
|
50
|
+
|
|
51
|
+
See module docstring for the three heuristics. Produces
|
|
52
|
+
FEATURE_APPROXIMATION decisions with context key
|
|
53
|
+
`selective_call_handling_pattern` set to one of:
|
|
54
|
+
- "multi_partition_dn"
|
|
55
|
+
- "low_membership_partition"
|
|
56
|
+
- "naming_convention"
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
name = "selective_call_handling"
|
|
60
|
+
decision_types = [DecisionType.FEATURE_APPROXIMATION]
|
|
61
|
+
# Run after css_routing so CSS decomposition decisions are merged first.
|
|
62
|
+
depends_on = ["css_routing"]
|
|
63
|
+
|
|
64
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
65
|
+
decisions: list[Decision] = []
|
|
66
|
+
decisions.extend(self._heuristic_multi_partition_dn(store))
|
|
67
|
+
decisions.extend(self._heuristic_low_membership_partition(store))
|
|
68
|
+
decisions.extend(self._heuristic_naming_convention(store, decisions))
|
|
69
|
+
return decisions
|
|
70
|
+
|
|
71
|
+
def fingerprint(
|
|
72
|
+
self, decision_type: DecisionType, context: dict[str, Any]
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Deterministic fingerprint from pattern type + natural keys."""
|
|
75
|
+
return self._hash_fingerprint({
|
|
76
|
+
"type": decision_type.value,
|
|
77
|
+
"pattern": context.get("selective_call_handling_pattern", ""),
|
|
78
|
+
"primary_key": context.get("primary_key", ""),
|
|
79
|
+
"partitions": sorted(context.get("partitions", [])),
|
|
80
|
+
"user": context.get("user_canonical_id", ""),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Heuristics — implemented in later tasks
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _heuristic_multi_partition_dn(
|
|
88
|
+
self, store: MigrationStore
|
|
89
|
+
) -> list[Decision]:
|
|
90
|
+
"""Find DNs in 2+ partitions reachable via different user CSSes."""
|
|
91
|
+
# Build {dn_number: {partition_name: dn_canonical_id}} from cross_refs.
|
|
92
|
+
# The dn_canonical_id form is "dn:{number}:{partition}".
|
|
93
|
+
rows = store.conn.execute(
|
|
94
|
+
"SELECT from_id, to_id FROM cross_refs WHERE relationship = 'dn_in_partition'"
|
|
95
|
+
).fetchall()
|
|
96
|
+
dn_to_partitions: dict[str, dict[str, str]] = defaultdict(dict)
|
|
97
|
+
for row in rows:
|
|
98
|
+
dn_canonical_id = row["from_id"]
|
|
99
|
+
partition_canonical_id = row["to_id"]
|
|
100
|
+
# Extract the dn number from "dn:{number}:{partition}"
|
|
101
|
+
parts = dn_canonical_id.split(":", 2)
|
|
102
|
+
if len(parts) < 3:
|
|
103
|
+
continue
|
|
104
|
+
dn_number = parts[1]
|
|
105
|
+
partition_name = (
|
|
106
|
+
partition_canonical_id.split(":", 1)[1]
|
|
107
|
+
if ":" in partition_canonical_id
|
|
108
|
+
else partition_canonical_id
|
|
109
|
+
)
|
|
110
|
+
dn_to_partitions[dn_number][partition_name] = dn_canonical_id
|
|
111
|
+
|
|
112
|
+
# Map user_id → set of CSS canonical_ids and CSS → set of partition_ids
|
|
113
|
+
user_csses = self._build_user_css_index(store)
|
|
114
|
+
css_partitions = self._build_css_partition_index(store)
|
|
115
|
+
# Map dn canonical_id → owner user canonical_id (from user_has_primary_dn)
|
|
116
|
+
owner_rows = store.conn.execute(
|
|
117
|
+
"SELECT from_id, to_id FROM cross_refs WHERE relationship = 'user_has_primary_dn'"
|
|
118
|
+
).fetchall()
|
|
119
|
+
dn_owner: dict[str, str] = {row["to_id"]: row["from_id"] for row in owner_rows}
|
|
120
|
+
|
|
121
|
+
decisions: list[Decision] = []
|
|
122
|
+
for dn_number, partition_map in sorted(dn_to_partitions.items()):
|
|
123
|
+
if len(partition_map) < 2:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Multi-site filter (mitigation 8a):
|
|
127
|
+
# Get owners across all partition variants for this DN number.
|
|
128
|
+
owner_ids: set[str] = set()
|
|
129
|
+
for dn_cid in partition_map.values():
|
|
130
|
+
owner = dn_owner.get(dn_cid)
|
|
131
|
+
if owner:
|
|
132
|
+
owner_ids.add(owner)
|
|
133
|
+
owner_locations = self._owner_locations(store, owner_ids)
|
|
134
|
+
if len(owner_locations) > 1:
|
|
135
|
+
# Multi-site routing pattern, not selective call handling.
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Determine severity: MEDIUM if owning user's CSSes have
|
|
139
|
+
# different scopes (different partition sets).
|
|
140
|
+
primary_owner = next(iter(owner_ids), "")
|
|
141
|
+
scopes = self._user_css_scopes(primary_owner, user_csses, css_partitions)
|
|
142
|
+
severity = "MEDIUM" if self._scopes_differ(scopes) else "LOW"
|
|
143
|
+
|
|
144
|
+
partitions_sorted = sorted(partition_map.keys())
|
|
145
|
+
context = {
|
|
146
|
+
"selective_call_handling_pattern": "multi_partition_dn",
|
|
147
|
+
"primary_key": dn_number,
|
|
148
|
+
"dn_number": dn_number,
|
|
149
|
+
"partitions": partitions_sorted,
|
|
150
|
+
"user_canonical_id": primary_owner,
|
|
151
|
+
"recommended_webex_feature": "Selective Forward",
|
|
152
|
+
"confidence": "HIGH" if severity == "MEDIUM" else "LOW",
|
|
153
|
+
}
|
|
154
|
+
options = self._build_options("Selective Forward")
|
|
155
|
+
summary = (
|
|
156
|
+
f"DN {dn_number} appears in {len(partitions_sorted)} partitions "
|
|
157
|
+
f"({', '.join(partitions_sorted)}); CUCM caller-specific routing "
|
|
158
|
+
f"pattern detected"
|
|
159
|
+
)
|
|
160
|
+
decisions.append(
|
|
161
|
+
self._create_decision(
|
|
162
|
+
store=store,
|
|
163
|
+
decision_type=DecisionType.FEATURE_APPROXIMATION,
|
|
164
|
+
severity=severity,
|
|
165
|
+
summary=summary,
|
|
166
|
+
context=context,
|
|
167
|
+
options=options,
|
|
168
|
+
affected_objects=[primary_owner] if primary_owner else [],
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return decisions
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
# Shared helpers
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def _build_user_css_index(
|
|
178
|
+
self, store: MigrationStore
|
|
179
|
+
) -> dict[str, set[str]]:
|
|
180
|
+
rows = store.conn.execute(
|
|
181
|
+
"SELECT from_id, to_id FROM cross_refs WHERE relationship = 'user_has_css'"
|
|
182
|
+
).fetchall()
|
|
183
|
+
index: dict[str, set[str]] = defaultdict(set)
|
|
184
|
+
for row in rows:
|
|
185
|
+
index[row["from_id"]].add(row["to_id"])
|
|
186
|
+
return index
|
|
187
|
+
|
|
188
|
+
def _build_css_partition_index(
|
|
189
|
+
self, store: MigrationStore
|
|
190
|
+
) -> dict[str, set[str]]:
|
|
191
|
+
rows = store.conn.execute(
|
|
192
|
+
"SELECT from_id, to_id FROM cross_refs WHERE relationship = 'css_contains_partition'"
|
|
193
|
+
).fetchall()
|
|
194
|
+
index: dict[str, set[str]] = defaultdict(set)
|
|
195
|
+
for row in rows:
|
|
196
|
+
index[row["from_id"]].add(row["to_id"])
|
|
197
|
+
return index
|
|
198
|
+
|
|
199
|
+
def _owner_locations(
|
|
200
|
+
self, store: MigrationStore, owner_ids: set[str]
|
|
201
|
+
) -> set[str]:
|
|
202
|
+
locations: set[str] = set()
|
|
203
|
+
for owner_id in owner_ids:
|
|
204
|
+
obj = store.get_object(owner_id)
|
|
205
|
+
if obj is None:
|
|
206
|
+
continue
|
|
207
|
+
loc = obj.get("location_id")
|
|
208
|
+
if loc:
|
|
209
|
+
locations.add(loc)
|
|
210
|
+
return locations
|
|
211
|
+
|
|
212
|
+
def _user_css_scopes(
|
|
213
|
+
self,
|
|
214
|
+
user_id: str,
|
|
215
|
+
user_csses: dict[str, set[str]],
|
|
216
|
+
css_partitions: dict[str, set[str]],
|
|
217
|
+
) -> list[set[str]]:
|
|
218
|
+
return [
|
|
219
|
+
css_partitions.get(css_id, set())
|
|
220
|
+
for css_id in user_csses.get(user_id, set())
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _scopes_differ(scopes: list[set[str]]) -> bool:
|
|
225
|
+
if len(scopes) < 2:
|
|
226
|
+
return False
|
|
227
|
+
# Any pair with non-empty symmetric difference → scopes differ.
|
|
228
|
+
for i in range(len(scopes)):
|
|
229
|
+
for j in range(i + 1, len(scopes)):
|
|
230
|
+
if scopes[i] ^ scopes[j]:
|
|
231
|
+
return True
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _build_options(recommended_feature: str) -> list[DecisionOption]:
|
|
236
|
+
return [
|
|
237
|
+
DecisionOption(
|
|
238
|
+
id="accept",
|
|
239
|
+
label=f"Configure {recommended_feature} post-migration",
|
|
240
|
+
impact=(
|
|
241
|
+
f"Operator manually configures Webex {recommended_feature} "
|
|
242
|
+
f"to preserve CUCM caller-specific routing behavior"
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
DecisionOption(
|
|
246
|
+
id="skip",
|
|
247
|
+
label="Skip — accept loss of caller-specific routing",
|
|
248
|
+
impact=(
|
|
249
|
+
"Per-caller routing differences are not preserved; "
|
|
250
|
+
"callers reach the user via standard Webex routing"
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
def _heuristic_low_membership_partition(
|
|
256
|
+
self, store: MigrationStore
|
|
257
|
+
) -> list[Decision]:
|
|
258
|
+
"""Find partitions with few DNs that appear in only a subset of CSSes."""
|
|
259
|
+
partitions = store.get_objects("partition")
|
|
260
|
+
if not partitions:
|
|
261
|
+
return []
|
|
262
|
+
css_partitions = self._build_css_partition_index(store)
|
|
263
|
+
# total_css_count must reflect all CSSes in the store (not just those
|
|
264
|
+
# that appear as keys in the cross_ref index — a CSS with no partitions
|
|
265
|
+
# assigned still counts toward the "universe" of CSSes).
|
|
266
|
+
total_css_count = len(store.get_objects("css"))
|
|
267
|
+
if total_css_count == 0:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
# Build reverse: partition_id → set of CSS canonical_ids that contain it
|
|
271
|
+
partition_in_css: dict[str, set[str]] = defaultdict(set)
|
|
272
|
+
for css_id, pt_ids in css_partitions.items():
|
|
273
|
+
for pt_id in pt_ids:
|
|
274
|
+
partition_in_css[pt_id].add(css_id)
|
|
275
|
+
|
|
276
|
+
decisions: list[Decision] = []
|
|
277
|
+
for pt in partitions:
|
|
278
|
+
pt_id = pt.get("canonical_id", "")
|
|
279
|
+
if not pt_id:
|
|
280
|
+
continue
|
|
281
|
+
dn_refs = store.find_cross_refs(pt_id, "partition_has_pattern")
|
|
282
|
+
dn_count = len(dn_refs)
|
|
283
|
+
if dn_count == 0 or dn_count > 10:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
css_count = len(partition_in_css.get(pt_id, set()))
|
|
287
|
+
# Universal partition or not in a strict minority of CSSes → not selective.
|
|
288
|
+
if css_count == 0 or css_count >= total_css_count * 0.5:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
partition_name = (
|
|
292
|
+
pt.get("pre_migration_state", {}).get("partition_name")
|
|
293
|
+
or pt_id.split(":", 1)[-1]
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
context = {
|
|
297
|
+
"selective_call_handling_pattern": "low_membership_partition",
|
|
298
|
+
"primary_key": partition_name,
|
|
299
|
+
"partitions": [partition_name],
|
|
300
|
+
"dn_count": dn_count,
|
|
301
|
+
"css_count": css_count,
|
|
302
|
+
"total_css_count": total_css_count,
|
|
303
|
+
"recommended_webex_feature": "Selective Accept",
|
|
304
|
+
"confidence": "HIGH",
|
|
305
|
+
}
|
|
306
|
+
options = self._build_options("Selective Accept")
|
|
307
|
+
summary = (
|
|
308
|
+
f"Partition '{partition_name}' has {dn_count} DN(s) and is "
|
|
309
|
+
f"reachable via only {css_count}/{total_css_count} CSSes "
|
|
310
|
+
f"(VIP/priority bypass pattern)"
|
|
311
|
+
)
|
|
312
|
+
decisions.append(
|
|
313
|
+
self._create_decision(
|
|
314
|
+
store=store,
|
|
315
|
+
decision_type=DecisionType.FEATURE_APPROXIMATION,
|
|
316
|
+
severity="MEDIUM",
|
|
317
|
+
summary=summary,
|
|
318
|
+
context=context,
|
|
319
|
+
options=options,
|
|
320
|
+
affected_objects=[pt_id],
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
return decisions
|
|
324
|
+
|
|
325
|
+
def _heuristic_naming_convention(
|
|
326
|
+
self,
|
|
327
|
+
store: MigrationStore,
|
|
328
|
+
existing_decisions: list[Decision],
|
|
329
|
+
) -> list[Decision]:
|
|
330
|
+
"""Flag VIP/executive-named partitions not already covered structurally."""
|
|
331
|
+
partitions = store.get_objects("partition")
|
|
332
|
+
if not partitions:
|
|
333
|
+
return []
|
|
334
|
+
|
|
335
|
+
# Set of partition canonical_ids already covered by a structural heuristic
|
|
336
|
+
covered: set[str] = set()
|
|
337
|
+
for dec in existing_decisions:
|
|
338
|
+
for obj_id in dec.affected_objects:
|
|
339
|
+
if obj_id.startswith("partition:"):
|
|
340
|
+
covered.add(obj_id)
|
|
341
|
+
|
|
342
|
+
decisions: list[Decision] = []
|
|
343
|
+
for pt in partitions:
|
|
344
|
+
pt_id = pt.get("canonical_id", "")
|
|
345
|
+
if pt_id in covered:
|
|
346
|
+
continue
|
|
347
|
+
partition_name = (
|
|
348
|
+
pt.get("pre_migration_state", {}).get("partition_name")
|
|
349
|
+
or pt_id.split(":", 1)[-1]
|
|
350
|
+
)
|
|
351
|
+
if not self._matches_vip_keyword(partition_name):
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
context = {
|
|
355
|
+
"selective_call_handling_pattern": "naming_convention",
|
|
356
|
+
"primary_key": partition_name,
|
|
357
|
+
"partitions": [partition_name],
|
|
358
|
+
"matched_keyword": self._matched_keyword(partition_name),
|
|
359
|
+
"recommended_webex_feature": "Priority Alert",
|
|
360
|
+
"confidence": "LOW",
|
|
361
|
+
}
|
|
362
|
+
options = self._build_options("Priority Alert")
|
|
363
|
+
summary = (
|
|
364
|
+
f"Partition '{partition_name}' name suggests selective access "
|
|
365
|
+
f"(matched keyword '{context['matched_keyword']}'); CUCM "
|
|
366
|
+
f"caller-specific routing intent inferred from naming only"
|
|
367
|
+
)
|
|
368
|
+
decisions.append(
|
|
369
|
+
self._create_decision(
|
|
370
|
+
store=store,
|
|
371
|
+
decision_type=DecisionType.FEATURE_APPROXIMATION,
|
|
372
|
+
severity="LOW",
|
|
373
|
+
summary=summary,
|
|
374
|
+
context=context,
|
|
375
|
+
options=options,
|
|
376
|
+
affected_objects=[pt_id],
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
return decisions
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _matches_vip_keyword(name: str) -> bool:
|
|
383
|
+
if not name:
|
|
384
|
+
return False
|
|
385
|
+
lowered = name.lower()
|
|
386
|
+
return any(kw in lowered for kw in _VIP_KEYWORDS)
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _matched_keyword(name: str) -> str:
|
|
390
|
+
lowered = (name or "").lower()
|
|
391
|
+
for kw in _VIP_KEYWORDS:
|
|
392
|
+
if kw in lowered:
|
|
393
|
+
return kw
|
|
394
|
+
return ""
|