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,1136 @@
|
|
|
1
|
+
"""Pass 2: CrossReferenceBuilder — builds indexes and enriches objects using full inventory.
|
|
2
|
+
|
|
3
|
+
After all objects are in SQLite (from pass 1 normalizers), this class sweeps the
|
|
4
|
+
inventory to build the cross_refs table, detect shared lines, classify phone models,
|
|
5
|
+
normalize DNs to E.164, and build the CSS partition graph.
|
|
6
|
+
|
|
7
|
+
(from 02-normalization-architecture.md, Pass 2 specification)
|
|
8
|
+
(from 02-normalization-architecture.md, Complete Cross-Reference Manifest — 27 rows)
|
|
9
|
+
|
|
10
|
+
Note: Relationship `device_pool_to_location` (#note in manifest) is NOT built here —
|
|
11
|
+
it is written by location_mapper during the transform pass.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from wxcli.migration.models import (
|
|
21
|
+
CanonicalDevice,
|
|
22
|
+
CanonicalSharedLine,
|
|
23
|
+
DeviceCompatibilityTier,
|
|
24
|
+
MigrationObject,
|
|
25
|
+
MigrationStatus,
|
|
26
|
+
Provenance,
|
|
27
|
+
)
|
|
28
|
+
from wxcli.migration.store import MigrationStore
|
|
29
|
+
from wxcli.migration.transform.e164 import E164Result, normalize_dn
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _now() -> datetime:
|
|
35
|
+
return datetime.now(timezone.utc)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Phone model compatibility table (four-tier classification)
|
|
40
|
+
# (from cucm-wxc-migration.md lines 363-369)
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# DECT handset models — wireless handsets that need DECT network provisioning,
|
|
44
|
+
# NOT desk phones. Checked before _NATIVE_MPP_PATTERNS to prevent false classification.
|
|
45
|
+
_DECT_PATTERNS = {
|
|
46
|
+
"Cisco 6823", "Cisco 6825", "Cisco 6825ip",
|
|
47
|
+
"Cisco IP Phone 6823", "Cisco IP Phone 6825", "Cisco IP Phone 6825ip",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Native MPP: phones that ship with or run MPP/PhoneOS firmware natively
|
|
51
|
+
# Phase 12c: expanded from original 68xx-only list after web search verification
|
|
52
|
+
# Sources: help.webex.com/qkwt4j, Cisco 9800 data sheet, 8875 landing page
|
|
53
|
+
_NATIVE_MPP_PATTERNS = {
|
|
54
|
+
# 6800 series — MPP firmware
|
|
55
|
+
"Cisco 6821", "Cisco 6841", "Cisco 6851", "Cisco 6861", "Cisco 6871",
|
|
56
|
+
"Cisco IP Phone 6821", "Cisco IP Phone 6841", "Cisco IP Phone 6851",
|
|
57
|
+
"Cisco IP Phone 6861", "Cisco IP Phone 6871",
|
|
58
|
+
# 8875 — PhoneOS video phone
|
|
59
|
+
"Cisco 8875", "Cisco IP Phone 8875",
|
|
60
|
+
# 9800 series — PhoneOS, native MPP, no firmware conversion needed
|
|
61
|
+
"Cisco 9811", "Cisco 9821", "Cisco 9841", "Cisco 9851", "Cisco 9861", "Cisco 9871",
|
|
62
|
+
"Cisco IP Phone 9811", "Cisco IP Phone 9821",
|
|
63
|
+
"Cisco IP Phone 9841", "Cisco IP Phone 9851",
|
|
64
|
+
"Cisco IP Phone 9861", "Cisco IP Phone 9871",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Firmware Convertible: Enterprise phones eligible for E2M conversion to MPP
|
|
68
|
+
# Source: Cisco E2M converter (upgrade.cisco.com/e2m_converter_wxc),
|
|
69
|
+
# help.webex.com/nl9j09w (Enterprise-to-MPP migration guide)
|
|
70
|
+
# Phase 12c: added 7811, 7832, 8811, 8832, 8841 per Cisco conversion guide
|
|
71
|
+
_CONVERTIBLE_PATTERNS = {
|
|
72
|
+
# 7800 series — all eligible per E2M converter
|
|
73
|
+
"Cisco 7811", "Cisco 7821", "Cisco 7832", "Cisco 7841", "Cisco 7861",
|
|
74
|
+
"Cisco IP Phone 7811", "Cisco IP Phone 7821", "Cisco IP Phone 7832",
|
|
75
|
+
"Cisco IP Phone 7841", "Cisco IP Phone 7861",
|
|
76
|
+
# 8800 series audio
|
|
77
|
+
"Cisco 8811", "Cisco 8832", "Cisco 8841", "Cisco 8851", "Cisco 8861",
|
|
78
|
+
"Cisco IP Phone 8811", "Cisco IP Phone 8832", "Cisco IP Phone 8841",
|
|
79
|
+
"Cisco IP Phone 8851", "Cisco IP Phone 8861",
|
|
80
|
+
# 8800 series video
|
|
81
|
+
"Cisco 8845", "Cisco 8865",
|
|
82
|
+
"Cisco IP Phone 8845", "Cisco IP Phone 8865",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Software phones / soft clients — these transition to Webex App, not incompatible.
|
|
86
|
+
# Users keep their number/extension and use Webex App for calling.
|
|
87
|
+
_WEBEX_APP_KEYWORDS = [
|
|
88
|
+
"jabber", "dual mode", "client services framework",
|
|
89
|
+
"ip communicator", "softphone", "soft phone",
|
|
90
|
+
# Video endpoints that transition to Webex Desk / RoomOS
|
|
91
|
+
"telepresence", "dx70", "dx80", "dx650",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
# Infrastructure devices — CUCM-only, not user phones, not migrated.
|
|
95
|
+
# Noted in reports but don't generate decisions or migration operations.
|
|
96
|
+
_INFRASTRUCTURE_KEYWORDS = [
|
|
97
|
+
"cti port", "cti route point", "cti remote device",
|
|
98
|
+
"cer ", "analog phone", "media termination point",
|
|
99
|
+
"conference bridge", "music on hold", "mtp",
|
|
100
|
+
"route point", "gateway",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
# Everything else is incompatible
|
|
104
|
+
# (79xx, 99xx, 69xx, 39xx series, third-party phones)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def classify_phone_model(model: str | None) -> DeviceCompatibilityTier:
|
|
108
|
+
"""Classify a CUCM phone model into the five-tier compatibility system.
|
|
109
|
+
|
|
110
|
+
(from cucm-wxc-migration.md lines 363-369, Phone Model Compatibility table)
|
|
111
|
+
"""
|
|
112
|
+
if not model:
|
|
113
|
+
return DeviceCompatibilityTier.INCOMPATIBLE
|
|
114
|
+
|
|
115
|
+
# Check DECT handsets first (6823/6825/6825ip are wireless, not desk phones)
|
|
116
|
+
if model in _DECT_PATTERNS:
|
|
117
|
+
return DeviceCompatibilityTier.DECT
|
|
118
|
+
|
|
119
|
+
# Check native MPP first
|
|
120
|
+
if model in _NATIVE_MPP_PATTERNS:
|
|
121
|
+
return DeviceCompatibilityTier.NATIVE_MPP
|
|
122
|
+
|
|
123
|
+
# Check convertible
|
|
124
|
+
if model in _CONVERTIBLE_PATTERNS:
|
|
125
|
+
return DeviceCompatibilityTier.CONVERTIBLE
|
|
126
|
+
|
|
127
|
+
model_lower = model.lower()
|
|
128
|
+
|
|
129
|
+
# Check for infrastructure devices (CTI ports, gateways, media resources)
|
|
130
|
+
if any(kw in model_lower for kw in _INFRASTRUCTURE_KEYWORDS):
|
|
131
|
+
return DeviceCompatibilityTier.INFRASTRUCTURE
|
|
132
|
+
|
|
133
|
+
# Check for software phones / soft clients → Webex App transition
|
|
134
|
+
if any(kw in model_lower for kw in _WEBEX_APP_KEYWORDS):
|
|
135
|
+
return DeviceCompatibilityTier.WEBEX_APP
|
|
136
|
+
|
|
137
|
+
# Check for MPP suffix pattern (some models include "MPP" in the name).
|
|
138
|
+
# Exclude known-incompatible models that might have MPP firmware loaded.
|
|
139
|
+
# 7811 removed Phase 12c — it IS convertible per Cisco E2M converter.
|
|
140
|
+
_INCOMPATIBLE_WITH_MPP = {"7911", "7912", "7940", "7941", "7942",
|
|
141
|
+
"7945", "7960", "7961", "7962", "7965", "7970",
|
|
142
|
+
"7971", "7975", "3905", "3911", "3951"}
|
|
143
|
+
if "MPP" in model.upper():
|
|
144
|
+
# Check if the model number indicates an incompatible device
|
|
145
|
+
model_nums = [s for s in model.split() if s.isdigit() or s.replace("-", "").isdigit()]
|
|
146
|
+
if not any(n in _INCOMPATIBLE_WITH_MPP for n in model_nums):
|
|
147
|
+
return DeviceCompatibilityTier.NATIVE_MPP
|
|
148
|
+
|
|
149
|
+
return DeviceCompatibilityTier.INCOMPATIBLE
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CrossReferenceBuilder:
|
|
153
|
+
"""Pass 2: builds indexes and enriches objects using full inventory.
|
|
154
|
+
|
|
155
|
+
(from 02-normalization-architecture.md, CrossReferenceBuilder class)
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
store: MigrationStore,
|
|
161
|
+
default_country_code: str = "US",
|
|
162
|
+
site_prefix_rules: list[dict] | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
self.store = store
|
|
165
|
+
self.default_country_code = default_country_code
|
|
166
|
+
self.site_prefix_rules = site_prefix_rules or []
|
|
167
|
+
|
|
168
|
+
def build(self) -> dict[str, int]:
|
|
169
|
+
"""Build all cross-references and enrichments.
|
|
170
|
+
|
|
171
|
+
Returns a summary dict with counts per relationship type built.
|
|
172
|
+
|
|
173
|
+
(from 02-normalization-architecture.md: CrossReferenceBuilder.build())
|
|
174
|
+
"""
|
|
175
|
+
# Clear existing cross-refs for clean rebuild
|
|
176
|
+
# (from 07-idempotency-resumability.md lines 183-187)
|
|
177
|
+
self.store.clear_cross_refs()
|
|
178
|
+
|
|
179
|
+
# Disable FK checks during cross-ref building. Cross-refs reference
|
|
180
|
+
# virtual IDs (DNs, partitions, CSS names) that may not exist as
|
|
181
|
+
# stored objects — they're graph edges for traversal, not strict
|
|
182
|
+
# FK relationships.
|
|
183
|
+
self.store.conn.execute("PRAGMA foreign_keys=OFF")
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
counts: dict[str, int] = {}
|
|
187
|
+
|
|
188
|
+
# Order matters: some steps depend on earlier cross-refs.
|
|
189
|
+
# Each method is wrapped individually so one failure doesn't
|
|
190
|
+
# prevent the remaining cross-refs from being built.
|
|
191
|
+
for method in [
|
|
192
|
+
self._build_device_pool_refs,
|
|
193
|
+
self._build_user_refs,
|
|
194
|
+
self._build_device_dn_refs,
|
|
195
|
+
self._build_device_ownership_refs,
|
|
196
|
+
self._build_css_partition_graph,
|
|
197
|
+
self._build_css_assignment_refs,
|
|
198
|
+
self._build_routing_refs,
|
|
199
|
+
self._build_feature_refs,
|
|
200
|
+
self._build_voicemail_refs,
|
|
201
|
+
self._build_voicemail_group_refs,
|
|
202
|
+
self._build_template_refs,
|
|
203
|
+
self._build_remote_destination_refs,
|
|
204
|
+
self._build_intercept_refs,
|
|
205
|
+
self._build_audio_refs,
|
|
206
|
+
self._build_executive_assistant_refs,
|
|
207
|
+
]:
|
|
208
|
+
try:
|
|
209
|
+
counts.update(method())
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
logger.warning("Cross-reference %s failed: %s", method.__name__, exc)
|
|
212
|
+
|
|
213
|
+
# Enrichment steps (depend on cross-refs above)
|
|
214
|
+
for name, method in [
|
|
215
|
+
("shared_lines_detected", self._detect_shared_lines),
|
|
216
|
+
("phones_classified", self._classify_phone_models),
|
|
217
|
+
("dns_normalized", self._normalize_dns_to_e164),
|
|
218
|
+
]:
|
|
219
|
+
try:
|
|
220
|
+
counts[name] = method()
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
logger.warning("Cross-reference %s failed: %s", method.__name__, exc)
|
|
223
|
+
|
|
224
|
+
return counts
|
|
225
|
+
finally:
|
|
226
|
+
# Re-enable FK checks even if build() throws
|
|
227
|
+
self.store.conn.execute("PRAGMA foreign_keys=ON")
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Cross-ref #1, #2: Device Pool relationships
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def _build_device_pool_refs(self) -> dict[str, int]:
|
|
234
|
+
"""Build device_pool_has_datetime_group (#1) and
|
|
235
|
+
device_pool_at_cucm_location (#2).
|
|
236
|
+
|
|
237
|
+
(from 02-normalization-architecture.md manifest rows 1-2)
|
|
238
|
+
"""
|
|
239
|
+
counts = {"device_pool_has_datetime_group": 0, "device_pool_at_cucm_location": 0}
|
|
240
|
+
for dp_data in self.store.get_objects("device_pool"):
|
|
241
|
+
dp_id = dp_data["canonical_id"]
|
|
242
|
+
state = dp_data.get("pre_migration_state") or {}
|
|
243
|
+
|
|
244
|
+
# #1: device_pool → datetime_group
|
|
245
|
+
dtg_name = state.get("cucm_datetime_group")
|
|
246
|
+
if dtg_name:
|
|
247
|
+
dtg_id = f"datetime_group:{dtg_name}"
|
|
248
|
+
if self.store.get_object(dtg_id):
|
|
249
|
+
self.store.add_cross_ref(dp_id, dtg_id, "device_pool_has_datetime_group")
|
|
250
|
+
counts["device_pool_has_datetime_group"] += 1
|
|
251
|
+
|
|
252
|
+
# #2: device_pool → cucm_location
|
|
253
|
+
loc_name = state.get("cucm_location_name")
|
|
254
|
+
if loc_name:
|
|
255
|
+
# CUCM location entities may not exist as separate objects in our store.
|
|
256
|
+
# Store the ref with the name-based ID regardless, for location_mapper.
|
|
257
|
+
loc_id = f"cucm_location:{loc_name}"
|
|
258
|
+
self.store.add_cross_ref(dp_id, loc_id, "device_pool_at_cucm_location")
|
|
259
|
+
counts["device_pool_at_cucm_location"] += 1
|
|
260
|
+
|
|
261
|
+
return counts
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# Cross-ref #3, #4: User relationships
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def _build_user_refs(self) -> dict[str, int]:
|
|
268
|
+
"""Build user_has_device (#3) and user_has_primary_dn (#4).
|
|
269
|
+
|
|
270
|
+
(from 02-normalization-architecture.md manifest rows 3-4)
|
|
271
|
+
"""
|
|
272
|
+
counts = {"user_has_device": 0, "user_has_primary_dn": 0}
|
|
273
|
+
for user_data in self.store.get_objects("user"):
|
|
274
|
+
user_id = user_data["canonical_id"]
|
|
275
|
+
state = user_data.get("pre_migration_state") or {}
|
|
276
|
+
|
|
277
|
+
# #3: user → devices
|
|
278
|
+
for dev_name in state.get("associated_devices", []):
|
|
279
|
+
dev_id = f"device:{dev_name}"
|
|
280
|
+
if self.store.get_object(dev_id):
|
|
281
|
+
self.store.add_cross_ref(user_id, dev_id, "user_has_device")
|
|
282
|
+
counts["user_has_device"] += 1
|
|
283
|
+
|
|
284
|
+
# #4: user → primary DN
|
|
285
|
+
ext = user_data.get("extension")
|
|
286
|
+
partition = state.get("primary_partition")
|
|
287
|
+
if ext and partition:
|
|
288
|
+
dn_id = f"dn:{ext}:{partition}"
|
|
289
|
+
self.store.add_cross_ref(user_id, dn_id, "user_has_primary_dn")
|
|
290
|
+
counts["user_has_primary_dn"] += 1
|
|
291
|
+
|
|
292
|
+
return counts
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
# Cross-ref #5, #6, #20: Device → DN, DN → Partition, Line → CSS
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def _build_device_dn_refs(self) -> dict[str, int]:
|
|
299
|
+
"""Build device_has_dn (#5), dn_in_partition (#6), and line_has_css (#20).
|
|
300
|
+
|
|
301
|
+
Iterates all devices (including common-area/workspace devices stored as
|
|
302
|
+
CanonicalDevice) and creates DN cross-refs from line appearances.
|
|
303
|
+
|
|
304
|
+
(from 02-normalization-architecture.md manifest rows 5, 6, 20)
|
|
305
|
+
"""
|
|
306
|
+
counts = {"device_has_dn": 0, "dn_in_partition": 0, "line_has_css": 0}
|
|
307
|
+
|
|
308
|
+
for dev_data in self.store.get_objects("device"):
|
|
309
|
+
dev_id = dev_data["canonical_id"]
|
|
310
|
+
for la in dev_data.get("line_appearances", []):
|
|
311
|
+
dn = la.get("dn")
|
|
312
|
+
partition = la.get("partition") or "<None>"
|
|
313
|
+
if not dn:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
dn_id = f"dn:{dn}:{partition}"
|
|
317
|
+
line_index = la.get("line_index", 0)
|
|
318
|
+
|
|
319
|
+
# #5: device → DN (with ordinal = line index)
|
|
320
|
+
self.store.add_cross_ref(dev_id, dn_id, "device_has_dn", ordinal=line_index)
|
|
321
|
+
counts["device_has_dn"] += 1
|
|
322
|
+
|
|
323
|
+
# #6: DN → partition
|
|
324
|
+
if partition != "<None>":
|
|
325
|
+
pt_id = f"partition:{partition}"
|
|
326
|
+
self.store.add_cross_ref(dn_id, pt_id, "dn_in_partition")
|
|
327
|
+
counts["dn_in_partition"] += 1
|
|
328
|
+
|
|
329
|
+
# #20: line → CSS (line-level CSS, higher priority than device CSS)
|
|
330
|
+
line_css = la.get("line_css")
|
|
331
|
+
if line_css:
|
|
332
|
+
css_id = f"css:{line_css}"
|
|
333
|
+
self.store.add_cross_ref(dn_id, css_id, "line_has_css")
|
|
334
|
+
counts["line_has_css"] += 1
|
|
335
|
+
|
|
336
|
+
return counts
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
# Cross-ref #7, #8, #9: Device → Pool, Device → Owner, Common-area
|
|
340
|
+
# ------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def _build_user_lookup(self) -> dict[str, str]:
|
|
343
|
+
"""Build a lookup table for matching device descriptions to users.
|
|
344
|
+
|
|
345
|
+
Returns {lowercase_key: canonical_id} for multiple match strategies:
|
|
346
|
+
- userid (e.g., "amckenzie")
|
|
347
|
+
- "firstname lastname" (e.g., "adam mckenzie")
|
|
348
|
+
- "lastname, firstname" (e.g., "mckenzie, adam")
|
|
349
|
+
- "firstname" if unique across all users
|
|
350
|
+
|
|
351
|
+
A user can own multiple phones — this lookup just identifies the user,
|
|
352
|
+
the cross-ref system handles multi-device naturally.
|
|
353
|
+
"""
|
|
354
|
+
lookup: dict[str, str] = {}
|
|
355
|
+
first_name_counts: dict[str, int] = {}
|
|
356
|
+
users_by_first: dict[str, str] = {}
|
|
357
|
+
|
|
358
|
+
for user_data in self.store.get_objects("user"):
|
|
359
|
+
cid = user_data["canonical_id"]
|
|
360
|
+
userid = (user_data.get("cucm_userid") or "").lower()
|
|
361
|
+
first = (user_data.get("first_name") or "").lower().strip()
|
|
362
|
+
last = (user_data.get("last_name") or "").lower().strip()
|
|
363
|
+
|
|
364
|
+
if userid:
|
|
365
|
+
lookup[userid] = cid
|
|
366
|
+
if first and last:
|
|
367
|
+
lookup[f"{first} {last}"] = cid
|
|
368
|
+
lookup[f"{last}, {first}"] = cid
|
|
369
|
+
lookup[f"{last} {first}"] = cid
|
|
370
|
+
if first:
|
|
371
|
+
first_name_counts[first] = first_name_counts.get(first, 0) + 1
|
|
372
|
+
users_by_first[first] = cid
|
|
373
|
+
|
|
374
|
+
# Only add first-name-only matches if the name is unique
|
|
375
|
+
for first, count in first_name_counts.items():
|
|
376
|
+
if count == 1:
|
|
377
|
+
lookup[first] = users_by_first[first]
|
|
378
|
+
|
|
379
|
+
return lookup
|
|
380
|
+
|
|
381
|
+
def _match_description_to_user(
|
|
382
|
+
self, description: str, user_lookup: dict[str, str]
|
|
383
|
+
) -> str | None:
|
|
384
|
+
"""Try to match a device description to a user.
|
|
385
|
+
|
|
386
|
+
Tries exact match first, then checks if any user key appears as a
|
|
387
|
+
substring in the description (handles "Adam McKenzie - 8845" style).
|
|
388
|
+
"""
|
|
389
|
+
if not description:
|
|
390
|
+
return None
|
|
391
|
+
desc_lower = description.lower().strip()
|
|
392
|
+
|
|
393
|
+
# Exact match
|
|
394
|
+
if desc_lower in user_lookup:
|
|
395
|
+
return user_lookup[desc_lower]
|
|
396
|
+
|
|
397
|
+
# Substring match — check if a user's full name appears in the description
|
|
398
|
+
# Only match on names with 2+ parts to avoid false positives on short strings
|
|
399
|
+
best_match: str | None = None
|
|
400
|
+
best_len = 0
|
|
401
|
+
for key, cid in user_lookup.items():
|
|
402
|
+
if len(key) < 4 or " " not in key:
|
|
403
|
+
continue # Skip short keys and single-word keys for substring matching
|
|
404
|
+
if key in desc_lower and len(key) > best_len:
|
|
405
|
+
best_match = cid
|
|
406
|
+
best_len = len(key)
|
|
407
|
+
|
|
408
|
+
return best_match
|
|
409
|
+
|
|
410
|
+
def _build_device_ownership_refs(self) -> dict[str, int]:
|
|
411
|
+
"""Build device_in_pool (#7), device_owned_by_user (#8),
|
|
412
|
+
and common_area_device_in_pool (#9).
|
|
413
|
+
|
|
414
|
+
For devices without an explicit CUCM owner, attempts to match the
|
|
415
|
+
device description to a known user (handles "Adam McKenzie - 8845"
|
|
416
|
+
style descriptions). A user can own multiple phones.
|
|
417
|
+
|
|
418
|
+
(from 02-normalization-architecture.md manifest rows 7-9)
|
|
419
|
+
"""
|
|
420
|
+
counts = {
|
|
421
|
+
"device_in_pool": 0,
|
|
422
|
+
"device_owned_by_user": 0,
|
|
423
|
+
"common_area_device_in_pool": 0,
|
|
424
|
+
"device_owner_inferred": 0,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
user_lookup = self._build_user_lookup()
|
|
428
|
+
|
|
429
|
+
for dev_data in self.store.get_objects("device"):
|
|
430
|
+
dev_id = dev_data["canonical_id"]
|
|
431
|
+
state = dev_data.get("pre_migration_state") or {}
|
|
432
|
+
dp_name = state.get("cucm_device_pool")
|
|
433
|
+
owner = state.get("cucm_owner_user")
|
|
434
|
+
|
|
435
|
+
# Fallback: try to infer owner from device description
|
|
436
|
+
if not owner:
|
|
437
|
+
description = dev_data.get("display_name", "")
|
|
438
|
+
matched_user = self._match_description_to_user(
|
|
439
|
+
description, user_lookup
|
|
440
|
+
)
|
|
441
|
+
if matched_user:
|
|
442
|
+
owner = matched_user.removeprefix("user:")
|
|
443
|
+
counts["device_owner_inferred"] += 1
|
|
444
|
+
logger.info(
|
|
445
|
+
"Inferred owner for %s from description '%s' → %s",
|
|
446
|
+
dev_id, description, matched_user,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if dp_name:
|
|
450
|
+
dp_id = f"device_pool:{dp_name}"
|
|
451
|
+
|
|
452
|
+
if owner:
|
|
453
|
+
# #7: user-owned device → pool
|
|
454
|
+
self.store.add_cross_ref(dev_id, dp_id, "device_in_pool")
|
|
455
|
+
counts["device_in_pool"] += 1
|
|
456
|
+
|
|
457
|
+
# #8: device → owner
|
|
458
|
+
user_id = f"user:{owner}"
|
|
459
|
+
self.store.add_cross_ref(dev_id, user_id, "device_owned_by_user")
|
|
460
|
+
counts["device_owned_by_user"] += 1
|
|
461
|
+
|
|
462
|
+
# Mirror from phone:{name} for mappers that read raw phone objects
|
|
463
|
+
# (CallForwardingMapper, MonitoringMapper, DeviceLayoutMapper)
|
|
464
|
+
device_name = dev_id.removeprefix("device:")
|
|
465
|
+
phone_id = f"phone:{device_name}"
|
|
466
|
+
self.store.add_cross_ref(phone_id, user_id, "device_owned_by_user")
|
|
467
|
+
else:
|
|
468
|
+
# #9: common-area device → pool
|
|
469
|
+
self.store.add_cross_ref(dev_id, dp_id, "common_area_device_in_pool")
|
|
470
|
+
counts["common_area_device_in_pool"] += 1
|
|
471
|
+
|
|
472
|
+
# Also handle workspace objects
|
|
473
|
+
for ws_data in self.store.get_objects("workspace"):
|
|
474
|
+
ws_id = ws_data["canonical_id"]
|
|
475
|
+
state = ws_data.get("pre_migration_state") or {}
|
|
476
|
+
dp_name = state.get("cucm_device_pool")
|
|
477
|
+
if dp_name:
|
|
478
|
+
dp_id = f"device_pool:{dp_name}"
|
|
479
|
+
self.store.add_cross_ref(ws_id, dp_id, "common_area_device_in_pool")
|
|
480
|
+
counts["common_area_device_in_pool"] += 1
|
|
481
|
+
|
|
482
|
+
return counts
|
|
483
|
+
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
# Cross-ref #16, #17: CSS → Partition graph
|
|
486
|
+
# ------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def _build_css_partition_graph(self) -> dict[str, int]:
|
|
489
|
+
"""Build css_contains_partition (#16) and partition_has_pattern (#17).
|
|
490
|
+
|
|
491
|
+
CSS partition ordering is CRITICAL — ordinal preserves partition priority.
|
|
492
|
+
|
|
493
|
+
(from 02-normalization-architecture.md manifest rows 16-17)
|
|
494
|
+
(from 04-css-decomposition.md: "ordinal values preserving partition priority")
|
|
495
|
+
"""
|
|
496
|
+
counts = {"css_contains_partition": 0, "partition_has_pattern": 0}
|
|
497
|
+
|
|
498
|
+
# #16: CSS → partitions (with ordinal)
|
|
499
|
+
for css_data in self.store.get_objects("css"):
|
|
500
|
+
css_id = css_data["canonical_id"]
|
|
501
|
+
state = css_data.get("pre_migration_state") or {}
|
|
502
|
+
for pt in state.get("partitions", []):
|
|
503
|
+
pt_name = pt.get("partition_name")
|
|
504
|
+
ordinal = pt.get("index", 0)
|
|
505
|
+
if pt_name:
|
|
506
|
+
pt_id = f"partition:{pt_name}"
|
|
507
|
+
self.store.add_cross_ref(
|
|
508
|
+
css_id, pt_id, "css_contains_partition", ordinal=ordinal
|
|
509
|
+
)
|
|
510
|
+
counts["css_contains_partition"] += 1
|
|
511
|
+
|
|
512
|
+
# #17: partition → patterns (reverse index built from route patterns and DNs)
|
|
513
|
+
# Route patterns
|
|
514
|
+
for rp_data in self.store.get_objects("route_pattern"):
|
|
515
|
+
rp_id = rp_data["canonical_id"]
|
|
516
|
+
state = rp_data.get("pre_migration_state") or {}
|
|
517
|
+
partition = state.get("partition")
|
|
518
|
+
if partition:
|
|
519
|
+
pt_id = f"partition:{partition}"
|
|
520
|
+
self.store.add_cross_ref(pt_id, rp_id, "partition_has_pattern")
|
|
521
|
+
counts["partition_has_pattern"] += 1
|
|
522
|
+
|
|
523
|
+
# DNs in partitions (already have dn_in_partition refs, build reverse)
|
|
524
|
+
dn_refs = self.store.get_cross_refs(relationship="dn_in_partition")
|
|
525
|
+
for ref in dn_refs:
|
|
526
|
+
# ref: from_id=dn_id, to_id=partition_id
|
|
527
|
+
self.store.add_cross_ref(ref["to_id"], ref["from_id"], "partition_has_pattern")
|
|
528
|
+
counts["partition_has_pattern"] += 1
|
|
529
|
+
|
|
530
|
+
return counts
|
|
531
|
+
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
# Cross-ref #18, #19: CSS assignments (user, device)
|
|
534
|
+
# ------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
def _build_css_assignment_refs(self) -> dict[str, int]:
|
|
537
|
+
"""Build user_has_css (#18) and device_has_css (#19).
|
|
538
|
+
|
|
539
|
+
(from 02-normalization-architecture.md manifest rows 18-19)
|
|
540
|
+
"""
|
|
541
|
+
counts = {"user_has_css": 0, "device_has_css": 0}
|
|
542
|
+
|
|
543
|
+
# #18: user → CSS
|
|
544
|
+
for user_data in self.store.get_objects("user"):
|
|
545
|
+
user_id = user_data["canonical_id"]
|
|
546
|
+
state = user_data.get("pre_migration_state") or {}
|
|
547
|
+
css_name = state.get("cucm_css")
|
|
548
|
+
if css_name:
|
|
549
|
+
css_id = f"css:{css_name}"
|
|
550
|
+
self.store.add_cross_ref(user_id, css_id, "user_has_css")
|
|
551
|
+
counts["user_has_css"] += 1
|
|
552
|
+
|
|
553
|
+
# #19: device → CSS
|
|
554
|
+
for dev_data in self.store.get_objects("device"):
|
|
555
|
+
dev_id = dev_data["canonical_id"]
|
|
556
|
+
state = dev_data.get("pre_migration_state") or {}
|
|
557
|
+
css_name = state.get("cucm_css")
|
|
558
|
+
if css_name:
|
|
559
|
+
css_id = f"css:{css_name}"
|
|
560
|
+
self.store.add_cross_ref(dev_id, css_id, "device_has_css")
|
|
561
|
+
counts["device_has_css"] += 1
|
|
562
|
+
|
|
563
|
+
return counts
|
|
564
|
+
|
|
565
|
+
# ------------------------------------------------------------------
|
|
566
|
+
# Cross-ref #10-15: Routing relationships
|
|
567
|
+
# ------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
def _build_routing_refs(self) -> dict[str, int]:
|
|
570
|
+
"""Build routing cross-refs #10-15.
|
|
571
|
+
|
|
572
|
+
(from 02-normalization-architecture.md manifest rows 10-15)
|
|
573
|
+
"""
|
|
574
|
+
counts = {
|
|
575
|
+
"route_pattern_in_partition": 0,
|
|
576
|
+
"route_pattern_uses_gateway": 0,
|
|
577
|
+
"route_pattern_uses_route_list": 0,
|
|
578
|
+
"route_group_has_trunk": 0,
|
|
579
|
+
"trunk_at_location": 0,
|
|
580
|
+
"translation_pattern_in_partition": 0,
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
# #10: route_pattern → partition
|
|
584
|
+
# #11: route_pattern → gateway (if destination is a gateway)
|
|
585
|
+
# #12: route_pattern → route_list (if destination is a route list)
|
|
586
|
+
for rp_data in self.store.get_objects("route_pattern"):
|
|
587
|
+
rp_id = rp_data["canonical_id"]
|
|
588
|
+
state = rp_data.get("pre_migration_state") or {}
|
|
589
|
+
partition = state.get("partition")
|
|
590
|
+
if partition:
|
|
591
|
+
pt_id = f"partition:{partition}"
|
|
592
|
+
self.store.add_cross_ref(rp_id, pt_id, "route_pattern_in_partition")
|
|
593
|
+
counts["route_pattern_in_partition"] += 1
|
|
594
|
+
|
|
595
|
+
# #11/#12: destination data comes from getRoutePattern detail.
|
|
596
|
+
# The normalizer stores target_type and target_name if available.
|
|
597
|
+
target_type = state.get("target_type")
|
|
598
|
+
target_name = state.get("target_name")
|
|
599
|
+
if target_name and target_type:
|
|
600
|
+
if target_type in ("gateway", "trunk"):
|
|
601
|
+
target_id = f"sip_trunk:{target_name}"
|
|
602
|
+
if not self.store.get_object(target_id):
|
|
603
|
+
target_id = f"gateway:{target_name}"
|
|
604
|
+
self.store.add_cross_ref(
|
|
605
|
+
rp_id, target_id, "route_pattern_uses_gateway"
|
|
606
|
+
)
|
|
607
|
+
counts["route_pattern_uses_gateway"] += 1
|
|
608
|
+
elif target_type in ("route_list", "routeList"):
|
|
609
|
+
target_id = f"route_list:{target_name}"
|
|
610
|
+
self.store.add_cross_ref(
|
|
611
|
+
rp_id, target_id, "route_pattern_uses_route_list"
|
|
612
|
+
)
|
|
613
|
+
counts["route_pattern_uses_route_list"] += 1
|
|
614
|
+
|
|
615
|
+
# #13: route_group → trunks
|
|
616
|
+
for rg_data in self.store.get_objects("route_group"):
|
|
617
|
+
rg_id = rg_data["canonical_id"]
|
|
618
|
+
state = rg_data.get("pre_migration_state") or {}
|
|
619
|
+
for trunk in state.get("trunks", []):
|
|
620
|
+
trunk_name = trunk.get("trunk_name")
|
|
621
|
+
if trunk_name:
|
|
622
|
+
# Try sip_trunk first, then gateway
|
|
623
|
+
trunk_id = f"sip_trunk:{trunk_name}"
|
|
624
|
+
if not self.store.get_object(trunk_id):
|
|
625
|
+
trunk_id = f"gateway:{trunk_name}"
|
|
626
|
+
self.store.add_cross_ref(rg_id, trunk_id, "route_group_has_trunk")
|
|
627
|
+
counts["route_group_has_trunk"] += 1
|
|
628
|
+
|
|
629
|
+
# #14: trunk → device_pool → location chain
|
|
630
|
+
for trunk_type in ("sip_trunk", "gateway"):
|
|
631
|
+
for trunk_data in self.store.get_objects(trunk_type):
|
|
632
|
+
trunk_id = trunk_data["canonical_id"]
|
|
633
|
+
state = trunk_data.get("pre_migration_state") or {}
|
|
634
|
+
dp_name = state.get("cucm_device_pool")
|
|
635
|
+
if dp_name:
|
|
636
|
+
dp_id = f"device_pool:{dp_name}"
|
|
637
|
+
self.store.add_cross_ref(trunk_id, dp_id, "trunk_at_location")
|
|
638
|
+
counts["trunk_at_location"] += 1
|
|
639
|
+
|
|
640
|
+
# #15: translation_pattern → partition
|
|
641
|
+
for tp_data in self.store.get_objects("translation_pattern"):
|
|
642
|
+
tp_id = tp_data["canonical_id"]
|
|
643
|
+
state = tp_data.get("pre_migration_state") or {}
|
|
644
|
+
partition = state.get("partition")
|
|
645
|
+
if partition:
|
|
646
|
+
pt_id = f"partition:{partition}"
|
|
647
|
+
self.store.add_cross_ref(tp_id, pt_id, "translation_pattern_in_partition")
|
|
648
|
+
counts["translation_pattern_in_partition"] += 1
|
|
649
|
+
|
|
650
|
+
return counts
|
|
651
|
+
|
|
652
|
+
# ------------------------------------------------------------------
|
|
653
|
+
# Cross-ref #21-25: Feature relationships
|
|
654
|
+
# ------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
def _build_feature_refs(self) -> dict[str, int]:
|
|
657
|
+
"""Build feature cross-refs #21-25.
|
|
658
|
+
|
|
659
|
+
(from 02-normalization-architecture.md manifest rows 21-25)
|
|
660
|
+
"""
|
|
661
|
+
counts = {
|
|
662
|
+
"hunt_pilot_has_hunt_list": 0,
|
|
663
|
+
"hunt_list_has_line_group": 0,
|
|
664
|
+
"line_group_has_members": 0,
|
|
665
|
+
"cti_rp_has_script": 0,
|
|
666
|
+
"schedule_has_time_period": 0,
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
# #21: hunt_pilot → hunt_list
|
|
670
|
+
for hp_data in self.store.get_objects("hunt_pilot"):
|
|
671
|
+
hp_id = hp_data["canonical_id"]
|
|
672
|
+
state = hp_data.get("pre_migration_state") or {}
|
|
673
|
+
hl_name = state.get("hunt_list_name")
|
|
674
|
+
if hl_name:
|
|
675
|
+
hl_id = f"hunt_list:{hl_name}"
|
|
676
|
+
self.store.add_cross_ref(hp_id, hl_id, "hunt_pilot_has_hunt_list")
|
|
677
|
+
counts["hunt_pilot_has_hunt_list"] += 1
|
|
678
|
+
|
|
679
|
+
# #22: hunt_list → line_groups
|
|
680
|
+
for hl_data in self.store.get_objects("hunt_list"):
|
|
681
|
+
hl_id = hl_data["canonical_id"]
|
|
682
|
+
state = hl_data.get("pre_migration_state") or {}
|
|
683
|
+
for lg_name in state.get("line_groups", []):
|
|
684
|
+
lg_id = f"line_group:{lg_name}"
|
|
685
|
+
self.store.add_cross_ref(hl_id, lg_id, "hunt_list_has_line_group")
|
|
686
|
+
counts["hunt_list_has_line_group"] += 1
|
|
687
|
+
|
|
688
|
+
# #23: line_group → members (DN/line references)
|
|
689
|
+
for lg_data in self.store.get_objects("line_group"):
|
|
690
|
+
lg_id = lg_data["canonical_id"]
|
|
691
|
+
state = lg_data.get("pre_migration_state") or {}
|
|
692
|
+
for member in state.get("members", []):
|
|
693
|
+
# Members may be DN references — structure varies by CUCM version.
|
|
694
|
+
# Extract both DN number and partition for accurate canonical_id.
|
|
695
|
+
#
|
|
696
|
+
# Live AXL returns nested dicts:
|
|
697
|
+
# {"lineSelectionOrder": 1,
|
|
698
|
+
# "directoryNumber": {"pattern": "1002",
|
|
699
|
+
# "routePartitionName": {"_value_1": "Internal-PT", "uuid": None},
|
|
700
|
+
# "uuid": "{537A119B-...}"},
|
|
701
|
+
# "uuid": "{50051150-...}"}
|
|
702
|
+
dn_num = None
|
|
703
|
+
partition = "<None>"
|
|
704
|
+
if isinstance(member, dict):
|
|
705
|
+
dn_ref = member.get("directoryNumber") or member.get("pattern")
|
|
706
|
+
if isinstance(dn_ref, dict):
|
|
707
|
+
# Nested AXL dict — extract pattern and partition from inside
|
|
708
|
+
dn_num = dn_ref.get("pattern")
|
|
709
|
+
rp = dn_ref.get("routePartitionName")
|
|
710
|
+
if isinstance(rp, dict):
|
|
711
|
+
partition = rp.get("_value_1") or "<None>"
|
|
712
|
+
elif isinstance(rp, str) and rp:
|
|
713
|
+
partition = rp
|
|
714
|
+
elif isinstance(dn_ref, str):
|
|
715
|
+
# Flat string — directoryNumber is already the pattern
|
|
716
|
+
dn_num = dn_ref
|
|
717
|
+
rp = member.get("routePartitionName")
|
|
718
|
+
if isinstance(rp, dict):
|
|
719
|
+
partition = rp.get("_value_1") or "<None>"
|
|
720
|
+
elif isinstance(rp, str) and rp:
|
|
721
|
+
partition = rp
|
|
722
|
+
elif isinstance(member, str):
|
|
723
|
+
dn_num = member
|
|
724
|
+
if dn_num:
|
|
725
|
+
member_id = f"dn:{dn_num}:{partition}"
|
|
726
|
+
self.store.add_cross_ref(lg_id, member_id, "line_group_has_members")
|
|
727
|
+
counts["line_group_has_members"] += 1
|
|
728
|
+
|
|
729
|
+
# #24: cti_rp → script/application
|
|
730
|
+
for cti_data in self.store.get_objects("cti_rp"):
|
|
731
|
+
cti_id = cti_data["canonical_id"]
|
|
732
|
+
state = cti_data.get("pre_migration_state") or {}
|
|
733
|
+
# Script/app reference is typically external — store if available
|
|
734
|
+
script = state.get("script") or state.get("application")
|
|
735
|
+
if script:
|
|
736
|
+
script_id = f"script:{script}"
|
|
737
|
+
self.store.add_cross_ref(cti_id, script_id, "cti_rp_has_script")
|
|
738
|
+
counts["cti_rp_has_script"] += 1
|
|
739
|
+
|
|
740
|
+
# #25: schedule → time_periods
|
|
741
|
+
for sched_data in self.store.get_objects("time_schedule"):
|
|
742
|
+
sched_id = sched_data["canonical_id"]
|
|
743
|
+
state = sched_data.get("pre_migration_state") or {}
|
|
744
|
+
for tp_name in state.get("time_periods", []):
|
|
745
|
+
tp_id = f"time_period:{tp_name}"
|
|
746
|
+
self.store.add_cross_ref(sched_id, tp_id, "schedule_has_time_period")
|
|
747
|
+
counts["schedule_has_time_period"] += 1
|
|
748
|
+
|
|
749
|
+
return counts
|
|
750
|
+
|
|
751
|
+
# ------------------------------------------------------------------
|
|
752
|
+
# Cross-ref #26, #27: Voicemail relationships
|
|
753
|
+
# ------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
def _build_voicemail_refs(self) -> dict[str, int]:
|
|
756
|
+
"""Build user_has_voicemail_profile (#26) and voicemail_profile_settings (#27).
|
|
757
|
+
|
|
758
|
+
(from 02-normalization-architecture.md manifest rows 26-27)
|
|
759
|
+
"""
|
|
760
|
+
counts = {"user_has_voicemail_profile": 0, "voicemail_profile_settings": 0}
|
|
761
|
+
|
|
762
|
+
# #26: user → voicemail_profile
|
|
763
|
+
for user_data in self.store.get_objects("user"):
|
|
764
|
+
user_id = user_data["canonical_id"]
|
|
765
|
+
state = user_data.get("pre_migration_state") or {}
|
|
766
|
+
vm_profile = state.get("cucm_voicemail_profile")
|
|
767
|
+
if vm_profile:
|
|
768
|
+
vm_id = f"voicemail_profile:{vm_profile}"
|
|
769
|
+
self.store.add_cross_ref(user_id, vm_id, "user_has_voicemail_profile")
|
|
770
|
+
counts["user_has_voicemail_profile"] += 1
|
|
771
|
+
|
|
772
|
+
# #27: voicemail_profile → settings
|
|
773
|
+
# Settings are stored in the voicemail profile object's pre_migration_state.
|
|
774
|
+
# The cross-ref links profile to itself with settings for the voicemail_mapper.
|
|
775
|
+
for vm_data in self.store.get_objects("voicemail_profile"):
|
|
776
|
+
vm_id = vm_data["canonical_id"]
|
|
777
|
+
state = vm_data.get("pre_migration_state") or {}
|
|
778
|
+
if state:
|
|
779
|
+
self.store.add_cross_ref(vm_id, vm_id, "voicemail_profile_settings")
|
|
780
|
+
counts["voicemail_profile_settings"] += 1
|
|
781
|
+
|
|
782
|
+
return counts
|
|
783
|
+
|
|
784
|
+
# ------------------------------------------------------------------
|
|
785
|
+
# Cross-ref: Hunt pilot/list → Voicemail Group
|
|
786
|
+
# (from docs/superpowers/specs/2026-04-10-voicemail-groups.md §Phase 3)
|
|
787
|
+
# ------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
def _build_voicemail_group_refs(self) -> dict[str, int]:
|
|
790
|
+
"""Build hunt_group_uses_voicemail_group cross-refs.
|
|
791
|
+
|
|
792
|
+
Links hunt pilots whose overflow/forward destinations match a
|
|
793
|
+
voicemail group extension. This allows the mapper and report to
|
|
794
|
+
understand which features depend on shared mailboxes.
|
|
795
|
+
|
|
796
|
+
voicemail_group_in_location is written by VoicemailGroupMapper
|
|
797
|
+
(not here) because location resolution requires mapper logic.
|
|
798
|
+
"""
|
|
799
|
+
counts = {"hunt_group_uses_voicemail_group": 0}
|
|
800
|
+
|
|
801
|
+
# Index voicemail groups by extension for O(1) lookup.
|
|
802
|
+
vg_by_ext: dict[str, str] = {}
|
|
803
|
+
for vg in self.store.get_objects("voicemail_group"):
|
|
804
|
+
state = vg.get("pre_migration_state") or {}
|
|
805
|
+
ext = state.get("extension")
|
|
806
|
+
if ext:
|
|
807
|
+
vg_by_ext[str(ext)] = vg["canonical_id"]
|
|
808
|
+
|
|
809
|
+
if not vg_by_ext:
|
|
810
|
+
return counts
|
|
811
|
+
|
|
812
|
+
# Check hunt pilot forward/overflow destinations against VM group extensions.
|
|
813
|
+
for hp in self.store.get_objects("hunt_pilot"):
|
|
814
|
+
hp_id = hp["canonical_id"]
|
|
815
|
+
state = hp.get("pre_migration_state") or {}
|
|
816
|
+
|
|
817
|
+
destinations = [
|
|
818
|
+
state.get("forward_no_answer_destination"),
|
|
819
|
+
state.get("forward_busy_destination"),
|
|
820
|
+
state.get("overflow_destination"),
|
|
821
|
+
]
|
|
822
|
+
for dest in destinations:
|
|
823
|
+
if dest and str(dest) in vg_by_ext:
|
|
824
|
+
self.store.add_cross_ref(
|
|
825
|
+
hp_id, vg_by_ext[str(dest)],
|
|
826
|
+
"hunt_group_uses_voicemail_group",
|
|
827
|
+
)
|
|
828
|
+
counts["hunt_group_uses_voicemail_group"] += 1
|
|
829
|
+
break # one link per hunt pilot is sufficient
|
|
830
|
+
|
|
831
|
+
return counts
|
|
832
|
+
|
|
833
|
+
# ------------------------------------------------------------------
|
|
834
|
+
# Cross-refs: Phone → Button Template, Phone → Softkey Template
|
|
835
|
+
# (from tier2-phase2-phone-config-design.md §2.4)
|
|
836
|
+
# ------------------------------------------------------------------
|
|
837
|
+
|
|
838
|
+
def _build_template_refs(self) -> dict[str, int]:
|
|
839
|
+
"""Build phone_uses_button_template, phone_uses_softkey_template, and
|
|
840
|
+
phone_uses_common_phone_config cross-references.
|
|
841
|
+
|
|
842
|
+
Raw phone objects store AXL zeep dicts in pre_migration_state.
|
|
843
|
+
Template names are in phoneTemplateName/softkeyTemplateName as
|
|
844
|
+
zeep ref dicts: {"_value_1": "Template Name", "uuid": "..."}.
|
|
845
|
+
Common phone config name may be a zeep ref dict or a plain string
|
|
846
|
+
(already normalized by normalize_phone).
|
|
847
|
+
"""
|
|
848
|
+
bt_count = 0
|
|
849
|
+
sk_count = 0
|
|
850
|
+
cpc_count = 0
|
|
851
|
+
|
|
852
|
+
for phone in self.store.get_objects("phone"):
|
|
853
|
+
phone_id = phone["canonical_id"]
|
|
854
|
+
state = phone.get("pre_migration_state") or {}
|
|
855
|
+
|
|
856
|
+
# Button template: raw AXL field is phoneTemplateName (zeep ref dict)
|
|
857
|
+
bt_raw = state.get("phoneTemplateName")
|
|
858
|
+
bt_name = self._ref_value(bt_raw)
|
|
859
|
+
if bt_name:
|
|
860
|
+
bt_id = f"button_template:{bt_name}"
|
|
861
|
+
self.store.add_cross_ref(phone_id, bt_id, "phone_uses_button_template")
|
|
862
|
+
bt_count += 1
|
|
863
|
+
|
|
864
|
+
# Softkey template: raw AXL field is softkeyTemplateName (zeep ref dict)
|
|
865
|
+
sk_raw = state.get("softkeyTemplateName")
|
|
866
|
+
sk_name = self._ref_value(sk_raw)
|
|
867
|
+
if sk_name:
|
|
868
|
+
sk_id = f"softkey_template:{sk_name}"
|
|
869
|
+
self.store.add_cross_ref(phone_id, sk_id, "phone_uses_softkey_template")
|
|
870
|
+
sk_count += 1
|
|
871
|
+
|
|
872
|
+
# Common phone config: raw AXL field or normalized field
|
|
873
|
+
cpc_raw = state.get("commonPhoneConfigName")
|
|
874
|
+
cpc_name = self._ref_value(cpc_raw)
|
|
875
|
+
if not cpc_name:
|
|
876
|
+
cpc_name = state.get("cucm_common_phone_config")
|
|
877
|
+
if cpc_name:
|
|
878
|
+
cpc_id = f"info_common_phone_config:{cpc_name}"
|
|
879
|
+
self.store.add_cross_ref(phone_id, cpc_id, "phone_uses_common_phone_config")
|
|
880
|
+
cpc_count += 1
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
"phone_uses_button_template": bt_count,
|
|
884
|
+
"phone_uses_softkey_template": sk_count,
|
|
885
|
+
"phone_uses_common_phone_config": cpc_count,
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
def _build_executive_assistant_refs(self) -> dict[str, int]:
|
|
889
|
+
"""Build executive/assistant relationship cross-refs.
|
|
890
|
+
|
|
891
|
+
(from executive-assistant-migration spec §4b)
|
|
892
|
+
"""
|
|
893
|
+
counts = {
|
|
894
|
+
"executive_has_assistant": 0,
|
|
895
|
+
"assistant_serves_executive": 0,
|
|
896
|
+
"user_is_executive": 0,
|
|
897
|
+
"user_is_assistant": 0,
|
|
898
|
+
}
|
|
899
|
+
for pair_data in self.store.get_objects("exec_asst_pair"):
|
|
900
|
+
state = pair_data.get("pre_migration_state") or {}
|
|
901
|
+
exec_userid = state.get("executive_userid")
|
|
902
|
+
asst_userid = state.get("assistant_userid")
|
|
903
|
+
if not exec_userid or not asst_userid:
|
|
904
|
+
continue
|
|
905
|
+
self.store.add_cross_ref(
|
|
906
|
+
f"user:{exec_userid}", f"user:{asst_userid}", "executive_has_assistant"
|
|
907
|
+
)
|
|
908
|
+
counts["executive_has_assistant"] += 1
|
|
909
|
+
self.store.add_cross_ref(
|
|
910
|
+
f"user:{asst_userid}", f"user:{exec_userid}", "assistant_serves_executive"
|
|
911
|
+
)
|
|
912
|
+
counts["assistant_serves_executive"] += 1
|
|
913
|
+
|
|
914
|
+
for setting_data in self.store.get_objects("exec_setting"):
|
|
915
|
+
state = setting_data.get("pre_migration_state") or {}
|
|
916
|
+
userid = state.get("userid")
|
|
917
|
+
role = state.get("role")
|
|
918
|
+
if not userid:
|
|
919
|
+
continue
|
|
920
|
+
setting_id = setting_data["canonical_id"]
|
|
921
|
+
user_id = f"user:{userid}"
|
|
922
|
+
if role == "EXECUTIVE":
|
|
923
|
+
self.store.add_cross_ref(user_id, setting_id, "user_is_executive")
|
|
924
|
+
counts["user_is_executive"] += 1
|
|
925
|
+
elif role == "EXECUTIVE_ASSISTANT":
|
|
926
|
+
self.store.add_cross_ref(user_id, setting_id, "user_is_assistant")
|
|
927
|
+
counts["user_is_assistant"] += 1
|
|
928
|
+
return counts
|
|
929
|
+
|
|
930
|
+
def _build_remote_destination_refs(self) -> dict[str, int]:
|
|
931
|
+
"""Build user → remote_destination cross-refs for SNR.
|
|
932
|
+
|
|
933
|
+
(from tier2-enterprise-expansion.md §4: user_has_snr)
|
|
934
|
+
"""
|
|
935
|
+
count = 0
|
|
936
|
+
for rd in self.store.get_objects("remote_destination"):
|
|
937
|
+
state = rd.get("pre_migration_state") or {}
|
|
938
|
+
owner_userid = state.get("ownerUserId") or ""
|
|
939
|
+
if not owner_userid:
|
|
940
|
+
continue
|
|
941
|
+
user_cid = f"user:{owner_userid}"
|
|
942
|
+
if self.store.get_object(user_cid):
|
|
943
|
+
self.store.add_cross_ref(user_cid, rd["canonical_id"], "user_has_remote_destination")
|
|
944
|
+
count += 1
|
|
945
|
+
return {"user_has_remote_destination": count}
|
|
946
|
+
|
|
947
|
+
def _build_intercept_refs(self) -> dict[str, int]:
|
|
948
|
+
"""Build user -> intercept_candidate cross-refs."""
|
|
949
|
+
count = 0
|
|
950
|
+
for ic in self.store.get_objects("intercept_candidate"):
|
|
951
|
+
state = ic.get("pre_migration_state") or {}
|
|
952
|
+
userid = state.get("userid") or ""
|
|
953
|
+
if not userid:
|
|
954
|
+
continue
|
|
955
|
+
user_cid = f"user:{userid}"
|
|
956
|
+
if self.store.get_object(user_cid):
|
|
957
|
+
self.store.add_cross_ref(user_cid, ic["canonical_id"], "user_has_intercept_signal")
|
|
958
|
+
count += 1
|
|
959
|
+
return {"user_has_intercept_signal": count}
|
|
960
|
+
|
|
961
|
+
# ------------------------------------------------------------------
|
|
962
|
+
# Cross-ref #33: Feature → MoH audio source
|
|
963
|
+
# ------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
def _build_audio_refs(self) -> dict[str, int]:
|
|
966
|
+
"""Build feature → MoH source cross-refs.
|
|
967
|
+
|
|
968
|
+
Hunt pilots with networkHoldMohAudioSourceID reference a CUCM MoH
|
|
969
|
+
audio source by numeric ID. Resolve to the canonical_id of the
|
|
970
|
+
music_on_hold object so the report can show which features use
|
|
971
|
+
which custom MoH sources.
|
|
972
|
+
"""
|
|
973
|
+
counts = {"feature_uses_moh_source": 0}
|
|
974
|
+
|
|
975
|
+
# Build lookup: cucm_source_id → canonical_id
|
|
976
|
+
moh_lookup: dict[str, str] = {}
|
|
977
|
+
for moh in self.store.get_objects("music_on_hold"):
|
|
978
|
+
source_id = moh.get("cucm_source_id")
|
|
979
|
+
if source_id:
|
|
980
|
+
moh_lookup[str(source_id)] = moh.get("canonical_id", "")
|
|
981
|
+
|
|
982
|
+
if not moh_lookup:
|
|
983
|
+
return counts
|
|
984
|
+
|
|
985
|
+
# Hunt pilots → MoH source
|
|
986
|
+
for hp_data in self.store.get_objects("hunt_pilot"):
|
|
987
|
+
hp_id = hp_data["canonical_id"]
|
|
988
|
+
state = hp_data.get("pre_migration_state") or {}
|
|
989
|
+
queue_calls = state.get("queueCalls") or {}
|
|
990
|
+
moh_source_id = queue_calls.get("networkHoldMohAudioSourceID")
|
|
991
|
+
if moh_source_id and str(moh_source_id) in moh_lookup:
|
|
992
|
+
moh_canonical = moh_lookup[str(moh_source_id)]
|
|
993
|
+
self.store.add_cross_ref(hp_id, moh_canonical, "feature_uses_moh_source")
|
|
994
|
+
counts["feature_uses_moh_source"] += 1
|
|
995
|
+
|
|
996
|
+
return counts
|
|
997
|
+
|
|
998
|
+
@staticmethod
|
|
999
|
+
def _ref_value(field: Any) -> str | None:
|
|
1000
|
+
"""Extract display value from a zeep reference field or plain string."""
|
|
1001
|
+
if field is None:
|
|
1002
|
+
return None
|
|
1003
|
+
if isinstance(field, str):
|
|
1004
|
+
return field or None
|
|
1005
|
+
if isinstance(field, dict):
|
|
1006
|
+
val = field.get("_value_1")
|
|
1007
|
+
return val if val else None
|
|
1008
|
+
return None
|
|
1009
|
+
|
|
1010
|
+
# ------------------------------------------------------------------
|
|
1011
|
+
# Enrichment: Shared line detection
|
|
1012
|
+
# ------------------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
def _detect_shared_lines(self) -> int:
|
|
1015
|
+
"""Detect DNs that appear on multiple devices, create CanonicalSharedLine objects.
|
|
1016
|
+
|
|
1017
|
+
(from 02-normalization-architecture.md, _detect_shared_lines)
|
|
1018
|
+
(from 02b-cucm-extraction.md §2.7, shared line detection)
|
|
1019
|
+
"""
|
|
1020
|
+
rows = self.store.conn.execute("""
|
|
1021
|
+
SELECT cr.to_id as dn_id, COUNT(DISTINCT cr.from_id) as device_count
|
|
1022
|
+
FROM cross_refs cr
|
|
1023
|
+
WHERE cr.relationship = 'device_has_dn'
|
|
1024
|
+
GROUP BY cr.to_id
|
|
1025
|
+
HAVING COUNT(DISTINCT cr.from_id) > 1
|
|
1026
|
+
""").fetchall()
|
|
1027
|
+
|
|
1028
|
+
count = 0
|
|
1029
|
+
for row in rows:
|
|
1030
|
+
dn_id = row["dn_id"]
|
|
1031
|
+
device_count = row["device_count"]
|
|
1032
|
+
|
|
1033
|
+
# Find all devices with this DN
|
|
1034
|
+
device_refs = self.store.get_cross_refs(
|
|
1035
|
+
to_id=dn_id, relationship="device_has_dn"
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
# Build device list with owner info
|
|
1039
|
+
device_canonical_ids = []
|
|
1040
|
+
owner_canonical_ids = []
|
|
1041
|
+
for ref in device_refs:
|
|
1042
|
+
dev_id = ref["from_id"]
|
|
1043
|
+
device_canonical_ids.append(dev_id)
|
|
1044
|
+
|
|
1045
|
+
# Find owner via device_owned_by_user
|
|
1046
|
+
owner_refs = self.store.find_cross_refs(dev_id, "device_owned_by_user")
|
|
1047
|
+
for owner_id in owner_refs:
|
|
1048
|
+
if owner_id not in owner_canonical_ids:
|
|
1049
|
+
owner_canonical_ids.append(owner_id)
|
|
1050
|
+
|
|
1051
|
+
# Extract DN pattern from dn_id (format: "dn:pattern:partition")
|
|
1052
|
+
parts = dn_id.split(":", 2)
|
|
1053
|
+
dn_pattern = parts[1] if len(parts) > 1 else dn_id
|
|
1054
|
+
|
|
1055
|
+
shared_line = CanonicalSharedLine(
|
|
1056
|
+
canonical_id=f"shared_line:{dn_id.replace('dn:', '')}",
|
|
1057
|
+
provenance=Provenance(
|
|
1058
|
+
source_system="cucm",
|
|
1059
|
+
source_id=dn_id,
|
|
1060
|
+
source_name=dn_pattern,
|
|
1061
|
+
extracted_at=_now(),
|
|
1062
|
+
),
|
|
1063
|
+
status=MigrationStatus.NORMALIZED,
|
|
1064
|
+
dn_canonical_id=dn_id,
|
|
1065
|
+
owner_canonical_ids=owner_canonical_ids,
|
|
1066
|
+
device_canonical_ids=device_canonical_ids,
|
|
1067
|
+
)
|
|
1068
|
+
self.store.upsert_object(shared_line)
|
|
1069
|
+
count += 1
|
|
1070
|
+
logger.info(
|
|
1071
|
+
"Shared line detected: %s on %d devices", dn_id, device_count
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
return count
|
|
1075
|
+
|
|
1076
|
+
# ------------------------------------------------------------------
|
|
1077
|
+
# Enrichment: Phone model classification
|
|
1078
|
+
# ------------------------------------------------------------------
|
|
1079
|
+
|
|
1080
|
+
def _classify_phone_models(self) -> int:
|
|
1081
|
+
"""Classify all devices by model compatibility tier.
|
|
1082
|
+
|
|
1083
|
+
(from 02-normalization-architecture.md: "apply the four-tier firmware
|
|
1084
|
+
compatibility table")
|
|
1085
|
+
(from cucm-wxc-migration.md lines 363-369)
|
|
1086
|
+
"""
|
|
1087
|
+
count = 0
|
|
1088
|
+
for dev_obj in self.store.query_by_type("device"):
|
|
1089
|
+
if not isinstance(dev_obj, CanonicalDevice):
|
|
1090
|
+
continue
|
|
1091
|
+
tier = classify_phone_model(dev_obj.model)
|
|
1092
|
+
if dev_obj.compatibility_tier != tier:
|
|
1093
|
+
dev_obj.compatibility_tier = tier
|
|
1094
|
+
self.store.upsert_object(dev_obj)
|
|
1095
|
+
count += 1
|
|
1096
|
+
|
|
1097
|
+
return count
|
|
1098
|
+
|
|
1099
|
+
# ------------------------------------------------------------------
|
|
1100
|
+
# Enrichment: E.164 DN normalization
|
|
1101
|
+
# ------------------------------------------------------------------
|
|
1102
|
+
|
|
1103
|
+
def _normalize_dns_to_e164(self) -> int:
|
|
1104
|
+
"""Normalize all DNs to E.164 using the e164 module.
|
|
1105
|
+
|
|
1106
|
+
For each device's line appearances, apply E.164 normalization using
|
|
1107
|
+
the device's resolved location (and therefore country code from config).
|
|
1108
|
+
|
|
1109
|
+
(from 02-normalization-architecture.md: "apply E.164 normalization")
|
|
1110
|
+
"""
|
|
1111
|
+
count = 0
|
|
1112
|
+
for dev_obj in self.store.query_by_type("device"):
|
|
1113
|
+
if not isinstance(dev_obj, CanonicalDevice):
|
|
1114
|
+
continue
|
|
1115
|
+
|
|
1116
|
+
updated = False
|
|
1117
|
+
for la in dev_obj.line_appearances:
|
|
1118
|
+
dn = la.get("dn")
|
|
1119
|
+
if not dn:
|
|
1120
|
+
continue
|
|
1121
|
+
|
|
1122
|
+
result = normalize_dn(
|
|
1123
|
+
dn, self.default_country_code, self.site_prefix_rules
|
|
1124
|
+
)
|
|
1125
|
+
la["e164_result"] = {
|
|
1126
|
+
"e164": result.e164,
|
|
1127
|
+
"extension": result.extension,
|
|
1128
|
+
"classification": result.classification,
|
|
1129
|
+
}
|
|
1130
|
+
updated = True
|
|
1131
|
+
|
|
1132
|
+
if updated:
|
|
1133
|
+
self.store.upsert_object(dev_obj)
|
|
1134
|
+
count += 1
|
|
1135
|
+
|
|
1136
|
+
return count
|