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,246 @@
|
|
|
1
|
+
"""CUCM digit pattern compiler and overlap detection.
|
|
2
|
+
|
|
3
|
+
Compiles CUCM route pattern strings into Python regexes and detects
|
|
4
|
+
whether two patterns can match the same digit string. Used by css_mapper
|
|
5
|
+
for ordering conflict detection.
|
|
6
|
+
|
|
7
|
+
CUCM pattern symbols (from 04-css-decomposition.md):
|
|
8
|
+
X — single digit 0-9
|
|
9
|
+
! — one or more digits (greedy wildcard)
|
|
10
|
+
[n-m] — digit range
|
|
11
|
+
[^n] — negated digit
|
|
12
|
+
@ — national numbering plan macro (country-specific)
|
|
13
|
+
. — separator (between access code and pattern)
|
|
14
|
+
+ — E.164 prefix (literal)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import random
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# @ macro expansion — intentionally broad for overlap detection safety.
|
|
25
|
+
# Real CUCM '@' expands to the loaded National Numbering Plan file, which
|
|
26
|
+
# varies by cluster/country. This catch-all never produces false negatives
|
|
27
|
+
# in overlap detection (it over-matches, so overlapping patterns are always
|
|
28
|
+
# detected). generate_representative_strings compensates by producing
|
|
29
|
+
# US-format 10-digit numbers for '@' patterns.
|
|
30
|
+
# CUCM @ pattern matches "any dialed number" — exact expansion varies by
|
|
31
|
+
# cluster/country dial plan. [0-9]{1,15} is a conservative over-match that
|
|
32
|
+
# never produces false negatives in overlap detection. Safe approximation.
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
_AT_MACRO_REGEX = "[0-9]{1,15}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cucm_pattern_to_regex(pattern: str) -> str:
|
|
38
|
+
"""Convert a CUCM digit pattern to a Python regex string.
|
|
39
|
+
|
|
40
|
+
X → [0-9], ! → [0-9]+, [1-4] → [1-4], [^5] → [^5]
|
|
41
|
+
. → '' (stripped — separator only), + → \\+, @ → expanded
|
|
42
|
+
|
|
43
|
+
(from 03b-transform-mappers.md, cucm_pattern.py interface)
|
|
44
|
+
"""
|
|
45
|
+
if not pattern or pattern.strip() == "" or pattern == ".":
|
|
46
|
+
raise ValueError(f"Empty or invalid CUCM pattern: {pattern!r}")
|
|
47
|
+
|
|
48
|
+
# Split on first '.' only — it's the access code separator
|
|
49
|
+
# (from 04-css-decomposition.md: '.' separates access code from pattern)
|
|
50
|
+
parts = pattern.split(".", 1)
|
|
51
|
+
if len(parts) == 2:
|
|
52
|
+
access_code, digit_pattern = parts[0], parts[1]
|
|
53
|
+
else:
|
|
54
|
+
access_code, digit_pattern = "", parts[0]
|
|
55
|
+
|
|
56
|
+
# Convert the access code (literal digits, + prefix)
|
|
57
|
+
regex = _convert_segment(access_code)
|
|
58
|
+
# Convert the digit pattern
|
|
59
|
+
regex += _convert_segment(digit_pattern)
|
|
60
|
+
|
|
61
|
+
return regex
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _convert_segment(segment: str) -> str:
|
|
65
|
+
"""Convert a CUCM pattern segment to regex."""
|
|
66
|
+
result = []
|
|
67
|
+
i = 0
|
|
68
|
+
while i < len(segment):
|
|
69
|
+
ch = segment[i]
|
|
70
|
+
if ch == "X":
|
|
71
|
+
result.append("[0-9]")
|
|
72
|
+
elif ch == "!":
|
|
73
|
+
result.append("[0-9]+")
|
|
74
|
+
elif ch == "@":
|
|
75
|
+
result.append(_AT_MACRO_REGEX)
|
|
76
|
+
elif ch == "+":
|
|
77
|
+
result.append("\\+")
|
|
78
|
+
elif ch == "[":
|
|
79
|
+
# Pass through bracket expressions (ranges and negation)
|
|
80
|
+
try:
|
|
81
|
+
end = segment.index("]", i)
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise ValueError(f"Unclosed bracket in CUCM pattern segment: {segment!r}")
|
|
84
|
+
result.append(segment[i : end + 1])
|
|
85
|
+
i = end
|
|
86
|
+
elif ch.isdigit():
|
|
87
|
+
result.append(ch)
|
|
88
|
+
# Skip any other character (shouldn't normally appear)
|
|
89
|
+
i += 1
|
|
90
|
+
return "".join(result)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def compile_cucm_pattern(pattern: str) -> re.Pattern:
|
|
94
|
+
"""Compile a CUCM digit pattern into a Python regex Pattern object.
|
|
95
|
+
|
|
96
|
+
(from phase-02-risk-spikes.md: compile_cucm_pattern(pattern: str) -> re.Pattern)
|
|
97
|
+
"""
|
|
98
|
+
regex_str = cucm_pattern_to_regex(pattern)
|
|
99
|
+
return re.compile(f"^{regex_str}$")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def generate_representative_strings(pattern: str, count: int = 10) -> list[str]:
|
|
103
|
+
"""Generate digit strings that match a CUCM pattern.
|
|
104
|
+
|
|
105
|
+
Uses random generation to produce representative strings for overlap testing.
|
|
106
|
+
For patterns with '!' (unbounded length), generates strings at representative
|
|
107
|
+
lengths (5, 7, 10, 11, 15 digits for the '!' portion).
|
|
108
|
+
(from 04-css-decomposition.md: enumeration approach #2)
|
|
109
|
+
"""
|
|
110
|
+
if not pattern or pattern.strip() == "" or pattern == ".":
|
|
111
|
+
raise ValueError(f"Empty or invalid CUCM pattern: {pattern!r}")
|
|
112
|
+
|
|
113
|
+
# Split on first '.' — access code separator
|
|
114
|
+
parts = pattern.split(".", 1)
|
|
115
|
+
if len(parts) == 2:
|
|
116
|
+
access_code, digit_pattern = parts[0], parts[1]
|
|
117
|
+
else:
|
|
118
|
+
access_code, digit_pattern = "", parts[0]
|
|
119
|
+
|
|
120
|
+
compiled = compile_cucm_pattern(pattern)
|
|
121
|
+
results = []
|
|
122
|
+
|
|
123
|
+
for _ in range(count * 5): # oversample to hit count
|
|
124
|
+
s = _generate_one(access_code) + _generate_one(digit_pattern)
|
|
125
|
+
if compiled.fullmatch(s):
|
|
126
|
+
results.append(s)
|
|
127
|
+
if len(results) >= count:
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
# Deduplicate but respect count
|
|
131
|
+
if len(results) < count:
|
|
132
|
+
# For exact patterns there's only one string
|
|
133
|
+
unique = list(dict.fromkeys(results))
|
|
134
|
+
# Pad with duplicates if needed
|
|
135
|
+
while len(results) < count and unique:
|
|
136
|
+
results.append(unique[0])
|
|
137
|
+
|
|
138
|
+
return results[:count]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _generate_one(segment: str) -> str:
|
|
142
|
+
"""Generate one random digit string matching a CUCM pattern segment."""
|
|
143
|
+
result = []
|
|
144
|
+
i = 0
|
|
145
|
+
while i < len(segment):
|
|
146
|
+
ch = segment[i]
|
|
147
|
+
if ch == "X":
|
|
148
|
+
result.append(str(random.randint(0, 9)))
|
|
149
|
+
elif ch == "!":
|
|
150
|
+
# Generate at representative lengths for the '!' portion
|
|
151
|
+
bang_len = random.choice([1, 2, 4, 7, 10])
|
|
152
|
+
result.append("".join(str(random.randint(0, 9)) for _ in range(bang_len)))
|
|
153
|
+
elif ch == "@":
|
|
154
|
+
# US national plan: generate 10-digit number
|
|
155
|
+
result.append(
|
|
156
|
+
str(random.randint(2, 9))
|
|
157
|
+
+ "".join(str(random.randint(0, 9)) for _ in range(9))
|
|
158
|
+
)
|
|
159
|
+
elif ch == "+":
|
|
160
|
+
result.append("+")
|
|
161
|
+
elif ch == "[":
|
|
162
|
+
try:
|
|
163
|
+
end = segment.index("]", i)
|
|
164
|
+
except ValueError:
|
|
165
|
+
raise ValueError(f"Unclosed bracket in CUCM pattern segment: {segment!r}")
|
|
166
|
+
bracket_content = segment[i + 1 : end]
|
|
167
|
+
digit = _random_from_bracket(bracket_content)
|
|
168
|
+
result.append(digit)
|
|
169
|
+
i = end
|
|
170
|
+
elif ch.isdigit():
|
|
171
|
+
result.append(ch)
|
|
172
|
+
i += 1
|
|
173
|
+
return "".join(result)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _random_from_bracket(content: str) -> str:
|
|
177
|
+
"""Pick a random digit matching a bracket expression like '2-9' or '^0'."""
|
|
178
|
+
negated = content.startswith("^")
|
|
179
|
+
if negated:
|
|
180
|
+
content = content[1:]
|
|
181
|
+
|
|
182
|
+
# Build set of allowed digits
|
|
183
|
+
allowed = set()
|
|
184
|
+
i = 0
|
|
185
|
+
while i < len(content):
|
|
186
|
+
if i + 2 < len(content) and content[i + 1] == "-":
|
|
187
|
+
start, end = int(content[i]), int(content[i + 2])
|
|
188
|
+
allowed.update(range(start, end + 1))
|
|
189
|
+
i += 3
|
|
190
|
+
else:
|
|
191
|
+
allowed.add(int(content[i]))
|
|
192
|
+
i += 1
|
|
193
|
+
|
|
194
|
+
if negated:
|
|
195
|
+
allowed = set(range(10)) - allowed
|
|
196
|
+
|
|
197
|
+
if not allowed:
|
|
198
|
+
return "0"
|
|
199
|
+
return str(random.choice(sorted(allowed)))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def cucm_patterns_overlap(pattern_a: str, pattern_b: str) -> bool:
|
|
203
|
+
"""Return True if two CUCM patterns can match any common digit string.
|
|
204
|
+
|
|
205
|
+
Uses enumeration approach: generate representative digit strings from each
|
|
206
|
+
pattern's match space and test against the other. Tests at representative
|
|
207
|
+
lengths (4, 7, 10, 11, 15 digits) for patterns involving !.
|
|
208
|
+
(from 04-css-decomposition.md, overlap detection via enumeration)
|
|
209
|
+
(from 03b-transform-mappers.md, cucm_pattern.py interface)
|
|
210
|
+
"""
|
|
211
|
+
compiled_a = compile_cucm_pattern(pattern_a)
|
|
212
|
+
compiled_b = compile_cucm_pattern(pattern_b)
|
|
213
|
+
|
|
214
|
+
# Generate samples from A, test against B
|
|
215
|
+
samples_a = generate_representative_strings(pattern_a, count=50)
|
|
216
|
+
for s in samples_a:
|
|
217
|
+
if compiled_b.fullmatch(s):
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
# Generate samples from B, test against A
|
|
221
|
+
samples_b = generate_representative_strings(pattern_b, count=50)
|
|
222
|
+
for s in samples_b:
|
|
223
|
+
if compiled_a.fullmatch(s):
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def classify_block_pattern(
|
|
230
|
+
pattern: str,
|
|
231
|
+
category_rules: list[dict],
|
|
232
|
+
) -> str | None:
|
|
233
|
+
"""Classify a CUCM blocking pattern into a Webex permission category.
|
|
234
|
+
|
|
235
|
+
Returns category string (e.g., 'international', 'premium') or None if
|
|
236
|
+
unclassifiable. Uses configurable rules from migration config.
|
|
237
|
+
(from 03b-transform-mappers.md, cucm_pattern.py interface)
|
|
238
|
+
|
|
239
|
+
A rule matches if the blocking pattern is identical to the rule's pattern
|
|
240
|
+
(exact string match). For more sophisticated matching (e.g., pattern
|
|
241
|
+
subsumption), extend this function.
|
|
242
|
+
"""
|
|
243
|
+
for rule in category_rules:
|
|
244
|
+
if rule["cucm_pattern"] == pattern:
|
|
245
|
+
return rule["webex_category"]
|
|
246
|
+
return None
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Decision model helpers for querying, summarizing, and formatting decisions.
|
|
2
|
+
|
|
3
|
+
Complements the Mapper._fingerprint() method with higher-level helpers
|
|
4
|
+
for CLI reporting and programmatic queries.
|
|
5
|
+
|
|
6
|
+
(from 03b-transform-mappers.md, shared patterns)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from wxcli.migration.store import MigrationStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def summarize_decisions(decisions: list[dict[str, Any]]) -> dict[str, dict[str, int]]:
|
|
18
|
+
"""Group decisions by type and severity.
|
|
19
|
+
|
|
20
|
+
Returns a nested dict: {type: {severity: count}}.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
decisions: list of decision dicts (from store.get_all_decisions() or similar).
|
|
24
|
+
|
|
25
|
+
Example return::
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
"DEVICE_INCOMPATIBLE": {"HIGH": 3, "MEDIUM": 1},
|
|
29
|
+
"MISSING_DATA": {"MEDIUM": 5, "LOW": 2},
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
summary: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
33
|
+
for dec in decisions:
|
|
34
|
+
dec_type = dec.get("type", "UNKNOWN")
|
|
35
|
+
severity = dec.get("severity", "UNKNOWN")
|
|
36
|
+
summary[dec_type][severity] += 1
|
|
37
|
+
# Convert defaultdicts to regular dicts for clean serialization
|
|
38
|
+
return {k: dict(v) for k, v in summary.items()}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_decision_report(decisions: list[dict[str, Any]]) -> str:
|
|
42
|
+
"""Human-readable decision summary for CLI output.
|
|
43
|
+
|
|
44
|
+
Produces a compact multi-line report grouped by type with counts
|
|
45
|
+
and resolution status.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
decisions: list of decision dicts.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Multi-line string suitable for console display.
|
|
52
|
+
"""
|
|
53
|
+
if not decisions:
|
|
54
|
+
return "No decisions to report."
|
|
55
|
+
|
|
56
|
+
summary = summarize_decisions(decisions)
|
|
57
|
+
|
|
58
|
+
# Count resolved vs pending
|
|
59
|
+
total = len(decisions)
|
|
60
|
+
resolved = sum(1 for d in decisions if d.get("chosen_option") is not None)
|
|
61
|
+
pending = total - resolved
|
|
62
|
+
|
|
63
|
+
lines: list[str] = [
|
|
64
|
+
f"Decision Report: {total} total ({resolved} resolved, {pending} pending)",
|
|
65
|
+
"-" * 60,
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# Sort types by total count descending
|
|
69
|
+
type_totals = {
|
|
70
|
+
dec_type: sum(sev_counts.values())
|
|
71
|
+
for dec_type, sev_counts in summary.items()
|
|
72
|
+
}
|
|
73
|
+
for dec_type in sorted(type_totals, key=type_totals.get, reverse=True): # type: ignore[arg-type]
|
|
74
|
+
sev_counts = summary[dec_type]
|
|
75
|
+
sev_parts = ", ".join(
|
|
76
|
+
f"{sev}: {count}" for sev, count in sorted(sev_counts.items())
|
|
77
|
+
)
|
|
78
|
+
lines.append(f" {dec_type}: {type_totals[dec_type]} ({sev_parts})")
|
|
79
|
+
|
|
80
|
+
# Pending breakdown
|
|
81
|
+
if pending > 0:
|
|
82
|
+
lines.append("")
|
|
83
|
+
lines.append(f"Pending decisions requiring resolution: {pending}")
|
|
84
|
+
pending_by_severity: dict[str, int] = defaultdict(int)
|
|
85
|
+
for d in decisions:
|
|
86
|
+
if d.get("chosen_option") is None:
|
|
87
|
+
pending_by_severity[d.get("severity", "UNKNOWN")] += 1
|
|
88
|
+
for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
|
|
89
|
+
count = pending_by_severity.get(sev, 0)
|
|
90
|
+
if count:
|
|
91
|
+
lines.append(f" {sev}: {count}")
|
|
92
|
+
|
|
93
|
+
return "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def decisions_by_type(
|
|
97
|
+
store: MigrationStore, decision_type: str
|
|
98
|
+
) -> list[dict[str, Any]]:
|
|
99
|
+
"""Query all decisions of a given type from the store.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
store: MigrationStore instance.
|
|
103
|
+
decision_type: DecisionType value string (e.g., "DEVICE_INCOMPATIBLE").
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
List of decision dicts matching the given type.
|
|
107
|
+
"""
|
|
108
|
+
all_decisions = store.get_all_decisions()
|
|
109
|
+
return [d for d in all_decisions if d.get("type") == decision_type]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def pending_decisions(store: MigrationStore) -> list[dict[str, Any]]:
|
|
113
|
+
"""Query all unresolved decisions from the store.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of decision dicts where chosen_option is None.
|
|
117
|
+
"""
|
|
118
|
+
all_decisions = store.get_all_decisions()
|
|
119
|
+
return [d for d in all_decisions if d.get("chosen_option") is None]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def resolved_decisions(store: MigrationStore) -> list[dict[str, Any]]:
|
|
123
|
+
"""Query all resolved decisions from the store.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List of decision dicts where chosen_option is not None.
|
|
127
|
+
"""
|
|
128
|
+
all_decisions = store.get_all_decisions()
|
|
129
|
+
return [d for d in all_decisions if d.get("chosen_option") is not None]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Auto-apply / needs-input classification
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def classify_decisions(
|
|
137
|
+
store: MigrationStore,
|
|
138
|
+
config: dict[str, Any],
|
|
139
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
140
|
+
"""Split pending decisions into auto-apply and needs-input groups.
|
|
141
|
+
|
|
142
|
+
Auto-apply = pending decisions that the project's ``config["auto_rules"]``
|
|
143
|
+
would resolve. Each item is augmented with ``auto_choice`` and
|
|
144
|
+
``auto_reason`` keys (from ``preview_auto_rules``).
|
|
145
|
+
|
|
146
|
+
Needs-input = pending decisions that no rule matches.
|
|
147
|
+
|
|
148
|
+
Both groups exclude already-resolved decisions and ``__stale__``
|
|
149
|
+
decisions.
|
|
150
|
+
"""
|
|
151
|
+
from wxcli.migration.transform.rules import preview_auto_rules
|
|
152
|
+
|
|
153
|
+
auto_apply = preview_auto_rules(store, config)
|
|
154
|
+
auto_apply_ids = {d["decision_id"] for d in auto_apply}
|
|
155
|
+
|
|
156
|
+
all_decisions = store.get_all_decisions()
|
|
157
|
+
needs_input = [
|
|
158
|
+
d for d in all_decisions
|
|
159
|
+
if d.get("chosen_option") is None
|
|
160
|
+
and d["decision_id"] not in auto_apply_ids
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
return auto_apply, needs_input
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Decision review export (markdown)
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
# Group labels for the needs-input section
|
|
171
|
+
_DECISION_CATEGORY: dict[str, str] = {
|
|
172
|
+
"LOCATION_AMBIGUOUS": "Location",
|
|
173
|
+
"DEVICE_FIRMWARE_CONVERTIBLE": "Device",
|
|
174
|
+
"DEVICE_INCOMPATIBLE": "Device",
|
|
175
|
+
"WORKSPACE_LICENSE_TIER": "Workspace",
|
|
176
|
+
"WORKSPACE_TYPE_UNCERTAIN": "Workspace",
|
|
177
|
+
"HOTDESK_DN_CONFLICT": "Workspace",
|
|
178
|
+
"EXTENSION_CONFLICT": "User",
|
|
179
|
+
"DN_AMBIGUOUS": "User",
|
|
180
|
+
"DUPLICATE_USER": "User",
|
|
181
|
+
"SHARED_LINE_COMPLEX": "User",
|
|
182
|
+
"MISSING_DATA": "Data Quality",
|
|
183
|
+
"CSS_ROUTING_MISMATCH": "Routing",
|
|
184
|
+
"CALLING_PERMISSION_MISMATCH": "Permissions",
|
|
185
|
+
"VOICEMAIL_INCOMPATIBLE": "Voicemail",
|
|
186
|
+
"FEATURE_APPROXIMATION": "Feature",
|
|
187
|
+
"NUMBER_CONFLICT": "Number",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def generate_decision_review(
|
|
192
|
+
store: MigrationStore,
|
|
193
|
+
project_id: str,
|
|
194
|
+
config: dict[str, Any],
|
|
195
|
+
) -> str:
|
|
196
|
+
"""Generate a markdown decision review file with auto-apply and needs-input sections."""
|
|
197
|
+
auto_apply, needs_input = classify_decisions(store, config)
|
|
198
|
+
|
|
199
|
+
lines: list[str] = []
|
|
200
|
+
lines.append(f"# Migration Decision Review — {project_id}")
|
|
201
|
+
lines.append(f"Generated: {_now_date()}")
|
|
202
|
+
lines.append("")
|
|
203
|
+
|
|
204
|
+
# --- Section 1: Auto-apply ---
|
|
205
|
+
lines.append(f"## Auto-Apply ({len(auto_apply)} decisions)")
|
|
206
|
+
lines.append("")
|
|
207
|
+
if auto_apply:
|
|
208
|
+
lines.append("These have only one sensible resolution and will be applied when you run")
|
|
209
|
+
lines.append("`wxcli cucm decide --apply-auto`. Review and raise any concerns before applying.")
|
|
210
|
+
lines.append("")
|
|
211
|
+
lines.append("| ID | Type | Object | Resolution | Reason |")
|
|
212
|
+
lines.append("|----|------|--------|------------|--------|")
|
|
213
|
+
for d in auto_apply:
|
|
214
|
+
did = d.get("decision_id", "")
|
|
215
|
+
dtype = d.get("type", "")
|
|
216
|
+
obj_name = _object_name(d)
|
|
217
|
+
choice = d.get("auto_choice", "")
|
|
218
|
+
reason = d.get("auto_reason", "")
|
|
219
|
+
lines.append(f"| {did} | {dtype} | {obj_name} | {choice} | {reason} |")
|
|
220
|
+
else:
|
|
221
|
+
lines.append("No auto-apply decisions.")
|
|
222
|
+
lines.append("")
|
|
223
|
+
|
|
224
|
+
# --- Section 2: Needs input ---
|
|
225
|
+
lines.append(f"## Needs Your Input ({len(needs_input)} decisions)")
|
|
226
|
+
lines.append("")
|
|
227
|
+
if needs_input:
|
|
228
|
+
lines.append("Each decision requires admin judgment. Options are listed — discuss with")
|
|
229
|
+
lines.append("your migration team if needed. Resolve via `wxcli cucm decide <ID> <choice>`")
|
|
230
|
+
lines.append("or tell the migration agent your choices.")
|
|
231
|
+
lines.append("")
|
|
232
|
+
|
|
233
|
+
# Group by category
|
|
234
|
+
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
235
|
+
for d in needs_input:
|
|
236
|
+
cat = _DECISION_CATEGORY.get(d.get("type", ""), "Other")
|
|
237
|
+
grouped[cat].append(d)
|
|
238
|
+
|
|
239
|
+
for category in sorted(grouped.keys()):
|
|
240
|
+
decs = grouped[category]
|
|
241
|
+
lines.append(f"### {category} Decisions ({len(decs)})")
|
|
242
|
+
lines.append("")
|
|
243
|
+
lines.append("| ID | Severity | Summary | Options |")
|
|
244
|
+
lines.append("|----|----------|---------|---------|")
|
|
245
|
+
for d in decs:
|
|
246
|
+
did = d.get("decision_id", "")
|
|
247
|
+
sev = d.get("severity", "")
|
|
248
|
+
summary = d.get("summary", "").replace("|", "/")
|
|
249
|
+
opts = _format_options_short(d.get("options", []))
|
|
250
|
+
lines.append(f"| {did} | {sev} | {summary} | {opts} |")
|
|
251
|
+
|
|
252
|
+
# Detail section: show context for each decision
|
|
253
|
+
lines.append("")
|
|
254
|
+
for d in decs:
|
|
255
|
+
did = d.get("decision_id", "")
|
|
256
|
+
lines.append(f"**{did}** — {d.get('summary', '')}")
|
|
257
|
+
lines.append("")
|
|
258
|
+
for i, opt in enumerate(d.get("options", [])):
|
|
259
|
+
letter = chr(ord("a") + i)
|
|
260
|
+
opt_id = opt.get("id", "") if isinstance(opt, dict) else str(opt)
|
|
261
|
+
label = opt.get("label", opt_id) if isinstance(opt, dict) else str(opt)
|
|
262
|
+
impact = opt.get("impact", "") if isinstance(opt, dict) else ""
|
|
263
|
+
lines.append(f" {letter}) **{label}** (`{opt_id}`) — {impact}")
|
|
264
|
+
lines.append("")
|
|
265
|
+
else:
|
|
266
|
+
lines.append("No decisions require input — all are auto-apply.")
|
|
267
|
+
lines.append("")
|
|
268
|
+
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _object_name(decision: dict[str, Any]) -> str:
|
|
273
|
+
"""Extract a readable object name from a decision's context or affected_objects."""
|
|
274
|
+
ctx = decision.get("context", {})
|
|
275
|
+
for key in ("device_name", "name", "canonical_id", "object_name"):
|
|
276
|
+
val = ctx.get(key)
|
|
277
|
+
if val:
|
|
278
|
+
return str(val)
|
|
279
|
+
affected = ctx.get("_affected_objects", [])
|
|
280
|
+
if affected:
|
|
281
|
+
return str(affected[0])
|
|
282
|
+
return "unknown"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _format_options_short(options: list) -> str:
|
|
286
|
+
"""Format options as a compact lettered list for table cells."""
|
|
287
|
+
parts = []
|
|
288
|
+
for i, opt in enumerate(options):
|
|
289
|
+
letter = chr(ord("a") + i)
|
|
290
|
+
if isinstance(opt, dict):
|
|
291
|
+
opt_id = opt.get("id", "")
|
|
292
|
+
label = opt.get("label", opt_id)
|
|
293
|
+
else:
|
|
294
|
+
label = str(opt)
|
|
295
|
+
parts.append(f"**{letter}**) {label}")
|
|
296
|
+
return " ".join(parts)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _now_date() -> str:
|
|
300
|
+
from datetime import datetime, timezone
|
|
301
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""E.164 normalization for CUCM Directory Numbers.
|
|
2
|
+
|
|
3
|
+
Classifies DNs as EXTENSION / NATIONAL / E164 / AMBIGUOUS and normalizes
|
|
4
|
+
to E.164 format using Google's phonenumbers library (libphonenumber).
|
|
5
|
+
|
|
6
|
+
Used by line_mapper during normalization pass 2 to produce CanonicalLine
|
|
7
|
+
objects with both bare extension and E.164 phone number fields.
|
|
8
|
+
|
|
9
|
+
(from 02-normalization-architecture.md, E.164 normalization section)
|
|
10
|
+
(from 03b-transform-mappers.md, line_mapper E.164 normalization algorithm)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import phonenumbers
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class E164Result:
|
|
22
|
+
"""Result of E.164 normalization for a single DN.
|
|
23
|
+
|
|
24
|
+
Fields match what line_mapper in 03b-transform-mappers.md expects:
|
|
25
|
+
- e164: normalized E.164 string (e.g., "+14155551234") or None
|
|
26
|
+
- extension: bare extension digits (e.g., "1001") or None
|
|
27
|
+
- raw: the original input DN string
|
|
28
|
+
- classification: one of EXTENSION, NATIONAL, E164, AMBIGUOUS
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
e164: str | None
|
|
32
|
+
extension: str | None
|
|
33
|
+
raw: str
|
|
34
|
+
classification: str # EXTENSION | NATIONAL | E164 | AMBIGUOUS
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Maximum digits for a DN to be classified as an extension.
|
|
38
|
+
# (from 02-normalization-architecture.md: "if len(stripped) <= 6")
|
|
39
|
+
# Extended to handle CUCM extensions up to 10 digits, but phonenumbers
|
|
40
|
+
# is tried first — only numbers that fail phonenumbers parsing fall back
|
|
41
|
+
# to extension classification.
|
|
42
|
+
_MAX_EXTENSION_DIGITS = 6
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def apply_prefix_rules(dn: str, rules: list[dict]) -> str:
|
|
46
|
+
"""Strip a site-specific prefix from a DN.
|
|
47
|
+
|
|
48
|
+
Rules are applied in order; the first matching rule is used.
|
|
49
|
+
Each rule is a dict with "prefix" (str) and optionally "description".
|
|
50
|
+
|
|
51
|
+
(from 02-normalization-architecture.md: "Step 1: Strip site prefix")
|
|
52
|
+
(from 03b-transform-mappers.md, line_mapper field table: site prefix rules)
|
|
53
|
+
"""
|
|
54
|
+
for rule in rules:
|
|
55
|
+
prefix = rule["prefix"]
|
|
56
|
+
if dn.startswith(prefix) and len(dn) > len(prefix):
|
|
57
|
+
return dn[len(prefix) :]
|
|
58
|
+
return dn
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_dn(
|
|
62
|
+
dn: str,
|
|
63
|
+
country_code: str,
|
|
64
|
+
site_prefix_rules: list[dict],
|
|
65
|
+
) -> E164Result:
|
|
66
|
+
"""Normalize a CUCM DN to E.164 using libphonenumber.
|
|
67
|
+
|
|
68
|
+
(from 02-normalization-architecture.md, normalize_dn function)
|
|
69
|
+
(from 03b-transform-mappers.md, line_mapper E.164 normalization algorithm)
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
dn: Raw directory number string from CUCM.
|
|
73
|
+
country_code: ISO 3166-1 alpha-2 code (e.g., "US", "GB", "DE").
|
|
74
|
+
Comes from the DN's resolved location.
|
|
75
|
+
site_prefix_rules: List of prefix stripping rules from migration config.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
E164Result with classification and normalized values.
|
|
79
|
+
Invalid numbers produce AMBIGUOUS, never exceptions.
|
|
80
|
+
"""
|
|
81
|
+
# Guard: empty or whitespace-only DN
|
|
82
|
+
if not dn or not dn.strip():
|
|
83
|
+
return E164Result(
|
|
84
|
+
e164=None,
|
|
85
|
+
extension=None,
|
|
86
|
+
raw=dn or "",
|
|
87
|
+
classification="AMBIGUOUS",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Guard: non-digit characters that aren't '+' indicate special codes
|
|
91
|
+
if not dn[0].isdigit() and dn[0] != "+":
|
|
92
|
+
return E164Result(
|
|
93
|
+
e164=None,
|
|
94
|
+
extension=None,
|
|
95
|
+
raw=dn,
|
|
96
|
+
classification="AMBIGUOUS",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Step 1: Strip site prefix (from 02-normalization-architecture.md)
|
|
100
|
+
stripped = apply_prefix_rules(dn, site_prefix_rules)
|
|
101
|
+
|
|
102
|
+
# Step 2: Try to parse as a phone number using phonenumbers
|
|
103
|
+
# (from 02-normalization-architecture.md: phonenumbers library usage)
|
|
104
|
+
try:
|
|
105
|
+
parsed = phonenumbers.parse(stripped, country_code)
|
|
106
|
+
if phonenumbers.is_valid_number(parsed):
|
|
107
|
+
e164 = phonenumbers.format_number(
|
|
108
|
+
parsed, phonenumbers.PhoneNumberFormat.E164
|
|
109
|
+
)
|
|
110
|
+
# Classify: already had '+' → E164, otherwise NATIONAL.
|
|
111
|
+
# Design doc (02-normalization-architecture.md:162) uses
|
|
112
|
+
# len(stripped) <= 10 heuristic; we use '+' prefix detection
|
|
113
|
+
# instead, which is more accurate for 11-digit national numbers
|
|
114
|
+
# like "14155551234" that shouldn't be classified as E164.
|
|
115
|
+
if stripped.startswith("+"):
|
|
116
|
+
classification = "E164"
|
|
117
|
+
else:
|
|
118
|
+
classification = "NATIONAL"
|
|
119
|
+
return E164Result(
|
|
120
|
+
e164=e164,
|
|
121
|
+
extension=None,
|
|
122
|
+
raw=dn,
|
|
123
|
+
classification=classification,
|
|
124
|
+
)
|
|
125
|
+
except phonenumbers.NumberParseException:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
# Step 3: Short number = extension
|
|
129
|
+
# (from 02-normalization-architecture.md: "if len(stripped) <= 6")
|
|
130
|
+
digits_only = stripped.replace("+", "")
|
|
131
|
+
if digits_only.isdigit() and len(digits_only) <= _MAX_EXTENSION_DIGITS:
|
|
132
|
+
return E164Result(
|
|
133
|
+
e164=None,
|
|
134
|
+
extension=stripped,
|
|
135
|
+
raw=dn,
|
|
136
|
+
classification="EXTENSION",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Step 4: Can't classify
|
|
140
|
+
return E164Result(
|
|
141
|
+
e164=None,
|
|
142
|
+
extension=None,
|
|
143
|
+
raw=dn,
|
|
144
|
+
classification="AMBIGUOUS",
|
|
145
|
+
)
|