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,201 @@
|
|
|
1
|
+
"""SNRMapper — CUCM Remote Destinations → Webex Single Number Reach.
|
|
2
|
+
|
|
3
|
+
Reads remote_destination objects from the store, groups by user,
|
|
4
|
+
resolves user ownership, normalizes phone numbers to E.164, and
|
|
5
|
+
produces CanonicalSingleNumberReach objects.
|
|
6
|
+
|
|
7
|
+
Timer controls (answerTooSoon, answerTooLate) have no Webex equivalent
|
|
8
|
+
and produce SNR_LOSSY decisions.
|
|
9
|
+
|
|
10
|
+
(from tier2-enterprise-expansion.md §4)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from wxcli.migration.models import (
|
|
20
|
+
CanonicalSingleNumberReach,
|
|
21
|
+
DecisionType,
|
|
22
|
+
MigrationStatus,
|
|
23
|
+
MapperResult,
|
|
24
|
+
)
|
|
25
|
+
from wxcli.migration.store import MigrationStore
|
|
26
|
+
from wxcli.migration.transform.mappers.base import (
|
|
27
|
+
Mapper,
|
|
28
|
+
accept_option,
|
|
29
|
+
extract_provenance,
|
|
30
|
+
manual_option,
|
|
31
|
+
skip_option,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# CUCM default timer values (milliseconds) — non-default triggers SNR_LOSSY
|
|
37
|
+
CUCM_DEFAULT_ANSWER_TOO_SOON = 1500
|
|
38
|
+
CUCM_DEFAULT_ANSWER_TOO_LATE = 19000
|
|
39
|
+
|
|
40
|
+
# Basic E.164 check: starts with + and has 8-15 digits
|
|
41
|
+
_E164_RE = re.compile(r"^\+\d{8,15}$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_e164(number: str) -> bool:
|
|
45
|
+
"""Check if a number looks like E.164 format."""
|
|
46
|
+
return bool(_E164_RE.match(number))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _safe_int(val: Any, default: int | None = None) -> int | None:
|
|
50
|
+
"""Safely coerce a value to int."""
|
|
51
|
+
if val is None:
|
|
52
|
+
return default
|
|
53
|
+
try:
|
|
54
|
+
return int(val)
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
return default
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SNRMapper(Mapper):
|
|
60
|
+
"""Map CUCM Remote Destinations to Webex Single Number Reach.
|
|
61
|
+
|
|
62
|
+
Tier 2 expansion: produces CanonicalSingleNumberReach objects and
|
|
63
|
+
SNR_LOSSY decisions for CUCM-only timer controls.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
name = "snr_mapper"
|
|
67
|
+
depends_on = ["user_mapper"]
|
|
68
|
+
|
|
69
|
+
def map(self, store: MigrationStore) -> MapperResult:
|
|
70
|
+
result = MapperResult()
|
|
71
|
+
|
|
72
|
+
# Group remote destinations by owner user ID
|
|
73
|
+
user_dests: dict[str, list[dict[str, Any]]] = {}
|
|
74
|
+
rd_count = 0
|
|
75
|
+
for rd in store.get_objects("remote_destination"):
|
|
76
|
+
rd_count += 1
|
|
77
|
+
state = rd.get("pre_migration_state") or {}
|
|
78
|
+
owner = state.get("ownerUserId") or ""
|
|
79
|
+
if not owner:
|
|
80
|
+
continue
|
|
81
|
+
user_dests.setdefault(owner, []).append(rd)
|
|
82
|
+
|
|
83
|
+
logger.info(
|
|
84
|
+
"SNR mapping: %d remote_destination objects, %d unique owners",
|
|
85
|
+
rd_count, len(user_dests),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
for owner_userid, rd_list in user_dests.items():
|
|
89
|
+
user_cid = f"user:{owner_userid}"
|
|
90
|
+
if not store.get_object(user_cid):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
# Build numbers list
|
|
94
|
+
numbers = []
|
|
95
|
+
lossy_timers = []
|
|
96
|
+
invalid_numbers = []
|
|
97
|
+
any_enabled = False
|
|
98
|
+
|
|
99
|
+
for rd in rd_list:
|
|
100
|
+
state = rd.get("pre_migration_state") or {}
|
|
101
|
+
dest = state.get("destination") or ""
|
|
102
|
+
name = state.get("name") or ""
|
|
103
|
+
is_mobile = str(state.get("isMobilePhone") or "").lower() in ("true", "t", "1")
|
|
104
|
+
mobile_connect = str(state.get("enableMobileConnect") or "").lower() in ("true", "t", "1")
|
|
105
|
+
|
|
106
|
+
if mobile_connect:
|
|
107
|
+
any_enabled = True
|
|
108
|
+
|
|
109
|
+
# Timer values
|
|
110
|
+
too_soon = _safe_int(state.get("answerTooSoonTimer"), CUCM_DEFAULT_ANSWER_TOO_SOON)
|
|
111
|
+
too_late = _safe_int(state.get("answerTooLateTimer"), CUCM_DEFAULT_ANSWER_TOO_LATE)
|
|
112
|
+
|
|
113
|
+
# Check for non-default timers
|
|
114
|
+
if too_soon != CUCM_DEFAULT_ANSWER_TOO_SOON or too_late != CUCM_DEFAULT_ANSWER_TOO_LATE:
|
|
115
|
+
lossy_timers.append({
|
|
116
|
+
"destination": dest,
|
|
117
|
+
"answer_too_soon": too_soon,
|
|
118
|
+
"answer_too_late": too_late,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
# Validate number format
|
|
122
|
+
if dest and not _is_e164(dest):
|
|
123
|
+
if len(dest) >= 10 and dest.isdigit():
|
|
124
|
+
# Could be national format — flag for admin validation
|
|
125
|
+
invalid_numbers.append(dest)
|
|
126
|
+
|
|
127
|
+
label = "Mobile" if is_mobile else name
|
|
128
|
+
numbers.append({
|
|
129
|
+
"phone_number": dest,
|
|
130
|
+
"enabled": mobile_connect,
|
|
131
|
+
"name": label,
|
|
132
|
+
"answer_confirmation": False,
|
|
133
|
+
"cucm_answer_too_soon": too_soon,
|
|
134
|
+
"cucm_answer_too_late": too_late,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
if not numbers:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Build canonical SNR object
|
|
141
|
+
prov = extract_provenance(rd_list[0])
|
|
142
|
+
snr = CanonicalSingleNumberReach(
|
|
143
|
+
canonical_id=f"single_number_reach:{owner_userid}",
|
|
144
|
+
provenance=prov,
|
|
145
|
+
status=MigrationStatus.ANALYZED,
|
|
146
|
+
user_canonical_id=user_cid,
|
|
147
|
+
enabled=any_enabled,
|
|
148
|
+
numbers=numbers,
|
|
149
|
+
)
|
|
150
|
+
store.upsert_object(snr)
|
|
151
|
+
result.objects_created += 1
|
|
152
|
+
|
|
153
|
+
# Write cross-ref
|
|
154
|
+
store.add_cross_ref(user_cid, snr.canonical_id, "user_has_snr")
|
|
155
|
+
|
|
156
|
+
# Decision: lossy timers
|
|
157
|
+
if lossy_timers:
|
|
158
|
+
decision = self._create_decision(
|
|
159
|
+
store=store,
|
|
160
|
+
decision_type=DecisionType.SNR_LOSSY,
|
|
161
|
+
severity="LOW",
|
|
162
|
+
summary=(
|
|
163
|
+
f"User '{owner_userid}' has {len(lossy_timers)} Remote Destination(s) "
|
|
164
|
+
f"with custom timer values that Webex SNR does not support"
|
|
165
|
+
),
|
|
166
|
+
context={
|
|
167
|
+
"user_id": user_cid,
|
|
168
|
+
"lossy_timers": lossy_timers,
|
|
169
|
+
},
|
|
170
|
+
options=[
|
|
171
|
+
accept_option("Accept loss — migrate numbers without timer control"),
|
|
172
|
+
skip_option("Skip SNR migration for this user"),
|
|
173
|
+
manual_option("Admin configures custom behavior in Webex"),
|
|
174
|
+
],
|
|
175
|
+
affected_objects=[user_cid, snr.canonical_id],
|
|
176
|
+
)
|
|
177
|
+
result.decisions.append(decision)
|
|
178
|
+
|
|
179
|
+
# Decision: invalid numbers
|
|
180
|
+
if invalid_numbers:
|
|
181
|
+
decision = self._create_decision(
|
|
182
|
+
store=store,
|
|
183
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
184
|
+
severity="MEDIUM",
|
|
185
|
+
summary=(
|
|
186
|
+
f"User '{owner_userid}' has {len(invalid_numbers)} Remote Destination(s) "
|
|
187
|
+
f"with non-E.164 numbers"
|
|
188
|
+
),
|
|
189
|
+
context={
|
|
190
|
+
"user_id": user_cid,
|
|
191
|
+
"invalid_numbers": invalid_numbers,
|
|
192
|
+
},
|
|
193
|
+
options=[
|
|
194
|
+
manual_option("Admin provides valid E.164 numbers"),
|
|
195
|
+
skip_option("Skip these entries"),
|
|
196
|
+
],
|
|
197
|
+
affected_objects=[user_cid, snr.canonical_id],
|
|
198
|
+
)
|
|
199
|
+
result.decisions.append(decision)
|
|
200
|
+
|
|
201
|
+
return result
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""SoftkeyMapper: CUCM Softkey Templates → Webex PSK config (9800/8875).
|
|
2
|
+
|
|
3
|
+
Maps CUCM per-call-state softkey assignments to Webex Programmable Softkey
|
|
4
|
+
(PSK) keywords and per-state key lists. Only actionable for 9800/8875
|
|
5
|
+
phones (PhoneOS with PSK support). Classic MPP phones produce report
|
|
6
|
+
entries only (is_psk_target=False).
|
|
7
|
+
|
|
8
|
+
(from tier2-phase2-phone-config-design.md §4.3)
|
|
9
|
+
|
|
10
|
+
Cross-ref reads:
|
|
11
|
+
phone_uses_softkey_template (Phone → SoftkeyTemplate) — usage + model
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from wxcli.migration.models import (
|
|
19
|
+
CanonicalSoftkeyConfig,
|
|
20
|
+
MapperResult,
|
|
21
|
+
MigrationStatus,
|
|
22
|
+
)
|
|
23
|
+
from wxcli.migration.store import MigrationStore
|
|
24
|
+
from wxcli.migration.transform.mappers.base import (
|
|
25
|
+
Mapper,
|
|
26
|
+
extract_provenance,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# CUCM softkey name → Webex PSK keyword (None = unmappable)
|
|
32
|
+
CUCM_SOFTKEY_TO_PSK: dict[str, str | None] = {
|
|
33
|
+
"Trnsfer": "xfer",
|
|
34
|
+
"Confrn": "conf",
|
|
35
|
+
"Park": "park",
|
|
36
|
+
"Pickup": "pickup",
|
|
37
|
+
"GPickUp": "gpickup",
|
|
38
|
+
"Hold": "hold",
|
|
39
|
+
"Resume": "resume",
|
|
40
|
+
"EndCall": "endcall",
|
|
41
|
+
"NewCall": "newcall",
|
|
42
|
+
"Redial": "redial",
|
|
43
|
+
"DND": "dnd",
|
|
44
|
+
"Barge": "bridgein",
|
|
45
|
+
"Join": "join",
|
|
46
|
+
"LiveRcd": "crdstart",
|
|
47
|
+
"CfwdAll": "cfwd",
|
|
48
|
+
"Unpark": "unpark",
|
|
49
|
+
"HLog": "acd_login",
|
|
50
|
+
"Answer": "answer",
|
|
51
|
+
# Unmappable
|
|
52
|
+
"CallBack": None,
|
|
53
|
+
"iDivert": None,
|
|
54
|
+
"QRT": None,
|
|
55
|
+
"MeetMe": None,
|
|
56
|
+
"Mobility": None,
|
|
57
|
+
"cBarge": None,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# CUCM call state → Webex PSK state key list name
|
|
61
|
+
CUCM_STATE_TO_PSK_STATE: dict[str, str | None] = {
|
|
62
|
+
"onHook": "idle",
|
|
63
|
+
"offHook": "offHook",
|
|
64
|
+
"ringIn": "ringing",
|
|
65
|
+
"ringOut": "progressing", # API key is "progressingKeyList", not "processingKeyList"
|
|
66
|
+
"connected": "connected",
|
|
67
|
+
"onHold": "hold",
|
|
68
|
+
"sharedActive": "sharedActive",
|
|
69
|
+
"sharedHeld": "sharedHeld",
|
|
70
|
+
"connectedTransfer": "startTransfer",
|
|
71
|
+
"connectedConference": "startConference",
|
|
72
|
+
"transition": "releasing",
|
|
73
|
+
# Unmappable
|
|
74
|
+
"remoteInUse": None,
|
|
75
|
+
"park": None,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# 9800/8875 model number substrings that indicate PSK capability
|
|
79
|
+
from wxcli.migration.phone_models import PHONEOS_MODEL_NUMBERS as _PSK_MODEL_NUMBERS
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_psk_capable_model(model: str | None) -> bool:
|
|
83
|
+
"""Check if a phone model supports Programmable Softkeys (PSK)."""
|
|
84
|
+
if not model:
|
|
85
|
+
return False
|
|
86
|
+
for num in _PSK_MODEL_NUMBERS:
|
|
87
|
+
if num in model:
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SoftkeyMapper(Mapper):
|
|
93
|
+
"""Maps CUCM Softkey Templates to Webex PSK config or report flags."""
|
|
94
|
+
|
|
95
|
+
name = "softkey_mapper"
|
|
96
|
+
depends_on = ["device_mapper"]
|
|
97
|
+
|
|
98
|
+
def map(self, store: MigrationStore) -> MapperResult:
|
|
99
|
+
result = MapperResult()
|
|
100
|
+
|
|
101
|
+
for tmpl_data in store.get_objects("softkey_template"):
|
|
102
|
+
tmpl_id = tmpl_data["canonical_id"]
|
|
103
|
+
state = tmpl_data.get("pre_migration_state") or {}
|
|
104
|
+
name = state.get("name", tmpl_id.split(":", 1)[-1])
|
|
105
|
+
call_states = state.get("call_states", {})
|
|
106
|
+
|
|
107
|
+
# Count phones and determine PSK eligibility
|
|
108
|
+
phone_refs = [r["from_id"] for r in store.get_cross_refs(to_id=tmpl_id, relationship="phone_uses_softkey_template")]
|
|
109
|
+
phones_using = len(phone_refs)
|
|
110
|
+
|
|
111
|
+
has_psk_phone = False
|
|
112
|
+
for phone_id in phone_refs:
|
|
113
|
+
phone = store.get_object(phone_id)
|
|
114
|
+
if phone:
|
|
115
|
+
# Model may be at top level of raw phone dict OR in pre_migration_state
|
|
116
|
+
model = phone.get("model")
|
|
117
|
+
if not model:
|
|
118
|
+
phone_state = phone.get("pre_migration_state") or {}
|
|
119
|
+
model = phone_state.get("model") or phone_state.get("cucm_product")
|
|
120
|
+
if _is_psk_capable_model(model):
|
|
121
|
+
has_psk_phone = True
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
# Map softkeys per call state
|
|
125
|
+
psk_slot_counter = 0
|
|
126
|
+
psk_mappings: list[dict[str, Any]] = []
|
|
127
|
+
state_key_lists: dict[str, list[str]] = {}
|
|
128
|
+
unmapped: list[dict[str, Any]] = []
|
|
129
|
+
|
|
130
|
+
for cucm_state, softkeys in call_states.items():
|
|
131
|
+
psk_state = CUCM_STATE_TO_PSK_STATE.get(cucm_state)
|
|
132
|
+
if psk_state is None:
|
|
133
|
+
# Unmappable state — flag all softkeys in it
|
|
134
|
+
for sk in softkeys:
|
|
135
|
+
unmapped.append({"cucm_name": sk, "call_state": cucm_state})
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
mapped_keywords = []
|
|
139
|
+
for sk_name in softkeys:
|
|
140
|
+
psk_keyword = CUCM_SOFTKEY_TO_PSK.get(sk_name)
|
|
141
|
+
if psk_keyword is None and sk_name not in CUCM_SOFTKEY_TO_PSK:
|
|
142
|
+
# Unknown softkey — treat as unmapped
|
|
143
|
+
psk_keyword = None
|
|
144
|
+
if psk_keyword is None:
|
|
145
|
+
unmapped.append({"cucm_name": sk_name, "call_state": cucm_state})
|
|
146
|
+
else:
|
|
147
|
+
mapped_keywords.append(psk_keyword)
|
|
148
|
+
if has_psk_phone and psk_slot_counter < 16:
|
|
149
|
+
psk_slot_counter += 1
|
|
150
|
+
psk_mappings.append({
|
|
151
|
+
"psk_slot": f"PSK{psk_slot_counter}",
|
|
152
|
+
"keyword": psk_keyword,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
if mapped_keywords:
|
|
156
|
+
state_key_lists[psk_state] = mapped_keywords
|
|
157
|
+
|
|
158
|
+
# Per-device fan-out: create one CanonicalSoftkeyConfig per PSK-capable phone.
|
|
159
|
+
# These per-device objects (is_psk_target=True) drive the execute handler.
|
|
160
|
+
per_device_count = 0
|
|
161
|
+
if has_psk_phone and psk_mappings:
|
|
162
|
+
for phone_id in phone_refs:
|
|
163
|
+
phone = store.get_object(phone_id)
|
|
164
|
+
if not phone:
|
|
165
|
+
continue
|
|
166
|
+
model = phone.get("model")
|
|
167
|
+
if not model:
|
|
168
|
+
phone_state = phone.get("pre_migration_state") or {}
|
|
169
|
+
model = phone_state.get("model") or phone_state.get("cucm_product")
|
|
170
|
+
if not _is_psk_capable_model(model):
|
|
171
|
+
continue
|
|
172
|
+
phone_state = phone.get("pre_migration_state") or {}
|
|
173
|
+
device_name = phone_state.get("name") or phone_id.replace("phone:", "")
|
|
174
|
+
per_device_config = CanonicalSoftkeyConfig(
|
|
175
|
+
canonical_id=f"softkey_config:device:{device_name}",
|
|
176
|
+
provenance=extract_provenance(tmpl_data),
|
|
177
|
+
status=MigrationStatus.ANALYZED,
|
|
178
|
+
cucm_template_name=name,
|
|
179
|
+
is_psk_target=True,
|
|
180
|
+
device_canonical_id=f"device:{device_name}",
|
|
181
|
+
psk_mappings=psk_mappings,
|
|
182
|
+
state_key_lists=state_key_lists,
|
|
183
|
+
unmapped_softkeys=unmapped,
|
|
184
|
+
phones_using=1,
|
|
185
|
+
)
|
|
186
|
+
store.upsert_object(per_device_config)
|
|
187
|
+
per_device_count += 1
|
|
188
|
+
|
|
189
|
+
# Template-level object: report-only (is_psk_target=False).
|
|
190
|
+
# Retains PSK mapping data for assessment; execution handled by per-device objects.
|
|
191
|
+
config = CanonicalSoftkeyConfig(
|
|
192
|
+
canonical_id=f"softkey_config:{name}",
|
|
193
|
+
provenance=extract_provenance(tmpl_data),
|
|
194
|
+
status=MigrationStatus.ANALYZED,
|
|
195
|
+
cucm_template_name=name,
|
|
196
|
+
is_psk_target=False,
|
|
197
|
+
psk_mappings=psk_mappings if has_psk_phone else [],
|
|
198
|
+
state_key_lists=state_key_lists,
|
|
199
|
+
unmapped_softkeys=unmapped,
|
|
200
|
+
phones_using=phones_using,
|
|
201
|
+
)
|
|
202
|
+
store.upsert_object(config)
|
|
203
|
+
result.objects_created += 1 + per_device_count
|
|
204
|
+
|
|
205
|
+
return result
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""User mapper: CUCM End Users -> Webex Calling People.
|
|
2
|
+
|
|
3
|
+
Maps CUCM end users to CanonicalUser objects with resolved email, location,
|
|
4
|
+
and primary extension. Email resolution uses mailid with userid as fallback.
|
|
5
|
+
Location resolution follows the cross-ref chain:
|
|
6
|
+
user -> user_has_device -> device -> device_in_pool -> device_pool
|
|
7
|
+
-> device_pool_to_location -> location
|
|
8
|
+
|
|
9
|
+
(from 03b-transform-mappers.md §2, user_mapper)
|
|
10
|
+
|
|
11
|
+
Cross-ref reads:
|
|
12
|
+
user_has_device (User -> Device)
|
|
13
|
+
user_has_primary_dn (User -> DN)
|
|
14
|
+
device_in_pool (Device -> DevicePool)
|
|
15
|
+
device_pool_to_location (DevicePool -> Location)
|
|
16
|
+
|
|
17
|
+
Does NOT produce: DUPLICATE_USER, EXTENSION_CONFLICT (analyzer-owned)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from wxcli.migration.models import (
|
|
27
|
+
CanonicalUser,
|
|
28
|
+
DecisionType,
|
|
29
|
+
MapperResult,
|
|
30
|
+
MigrationStatus,
|
|
31
|
+
)
|
|
32
|
+
from wxcli.migration.store import MigrationStore
|
|
33
|
+
from wxcli.migration.transform.mappers.base import (
|
|
34
|
+
Mapper,
|
|
35
|
+
accept_option,
|
|
36
|
+
decision_to_store_dict,
|
|
37
|
+
extract_provenance,
|
|
38
|
+
manual_option,
|
|
39
|
+
skip_option,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
# Simple email validation pattern
|
|
45
|
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UserMapper(Mapper):
|
|
49
|
+
"""Maps CUCM End Users to Webex Calling People.
|
|
50
|
+
|
|
51
|
+
(from 03b-transform-mappers.md §2)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name = "user_mapper"
|
|
55
|
+
depends_on = ["location_mapper"]
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
create_method: str = "people_api",
|
|
60
|
+
include_phoneless_users: bool = False,
|
|
61
|
+
use_device_dn_as_extension: bool = True,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Args:
|
|
65
|
+
create_method: "scim" or "people_api" — execution strategy
|
|
66
|
+
(from 03b-transform-mappers.md §2: execution strategy note)
|
|
67
|
+
include_phoneless_users: If True, map users without phones.
|
|
68
|
+
(from 03b-transform-mappers.md §2: edge case — users with no phone)
|
|
69
|
+
use_device_dn_as_extension: If True, fall back to device line 1 DN
|
|
70
|
+
when primaryExtension is not set on the user record.
|
|
71
|
+
"""
|
|
72
|
+
self.create_method = create_method
|
|
73
|
+
self.include_phoneless_users = include_phoneless_users
|
|
74
|
+
self.use_device_dn_as_extension = use_device_dn_as_extension
|
|
75
|
+
|
|
76
|
+
def map(self, store: MigrationStore) -> MapperResult:
|
|
77
|
+
"""Read normalized CUCM users and produce CanonicalUser objects."""
|
|
78
|
+
result = MapperResult()
|
|
79
|
+
|
|
80
|
+
for user_data in store.get_objects("user"):
|
|
81
|
+
user_id = user_data["canonical_id"]
|
|
82
|
+
state = user_data.get("pre_migration_state") or {}
|
|
83
|
+
|
|
84
|
+
# --- Email resolution ---
|
|
85
|
+
# (from 03b-transform-mappers.md §2: mailid primary, userid fallback)
|
|
86
|
+
emails = user_data.get("emails", [])
|
|
87
|
+
cucm_mailid = user_data.get("cucm_mailid")
|
|
88
|
+
cucm_userid = user_data.get("cucm_userid", "")
|
|
89
|
+
|
|
90
|
+
if not emails:
|
|
91
|
+
if cucm_mailid and _EMAIL_RE.match(cucm_mailid):
|
|
92
|
+
emails = [cucm_mailid.lower()]
|
|
93
|
+
elif cucm_userid and _EMAIL_RE.match(cucm_userid):
|
|
94
|
+
emails = [cucm_userid.lower()]
|
|
95
|
+
|
|
96
|
+
if not emails:
|
|
97
|
+
# No email available
|
|
98
|
+
# (from 03b-transform-mappers.md §2: MISSING_DATA if no mailid and userid not email)
|
|
99
|
+
decision = self._create_decision(
|
|
100
|
+
store=store,
|
|
101
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
102
|
+
severity="HIGH",
|
|
103
|
+
summary=f"User '{cucm_userid}' has no email address (mailid empty, userid not email format)",
|
|
104
|
+
context={
|
|
105
|
+
"user_id": user_id,
|
|
106
|
+
"cucm_userid": cucm_userid,
|
|
107
|
+
"cucm_mailid": cucm_mailid,
|
|
108
|
+
},
|
|
109
|
+
options=[
|
|
110
|
+
manual_option("Admin provides email address"),
|
|
111
|
+
skip_option("User not migrated to Webex Calling"),
|
|
112
|
+
],
|
|
113
|
+
affected_objects=[user_id],
|
|
114
|
+
)
|
|
115
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
116
|
+
result.decisions.append(decision)
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# --- Device and location resolution ---
|
|
120
|
+
# Chain: user -> user_has_device -> device -> device_in_pool -> pool -> device_pool_to_location -> location
|
|
121
|
+
device_refs = store.find_cross_refs(user_id, "user_has_device")
|
|
122
|
+
location_id = None
|
|
123
|
+
|
|
124
|
+
if device_refs:
|
|
125
|
+
# Try each device until we find one with a location chain
|
|
126
|
+
for dev_id in device_refs:
|
|
127
|
+
loc_id = store.resolve_chain(
|
|
128
|
+
dev_id, "device_in_pool", "device_pool_to_location"
|
|
129
|
+
)
|
|
130
|
+
if loc_id:
|
|
131
|
+
location_id = loc_id
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
if not location_id and not self.include_phoneless_users:
|
|
135
|
+
# No device -> no location
|
|
136
|
+
# (from 03b-transform-mappers.md §2: MISSING_DATA if no device/location)
|
|
137
|
+
decision = self._create_decision(
|
|
138
|
+
store=store,
|
|
139
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
140
|
+
severity="MEDIUM",
|
|
141
|
+
summary=f"User '{cucm_userid}' has no associated phone — cannot resolve Webex location",
|
|
142
|
+
context={
|
|
143
|
+
"user_id": user_id,
|
|
144
|
+
"cucm_userid": cucm_userid,
|
|
145
|
+
"reason": "no_device_no_location",
|
|
146
|
+
},
|
|
147
|
+
options=[
|
|
148
|
+
manual_option("Admin assigns user to a location manually"),
|
|
149
|
+
skip_option("User not migrated to Webex Calling"),
|
|
150
|
+
],
|
|
151
|
+
affected_objects=[user_id],
|
|
152
|
+
)
|
|
153
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
154
|
+
result.decisions.append(decision)
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# --- Extension resolution ---
|
|
158
|
+
# (from 03b-transform-mappers.md §2: primary DN, bare extension without routing prefix)
|
|
159
|
+
extension = user_data.get("extension")
|
|
160
|
+
primary_dn_refs = store.find_cross_refs(user_id, "user_has_primary_dn")
|
|
161
|
+
if primary_dn_refs:
|
|
162
|
+
dn_id = primary_dn_refs[0]
|
|
163
|
+
# DN IDs are formatted as "dn:pattern:partition"
|
|
164
|
+
parts = dn_id.split(":", 2)
|
|
165
|
+
if len(parts) >= 2:
|
|
166
|
+
extension = parts[1]
|
|
167
|
+
|
|
168
|
+
# --- Device line 1 DN fallback ---
|
|
169
|
+
# When CUCM has no primaryExtension set on the user record,
|
|
170
|
+
# fall back to the DN on line 1 (ordinal 0) of the user's first device.
|
|
171
|
+
if extension is None and self.use_device_dn_as_extension and device_refs:
|
|
172
|
+
# Collect ALL line-1 DN candidates across devices so we can warn
|
|
173
|
+
# on genuine multi-device conflicts. We still pick the first
|
|
174
|
+
# candidate (legacy selection behaviour) — the warning just
|
|
175
|
+
# surfaces ambiguity for operator review.
|
|
176
|
+
line1_candidates: list[str] = []
|
|
177
|
+
for dev_id in device_refs:
|
|
178
|
+
dn_xrefs = store.get_cross_refs(
|
|
179
|
+
from_id=dev_id, relationship="device_has_dn"
|
|
180
|
+
)
|
|
181
|
+
for xref in dn_xrefs:
|
|
182
|
+
if xref.get("ordinal") == 0:
|
|
183
|
+
dn_target = xref["to_id"]
|
|
184
|
+
# DN IDs are formatted as "dn:pattern:partition"
|
|
185
|
+
dn_parts = dn_target.split(":", 2)
|
|
186
|
+
if len(dn_parts) >= 2:
|
|
187
|
+
line1_candidates.append(dn_parts[1])
|
|
188
|
+
break
|
|
189
|
+
if line1_candidates:
|
|
190
|
+
extension = line1_candidates[0]
|
|
191
|
+
distinct = sorted(set(line1_candidates))
|
|
192
|
+
if len(distinct) > 1:
|
|
193
|
+
logger.warning(
|
|
194
|
+
"Multiple devices report different line-1 DNs for %s: "
|
|
195
|
+
"candidates=%s picked=%s (legacy selection: first device). "
|
|
196
|
+
"Operator should verify the correct primary extension.",
|
|
197
|
+
user_id,
|
|
198
|
+
distinct,
|
|
199
|
+
extension,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# --- Build display name ---
|
|
203
|
+
first_name = user_data.get("first_name")
|
|
204
|
+
last_name = user_data.get("last_name")
|
|
205
|
+
display_name = user_data.get("display_name")
|
|
206
|
+
if not display_name:
|
|
207
|
+
name_parts = [p for p in [first_name, last_name] if p]
|
|
208
|
+
display_name = " ".join(name_parts) or None
|
|
209
|
+
|
|
210
|
+
# --- SQL-extracted user detection ---
|
|
211
|
+
# Users extracted via SQL fallback have limited fields.
|
|
212
|
+
# (from Phase 04 corrections: pre_migration_state.extracted_via_sql = true)
|
|
213
|
+
sql_extracted = state.get("extracted_via_sql", False)
|
|
214
|
+
if sql_extracted:
|
|
215
|
+
decision = self._create_decision(
|
|
216
|
+
store=store,
|
|
217
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
218
|
+
severity="LOW",
|
|
219
|
+
summary=f"User '{cucm_userid}' was extracted via SQL fallback — limited field data available",
|
|
220
|
+
context={
|
|
221
|
+
"user_id": user_id,
|
|
222
|
+
"cucm_userid": cucm_userid,
|
|
223
|
+
"reason": "sql_extracted",
|
|
224
|
+
"missing_fields": ["cucm_css", "cucm_voicemail_profile", "selfService", "userLocale"],
|
|
225
|
+
},
|
|
226
|
+
options=[
|
|
227
|
+
manual_option("Admin verifies and supplements user data"),
|
|
228
|
+
accept_option("Proceed with limited data — CSS and voicemail settings will not be migrated"),
|
|
229
|
+
],
|
|
230
|
+
affected_objects=[user_id],
|
|
231
|
+
)
|
|
232
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
233
|
+
result.decisions.append(decision)
|
|
234
|
+
|
|
235
|
+
# --- Build CanonicalUser ---
|
|
236
|
+
user_obj = CanonicalUser(
|
|
237
|
+
canonical_id=user_id,
|
|
238
|
+
provenance=extract_provenance(user_data),
|
|
239
|
+
status=MigrationStatus.ANALYZED,
|
|
240
|
+
emails=emails,
|
|
241
|
+
first_name=first_name,
|
|
242
|
+
last_name=last_name,
|
|
243
|
+
display_name=display_name,
|
|
244
|
+
location_id=location_id,
|
|
245
|
+
extension=extension,
|
|
246
|
+
department=user_data.get("department"),
|
|
247
|
+
title=user_data.get("title"),
|
|
248
|
+
create_method=self.create_method,
|
|
249
|
+
calling_data=True,
|
|
250
|
+
cucm_manager_user_id=user_data.get("cucm_manager_user_id"),
|
|
251
|
+
cucm_userid=cucm_userid,
|
|
252
|
+
cucm_mailid=cucm_mailid,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
store.upsert_object(user_obj)
|
|
256
|
+
result.objects_updated += 1
|
|
257
|
+
|
|
258
|
+
# Fix 8: Write user_in_location cross-ref for dependency graph
|
|
259
|
+
if location_id:
|
|
260
|
+
store.add_cross_ref(user_id, location_id, "user_in_location")
|
|
261
|
+
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|