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,170 @@
|
|
|
1
|
+
"""Shared line analyzer — finds DNs shared across multiple device owners.
|
|
2
|
+
|
|
3
|
+
Detects directory numbers that appear on devices belonging to different users.
|
|
4
|
+
These shared-line appearances require a migration decision because Webex Calling
|
|
5
|
+
handles multi-user shared lines differently from CUCM (virtual lines, Webex
|
|
6
|
+
shared lines, or call park + BLF).
|
|
7
|
+
|
|
8
|
+
Decision ownership: ANALYZER-OWNED (no mapper produces SHARED_LINE_COMPLEX).
|
|
9
|
+
|
|
10
|
+
(from 03-conflict-detection-engine.md, shared line 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 SharedLineAnalyzer(Analyzer):
|
|
25
|
+
"""Finds DNs shared across multiple device owners.
|
|
26
|
+
|
|
27
|
+
CUCM allows a single DN to appear on multiple devices owned by different
|
|
28
|
+
users. Webex Calling supports simple two-device shared lines natively,
|
|
29
|
+
but more complex arrangements (3+ devices, multiple owners) require
|
|
30
|
+
virtual lines, call park + BLF, or other workarounds.
|
|
31
|
+
|
|
32
|
+
Detection: query cross_refs for device_has_dn relationships, group by DN,
|
|
33
|
+
find DNs referenced by 2+ devices, then resolve unique owners.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name = "shared_lines"
|
|
37
|
+
decision_types = [DecisionType.SHARED_LINE_COMPLEX]
|
|
38
|
+
|
|
39
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
40
|
+
"""Sweep all device-to-DN cross-refs, find DNs with multiple device owners."""
|
|
41
|
+
decisions: list[Decision] = []
|
|
42
|
+
|
|
43
|
+
# Step 1: Find DNs referenced by multiple devices via raw SQL
|
|
44
|
+
rows = store.conn.execute("""
|
|
45
|
+
SELECT cr.to_id AS dn_id, COUNT(DISTINCT cr.from_id) AS device_count
|
|
46
|
+
FROM cross_refs cr
|
|
47
|
+
WHERE cr.relationship = 'device_has_dn'
|
|
48
|
+
GROUP BY cr.to_id
|
|
49
|
+
HAVING COUNT(DISTINCT cr.from_id) > 1
|
|
50
|
+
""").fetchall()
|
|
51
|
+
|
|
52
|
+
if not rows:
|
|
53
|
+
return decisions
|
|
54
|
+
|
|
55
|
+
# Step 2: For each shared DN, gather details and resolve owners
|
|
56
|
+
for row in rows:
|
|
57
|
+
dn_id: str = row["dn_id"]
|
|
58
|
+
device_count: int = row["device_count"]
|
|
59
|
+
|
|
60
|
+
# Get DN data
|
|
61
|
+
dn_data = store.get_object(dn_id)
|
|
62
|
+
if dn_data is None:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
extension = dn_data.get("extension")
|
|
66
|
+
cucm_pattern = dn_data.get("cucm_pattern")
|
|
67
|
+
route_partition_name = dn_data.get("route_partition_name")
|
|
68
|
+
|
|
69
|
+
# Get all devices that reference this DN
|
|
70
|
+
device_refs = store.conn.execute(
|
|
71
|
+
"SELECT from_id FROM cross_refs WHERE to_id = ? AND relationship = 'device_has_dn'",
|
|
72
|
+
(dn_id,),
|
|
73
|
+
).fetchall()
|
|
74
|
+
device_ids = [r["from_id"] for r in device_refs]
|
|
75
|
+
|
|
76
|
+
# Resolve unique owners from each device's data
|
|
77
|
+
owner_set: set[str] = set()
|
|
78
|
+
owner_ids: list[str] = []
|
|
79
|
+
for dev_id in device_ids:
|
|
80
|
+
dev_data = store.get_object(dev_id)
|
|
81
|
+
if dev_data is not None:
|
|
82
|
+
owner = dev_data.get("owner_canonical_id")
|
|
83
|
+
if owner and owner not in owner_set:
|
|
84
|
+
owner_set.add(owner)
|
|
85
|
+
owner_ids.append(owner)
|
|
86
|
+
|
|
87
|
+
# Only flag if there are multiple unique owners
|
|
88
|
+
if len(owner_ids) < 2:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
unique_owners = len(owner_ids)
|
|
92
|
+
severity = "HIGH" if unique_owners > 2 else "MEDIUM"
|
|
93
|
+
|
|
94
|
+
# Build options
|
|
95
|
+
dn_display = extension or cucm_pattern or dn_id
|
|
96
|
+
|
|
97
|
+
options = [
|
|
98
|
+
DecisionOption(
|
|
99
|
+
id="virtual_line",
|
|
100
|
+
label="Virtual Line",
|
|
101
|
+
impact=f"1 virtual line + {device_count} line assignments",
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
if device_count <= 2:
|
|
106
|
+
options.append(
|
|
107
|
+
DecisionOption(
|
|
108
|
+
id="shared_line",
|
|
109
|
+
label="Webex Shared Line",
|
|
110
|
+
impact="Simple shared line between 2 devices",
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
options.extend([
|
|
115
|
+
DecisionOption(
|
|
116
|
+
id="call_park",
|
|
117
|
+
label="Call Park + BLF",
|
|
118
|
+
impact="Loses real-time shared state",
|
|
119
|
+
),
|
|
120
|
+
DecisionOption(
|
|
121
|
+
id="skip",
|
|
122
|
+
label="Skip",
|
|
123
|
+
impact="DN migrated only on primary owner's device",
|
|
124
|
+
),
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
context = {
|
|
128
|
+
"dn": dn_display,
|
|
129
|
+
"partition": route_partition_name,
|
|
130
|
+
"device_count": device_count,
|
|
131
|
+
"owner_ids": sorted(owner_ids),
|
|
132
|
+
"dn_canonical_id": dn_id,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
affected_objects = [dn_id] + sorted(device_ids)
|
|
136
|
+
|
|
137
|
+
decision = self._create_decision(
|
|
138
|
+
store=store,
|
|
139
|
+
decision_type=DecisionType.SHARED_LINE_COMPLEX,
|
|
140
|
+
severity=severity,
|
|
141
|
+
summary=(
|
|
142
|
+
f"DN {dn_display} shared across {device_count} devices "
|
|
143
|
+
f"with {unique_owners} unique owners"
|
|
144
|
+
),
|
|
145
|
+
context=context,
|
|
146
|
+
options=options,
|
|
147
|
+
affected_objects=affected_objects,
|
|
148
|
+
)
|
|
149
|
+
decisions.append(decision)
|
|
150
|
+
|
|
151
|
+
return decisions
|
|
152
|
+
|
|
153
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
154
|
+
"""Deterministic fingerprint from DN, partition, owners, and device count.
|
|
155
|
+
|
|
156
|
+
Includes:
|
|
157
|
+
- dn (the shared extension or pattern)
|
|
158
|
+
- partition (route partition — scope of the DN)
|
|
159
|
+
- sorted owner_ids (all users who share this DN)
|
|
160
|
+
- device_count (number of devices — change invalidates resolution)
|
|
161
|
+
|
|
162
|
+
(from 07-idempotency-resumability.md lines 30-43)
|
|
163
|
+
"""
|
|
164
|
+
return self._hash_fingerprint({
|
|
165
|
+
"type": decision_type.value,
|
|
166
|
+
"dn": context.get("dn"),
|
|
167
|
+
"partition": context.get("partition"),
|
|
168
|
+
"owner_ids": sorted(context.get("owner_ids", [])),
|
|
169
|
+
"device_count": context.get("device_count"),
|
|
170
|
+
})
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Voicemail compatibility analyzer for CUCM-to-Webex migration.
|
|
2
|
+
|
|
3
|
+
Finds voicemail profiles with Unity Connection features that lack
|
|
4
|
+
Webex Calling equivalents (fax-to-voicemail, non-standard message
|
|
5
|
+
storage, complex transfer routing).
|
|
6
|
+
|
|
7
|
+
VOICEMAIL_INCOMPATIBLE is a mapper-owned decision type — the
|
|
8
|
+
voicemail_mapper already produces these during transform. This
|
|
9
|
+
analyzer backfills any profiles the mapper missed or that were
|
|
10
|
+
added after initial mapping.
|
|
11
|
+
|
|
12
|
+
(from 03-conflict-detection-engine.md, linter pattern)
|
|
13
|
+
(from phase-06-analyzers.md, Decision Ownership Table — VOICEMAIL_INCOMPATIBLE is mapper-owned)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from wxcli.migration.models import DecisionOption, DecisionType
|
|
21
|
+
from wxcli.migration.store import MigrationStore
|
|
22
|
+
from wxcli.migration.transform.analyzers import Analyzer, Decision
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Incompatibility detection helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def _has_fax_message(profile: dict[str, Any]) -> str | None:
|
|
30
|
+
"""Return issue string if fax-to-voicemail is configured."""
|
|
31
|
+
fax = profile.get("fax_message")
|
|
32
|
+
if fax:
|
|
33
|
+
return "fax_message: Webex has no fax-to-voicemail"
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _has_nonstandard_storage(profile: dict[str, Any]) -> str | None:
|
|
38
|
+
"""Return issue string if message_storage uses non-standard settings."""
|
|
39
|
+
storage = profile.get("message_storage")
|
|
40
|
+
if storage is None:
|
|
41
|
+
return None
|
|
42
|
+
if isinstance(storage, str):
|
|
43
|
+
normalized = storage.strip().lower()
|
|
44
|
+
if normalized in ("", "standard", "default", "mailbox"):
|
|
45
|
+
return None
|
|
46
|
+
return f"message_storage '{storage}': non-standard storage not supported in Webex"
|
|
47
|
+
if isinstance(storage, dict):
|
|
48
|
+
# Dict-form storage — check for MWI-only or external indicators
|
|
49
|
+
storage_type = storage.get("type", "").lower()
|
|
50
|
+
if storage_type in ("mwi_only", "mwi-only", "external"):
|
|
51
|
+
return f"message_storage type '{storage_type}': not supported in Webex"
|
|
52
|
+
# Non-empty dict with unexpected keys suggests custom config
|
|
53
|
+
if storage and storage_type not in ("", "standard", "default", "mailbox"):
|
|
54
|
+
return f"message_storage type '{storage_type}': non-standard storage not supported in Webex"
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _has_complex_transfer(profile: dict[str, Any]) -> str | None:
|
|
59
|
+
"""Return issue string if transfer_to_number has complex routing."""
|
|
60
|
+
transfer = profile.get("transfer_to_number")
|
|
61
|
+
if transfer is None:
|
|
62
|
+
return None
|
|
63
|
+
if isinstance(transfer, str) and transfer.strip():
|
|
64
|
+
# Simple transfer number — this maps to Webex forwarding, minor gap
|
|
65
|
+
return None
|
|
66
|
+
if isinstance(transfer, dict):
|
|
67
|
+
# Dict-form transfer implies complex routing rules
|
|
68
|
+
rules = transfer.get("rules") or transfer.get("conditions")
|
|
69
|
+
if rules:
|
|
70
|
+
return "transfer_to_number: complex routing rules cannot map to Webex"
|
|
71
|
+
# Has a transfer config dict but no complex rules — check for non-empty
|
|
72
|
+
transfer_type = transfer.get("type", "")
|
|
73
|
+
if transfer_type and transfer_type.lower() not in ("", "simple", "direct"):
|
|
74
|
+
return f"transfer_to_number type '{transfer_type}': complex transfer not supported in Webex"
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _check_warnings(profile: dict[str, Any]) -> list[str]:
|
|
79
|
+
"""Extract voicemail-related warnings from the object's warnings list."""
|
|
80
|
+
warnings = profile.get("warnings") or []
|
|
81
|
+
issues: list[str] = []
|
|
82
|
+
for w in warnings:
|
|
83
|
+
if not isinstance(w, str):
|
|
84
|
+
continue
|
|
85
|
+
w_lower = w.lower()
|
|
86
|
+
if any(kw in w_lower for kw in (
|
|
87
|
+
"voicemail", "unity", "fax", "message storage",
|
|
88
|
+
"not supported", "incompatible", "mwi",
|
|
89
|
+
)):
|
|
90
|
+
issues.append(f"warning: {w}")
|
|
91
|
+
return issues
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Severity assignment: fax and complex transfer are MEDIUM;
|
|
95
|
+
# non-standard storage and warning-only issues are MEDIUM;
|
|
96
|
+
# simple warnings are LOW.
|
|
97
|
+
_HIGH_KEYWORDS = frozenset() # nothing is HIGH for voicemail incompatibilities
|
|
98
|
+
_MEDIUM_KEYWORDS = frozenset((
|
|
99
|
+
"fax_message", "message_storage", "transfer_to_number",
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class VoicemailCompatibilityAnalyzer(Analyzer):
|
|
104
|
+
"""Detects voicemail profiles with Unity Connection features that
|
|
105
|
+
lack Webex Calling equivalents.
|
|
106
|
+
|
|
107
|
+
Checks each voicemail_profile object for:
|
|
108
|
+
- fax_message configured (Webex has no fax-to-voicemail)
|
|
109
|
+
- message_storage with MWI-only or external storage
|
|
110
|
+
- transfer_to_number with complex routing rules
|
|
111
|
+
- Warnings on the object indicating incompatibility
|
|
112
|
+
|
|
113
|
+
Skips profiles already covered by voicemail_mapper decisions.
|
|
114
|
+
|
|
115
|
+
(from 03-conflict-detection-engine.md, voicemail compatibility linter)
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
name = "voicemail_compatibility"
|
|
119
|
+
decision_types = [DecisionType.VOICEMAIL_INCOMPATIBLE]
|
|
120
|
+
depends_on: list[str] = []
|
|
121
|
+
|
|
122
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
123
|
+
"""Sweep voicemail profiles for incompatible features."""
|
|
124
|
+
# Existing mapper-produced decisions indexed by canonical_id
|
|
125
|
+
existing = self._get_existing_decisions_for_type(
|
|
126
|
+
store, DecisionType.VOICEMAIL_INCOMPATIBLE
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
decisions: list[Decision] = []
|
|
130
|
+
profiles = store.get_objects("voicemail_profile")
|
|
131
|
+
|
|
132
|
+
for profile in profiles:
|
|
133
|
+
canonical_id = profile.get("canonical_id", "")
|
|
134
|
+
|
|
135
|
+
# Skip if mapper already produced a decision for this profile
|
|
136
|
+
if canonical_id in existing:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
incompatible_features = self._detect_incompatibilities(profile)
|
|
140
|
+
if not incompatible_features:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
severity = self._determine_severity(incompatible_features)
|
|
144
|
+
profile_name = (
|
|
145
|
+
profile.get("cucm_voicemail_profile_name")
|
|
146
|
+
or profile.get("name")
|
|
147
|
+
or canonical_id
|
|
148
|
+
)
|
|
149
|
+
user_canonical_id = profile.get("user_canonical_id", "")
|
|
150
|
+
|
|
151
|
+
summary = (
|
|
152
|
+
f"Voicemail profile '{profile_name}' has {len(incompatible_features)} "
|
|
153
|
+
f"feature(s) incompatible with Webex: "
|
|
154
|
+
f"{', '.join(self._feature_names(incompatible_features))}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
context = {
|
|
158
|
+
"canonical_id": canonical_id,
|
|
159
|
+
"profile_name": profile_name,
|
|
160
|
+
"user_canonical_id": user_canonical_id,
|
|
161
|
+
"incompatible_features": incompatible_features,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
options = [
|
|
165
|
+
DecisionOption(
|
|
166
|
+
id="accept",
|
|
167
|
+
label="Accept fidelity loss",
|
|
168
|
+
impact=(
|
|
169
|
+
f"Voicemail profile '{profile_name}' migrated without "
|
|
170
|
+
f"{', '.join(self._feature_names(incompatible_features))}"
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
DecisionOption(
|
|
174
|
+
id="manual",
|
|
175
|
+
label="Configure manually post-migration",
|
|
176
|
+
impact=(
|
|
177
|
+
f"Voicemail profile '{profile_name}' flagged for manual "
|
|
178
|
+
f"post-migration configuration"
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
DecisionOption(
|
|
182
|
+
id="skip",
|
|
183
|
+
label="Skip this profile",
|
|
184
|
+
impact=f"Voicemail profile '{profile_name}' excluded from migration",
|
|
185
|
+
),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
affected = [canonical_id]
|
|
189
|
+
if user_canonical_id:
|
|
190
|
+
affected.append(user_canonical_id)
|
|
191
|
+
|
|
192
|
+
decision = self._create_decision(
|
|
193
|
+
store=store,
|
|
194
|
+
decision_type=DecisionType.VOICEMAIL_INCOMPATIBLE,
|
|
195
|
+
severity=severity,
|
|
196
|
+
summary=summary,
|
|
197
|
+
context=context,
|
|
198
|
+
options=options,
|
|
199
|
+
affected_objects=affected,
|
|
200
|
+
)
|
|
201
|
+
decisions.append(decision)
|
|
202
|
+
|
|
203
|
+
return decisions
|
|
204
|
+
|
|
205
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
206
|
+
"""Deterministic fingerprint from canonical_id + sorted incompatible feature names."""
|
|
207
|
+
features = context.get("incompatible_features", [])
|
|
208
|
+
return self._hash_fingerprint({
|
|
209
|
+
"type": decision_type.value,
|
|
210
|
+
"canonical_id": context.get("canonical_id", ""),
|
|
211
|
+
"incompatible_features": sorted(self._feature_names(features)),
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
# Internal helpers
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _detect_incompatibilities(profile: dict[str, Any]) -> list[str]:
|
|
220
|
+
"""Return list of issue description strings for all detected incompatibilities."""
|
|
221
|
+
issues: list[str] = []
|
|
222
|
+
|
|
223
|
+
fax_issue = _has_fax_message(profile)
|
|
224
|
+
if fax_issue:
|
|
225
|
+
issues.append(fax_issue)
|
|
226
|
+
|
|
227
|
+
storage_issue = _has_nonstandard_storage(profile)
|
|
228
|
+
if storage_issue:
|
|
229
|
+
issues.append(storage_issue)
|
|
230
|
+
|
|
231
|
+
transfer_issue = _has_complex_transfer(profile)
|
|
232
|
+
if transfer_issue:
|
|
233
|
+
issues.append(transfer_issue)
|
|
234
|
+
|
|
235
|
+
warning_issues = _check_warnings(profile)
|
|
236
|
+
issues.extend(warning_issues)
|
|
237
|
+
|
|
238
|
+
return issues
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _feature_names(issues: list[str]) -> list[str]:
|
|
242
|
+
"""Extract short feature names from issue description strings.
|
|
243
|
+
|
|
244
|
+
E.g., "fax_message: Webex has no fax-to-voicemail" -> "fax_message"
|
|
245
|
+
"""
|
|
246
|
+
names: list[str] = []
|
|
247
|
+
for issue in issues:
|
|
248
|
+
if ":" in issue:
|
|
249
|
+
name = issue.split(":")[0].strip()
|
|
250
|
+
else:
|
|
251
|
+
name = issue[:40]
|
|
252
|
+
names.append(name)
|
|
253
|
+
return names
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _determine_severity(issues: list[str]) -> str:
|
|
257
|
+
"""Determine decision severity based on the incompatible features found.
|
|
258
|
+
|
|
259
|
+
MEDIUM for feature-level incompatibilities (fax, storage, transfer).
|
|
260
|
+
LOW for warning-only issues.
|
|
261
|
+
"""
|
|
262
|
+
for issue in issues:
|
|
263
|
+
prefix = issue.split(":")[0].strip() if ":" in issue else ""
|
|
264
|
+
if prefix in _MEDIUM_KEYWORDS:
|
|
265
|
+
return "MEDIUM"
|
|
266
|
+
# All issues are warning-based
|
|
267
|
+
return "LOW"
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Workspace license tier analyzer for CUCM-to-Webex migration.
|
|
2
|
+
|
|
3
|
+
Determines whether each workspace needs a Workspace or Professional Workspace
|
|
4
|
+
Webex Calling license based on its configured features.
|
|
5
|
+
|
|
6
|
+
WORKSPACE_LICENSE_TIER is a mapper-owned decision type — the
|
|
7
|
+
workspace_mapper may already produce these during transform.
|
|
8
|
+
This analyzer catches workspaces the mapper didn't cover and
|
|
9
|
+
workspaces whose feature profile suggests a tier mismatch.
|
|
10
|
+
|
|
11
|
+
(from 03-conflict-detection-engine.md, linter pattern)
|
|
12
|
+
(from phase-06-analyzers.md, Decision Ownership Table — WORKSPACE_LICENSE_TIER 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
|
+
# Feature indicators that suggest Professional Workspace license is needed
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Each entry: (field_name, check_fn, feature_label)
|
|
28
|
+
# check_fn receives the field value and returns True if the feature IS present.
|
|
29
|
+
|
|
30
|
+
def _has_value(val: Any) -> bool:
|
|
31
|
+
"""True if the value is not None and not empty/falsy."""
|
|
32
|
+
if val is None:
|
|
33
|
+
return False
|
|
34
|
+
if isinstance(val, (str, list, dict)) and not val:
|
|
35
|
+
return False
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_hotdesking_on(val: Any) -> bool:
|
|
40
|
+
return isinstance(val, str) and val.lower() == "on"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_PROFESSIONAL_INDICATORS: list[tuple[str, Any, str]] = [
|
|
44
|
+
("hotdesking_status", _is_hotdesking_on, "hot-desking enabled"),
|
|
45
|
+
("phone_number", _has_value, "DID phone number assigned"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Features detected via cross-references (relationship, label)
|
|
49
|
+
_XREF_INDICATORS: list[tuple[str, str]] = [
|
|
50
|
+
("workspace_has_voicemail", "voicemail configured"),
|
|
51
|
+
("workspace_has_call_forwarding", "call forwarding configured"),
|
|
52
|
+
("workspace_has_shared_line", "shared line appearance"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WorkspaceLicenseAnalyzer(Analyzer):
|
|
57
|
+
"""Determines Workspace vs Professional Workspace license for workspaces.
|
|
58
|
+
|
|
59
|
+
Webex Calling workspaces can be provisioned with Workspace or
|
|
60
|
+
Professional Workspace licenses. Several features require Professional Workspace:
|
|
61
|
+
- Hot-desking
|
|
62
|
+
- DID phone numbers
|
|
63
|
+
- Voicemail
|
|
64
|
+
- Call forwarding
|
|
65
|
+
- Shared line appearances
|
|
66
|
+
- Most /telephony/config/workspaces/ settings
|
|
67
|
+
|
|
68
|
+
The workspace_mapper sets ``license_tier`` during transform when the
|
|
69
|
+
answer is obvious. This analyzer catches workspaces where:
|
|
70
|
+
1. The mapper didn't set a tier (license_tier is None)
|
|
71
|
+
2. The workspace has feature indicators that create ambiguity
|
|
72
|
+
|
|
73
|
+
(from docs/reference/devices-workspaces.md, Workspace vs Professional Workspace)
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
name = "workspace_license"
|
|
77
|
+
decision_types = [DecisionType.WORKSPACE_LICENSE_TIER]
|
|
78
|
+
|
|
79
|
+
def analyze(self, store: MigrationStore) -> list[Decision]:
|
|
80
|
+
"""Sweep workspaces for undecided or ambiguous license tiers."""
|
|
81
|
+
# Existing mapper-produced decisions indexed by canonical_id
|
|
82
|
+
existing = self._get_existing_decisions_for_type(
|
|
83
|
+
store, DecisionType.WORKSPACE_LICENSE_TIER
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
decisions: list[Decision] = []
|
|
87
|
+
workspaces = store.get_objects("workspace")
|
|
88
|
+
|
|
89
|
+
for ws in workspaces:
|
|
90
|
+
canonical_id = ws.get("canonical_id", "")
|
|
91
|
+
|
|
92
|
+
# Skip workspaces the mapper already produced a decision for
|
|
93
|
+
if canonical_id in existing:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
license_tier = ws.get("license_tier")
|
|
97
|
+
features_detected = self._detect_features(ws, store, canonical_id)
|
|
98
|
+
|
|
99
|
+
# Case 1: mapper already set a definite tier and no conflicting features
|
|
100
|
+
if license_tier is not None and not features_detected:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Case 2: tier is set but features suggest it might be wrong
|
|
104
|
+
# (e.g., mapper said Workspace but workspace has voicemail)
|
|
105
|
+
if license_tier == "Workspace" and features_detected:
|
|
106
|
+
summary = (
|
|
107
|
+
f"Workspace '{ws.get('display_name', canonical_id)}' set to Workspace "
|
|
108
|
+
f"but has Professional Workspace features: {', '.join(features_detected)}"
|
|
109
|
+
)
|
|
110
|
+
# Case 3: no tier set at all
|
|
111
|
+
elif license_tier is None:
|
|
112
|
+
if features_detected:
|
|
113
|
+
summary = (
|
|
114
|
+
f"Workspace '{ws.get('display_name', canonical_id)}' needs license "
|
|
115
|
+
f"tier — detected features: {', '.join(features_detected)}"
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
summary = (
|
|
119
|
+
f"Workspace '{ws.get('display_name', canonical_id)}' needs license "
|
|
120
|
+
f"tier assignment (no feature indicators detected)"
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
# Professional Workspace tier set, features present — consistent, no decision needed
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
context = {
|
|
127
|
+
"canonical_id": canonical_id,
|
|
128
|
+
"display_name": ws.get("display_name"),
|
|
129
|
+
"license_tier": license_tier,
|
|
130
|
+
"features_detected": features_detected,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
options = [
|
|
134
|
+
DecisionOption(
|
|
135
|
+
id="workspace",
|
|
136
|
+
label="Workspace",
|
|
137
|
+
impact="Workspace license; limited to basic calling features",
|
|
138
|
+
),
|
|
139
|
+
DecisionOption(
|
|
140
|
+
id="professional",
|
|
141
|
+
label="Professional Workspace",
|
|
142
|
+
impact="Professional Workspace license; full calling features including voicemail, forwarding, hot-desking",
|
|
143
|
+
),
|
|
144
|
+
DecisionOption(
|
|
145
|
+
id="skip",
|
|
146
|
+
label="Skip this workspace",
|
|
147
|
+
impact="1 workspace excluded from migration",
|
|
148
|
+
),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
decision = self._create_decision(
|
|
152
|
+
store=store,
|
|
153
|
+
decision_type=DecisionType.WORKSPACE_LICENSE_TIER,
|
|
154
|
+
severity="MEDIUM",
|
|
155
|
+
summary=summary,
|
|
156
|
+
context=context,
|
|
157
|
+
options=options,
|
|
158
|
+
affected_objects=[canonical_id],
|
|
159
|
+
)
|
|
160
|
+
decisions.append(decision)
|
|
161
|
+
|
|
162
|
+
return decisions
|
|
163
|
+
|
|
164
|
+
def fingerprint(self, decision_type: DecisionType, context: dict[str, Any]) -> str:
|
|
165
|
+
"""Deterministic fingerprint from canonical_id + license tier + detected features.
|
|
166
|
+
|
|
167
|
+
Includes features_detected so that discovering new Professional Workspace-tier
|
|
168
|
+
features invalidates a previous "Workspace" resolution.
|
|
169
|
+
(from 07-idempotency-resumability.md: include data whose change would
|
|
170
|
+
invalidate a previous resolution)
|
|
171
|
+
"""
|
|
172
|
+
return self._hash_fingerprint({
|
|
173
|
+
"type": decision_type.value,
|
|
174
|
+
"canonical_id": context.get("canonical_id", ""),
|
|
175
|
+
"license_tier": context.get("license_tier"),
|
|
176
|
+
"features_detected": sorted(context.get("features_detected", [])),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# Internal helpers
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _detect_features(
|
|
185
|
+
ws: dict[str, Any],
|
|
186
|
+
store: MigrationStore,
|
|
187
|
+
canonical_id: str,
|
|
188
|
+
) -> list[str]:
|
|
189
|
+
"""Detect features that suggest Professional Workspace license is needed.
|
|
190
|
+
|
|
191
|
+
Checks both workspace data fields and cross-references to
|
|
192
|
+
related objects (voicemail profiles, call forwarding, shared lines).
|
|
193
|
+
"""
|
|
194
|
+
features: list[str] = []
|
|
195
|
+
|
|
196
|
+
# Check data fields
|
|
197
|
+
for field_name, check_fn, label in _PROFESSIONAL_INDICATORS:
|
|
198
|
+
if check_fn(ws.get(field_name)):
|
|
199
|
+
features.append(label)
|
|
200
|
+
|
|
201
|
+
# Check multiple line appearances (>1 line suggests shared/complex config)
|
|
202
|
+
line_refs = store.find_cross_refs(canonical_id, "workspace_has_line")
|
|
203
|
+
if len(line_refs) > 1:
|
|
204
|
+
features.append(f"multiple lines ({len(line_refs)})")
|
|
205
|
+
|
|
206
|
+
# Check cross-reference indicators
|
|
207
|
+
for relationship, label in _XREF_INDICATORS:
|
|
208
|
+
refs = store.find_cross_refs(canonical_id, relationship)
|
|
209
|
+
if refs:
|
|
210
|
+
features.append(label)
|
|
211
|
+
|
|
212
|
+
return features
|