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,122 @@
|
|
|
1
|
+
"""Device compatibility analyzer — finds incompatible devices.
|
|
2
|
+
|
|
3
|
+
Mapper-owned decision type: device_mapper may already produce DEVICE_INCOMPATIBLE
|
|
4
|
+
decisions. This analyzer backfills any devices the mapper missed (e.g., devices
|
|
5
|
+
added after initial mapping).
|
|
6
|
+
|
|
7
|
+
Convertible devices are NOT surfaced as decisions. Convertibility is a phone
|
|
8
|
+
model classification (``compatibility_tier == 'convertible'``), not an operator
|
|
9
|
+
choice — the planner unconditionally emits an activation-code op for those.
|
|
10
|
+
|
|
11
|
+
(from 03-conflict-detection-engine.md, device compatibility linter)
|
|
12
|
+
(from phase-06-analyzers.md, Decision Ownership Table — 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
|
+
class DeviceCompatibilityAnalyzer(Analyzer):
|
|
25
|
+
"""Detects devices that are incompatible with Webex Calling.
|
|
26
|
+
|
|
27
|
+
Sweeps all device objects and checks ``compatibility_tier``:
|
|
28
|
+
- ``incompatible`` -> DEVICE_INCOMPATIBLE decision
|
|
29
|
+
- ``convertible`` -> no decision (planner emits conversion op unconditionally)
|
|
30
|
+
- ``native_mpp`` -> no decision needed
|
|
31
|
+
|
|
32
|
+
Skips devices that already have a mapper-produced decision for the same type.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name = "device_compatibility"
|
|
36
|
+
decision_types = [DecisionType.DEVICE_INCOMPATIBLE]
|
|
37
|
+
depends_on: list[str] = []
|
|
38
|
+
|
|
39
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
40
|
+
decisions: list[Decision] = []
|
|
41
|
+
|
|
42
|
+
# Get existing mapper-produced decisions indexed by affected object canonical_id
|
|
43
|
+
existing_incompatible = self._get_existing_decisions_for_type(
|
|
44
|
+
store, DecisionType.DEVICE_INCOMPATIBLE
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
devices = store.get_objects("device")
|
|
48
|
+
|
|
49
|
+
for device in devices:
|
|
50
|
+
canonical_id = device.get("canonical_id", "")
|
|
51
|
+
tier = device.get("compatibility_tier")
|
|
52
|
+
|
|
53
|
+
if tier in ("webex_app", "infrastructure", "dect", "convertible"):
|
|
54
|
+
# Software phones transition to Webex App; infrastructure devices
|
|
55
|
+
# (CTI, CER, gateways) are CUCM-only; DECT is handled via DECT network
|
|
56
|
+
# provisioning; convertible devices always convert (no operator choice).
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
elif tier == "incompatible":
|
|
60
|
+
# Skip if mapper already produced this decision for this device
|
|
61
|
+
if canonical_id in existing_incompatible:
|
|
62
|
+
continue
|
|
63
|
+
decisions.append(self._build_incompatible_decision(store, device))
|
|
64
|
+
|
|
65
|
+
# native_mpp and None — no decision needed
|
|
66
|
+
|
|
67
|
+
return decisions
|
|
68
|
+
|
|
69
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
70
|
+
return self._hash_fingerprint({
|
|
71
|
+
"canonical_id": context.get("canonical_id", ""),
|
|
72
|
+
"model": context.get("model", ""),
|
|
73
|
+
"compatibility_tier": context.get("compatibility_tier", ""),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Decision builders
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def _build_incompatible_decision(
|
|
81
|
+
self, store: MigrationStore, device: dict[str, Any]
|
|
82
|
+
) -> Decision:
|
|
83
|
+
canonical_id = device.get("canonical_id", "")
|
|
84
|
+
model = device.get("model", "unknown")
|
|
85
|
+
mac = device.get("mac", "")
|
|
86
|
+
cucm_device_name = device.get("cucm_device_name", "")
|
|
87
|
+
owner_canonical_id = device.get("owner_canonical_id", "")
|
|
88
|
+
|
|
89
|
+
context = {
|
|
90
|
+
"canonical_id": canonical_id,
|
|
91
|
+
"model": model,
|
|
92
|
+
"mac": mac,
|
|
93
|
+
"cucm_device_name": cucm_device_name,
|
|
94
|
+
"owner_canonical_id": owner_canonical_id,
|
|
95
|
+
"compatibility_tier": "incompatible",
|
|
96
|
+
}
|
|
97
|
+
options = [
|
|
98
|
+
DecisionOption(
|
|
99
|
+
id="skip",
|
|
100
|
+
label="Skip device",
|
|
101
|
+
impact=f"Device {model} ({cucm_device_name}) will not be migrated",
|
|
102
|
+
),
|
|
103
|
+
DecisionOption(
|
|
104
|
+
id="manual",
|
|
105
|
+
label="Handle manually",
|
|
106
|
+
impact=f"Device {model} ({cucm_device_name}) flagged for manual replacement",
|
|
107
|
+
),
|
|
108
|
+
]
|
|
109
|
+
affected = [canonical_id]
|
|
110
|
+
if owner_canonical_id:
|
|
111
|
+
affected.append(owner_canonical_id)
|
|
112
|
+
|
|
113
|
+
return self._create_decision(
|
|
114
|
+
store=store,
|
|
115
|
+
decision_type=DecisionType.DEVICE_INCOMPATIBLE,
|
|
116
|
+
severity="MEDIUM",
|
|
117
|
+
summary=f"Device {model} ({cucm_device_name}) is incompatible with Webex Calling",
|
|
118
|
+
context=context,
|
|
119
|
+
options=options,
|
|
120
|
+
affected_objects=affected,
|
|
121
|
+
)
|
|
122
|
+
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""DN ambiguity analyzer — finds lines with AMBIGUOUS E.164 classification.
|
|
2
|
+
|
|
3
|
+
The line_mapper's E.164 normalization algorithm classifies each DN as
|
|
4
|
+
EXTENSION, NATIONAL, E164, or AMBIGUOUS. This analyzer surfaces the
|
|
5
|
+
AMBIGUOUS cases so a human can make the call.
|
|
6
|
+
|
|
7
|
+
Decision ownership: MAPPER-OWNED (line_mapper produces DN_AMBIGUOUS decisions).
|
|
8
|
+
This analyzer checks for existing mapper decisions before creating new ones.
|
|
9
|
+
|
|
10
|
+
(from 03-conflict-detection-engine.md, DN ambiguity detection)
|
|
11
|
+
(from 03b-transform-mappers.md, line_mapper E.164 algorithm lines 208-211)
|
|
12
|
+
(from 07-idempotency-resumability.md, fingerprint-based identity)
|
|
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
|
+
class DNAmbiguityAnalyzer(Analyzer):
|
|
25
|
+
"""Finds DNs where E.164 classification is AMBIGUOUS.
|
|
26
|
+
|
|
27
|
+
The line_mapper may already have produced DN_AMBIGUOUS decisions for some
|
|
28
|
+
lines. This analyzer only creates decisions for ambiguous lines that
|
|
29
|
+
don't already have one, acting as a safety net for anything the mapper
|
|
30
|
+
missed.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
name = "dn_ambiguity"
|
|
34
|
+
decision_types = [DecisionType.DN_AMBIGUOUS]
|
|
35
|
+
|
|
36
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
37
|
+
"""Sweep all lines, find AMBIGUOUS classification, skip already-decided."""
|
|
38
|
+
decisions: list[Decision] = []
|
|
39
|
+
|
|
40
|
+
# Step 1: Get existing mapper-produced DN_AMBIGUOUS decisions
|
|
41
|
+
existing = self._get_existing_decisions_for_type(store, DecisionType.DN_AMBIGUOUS)
|
|
42
|
+
|
|
43
|
+
# Step 2: Load all lines and filter for AMBIGUOUS classification
|
|
44
|
+
lines = store.get_objects("line")
|
|
45
|
+
if not lines:
|
|
46
|
+
return decisions
|
|
47
|
+
|
|
48
|
+
for line in lines:
|
|
49
|
+
classification = line.get("classification")
|
|
50
|
+
if classification != "AMBIGUOUS":
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
canonical_id = line.get("canonical_id", "")
|
|
54
|
+
if not canonical_id:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
# Skip if mapper already produced a decision for this line
|
|
58
|
+
if canonical_id in existing:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
dn_pattern = line.get("cucm_pattern", "")
|
|
62
|
+
partition = line.get("route_partition_name", "")
|
|
63
|
+
# Count fixed (non-wildcard) digits after stripping + prefix
|
|
64
|
+
# e.g., "+1555XXXX" -> "1555XXXX" -> count chars that aren't X/x/. -> 4
|
|
65
|
+
stripped = dn_pattern.lstrip("+")
|
|
66
|
+
dn_length = sum(1 for c in stripped if c not in "Xx.![]")
|
|
67
|
+
|
|
68
|
+
severity = "LOW" if dn_length <= 4 else "MEDIUM"
|
|
69
|
+
|
|
70
|
+
context = {
|
|
71
|
+
"dn": dn_pattern,
|
|
72
|
+
"partition": partition,
|
|
73
|
+
"dn_length": dn_length,
|
|
74
|
+
"canonical_id": canonical_id,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
options = [
|
|
78
|
+
DecisionOption(
|
|
79
|
+
id="extension_only",
|
|
80
|
+
label="Treat as Extension",
|
|
81
|
+
impact=f"Map '{dn_pattern}' as internal extension only — no DID",
|
|
82
|
+
),
|
|
83
|
+
DecisionOption(
|
|
84
|
+
id="national",
|
|
85
|
+
label="Treat as National Number",
|
|
86
|
+
impact=f"Map '{dn_pattern}' as national-format DID",
|
|
87
|
+
),
|
|
88
|
+
DecisionOption(
|
|
89
|
+
id="e164",
|
|
90
|
+
label="Treat as E.164",
|
|
91
|
+
impact=f"Map '{dn_pattern}' as full E.164 DID with + prefix",
|
|
92
|
+
),
|
|
93
|
+
DecisionOption(
|
|
94
|
+
id="skip",
|
|
95
|
+
label="Skip",
|
|
96
|
+
impact="Leave DN unmapped — manual intervention required",
|
|
97
|
+
),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
decision = self._create_decision(
|
|
101
|
+
store=store,
|
|
102
|
+
decision_type=DecisionType.DN_AMBIGUOUS,
|
|
103
|
+
severity=severity,
|
|
104
|
+
summary=(
|
|
105
|
+
f"DN '{dn_pattern}' in partition '{partition}' has ambiguous "
|
|
106
|
+
f"E.164 classification ({dn_length} digits)"
|
|
107
|
+
),
|
|
108
|
+
context=context,
|
|
109
|
+
options=options,
|
|
110
|
+
affected_objects=[canonical_id],
|
|
111
|
+
)
|
|
112
|
+
decisions.append(decision)
|
|
113
|
+
|
|
114
|
+
return decisions
|
|
115
|
+
|
|
116
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
117
|
+
"""Deterministic fingerprint from DN pattern, partition, and line ID.
|
|
118
|
+
|
|
119
|
+
Includes:
|
|
120
|
+
- dn (the pattern that's ambiguous)
|
|
121
|
+
- partition (routing scope — different partitions are different decisions)
|
|
122
|
+
- canonical_id (the specific line object)
|
|
123
|
+
|
|
124
|
+
(from 07-idempotency-resumability.md lines 30-43)
|
|
125
|
+
"""
|
|
126
|
+
return self._hash_fingerprint({
|
|
127
|
+
"type": decision_type.value,
|
|
128
|
+
"dn": context.get("dn"),
|
|
129
|
+
"partition": context.get("partition"),
|
|
130
|
+
"canonical_id": context.get("canonical_id"),
|
|
131
|
+
})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Duplicate user analyzer — finds users with matching email or name across CUCM inventory.
|
|
2
|
+
|
|
3
|
+
Detects users that would collide in Webex because they share the same email
|
|
4
|
+
address, or users without emails who share the same (first_name, last_name)
|
|
5
|
+
pair. Duplicate users block migration because Webex requires unique email
|
|
6
|
+
identities.
|
|
7
|
+
|
|
8
|
+
Decision ownership: ANALYZER-OWNED (no mapper produces DUPLICATE_USER).
|
|
9
|
+
|
|
10
|
+
(from 03-conflict-detection-engine.md, duplicate user detection)
|
|
11
|
+
(from 07-idempotency-resumability.md, fingerprint-based identity)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections import defaultdict
|
|
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
|
+
class DuplicateUserAnalyzer(Analyzer):
|
|
25
|
+
"""Finds users with matching email or name across CUCM inventory.
|
|
26
|
+
|
|
27
|
+
Webex requires each person to have a unique email address. This analyzer
|
|
28
|
+
detects two collision patterns:
|
|
29
|
+
|
|
30
|
+
1. **Email collisions**: 2+ users share the same email (case-insensitive).
|
|
31
|
+
2. **Name collisions (no email)**: 2+ users with empty emails share the
|
|
32
|
+
same (first_name, last_name) pair — likely the same person entered
|
|
33
|
+
multiple times in CUCM.
|
|
34
|
+
|
|
35
|
+
Both patterns produce DUPLICATE_USER decisions with HIGH severity since
|
|
36
|
+
they block migration.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name = "duplicate_user"
|
|
40
|
+
decision_types = [DecisionType.DUPLICATE_USER]
|
|
41
|
+
|
|
42
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
43
|
+
"""Sweep all users, group by email and name, flag duplicates."""
|
|
44
|
+
decisions: list[Decision] = []
|
|
45
|
+
|
|
46
|
+
users = store.get_objects("user")
|
|
47
|
+
if not users:
|
|
48
|
+
return decisions
|
|
49
|
+
|
|
50
|
+
# Step 1: Group users by lowercase email
|
|
51
|
+
email_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
52
|
+
# Track users with no email for name-based duplicate check
|
|
53
|
+
no_email_users: list[dict[str, Any]] = []
|
|
54
|
+
|
|
55
|
+
for user in users:
|
|
56
|
+
emails = user.get("emails", [])
|
|
57
|
+
if emails:
|
|
58
|
+
for email in emails:
|
|
59
|
+
email_lower = email.strip().lower()
|
|
60
|
+
if email_lower:
|
|
61
|
+
email_groups[email_lower].append(user)
|
|
62
|
+
else:
|
|
63
|
+
no_email_users.append(user)
|
|
64
|
+
|
|
65
|
+
# Step 2: Produce decisions for email-based duplicates (2+ users per email)
|
|
66
|
+
seen_ids: set[str] = set() # track which users already have a decision
|
|
67
|
+
|
|
68
|
+
for email, group_users in sorted(email_groups.items()):
|
|
69
|
+
if len(group_users) < 2:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Deduplicate by canonical_id within the group (a user with multiple
|
|
73
|
+
# emails could appear twice in the same group)
|
|
74
|
+
unique_users: dict[str, dict[str, Any]] = {}
|
|
75
|
+
for u in group_users:
|
|
76
|
+
cid = u.get("canonical_id", "")
|
|
77
|
+
if cid:
|
|
78
|
+
unique_users[cid] = u
|
|
79
|
+
|
|
80
|
+
if len(unique_users) < 2:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
user_ids = sorted(unique_users.keys())
|
|
84
|
+
user_names = [
|
|
85
|
+
unique_users[uid].get("display_name")
|
|
86
|
+
or f"{unique_users[uid].get('first_name', '')} {unique_users[uid].get('last_name', '')}".strip()
|
|
87
|
+
or uid
|
|
88
|
+
for uid in user_ids
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
context = {
|
|
92
|
+
"email": email,
|
|
93
|
+
"user_ids": user_ids,
|
|
94
|
+
"user_names": user_names,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
options = [
|
|
98
|
+
DecisionOption(
|
|
99
|
+
id="merge",
|
|
100
|
+
label="Merge",
|
|
101
|
+
impact=f"Merge {len(user_ids)} users into single Webex person",
|
|
102
|
+
),
|
|
103
|
+
DecisionOption(
|
|
104
|
+
id="keep_primary",
|
|
105
|
+
label="Keep Primary",
|
|
106
|
+
impact=f"Keep first user, skip {len(user_ids) - 1} others",
|
|
107
|
+
),
|
|
108
|
+
DecisionOption(
|
|
109
|
+
id="skip_all",
|
|
110
|
+
label="Skip All",
|
|
111
|
+
impact=f"Skip all {len(user_ids)} duplicate users",
|
|
112
|
+
),
|
|
113
|
+
DecisionOption(
|
|
114
|
+
id="manual",
|
|
115
|
+
label="Manual Resolution",
|
|
116
|
+
impact="Flag for manual review and resolution",
|
|
117
|
+
),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
decision = self._create_decision(
|
|
121
|
+
store=store,
|
|
122
|
+
decision_type=DecisionType.DUPLICATE_USER,
|
|
123
|
+
severity="HIGH",
|
|
124
|
+
summary=f"Email {email} shared by {len(user_ids)} users",
|
|
125
|
+
context=context,
|
|
126
|
+
options=options,
|
|
127
|
+
affected_objects=user_ids,
|
|
128
|
+
)
|
|
129
|
+
decisions.append(decision)
|
|
130
|
+
seen_ids.update(user_ids)
|
|
131
|
+
|
|
132
|
+
# Step 3: Check no-email users for (first_name, last_name) collisions
|
|
133
|
+
name_groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
|
134
|
+
|
|
135
|
+
for user in no_email_users:
|
|
136
|
+
cid = user.get("canonical_id", "")
|
|
137
|
+
if cid in seen_ids:
|
|
138
|
+
continue # already covered by an email-based decision
|
|
139
|
+
|
|
140
|
+
first = (user.get("first_name") or "").strip().lower()
|
|
141
|
+
last = (user.get("last_name") or "").strip().lower()
|
|
142
|
+
if first or last:
|
|
143
|
+
name_groups[(first, last)].append(user)
|
|
144
|
+
|
|
145
|
+
for (first, last), group_users in sorted(name_groups.items()):
|
|
146
|
+
if len(group_users) < 2:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
user_ids = sorted(u.get("canonical_id", "") for u in group_users if u.get("canonical_id"))
|
|
150
|
+
if len(user_ids) < 2:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
display_name = f"{first} {last}".strip()
|
|
154
|
+
user_names = [
|
|
155
|
+
u.get("display_name") or display_name or u.get("canonical_id", "")
|
|
156
|
+
for u in group_users
|
|
157
|
+
if u.get("canonical_id")
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
# Use a synthetic email key for context — no real email exists
|
|
161
|
+
name_key = f"<no-email>:{first}:{last}"
|
|
162
|
+
|
|
163
|
+
context = {
|
|
164
|
+
"email": name_key,
|
|
165
|
+
"user_ids": user_ids,
|
|
166
|
+
"user_names": sorted(user_names),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
options = [
|
|
170
|
+
DecisionOption(
|
|
171
|
+
id="merge",
|
|
172
|
+
label="Merge",
|
|
173
|
+
impact=f"Merge {len(user_ids)} users into single Webex person",
|
|
174
|
+
),
|
|
175
|
+
DecisionOption(
|
|
176
|
+
id="keep_primary",
|
|
177
|
+
label="Keep Primary",
|
|
178
|
+
impact=f"Keep first user, skip {len(user_ids) - 1} others",
|
|
179
|
+
),
|
|
180
|
+
DecisionOption(
|
|
181
|
+
id="skip_all",
|
|
182
|
+
label="Skip All",
|
|
183
|
+
impact=f"Skip all {len(user_ids)} duplicate users",
|
|
184
|
+
),
|
|
185
|
+
DecisionOption(
|
|
186
|
+
id="manual",
|
|
187
|
+
label="Manual Resolution",
|
|
188
|
+
impact="Flag for manual review and resolution",
|
|
189
|
+
),
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
decision = self._create_decision(
|
|
193
|
+
store=store,
|
|
194
|
+
decision_type=DecisionType.DUPLICATE_USER,
|
|
195
|
+
severity="HIGH",
|
|
196
|
+
summary=(
|
|
197
|
+
f"Name '{display_name}' shared by {len(user_ids)} users "
|
|
198
|
+
f"(no email set)"
|
|
199
|
+
),
|
|
200
|
+
context=context,
|
|
201
|
+
options=options,
|
|
202
|
+
affected_objects=user_ids,
|
|
203
|
+
)
|
|
204
|
+
decisions.append(decision)
|
|
205
|
+
|
|
206
|
+
return decisions
|
|
207
|
+
|
|
208
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
209
|
+
"""Deterministic fingerprint from shared email and affected user IDs.
|
|
210
|
+
|
|
211
|
+
Includes:
|
|
212
|
+
- email (the shared email, or synthetic name key for no-email dupes)
|
|
213
|
+
- sorted user canonical_ids (all users in the duplicate group)
|
|
214
|
+
|
|
215
|
+
(from 07-idempotency-resumability.md lines 30-43)
|
|
216
|
+
"""
|
|
217
|
+
return self._hash_fingerprint({
|
|
218
|
+
"type": decision_type.value,
|
|
219
|
+
"email": context.get("email"),
|
|
220
|
+
"user_ids": sorted(context.get("user_ids", [])),
|
|
221
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Extension conflict analyzer — finds DNs sharing the same extension within a location.
|
|
2
|
+
|
|
3
|
+
Detects lines that would collide in Webex Calling because they share the same
|
|
4
|
+
extension number within the same location. CUCM allows this via partitions,
|
|
5
|
+
but Webex Calling enforces unique extensions per location.
|
|
6
|
+
|
|
7
|
+
Decision ownership: ANALYZER-OWNED (no mapper produces EXTENSION_CONFLICT).
|
|
8
|
+
|
|
9
|
+
(from 03-conflict-detection-engine.md, extension conflict detection)
|
|
10
|
+
(from 07-idempotency-resumability.md, fingerprint-based identity)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections import defaultdict
|
|
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
|
+
class ExtensionConflictAnalyzer(Analyzer):
|
|
25
|
+
"""Finds extension conflicts within the same Webex Calling location.
|
|
26
|
+
|
|
27
|
+
CUCM allows multiple DNs with the same pattern in different partitions.
|
|
28
|
+
Webex Calling requires unique extensions per location. This analyzer
|
|
29
|
+
groups lines by (extension, location) and flags collisions.
|
|
30
|
+
|
|
31
|
+
Location resolution: line → device (via device_has_dn cross-ref) →
|
|
32
|
+
device.location_canonical_id.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name = "extension_conflict"
|
|
36
|
+
decision_types = [DecisionType.EXTENSION_CONFLICT]
|
|
37
|
+
|
|
38
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
39
|
+
"""Sweep all lines, group by (extension, location), flag conflicts."""
|
|
40
|
+
decisions: list[Decision] = []
|
|
41
|
+
|
|
42
|
+
# Step 1: Load all lines and build a map of canonical_id → line data
|
|
43
|
+
lines = store.get_objects("line")
|
|
44
|
+
if not lines:
|
|
45
|
+
return decisions
|
|
46
|
+
|
|
47
|
+
line_by_id: dict[str, dict[str, Any]] = {}
|
|
48
|
+
for line in lines:
|
|
49
|
+
cid = line.get("canonical_id", "")
|
|
50
|
+
if cid:
|
|
51
|
+
line_by_id[cid] = line
|
|
52
|
+
|
|
53
|
+
# Step 2: For each line, resolve its location via:
|
|
54
|
+
# line ←(device_has_dn)← device → device.location_canonical_id
|
|
55
|
+
# Query cross_refs where relationship='device_has_dn' and to_id is a line
|
|
56
|
+
line_location: dict[str, str] = {} # line canonical_id → location canonical_id
|
|
57
|
+
|
|
58
|
+
device_rows = store.conn.execute(
|
|
59
|
+
"SELECT from_id, to_id FROM cross_refs WHERE relationship = 'device_has_dn'"
|
|
60
|
+
).fetchall()
|
|
61
|
+
|
|
62
|
+
# Build line → device(s) mapping
|
|
63
|
+
line_to_devices: dict[str, list[str]] = defaultdict(list)
|
|
64
|
+
for row in device_rows:
|
|
65
|
+
line_to_devices[row["to_id"]].append(row["from_id"])
|
|
66
|
+
|
|
67
|
+
# Load all devices once for location lookups
|
|
68
|
+
devices = store.get_objects("device")
|
|
69
|
+
device_location: dict[str, str] = {}
|
|
70
|
+
for dev in devices:
|
|
71
|
+
dev_cid = dev.get("canonical_id", "")
|
|
72
|
+
loc_cid = dev.get("location_canonical_id", "")
|
|
73
|
+
if dev_cid and loc_cid:
|
|
74
|
+
device_location[dev_cid] = loc_cid
|
|
75
|
+
|
|
76
|
+
# Resolve each line's location via its owning device
|
|
77
|
+
for line_cid in line_by_id:
|
|
78
|
+
owning_devices = line_to_devices.get(line_cid, [])
|
|
79
|
+
for dev_cid in owning_devices:
|
|
80
|
+
loc = device_location.get(dev_cid)
|
|
81
|
+
if loc:
|
|
82
|
+
line_location[line_cid] = loc
|
|
83
|
+
break # use the first device that has a location
|
|
84
|
+
|
|
85
|
+
# Step 3: Group lines by (extension, location)
|
|
86
|
+
groups: dict[tuple[str, str], list[str]] = defaultdict(list)
|
|
87
|
+
for line_cid, line_data in line_by_id.items():
|
|
88
|
+
ext = line_data.get("extension")
|
|
89
|
+
loc = line_location.get(line_cid)
|
|
90
|
+
if ext and loc:
|
|
91
|
+
groups[(ext, loc)].append(line_cid)
|
|
92
|
+
|
|
93
|
+
# Step 4: Produce decisions for groups with 2+ lines
|
|
94
|
+
for (ext, loc_cid), conflicting_ids in sorted(groups.items()):
|
|
95
|
+
if len(conflicting_ids) < 2:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
severity = "HIGH" if len(conflicting_ids) >= 3 else "MEDIUM"
|
|
99
|
+
|
|
100
|
+
# Build context with details about each conflicting line
|
|
101
|
+
line_details = []
|
|
102
|
+
for cid in sorted(conflicting_ids):
|
|
103
|
+
ld = line_by_id.get(cid, {})
|
|
104
|
+
line_details.append({
|
|
105
|
+
"canonical_id": cid,
|
|
106
|
+
"cucm_pattern": ld.get("cucm_pattern"),
|
|
107
|
+
"route_partition_name": ld.get("route_partition_name"),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
context = {
|
|
111
|
+
"extension": ext,
|
|
112
|
+
"location_canonical_id": loc_cid,
|
|
113
|
+
"conflict_count": len(conflicting_ids),
|
|
114
|
+
"conflicting_lines": line_details,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
options = [
|
|
118
|
+
DecisionOption(
|
|
119
|
+
id="renumber",
|
|
120
|
+
label="Renumber Extension",
|
|
121
|
+
impact=f"Change extension for {len(conflicting_ids) - 1} of "
|
|
122
|
+
f"{len(conflicting_ids)} conflicting lines",
|
|
123
|
+
),
|
|
124
|
+
DecisionOption(
|
|
125
|
+
id="virtual_line",
|
|
126
|
+
label="Use Virtual Line",
|
|
127
|
+
impact=f"Create virtual line(s) for {len(conflicting_ids) - 1} "
|
|
128
|
+
f"conflicting appearances",
|
|
129
|
+
),
|
|
130
|
+
DecisionOption(
|
|
131
|
+
id="skip",
|
|
132
|
+
label="Skip",
|
|
133
|
+
impact=f"Leave {len(conflicting_ids)} lines unresolved — "
|
|
134
|
+
f"manual intervention required",
|
|
135
|
+
),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
decision = self._create_decision(
|
|
139
|
+
store=store,
|
|
140
|
+
decision_type=DecisionType.EXTENSION_CONFLICT,
|
|
141
|
+
severity=severity,
|
|
142
|
+
summary=(
|
|
143
|
+
f"Extension {ext} used by {len(conflicting_ids)} lines "
|
|
144
|
+
f"in location {loc_cid}"
|
|
145
|
+
),
|
|
146
|
+
context=context,
|
|
147
|
+
options=options,
|
|
148
|
+
affected_objects=sorted(conflicting_ids),
|
|
149
|
+
)
|
|
150
|
+
decisions.append(decision)
|
|
151
|
+
|
|
152
|
+
return decisions
|
|
153
|
+
|
|
154
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
155
|
+
"""Deterministic fingerprint from extension, location, and affected line IDs.
|
|
156
|
+
|
|
157
|
+
Includes:
|
|
158
|
+
- extension (the triggering condition)
|
|
159
|
+
- location_canonical_id (scope of the conflict)
|
|
160
|
+
- sorted canonical_ids of all conflicting lines (affected objects)
|
|
161
|
+
|
|
162
|
+
(from 07-idempotency-resumability.md lines 30-43)
|
|
163
|
+
"""
|
|
164
|
+
conflicting_ids = sorted(
|
|
165
|
+
line.get("canonical_id", "")
|
|
166
|
+
for line in context.get("conflicting_lines", [])
|
|
167
|
+
)
|
|
168
|
+
return self._hash_fingerprint({
|
|
169
|
+
"type": decision_type.value,
|
|
170
|
+
"extension": context.get("extension"),
|
|
171
|
+
"location_canonical_id": context.get("location_canonical_id"),
|
|
172
|
+
"conflicting_line_ids": conflicting_ids,
|
|
173
|
+
})
|