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,2008 @@
|
|
|
1
|
+
"""Pass 1 normalizers: stateless pure functions (one CUCM dict → one canonical model).
|
|
2
|
+
|
|
3
|
+
Each normalizer takes a raw CUCM dict (as returned by zeep from AXL) and returns
|
|
4
|
+
either a canonical Pydantic model or a MigrationObject with cleaned fields.
|
|
5
|
+
Foreign keys stay as CUCM name strings — pass 2 (CrossReferenceBuilder) resolves them.
|
|
6
|
+
|
|
7
|
+
Properties:
|
|
8
|
+
- Testable in isolation (no database, no index, no other objects)
|
|
9
|
+
- Order-independent (process in any sequence)
|
|
10
|
+
- Parallel-safe (no shared state)
|
|
11
|
+
|
|
12
|
+
(from 02-normalization-architecture.md, Pass 1 specification)
|
|
13
|
+
(from 02b-cucm-extraction.md, per-extractor zeep→canonical mapping tables)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from wxcli.migration.models import (
|
|
22
|
+
CanonicalDECTNetwork,
|
|
23
|
+
CanonicalDevice,
|
|
24
|
+
CanonicalTranslationPattern,
|
|
25
|
+
CanonicalUser,
|
|
26
|
+
CanonicalVoicemailProfile,
|
|
27
|
+
CanonicalWorkspace,
|
|
28
|
+
DeviceCompatibilityTier,
|
|
29
|
+
MigrationObject,
|
|
30
|
+
MigrationStatus,
|
|
31
|
+
Provenance,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _now() -> datetime:
|
|
36
|
+
return datetime.now(timezone.utc)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _to_list(field: dict | list | None, key: str) -> list:
|
|
40
|
+
"""Normalize a list field to a flat Python list.
|
|
41
|
+
|
|
42
|
+
Handles both extractor format (already flat list from to_list() helper)
|
|
43
|
+
and raw zeep format (nested dict with key wrapper).
|
|
44
|
+
(Reconciled with Phase 03 extractors which pre-normalize via to_list())
|
|
45
|
+
"""
|
|
46
|
+
if field is None:
|
|
47
|
+
return []
|
|
48
|
+
if isinstance(field, list):
|
|
49
|
+
# Extractor format: already a flat list
|
|
50
|
+
return field
|
|
51
|
+
if isinstance(field, dict):
|
|
52
|
+
# Raw zeep format: {'member': [...]} or {'member': single_dict}
|
|
53
|
+
inner = field.get(key, [])
|
|
54
|
+
if isinstance(inner, list):
|
|
55
|
+
return inner
|
|
56
|
+
if isinstance(inner, dict):
|
|
57
|
+
return [inner]
|
|
58
|
+
if inner is None:
|
|
59
|
+
return []
|
|
60
|
+
return [inner]
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_member_uuids(members: list) -> list[str]:
|
|
65
|
+
"""Extract UUID strings from a list of CUCM member dicts.
|
|
66
|
+
|
|
67
|
+
Pickup group members come from AXL as dicts like:
|
|
68
|
+
{'priority': 1, 'pickupGroupLineMember': {'_value_1': 'name', 'uuid': '{UUID}'}}
|
|
69
|
+
This helper walks each member dict to find a UUID in any nested reference
|
|
70
|
+
field and returns a flat list of UUID strings. Already-string members are
|
|
71
|
+
passed through unchanged.
|
|
72
|
+
(Verified via live CUCM — pickup group member dicts contain nested refs)
|
|
73
|
+
"""
|
|
74
|
+
result: list[str] = []
|
|
75
|
+
for member in members:
|
|
76
|
+
if isinstance(member, str):
|
|
77
|
+
result.append(member)
|
|
78
|
+
continue
|
|
79
|
+
if isinstance(member, dict):
|
|
80
|
+
uuid_val = _find_uuid_in_member(member)
|
|
81
|
+
if uuid_val:
|
|
82
|
+
result.append(uuid_val)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _find_uuid_in_member(member: dict) -> str | None:
|
|
87
|
+
"""Find the UUID in a CUCM member dict by scanning nested reference fields.
|
|
88
|
+
|
|
89
|
+
Looks for the zeep reference pattern {'_value_1': str, 'uuid': str} inside
|
|
90
|
+
member dicts. If a top-level 'uuid' key exists on the member itself, that
|
|
91
|
+
is used as a last resort.
|
|
92
|
+
"""
|
|
93
|
+
# First: scan values for nested zeep reference dicts containing 'uuid'
|
|
94
|
+
for key, val in member.items():
|
|
95
|
+
if key == "priority":
|
|
96
|
+
continue
|
|
97
|
+
if isinstance(val, dict) and "uuid" in val:
|
|
98
|
+
uuid = val["uuid"]
|
|
99
|
+
if uuid is not None:
|
|
100
|
+
return str(uuid)
|
|
101
|
+
if isinstance(val, str) and (val.startswith("{") or len(val) == 36):
|
|
102
|
+
# Bare UUID string as a direct value
|
|
103
|
+
return val
|
|
104
|
+
# Fallback: top-level uuid on the member dict itself
|
|
105
|
+
if "uuid" in member:
|
|
106
|
+
uuid = member["uuid"]
|
|
107
|
+
if uuid is not None:
|
|
108
|
+
return str(uuid)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_ref(field: dict | str | None) -> str | None:
|
|
113
|
+
"""Extract _value_1 from a zeep reference field.
|
|
114
|
+
|
|
115
|
+
Zeep AXL references use the pattern {'_value_1': str, 'uuid': str}.
|
|
116
|
+
Empty references come back as {'_value_1': None, 'uuid': None}, NOT plain None.
|
|
117
|
+
(Verified via live CUCM Phase 03)
|
|
118
|
+
"""
|
|
119
|
+
if field is None:
|
|
120
|
+
return None
|
|
121
|
+
if isinstance(field, dict):
|
|
122
|
+
val = field.get("_value_1")
|
|
123
|
+
return val if val is not None else None
|
|
124
|
+
return str(field)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _make_provenance(
|
|
128
|
+
raw: dict, cluster: str = "default", source_name: str | None = None
|
|
129
|
+
) -> Provenance:
|
|
130
|
+
"""Build a Provenance from a raw CUCM dict.
|
|
131
|
+
|
|
132
|
+
(from cucm-wxc-migration.md lines 125-131)
|
|
133
|
+
"""
|
|
134
|
+
return Provenance(
|
|
135
|
+
source_system="cucm",
|
|
136
|
+
source_id=raw.get("pkid", ""),
|
|
137
|
+
source_name=source_name or raw.get("name", raw.get("pattern", raw.get("userid", raw.get("dirn", raw.get("domainName", ""))))),
|
|
138
|
+
cluster=cluster,
|
|
139
|
+
extracted_at=_now(),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# §2.1 — Device Pool normalizer
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def normalize_device_pool(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
148
|
+
"""Normalize a CUCM Device Pool dict into a MigrationObject.
|
|
149
|
+
|
|
150
|
+
Device pools are intermediate objects — they feed location_mapper in Phase 05,
|
|
151
|
+
not a direct canonical type. Stored with object_type='device_pool'.
|
|
152
|
+
|
|
153
|
+
(from 02b-cucm-extraction.md §2.1, zeep→canonical mapping table)
|
|
154
|
+
"""
|
|
155
|
+
name = raw.get("name", "")
|
|
156
|
+
return MigrationObject(
|
|
157
|
+
canonical_id=f"device_pool:{name}",
|
|
158
|
+
provenance=_make_provenance(raw, cluster),
|
|
159
|
+
status=MigrationStatus.NORMALIZED,
|
|
160
|
+
pre_migration_state={
|
|
161
|
+
"device_pool_name": name,
|
|
162
|
+
"cucm_datetime_group": _extract_ref(raw.get("dateTimeSettingName")),
|
|
163
|
+
"cucm_location_name": _extract_ref(raw.get("locationName")),
|
|
164
|
+
"cucm_cm_group": _extract_ref(raw.get("callManagerGroupName")),
|
|
165
|
+
"cucm_srst": _extract_ref(raw.get("srstName")),
|
|
166
|
+
"cucm_region": _extract_ref(raw.get("regionName")),
|
|
167
|
+
"cucm_media_resource_list": _extract_ref(
|
|
168
|
+
raw.get("mediaResourceListName")
|
|
169
|
+
),
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# §2.1 — DateTime Group normalizer
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def normalize_datetime_group(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
179
|
+
"""Normalize a CUCM DateTime Group dict into a MigrationObject.
|
|
180
|
+
|
|
181
|
+
DateTime groups are intermediate objects — they provide timezone data
|
|
182
|
+
for location_mapper. Stored with object_type='datetime_group'.
|
|
183
|
+
|
|
184
|
+
(from 02b-cucm-extraction.md §2.1, DateTime Group mapping table)
|
|
185
|
+
"""
|
|
186
|
+
name = raw.get("name", "")
|
|
187
|
+
return MigrationObject(
|
|
188
|
+
canonical_id=f"datetime_group:{name}",
|
|
189
|
+
provenance=_make_provenance(raw, cluster),
|
|
190
|
+
status=MigrationStatus.NORMALIZED,
|
|
191
|
+
pre_migration_state={
|
|
192
|
+
"datetime_group_name": name,
|
|
193
|
+
"timezone": raw.get("timeZone"),
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# §2.2 — End User normalizer
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def normalize_user(raw: dict, cluster: str = "default") -> CanonicalUser:
|
|
203
|
+
"""Normalize a CUCM End User dict into a CanonicalUser.
|
|
204
|
+
|
|
205
|
+
Handles two extraction formats:
|
|
206
|
+
- AXL/zeep: associatedDevices is {'device': [str, ...]}, primaryExtension
|
|
207
|
+
has UUID wrappers, callingSearchSpaceName/voiceMailProfile are references
|
|
208
|
+
- SQL (_extracted_via="sql"): associatedDevices is already a flat list,
|
|
209
|
+
primaryExtension is {pattern, routePartitionName} without UUID wrappers,
|
|
210
|
+
callingSearchSpaceName/voiceMailProfile/selfService/userLocale are None
|
|
211
|
+
|
|
212
|
+
(from 02b-cucm-extraction.md §2.2, zeep→canonical mapping table)
|
|
213
|
+
(from 03b-transform-mappers.md §2, user_mapper field table)
|
|
214
|
+
(Verified via live CUCM Phase 03)
|
|
215
|
+
"""
|
|
216
|
+
userid = raw.get("userid", "")
|
|
217
|
+
mailid = raw.get("mailid")
|
|
218
|
+
extracted_via_sql = raw.get("_extracted_via") == "sql"
|
|
219
|
+
|
|
220
|
+
# Email: direct copy, lowercase (from 03b §2: "validated as email format")
|
|
221
|
+
emails: list[str] = []
|
|
222
|
+
if mailid:
|
|
223
|
+
emails = [mailid.lower()]
|
|
224
|
+
|
|
225
|
+
# associatedDevices: AXL format {'device': [str, ...]} or SQL flat list
|
|
226
|
+
# (Verified via live CUCM: SQL extraction returns flat list directly)
|
|
227
|
+
associated_devices: list[str] = []
|
|
228
|
+
assoc = raw.get("associatedDevices")
|
|
229
|
+
if assoc is not None:
|
|
230
|
+
if isinstance(assoc, dict):
|
|
231
|
+
devs = assoc.get("device", [])
|
|
232
|
+
if isinstance(devs, str):
|
|
233
|
+
associated_devices = [devs]
|
|
234
|
+
elif isinstance(devs, list):
|
|
235
|
+
associated_devices = devs
|
|
236
|
+
elif isinstance(assoc, list):
|
|
237
|
+
# SQL-extracted: already a flat list of device names
|
|
238
|
+
associated_devices = assoc
|
|
239
|
+
|
|
240
|
+
# primaryExtension: AXL has UUID wrappers on routePartitionName,
|
|
241
|
+
# SQL-extracted has plain {pattern, routePartitionName} without wrappers
|
|
242
|
+
primary_ext = raw.get("primaryExtension")
|
|
243
|
+
extension = None
|
|
244
|
+
primary_partition = None
|
|
245
|
+
if isinstance(primary_ext, dict):
|
|
246
|
+
extension = primary_ext.get("pattern")
|
|
247
|
+
rp = primary_ext.get("routePartitionName")
|
|
248
|
+
# Handle both AXL reference format and SQL plain string
|
|
249
|
+
if isinstance(rp, dict):
|
|
250
|
+
primary_partition = _extract_ref(rp)
|
|
251
|
+
elif isinstance(rp, str):
|
|
252
|
+
primary_partition = rp
|
|
253
|
+
|
|
254
|
+
# enableCti: may be string or bool depending on extraction method
|
|
255
|
+
enable_cti_raw = raw.get("enableCti")
|
|
256
|
+
if isinstance(enable_cti_raw, bool):
|
|
257
|
+
enable_cti = enable_cti_raw
|
|
258
|
+
else:
|
|
259
|
+
enable_cti = str(enable_cti_raw).lower() == "true" if enable_cti_raw else False
|
|
260
|
+
|
|
261
|
+
return CanonicalUser(
|
|
262
|
+
canonical_id=f"user:{userid}",
|
|
263
|
+
provenance=_make_provenance(raw, cluster),
|
|
264
|
+
status=MigrationStatus.NORMALIZED,
|
|
265
|
+
emails=emails,
|
|
266
|
+
first_name=raw.get("firstName"),
|
|
267
|
+
last_name=raw.get("lastName"),
|
|
268
|
+
display_name=f"{raw.get('firstName', '')} {raw.get('lastName', '')}".strip()
|
|
269
|
+
or None,
|
|
270
|
+
extension=extension,
|
|
271
|
+
department=raw.get("department"),
|
|
272
|
+
title=raw.get("title"),
|
|
273
|
+
cucm_manager_user_id=_extract_ref(raw.get("manager")),
|
|
274
|
+
cucm_userid=userid,
|
|
275
|
+
cucm_mailid=mailid,
|
|
276
|
+
pre_migration_state={
|
|
277
|
+
"telephone_number": raw.get("telephoneNumber"),
|
|
278
|
+
"directory_uri": raw.get("directoryUri"),
|
|
279
|
+
"user_locale": raw.get("userLocale"),
|
|
280
|
+
"voicemail_pilot": raw.get("selfService"),
|
|
281
|
+
"associated_devices": associated_devices,
|
|
282
|
+
"enable_cti": enable_cti,
|
|
283
|
+
"primary_partition": primary_partition,
|
|
284
|
+
"cucm_css": _extract_ref(raw.get("callingSearchSpaceName")),
|
|
285
|
+
"cucm_voicemail_profile": _extract_ref(raw.get("voiceMailProfile")),
|
|
286
|
+
# Flag SQL-extracted users so mappers know some fields are unavailable
|
|
287
|
+
# (callingSearchSpaceName, voiceMailProfile, selfService, userLocale)
|
|
288
|
+
"extracted_via_sql": extracted_via_sql,
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# §2.3 — Phone normalizer (→ CanonicalDevice)
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def _normalize_line_appearance(raw_line: dict) -> dict:
|
|
298
|
+
"""Normalize a single line appearance from a phone's lines array.
|
|
299
|
+
|
|
300
|
+
(from 02b-cucm-extraction.md §2.3, per-line appearance mapping table)
|
|
301
|
+
"""
|
|
302
|
+
dirn = raw_line.get("dirn") or {}
|
|
303
|
+
|
|
304
|
+
# associatedEndusers: extract list of userId values
|
|
305
|
+
# Extractor format (Phase 03): already flat list of strings ['jdoe', 'asmith']
|
|
306
|
+
# Zeep raw format: {'enduser': [{'userId': 'jdoe'}, ...]}
|
|
307
|
+
endusers: list[str] = []
|
|
308
|
+
assoc = raw_line.get("associatedEndusers")
|
|
309
|
+
if isinstance(assoc, list):
|
|
310
|
+
# Extractor format: flat list of user ID strings
|
|
311
|
+
endusers = [str(u) for u in assoc if u]
|
|
312
|
+
elif isinstance(assoc, dict):
|
|
313
|
+
# Zeep raw format
|
|
314
|
+
eu_list = assoc.get("enduser", [])
|
|
315
|
+
if isinstance(eu_list, dict):
|
|
316
|
+
eu_list = [eu_list]
|
|
317
|
+
for eu in eu_list:
|
|
318
|
+
if isinstance(eu, dict) and "userId" in eu:
|
|
319
|
+
endusers.append(eu["userId"])
|
|
320
|
+
elif isinstance(eu, str):
|
|
321
|
+
endusers.append(eu)
|
|
322
|
+
|
|
323
|
+
# Call forwarding fields — 10 fields per line, verified via live CUCM Phase 03.
|
|
324
|
+
# voicemail_mapper reads forwardToVoiceMail flags from these.
|
|
325
|
+
def _extract_cf(field_name: str) -> dict | None:
|
|
326
|
+
cf = dirn.get(field_name)
|
|
327
|
+
if not cf or not isinstance(cf, dict):
|
|
328
|
+
return None
|
|
329
|
+
return {
|
|
330
|
+
"destination": cf.get("destination"),
|
|
331
|
+
"forward_to_voicemail": cf.get("forwardToVoiceMail"),
|
|
332
|
+
"calling_search_space": _extract_ref(cf.get("callingSearchSpaceName")),
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
"line_index": int(raw_line.get("index", 0)),
|
|
337
|
+
"dn": dirn.get("pattern"),
|
|
338
|
+
"partition": _extract_ref(dirn.get("routePartitionName")),
|
|
339
|
+
"line_label": raw_line.get("label"),
|
|
340
|
+
"display_name": raw_line.get("display"),
|
|
341
|
+
"e164_mask": raw_line.get("e164Mask"),
|
|
342
|
+
"alerting_name": dirn.get("alertingName"),
|
|
343
|
+
"dn_description": dirn.get("description"),
|
|
344
|
+
"shared_line_css": _extract_ref(dirn.get("shareLineAppearanceCssName")),
|
|
345
|
+
"line_css": _extract_ref(dirn.get("callingSearchSpaceName")),
|
|
346
|
+
"associated_endusers": endusers,
|
|
347
|
+
# Call forwarding (verified via live CUCM Phase 03)
|
|
348
|
+
"call_forward_all": _extract_cf("callForwardAll"),
|
|
349
|
+
"call_forward_busy": _extract_cf("callForwardBusy"),
|
|
350
|
+
"call_forward_busy_int": _extract_cf("callForwardBusyInt"),
|
|
351
|
+
"call_forward_no_answer": _extract_cf("callForwardNoAnswer"),
|
|
352
|
+
"call_forward_no_answer_int": _extract_cf("callForwardNoAnswerInt"),
|
|
353
|
+
"call_forward_no_coverage": _extract_cf("callForwardNoCoverage"),
|
|
354
|
+
"call_forward_no_coverage_int": _extract_cf("callForwardNoCoverageInt"),
|
|
355
|
+
"call_forward_on_failure": _extract_cf("callForwardOnFailure"),
|
|
356
|
+
"call_forward_alternate_party": _extract_cf("callForwardAlternateParty"),
|
|
357
|
+
"call_forward_unregistered": _extract_cf("callForwardUnregistered"),
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _normalize_psc(psc: Any) -> dict | None:
|
|
362
|
+
"""Normalize productSpecificConfiguration to a flat dict."""
|
|
363
|
+
if psc is None:
|
|
364
|
+
return None
|
|
365
|
+
if isinstance(psc, dict):
|
|
366
|
+
return {k: v for k, v in psc.items() if v is not None} or None
|
|
367
|
+
if isinstance(psc, str):
|
|
368
|
+
return _parse_vendor_config_xml(psc)
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _parse_vendor_config_xml(xml_str: str) -> dict | None:
|
|
373
|
+
"""Parse a vendorConfig or productSpecificConfiguration XML string to flat dict."""
|
|
374
|
+
import xml.etree.ElementTree as ET
|
|
375
|
+
try:
|
|
376
|
+
root = ET.fromstring(xml_str)
|
|
377
|
+
except ET.ParseError:
|
|
378
|
+
return None
|
|
379
|
+
result = {}
|
|
380
|
+
for child in root:
|
|
381
|
+
tag = child.tag
|
|
382
|
+
text = child.text
|
|
383
|
+
if text is not None:
|
|
384
|
+
result[tag] = text.strip()
|
|
385
|
+
return result or None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def normalize_phone(raw: dict, cluster: str = "default") -> CanonicalDevice:
|
|
389
|
+
"""Normalize a CUCM Phone dict into a CanonicalDevice.
|
|
390
|
+
|
|
391
|
+
(from 02-normalization-architecture.md, Pass 1 example)
|
|
392
|
+
(from 02b-cucm-extraction.md §2.3, zeep→canonical mapping table)
|
|
393
|
+
"""
|
|
394
|
+
device_name = raw.get("name", "")
|
|
395
|
+
|
|
396
|
+
# MAC extraction: 12 hex digits after SEP prefix
|
|
397
|
+
# (from 03b §4: "Extract 12 hex digits after SEP prefix")
|
|
398
|
+
mac = None
|
|
399
|
+
if device_name.startswith("SEP") and len(device_name) >= 15:
|
|
400
|
+
mac = device_name[3:]
|
|
401
|
+
|
|
402
|
+
# Lines: handle empty, None, single dict, or list
|
|
403
|
+
# (from 02b §2.3: "lines may be empty dict or None",
|
|
404
|
+
# "lines.line may be a single dict, not a list")
|
|
405
|
+
# Lines: extractor returns flat list, zeep returns {'line': [...]}
|
|
406
|
+
line_entries = _to_list(raw.get("lines"), "line")
|
|
407
|
+
|
|
408
|
+
# Filter out speed dials: only entries with a dirn element are line appearances
|
|
409
|
+
# (from 02b §2.3: "Speed dial entries have no dirn element")
|
|
410
|
+
line_appearances = [
|
|
411
|
+
_normalize_line_appearance(l) for l in line_entries if l.get("dirn")
|
|
412
|
+
]
|
|
413
|
+
line_appearances.sort(key=lambda x: x["line_index"])
|
|
414
|
+
|
|
415
|
+
return CanonicalDevice(
|
|
416
|
+
canonical_id=f"device:{device_name}",
|
|
417
|
+
provenance=_make_provenance(raw, cluster),
|
|
418
|
+
status=MigrationStatus.NORMALIZED,
|
|
419
|
+
mac=mac,
|
|
420
|
+
model=raw.get("model"),
|
|
421
|
+
display_name=raw.get("description") or device_name,
|
|
422
|
+
cucm_protocol=raw.get("protocol"),
|
|
423
|
+
cucm_device_name=device_name,
|
|
424
|
+
line_appearances=line_appearances,
|
|
425
|
+
pre_migration_state={
|
|
426
|
+
"cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
|
|
427
|
+
"cucm_css": _extract_ref(raw.get("callingSearchSpaceName")),
|
|
428
|
+
"cucm_owner_user": _extract_ref(raw.get("ownerUserName")),
|
|
429
|
+
"cucm_phone_template": _extract_ref(raw.get("phoneTemplateName")),
|
|
430
|
+
"cucm_softkey_template": _extract_ref(raw.get("softkeyTemplateName")),
|
|
431
|
+
"cucm_product": raw.get("product"),
|
|
432
|
+
"cucm_class": raw.get("class"),
|
|
433
|
+
# Device settings fields (for DeviceSettingsMapper)
|
|
434
|
+
"cucm_common_phone_config": _extract_ref(raw.get("commonPhoneConfigName")),
|
|
435
|
+
"product_specific_config": _normalize_psc(raw.get("productSpecificConfiguration")),
|
|
436
|
+
"cucm_user_locale": raw.get("userLocale"),
|
|
437
|
+
"cucm_network_locale": raw.get("networkLocale"),
|
|
438
|
+
"cucm_dnd_option": raw.get("dndOption"),
|
|
439
|
+
"cucm_dnd_status": raw.get("dndStatus"),
|
|
440
|
+
"cucm_extension_mobility": raw.get("enableExtensionMobility"),
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
# §2.8 — Common-Area Phone normalizer (→ CanonicalWorkspace)
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
def is_common_area_device(raw: dict) -> bool:
|
|
450
|
+
"""Classify a phone as common-area based on ownership and type.
|
|
451
|
+
|
|
452
|
+
(from 02b-cucm-extraction.md §2.8: classification filter)
|
|
453
|
+
(from 03b §5: "A CUCM common-area phone with no user owner is a workspace")
|
|
454
|
+
"""
|
|
455
|
+
# Use _extract_ref to unwrap zeep empty references:
|
|
456
|
+
# {'_value_1': None, 'uuid': None} should be treated as no owner.
|
|
457
|
+
# (Found by code review — zeep returns empty refs as dicts, not None)
|
|
458
|
+
if _extract_ref(raw.get("ownerUserName")) is not None:
|
|
459
|
+
return False
|
|
460
|
+
device_class = raw.get("class", "")
|
|
461
|
+
return device_class == "Phone"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def normalize_workspace(raw: dict, cluster: str = "default") -> CanonicalWorkspace:
|
|
465
|
+
"""Normalize a CUCM common-area phone dict into a CanonicalWorkspace.
|
|
466
|
+
|
|
467
|
+
(from 02b-cucm-extraction.md §2.8)
|
|
468
|
+
(from 03b-transform-mappers.md §5, workspace_mapper field table)
|
|
469
|
+
"""
|
|
470
|
+
device_name = raw.get("name", "")
|
|
471
|
+
|
|
472
|
+
# Primary line extension
|
|
473
|
+
# Lines: extractor returns flat list, zeep returns {'line': [...]}
|
|
474
|
+
line_entries = _to_list(raw.get("lines"), "line")
|
|
475
|
+
|
|
476
|
+
extension = None
|
|
477
|
+
for l in line_entries:
|
|
478
|
+
dirn = l.get("dirn")
|
|
479
|
+
if dirn and str(l.get("index", "0")) == "1":
|
|
480
|
+
extension = dirn.get("pattern")
|
|
481
|
+
break
|
|
482
|
+
|
|
483
|
+
return CanonicalWorkspace(
|
|
484
|
+
canonical_id=f"workspace:{device_name}",
|
|
485
|
+
provenance=_make_provenance(raw, cluster),
|
|
486
|
+
status=MigrationStatus.NORMALIZED,
|
|
487
|
+
display_name=raw.get("description") or device_name,
|
|
488
|
+
extension=extension,
|
|
489
|
+
is_common_area=True,
|
|
490
|
+
pre_migration_state={
|
|
491
|
+
"cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
|
|
492
|
+
"cucm_css": _extract_ref(raw.get("callingSearchSpaceName")),
|
|
493
|
+
"cucm_model": raw.get("model"),
|
|
494
|
+
"cucm_protocol": raw.get("protocol"),
|
|
495
|
+
"cucm_product": raw.get("product"),
|
|
496
|
+
"cucm_device_name": device_name,
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
# §2.4 — CSS normalizer (intermediate for cross-refs)
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
def normalize_css(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
506
|
+
"""Normalize a CUCM CSS dict into a MigrationObject.
|
|
507
|
+
|
|
508
|
+
CSS objects are intermediate — css_mapper in Phase 05 consumes them.
|
|
509
|
+
The critical data is the ordered partition membership list.
|
|
510
|
+
|
|
511
|
+
(from 02b-cucm-extraction.md §2.4, CSS mapping table)
|
|
512
|
+
(from 04-css-decomposition.md: "ordinal values preserving partition priority")
|
|
513
|
+
"""
|
|
514
|
+
name = raw.get("name", "")
|
|
515
|
+
|
|
516
|
+
# Members: ordered list of (partition_name, index) — MUST preserve ordering
|
|
517
|
+
# (from 02b §2.4: "The index field is the partition priority within this CSS
|
|
518
|
+
# and MUST be preserved")
|
|
519
|
+
# Members: extractor returns flat list, zeep returns {'member': [...]}
|
|
520
|
+
member_list = _to_list(raw.get("members"), "member")
|
|
521
|
+
|
|
522
|
+
partitions = []
|
|
523
|
+
for m in member_list:
|
|
524
|
+
pt_name = _extract_ref(m.get("routePartitionName"))
|
|
525
|
+
index = int(m.get("index", 0))
|
|
526
|
+
if pt_name:
|
|
527
|
+
partitions.append({"partition_name": pt_name, "index": index})
|
|
528
|
+
partitions.sort(key=lambda x: x["index"])
|
|
529
|
+
|
|
530
|
+
return MigrationObject(
|
|
531
|
+
canonical_id=f"css:{name}",
|
|
532
|
+
provenance=_make_provenance(raw, cluster),
|
|
533
|
+
status=MigrationStatus.NORMALIZED,
|
|
534
|
+
pre_migration_state={
|
|
535
|
+
"css_name": name,
|
|
536
|
+
"description": raw.get("description"),
|
|
537
|
+
"partitions": partitions,
|
|
538
|
+
},
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ---------------------------------------------------------------------------
|
|
543
|
+
# §2.4 — Partition normalizer (intermediate)
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
def normalize_partition(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
547
|
+
"""Normalize a CUCM Route Partition dict into a MigrationObject.
|
|
548
|
+
|
|
549
|
+
Partitions are intermediate objects used by cross-reference builder.
|
|
550
|
+
|
|
551
|
+
(from 02b-cucm-extraction.md §2.4, Partition returnedTags)
|
|
552
|
+
"""
|
|
553
|
+
name = raw.get("name", "")
|
|
554
|
+
return MigrationObject(
|
|
555
|
+
canonical_id=f"partition:{name}",
|
|
556
|
+
provenance=_make_provenance(raw, cluster),
|
|
557
|
+
status=MigrationStatus.NORMALIZED,
|
|
558
|
+
pre_migration_state={
|
|
559
|
+
"partition_name": name,
|
|
560
|
+
"description": raw.get("description"),
|
|
561
|
+
},
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# ---------------------------------------------------------------------------
|
|
566
|
+
# §2.4 — Route Pattern normalizer (intermediate)
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
def normalize_route_pattern(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
570
|
+
"""Normalize a CUCM Route Pattern dict into a MigrationObject.
|
|
571
|
+
|
|
572
|
+
Route patterns are intermediate — routing_mapper in Phase 05 consumes them.
|
|
573
|
+
|
|
574
|
+
(from 02b-cucm-extraction.md §2.4, Route Pattern mapping table)
|
|
575
|
+
"""
|
|
576
|
+
pattern = raw.get("pattern", "")
|
|
577
|
+
partition = _extract_ref(raw.get("routePartitionName"))
|
|
578
|
+
# Canonical ID includes partition for uniqueness (same pattern in different partitions)
|
|
579
|
+
canonical_id = f"route_pattern:{pattern}:{partition or '<None>'}"
|
|
580
|
+
|
|
581
|
+
# blockEnable: string to action classification
|
|
582
|
+
# (from 04-css-decomposition.md Step 2: "classify patterns as ROUTE or BLOCK")
|
|
583
|
+
block_enable = raw.get("blockEnable")
|
|
584
|
+
action = "BLOCK" if str(block_enable).lower() == "true" else "ROUTE"
|
|
585
|
+
|
|
586
|
+
# Destination/target extraction — getRoutePattern returns the routing target
|
|
587
|
+
# as one of: destination (complex), gatewayName (ref), routeListName (ref)
|
|
588
|
+
gateway_name = _extract_ref(raw.get("gatewayName"))
|
|
589
|
+
route_list_name = _extract_ref(raw.get("routeListName"))
|
|
590
|
+
# Determine target type and name for cross-ref builder and routing mapper
|
|
591
|
+
if gateway_name:
|
|
592
|
+
target_type = "gateway"
|
|
593
|
+
target_name = gateway_name
|
|
594
|
+
elif route_list_name:
|
|
595
|
+
target_type = "route_list"
|
|
596
|
+
target_name = route_list_name
|
|
597
|
+
else:
|
|
598
|
+
target_type = None
|
|
599
|
+
target_name = None
|
|
600
|
+
|
|
601
|
+
return MigrationObject(
|
|
602
|
+
canonical_id=canonical_id,
|
|
603
|
+
provenance=_make_provenance(raw, cluster),
|
|
604
|
+
status=MigrationStatus.NORMALIZED,
|
|
605
|
+
pre_migration_state={
|
|
606
|
+
"pattern": pattern,
|
|
607
|
+
"partition": partition,
|
|
608
|
+
"action": action,
|
|
609
|
+
"description": raw.get("description"),
|
|
610
|
+
"called_transform_mask": raw.get("calledPartyTransformationMask"),
|
|
611
|
+
"calling_transform_mask": raw.get("callingPartyTransformationMask"),
|
|
612
|
+
"prefix_digits_out": raw.get("prefixDigitsOut"),
|
|
613
|
+
"network_location": raw.get("networkLocation"),
|
|
614
|
+
"target_type": target_type,
|
|
615
|
+
"target_name": target_name,
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
# §2.4 — Translation Pattern normalizer
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
def normalize_translation_pattern(
|
|
625
|
+
raw: dict, cluster: str = "default"
|
|
626
|
+
) -> CanonicalTranslationPattern:
|
|
627
|
+
"""Normalize a CUCM Translation Pattern dict into a CanonicalTranslationPattern.
|
|
628
|
+
|
|
629
|
+
(from 02b-cucm-extraction.md §2.4, Translation Pattern mapping table)
|
|
630
|
+
(from 03b-transform-mappers.md §6, translation pattern field table)
|
|
631
|
+
"""
|
|
632
|
+
pattern = raw.get("pattern", "")
|
|
633
|
+
partition = _extract_ref(raw.get("routePartitionName"))
|
|
634
|
+
canonical_id = f"translation_pattern:{pattern}:{partition or '<None>'}"
|
|
635
|
+
|
|
636
|
+
return CanonicalTranslationPattern(
|
|
637
|
+
canonical_id=canonical_id,
|
|
638
|
+
provenance=_make_provenance(raw, cluster),
|
|
639
|
+
status=MigrationStatus.NORMALIZED,
|
|
640
|
+
name=raw.get("description") or pattern,
|
|
641
|
+
matching_pattern=pattern,
|
|
642
|
+
replacement_pattern=raw.get("calledPartyTransformationMask"),
|
|
643
|
+
pre_migration_state={
|
|
644
|
+
"partition": partition,
|
|
645
|
+
},
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
# §2.5 — Hunt Pilot normalizer (intermediate)
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
def normalize_hunt_pilot(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
654
|
+
"""Normalize a CUCM Hunt Pilot dict into a MigrationObject.
|
|
655
|
+
|
|
656
|
+
Hunt pilots are intermediate — feature_mapper resolves the hunt_pilot →
|
|
657
|
+
hunt_list → line_group chain to produce CanonicalHuntGroup or CanonicalCallQueue.
|
|
658
|
+
|
|
659
|
+
(from 02b-cucm-extraction.md §2.5, Hunt Pilot mapping)
|
|
660
|
+
(from 03b-transform-mappers.md §8, feature_mapper)
|
|
661
|
+
"""
|
|
662
|
+
pattern = raw.get("pattern", "")
|
|
663
|
+
partition = _extract_ref(raw.get("routePartitionName"))
|
|
664
|
+
canonical_id = f"hunt_pilot:{pattern}:{partition or '<None>'}"
|
|
665
|
+
|
|
666
|
+
# Queue-style detection fields
|
|
667
|
+
# (from 03b §10: queue-style heuristics)
|
|
668
|
+
queue_calls = raw.get("queueCalls")
|
|
669
|
+
queue_enabled = False
|
|
670
|
+
if isinstance(queue_calls, dict):
|
|
671
|
+
queue_enabled = str(queue_calls.get("enabled", "false")).lower() == "true"
|
|
672
|
+
elif isinstance(queue_calls, str):
|
|
673
|
+
queue_enabled = queue_calls.lower() == "true"
|
|
674
|
+
|
|
675
|
+
# Forward settings
|
|
676
|
+
fwd_no_answer = raw.get("forwardHuntNoAnswer") or {}
|
|
677
|
+
fwd_busy = raw.get("forwardHuntBusy") or {}
|
|
678
|
+
|
|
679
|
+
return MigrationObject(
|
|
680
|
+
canonical_id=canonical_id,
|
|
681
|
+
provenance=_make_provenance(raw, cluster),
|
|
682
|
+
status=MigrationStatus.NORMALIZED,
|
|
683
|
+
pre_migration_state={
|
|
684
|
+
"pattern": pattern,
|
|
685
|
+
"partition": partition,
|
|
686
|
+
"description": raw.get("description"),
|
|
687
|
+
"hunt_list_name": _extract_ref(raw.get("huntListName")),
|
|
688
|
+
"forward_no_answer_destination": fwd_no_answer.get("destination"),
|
|
689
|
+
"forward_no_answer_enabled": str(
|
|
690
|
+
fwd_no_answer.get("enabled", "false")
|
|
691
|
+
).lower()
|
|
692
|
+
== "true",
|
|
693
|
+
"forward_busy_destination": fwd_busy.get("destination"),
|
|
694
|
+
"forward_busy_enabled": str(
|
|
695
|
+
fwd_busy.get("enabled", "false")
|
|
696
|
+
).lower()
|
|
697
|
+
== "true",
|
|
698
|
+
"queue_calls_enabled": queue_enabled,
|
|
699
|
+
"max_callers_in_queue": raw.get("maxCallersInQueue"),
|
|
700
|
+
"moh_source_id": raw.get("mohSourceId"),
|
|
701
|
+
"hunt_timer_call_pick": raw.get("huntTimerCallPick"),
|
|
702
|
+
"overflow_destination": raw.get("overflowDestination"),
|
|
703
|
+
"enabled": str(raw.get("enabled", "true")).lower() == "true",
|
|
704
|
+
},
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ---------------------------------------------------------------------------
|
|
709
|
+
# §2.5 — Hunt List normalizer (intermediate)
|
|
710
|
+
# ---------------------------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
def normalize_hunt_list(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
713
|
+
"""Normalize a CUCM Hunt List dict into a MigrationObject.
|
|
714
|
+
|
|
715
|
+
(from 02b-cucm-extraction.md §2.5, Hunt List returnedTags)
|
|
716
|
+
"""
|
|
717
|
+
name = raw.get("name", "")
|
|
718
|
+
|
|
719
|
+
# Members: list of line group references
|
|
720
|
+
# Members: extractor returns flat list, zeep returns {'member': [...]}
|
|
721
|
+
member_list = _to_list(raw.get("members"), "member")
|
|
722
|
+
|
|
723
|
+
line_groups = []
|
|
724
|
+
for m in member_list:
|
|
725
|
+
# Extractor uses 'userOrDeviceName', design spec uses 'lineGroupName'
|
|
726
|
+
lg_name = (
|
|
727
|
+
_extract_ref(m.get("lineGroupName"))
|
|
728
|
+
or _extract_ref(m.get("userOrDeviceName"))
|
|
729
|
+
)
|
|
730
|
+
if lg_name:
|
|
731
|
+
line_groups.append(lg_name)
|
|
732
|
+
|
|
733
|
+
return MigrationObject(
|
|
734
|
+
canonical_id=f"hunt_list:{name}",
|
|
735
|
+
provenance=_make_provenance(raw, cluster),
|
|
736
|
+
status=MigrationStatus.NORMALIZED,
|
|
737
|
+
pre_migration_state={
|
|
738
|
+
"hunt_list_name": name,
|
|
739
|
+
"description": raw.get("description"),
|
|
740
|
+
"hunt_algorithm": raw.get("huntAlgorithm"),
|
|
741
|
+
"voice_mail_usage": raw.get("voiceMailUsage"),
|
|
742
|
+
"route_list_enabled": raw.get("routeListEnabled"),
|
|
743
|
+
"line_groups": line_groups,
|
|
744
|
+
},
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# ---------------------------------------------------------------------------
|
|
749
|
+
# §2.5 — Line Group normalizer (intermediate)
|
|
750
|
+
# ---------------------------------------------------------------------------
|
|
751
|
+
|
|
752
|
+
def normalize_line_group(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
753
|
+
"""Normalize a CUCM Line Group dict into a MigrationObject.
|
|
754
|
+
|
|
755
|
+
(from 02b-cucm-extraction.md §2.5, Line Group returnedTags)
|
|
756
|
+
"""
|
|
757
|
+
name = raw.get("name", "")
|
|
758
|
+
|
|
759
|
+
# Members: list of DN/line references
|
|
760
|
+
# Members: extractor returns flat list, zeep returns {'member': [...]}
|
|
761
|
+
member_list = _to_list(raw.get("members"), "member")
|
|
762
|
+
|
|
763
|
+
return MigrationObject(
|
|
764
|
+
canonical_id=f"line_group:{name}",
|
|
765
|
+
provenance=_make_provenance(raw, cluster),
|
|
766
|
+
status=MigrationStatus.NORMALIZED,
|
|
767
|
+
pre_migration_state={
|
|
768
|
+
"line_group_name": name,
|
|
769
|
+
"rna_reversion_timeout": raw.get("rnaReversionTimeOut"),
|
|
770
|
+
"distribution_algorithm": raw.get("distributionAlgorithm"),
|
|
771
|
+
"members": member_list,
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
# ---------------------------------------------------------------------------
|
|
777
|
+
# §2.5 — CTI Route Point normalizer (intermediate, feeds AA creation)
|
|
778
|
+
# ---------------------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
def normalize_cti_route_point(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
781
|
+
"""Normalize a CUCM CTI Route Point dict into a MigrationObject.
|
|
782
|
+
|
|
783
|
+
CTI RPs feed feature_mapper for Auto Attendant creation.
|
|
784
|
+
|
|
785
|
+
(from 02b-cucm-extraction.md §2.5, CTI Route Point returnedTags)
|
|
786
|
+
"""
|
|
787
|
+
name = raw.get("name", "")
|
|
788
|
+
|
|
789
|
+
# Lines: same structure as phones
|
|
790
|
+
# Lines: extractor returns flat list, zeep returns {'line': [...]}
|
|
791
|
+
line_entries = _to_list(raw.get("lines"), "line")
|
|
792
|
+
|
|
793
|
+
line_appearances = [
|
|
794
|
+
_normalize_line_appearance(l) for l in line_entries if l.get("dirn")
|
|
795
|
+
]
|
|
796
|
+
|
|
797
|
+
return MigrationObject(
|
|
798
|
+
canonical_id=f"cti_rp:{name}",
|
|
799
|
+
provenance=_make_provenance(raw, cluster),
|
|
800
|
+
status=MigrationStatus.NORMALIZED,
|
|
801
|
+
pre_migration_state={
|
|
802
|
+
"cti_rp_name": name,
|
|
803
|
+
"description": raw.get("description"),
|
|
804
|
+
"cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
|
|
805
|
+
"cucm_css": _extract_ref(raw.get("callingSearchSpaceName")),
|
|
806
|
+
"lines": line_appearances,
|
|
807
|
+
},
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# ---------------------------------------------------------------------------
|
|
812
|
+
# §2.5 — Call Park normalizer (intermediate)
|
|
813
|
+
# ---------------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
def normalize_call_park(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
816
|
+
"""Normalize a CUCM Call Park Number dict into a MigrationObject.
|
|
817
|
+
|
|
818
|
+
(from 02b-cucm-extraction.md §2.5, Call Park returnedTags)
|
|
819
|
+
"""
|
|
820
|
+
pattern = raw.get("pattern", "")
|
|
821
|
+
partition = _extract_ref(raw.get("routePartitionName"))
|
|
822
|
+
|
|
823
|
+
return MigrationObject(
|
|
824
|
+
canonical_id=f"call_park:{pattern}:{partition or '<None>'}",
|
|
825
|
+
provenance=_make_provenance(raw, cluster),
|
|
826
|
+
status=MigrationStatus.NORMALIZED,
|
|
827
|
+
pre_migration_state={
|
|
828
|
+
"pattern": pattern,
|
|
829
|
+
"partition": partition,
|
|
830
|
+
"description": raw.get("description"),
|
|
831
|
+
},
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# ---------------------------------------------------------------------------
|
|
836
|
+
# §2.5 — Pickup Group normalizer (intermediate)
|
|
837
|
+
# ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
def normalize_pickup_group(raw: dict, cluster: str = "default") -> MigrationObject | None:
|
|
840
|
+
"""Normalize a CUCM Pickup Group dict into a MigrationObject.
|
|
841
|
+
|
|
842
|
+
Verified via live CUCM: pickup groups use 'pattern' as search criteria,
|
|
843
|
+
and the AXL object has both 'name' and 'pattern' fields.
|
|
844
|
+
|
|
845
|
+
Returns None if the pickup group has no usable name (Fix 7: dedup guard).
|
|
846
|
+
|
|
847
|
+
(from 02b-cucm-extraction.md §2.5, Pickup Group returnedTags)
|
|
848
|
+
"""
|
|
849
|
+
# Use name if available, fall back to pattern
|
|
850
|
+
name = raw.get("name", "") or raw.get("pattern", "")
|
|
851
|
+
|
|
852
|
+
# Fix 7: Guard against empty/None name — skip objects with no identity
|
|
853
|
+
if not name or not name.strip():
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
# Members: extractor returns flat list, zeep returns {'member': [...]}
|
|
857
|
+
# Each member is a dict like {'priority': int, 'someRefField': {'_value_1': name, 'uuid': '{UUID}'}}.
|
|
858
|
+
# Extract UUID strings so downstream consumers (CanonicalPickupGroup.agents) get list[str].
|
|
859
|
+
member_list = _to_list(raw.get("members"), "member")
|
|
860
|
+
member_ids = _extract_member_uuids(member_list)
|
|
861
|
+
|
|
862
|
+
return MigrationObject(
|
|
863
|
+
canonical_id=f"pickup_group:{name}",
|
|
864
|
+
provenance=_make_provenance(raw, cluster),
|
|
865
|
+
status=MigrationStatus.NORMALIZED,
|
|
866
|
+
pre_migration_state={
|
|
867
|
+
"pickup_group_name": name,
|
|
868
|
+
"pattern": raw.get("pattern"),
|
|
869
|
+
"members": member_ids,
|
|
870
|
+
},
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# ---------------------------------------------------------------------------
|
|
875
|
+
# §2.5 — Time Schedule normalizer (intermediate)
|
|
876
|
+
# ---------------------------------------------------------------------------
|
|
877
|
+
|
|
878
|
+
def normalize_time_schedule(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
879
|
+
"""Normalize a CUCM Time Schedule dict into a MigrationObject.
|
|
880
|
+
|
|
881
|
+
(from 02b-cucm-extraction.md §2.5, Time Schedule returnedTags)
|
|
882
|
+
"""
|
|
883
|
+
name = raw.get("name", "")
|
|
884
|
+
|
|
885
|
+
# Members: extractor returns flat list, zeep returns {'member': [...]}
|
|
886
|
+
member_list = _to_list(raw.get("members"), "member")
|
|
887
|
+
|
|
888
|
+
time_period_names = []
|
|
889
|
+
for m in member_list:
|
|
890
|
+
tp_name = _extract_ref(m.get("timePeriodName"))
|
|
891
|
+
if tp_name:
|
|
892
|
+
time_period_names.append(tp_name)
|
|
893
|
+
|
|
894
|
+
return MigrationObject(
|
|
895
|
+
canonical_id=f"time_schedule:{name}",
|
|
896
|
+
provenance=_make_provenance(raw, cluster),
|
|
897
|
+
status=MigrationStatus.NORMALIZED,
|
|
898
|
+
pre_migration_state={
|
|
899
|
+
"schedule_name": name,
|
|
900
|
+
"time_periods": time_period_names,
|
|
901
|
+
},
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# ---------------------------------------------------------------------------
|
|
906
|
+
# §2.5 — Time Period normalizer (intermediate)
|
|
907
|
+
# ---------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
def normalize_time_period(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
910
|
+
"""Normalize a CUCM Time Period dict into a MigrationObject.
|
|
911
|
+
|
|
912
|
+
(from 02b-cucm-extraction.md §2.5, Time Period returnedTags)
|
|
913
|
+
"""
|
|
914
|
+
name = raw.get("name", "")
|
|
915
|
+
|
|
916
|
+
return MigrationObject(
|
|
917
|
+
canonical_id=f"time_period:{name}",
|
|
918
|
+
provenance=_make_provenance(raw, cluster),
|
|
919
|
+
status=MigrationStatus.NORMALIZED,
|
|
920
|
+
pre_migration_state={
|
|
921
|
+
"time_period_name": name,
|
|
922
|
+
"start_time": raw.get("startTime"),
|
|
923
|
+
"end_time": raw.get("endTime"),
|
|
924
|
+
"start_date": raw.get("startDate"),
|
|
925
|
+
"end_date": raw.get("endDate"),
|
|
926
|
+
"month_of_year": raw.get("monthOfYear"),
|
|
927
|
+
"day_of_month": raw.get("dayOfMonth"),
|
|
928
|
+
"day_of_week": raw.get("dayOfWeek"),
|
|
929
|
+
},
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
# ---------------------------------------------------------------------------
|
|
934
|
+
# §2.4 — Gateway normalizer (intermediate)
|
|
935
|
+
# ---------------------------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
def normalize_gateway(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
938
|
+
"""Normalize a CUCM Gateway dict into a MigrationObject.
|
|
939
|
+
|
|
940
|
+
(from 02b-cucm-extraction.md §2.4, Gateway returnedTags)
|
|
941
|
+
"""
|
|
942
|
+
name = raw.get("domainName", raw.get("name", ""))
|
|
943
|
+
|
|
944
|
+
return MigrationObject(
|
|
945
|
+
canonical_id=f"gateway:{name}",
|
|
946
|
+
provenance=_make_provenance(raw, cluster),
|
|
947
|
+
status=MigrationStatus.NORMALIZED,
|
|
948
|
+
pre_migration_state={
|
|
949
|
+
"gateway_name": name,
|
|
950
|
+
"description": raw.get("description"),
|
|
951
|
+
"product": raw.get("product"),
|
|
952
|
+
"protocol": raw.get("protocol"),
|
|
953
|
+
"cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
|
|
954
|
+
},
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
# ---------------------------------------------------------------------------
|
|
959
|
+
# §2.4 — SIP Trunk normalizer (intermediate)
|
|
960
|
+
# ---------------------------------------------------------------------------
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _get_profile_field(profile_detail: dict | None, field_name: str) -> Any:
|
|
964
|
+
"""Extract a field from a profile detail dict, returning None if not present."""
|
|
965
|
+
if profile_detail is None:
|
|
966
|
+
return None
|
|
967
|
+
return profile_detail.get(field_name)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def normalize_sip_trunk(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
971
|
+
"""Normalize a CUCM SIP Trunk dict into a MigrationObject.
|
|
972
|
+
|
|
973
|
+
(from 02b-cucm-extraction.md §2.4, SIP Trunk returnedTags)
|
|
974
|
+
(from 03b-transform-mappers.md §6, trunk field table)
|
|
975
|
+
"""
|
|
976
|
+
name = raw.get("name", "")
|
|
977
|
+
|
|
978
|
+
# Verified via live CUCM: destinations is a list of dicts with
|
|
979
|
+
# {addressIpv4, addressIpv6, port, sortOrder}, NOT flat
|
|
980
|
+
# destinationAddress/destinationPort fields.
|
|
981
|
+
# Extractor returns flat list, zeep returns {'destination': [...]}
|
|
982
|
+
dest_list = _to_list(raw.get("destinations"), "destination")
|
|
983
|
+
|
|
984
|
+
destinations = []
|
|
985
|
+
for d in dest_list:
|
|
986
|
+
if isinstance(d, dict):
|
|
987
|
+
destinations.append({
|
|
988
|
+
"address": d.get("addressIpv4") or d.get("addressIpv6"),
|
|
989
|
+
"port": d.get("port"),
|
|
990
|
+
"sort_order": d.get("sortOrder"),
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
# Fallback: also check flat fields for design-spec format
|
|
994
|
+
if not destinations:
|
|
995
|
+
flat_addr = raw.get("destinationAddress")
|
|
996
|
+
flat_port = raw.get("destinationPort")
|
|
997
|
+
if flat_addr:
|
|
998
|
+
destinations = [{"address": flat_addr, "port": flat_port, "sort_order": 1}]
|
|
999
|
+
|
|
1000
|
+
return MigrationObject(
|
|
1001
|
+
canonical_id=f"sip_trunk:{name}",
|
|
1002
|
+
provenance=_make_provenance(raw, cluster),
|
|
1003
|
+
status=MigrationStatus.NORMALIZED,
|
|
1004
|
+
pre_migration_state={
|
|
1005
|
+
"trunk_name": name,
|
|
1006
|
+
"description": raw.get("description"),
|
|
1007
|
+
"destinations": destinations,
|
|
1008
|
+
"max_num_calls": raw.get("maxNumCalls"),
|
|
1009
|
+
"cucm_device_pool": _extract_ref(raw.get("devicePoolName")),
|
|
1010
|
+
"sip_profile": _extract_ref(raw.get("sipProfileName")),
|
|
1011
|
+
"security_profile": _extract_ref(raw.get("securityProfileName")),
|
|
1012
|
+
"sip_trunk_type": raw.get("sipTrunkType"),
|
|
1013
|
+
"dual_identity_support": raw.get("dualIdentitySupportEnabled"),
|
|
1014
|
+
"sip_profile_early_offer": _get_profile_field(raw.get("_sip_profile_detail"), "earlyOfferSupportForVoiceCall"),
|
|
1015
|
+
"sip_profile_srtp_fallback": _get_profile_field(raw.get("_sip_profile_detail"), "srtpFallbackAllowed"),
|
|
1016
|
+
"security_mode": _get_profile_field(raw.get("_security_profile_detail"), "securityMode"),
|
|
1017
|
+
"security_digest_auth": _get_profile_field(raw.get("_security_profile_detail"), "enableDigestAuthentication"),
|
|
1018
|
+
},
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
# ---------------------------------------------------------------------------
|
|
1023
|
+
# §2.4 — Route Group normalizer (intermediate)
|
|
1024
|
+
# ---------------------------------------------------------------------------
|
|
1025
|
+
|
|
1026
|
+
def normalize_route_group(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
1027
|
+
"""Normalize a CUCM Route Group dict into a MigrationObject.
|
|
1028
|
+
|
|
1029
|
+
(from 02b-cucm-extraction.md §2.4, Route Group returnedTags)
|
|
1030
|
+
"""
|
|
1031
|
+
name = raw.get("name", "")
|
|
1032
|
+
|
|
1033
|
+
# Members: extractor returns flat list, zeep returns {'member': [...]}
|
|
1034
|
+
member_list = _to_list(raw.get("members"), "member")
|
|
1035
|
+
|
|
1036
|
+
trunks = []
|
|
1037
|
+
for m in member_list:
|
|
1038
|
+
# Extractor uses 'sipTrunkName', design spec uses 'deviceName'
|
|
1039
|
+
trunk_name = (
|
|
1040
|
+
_extract_ref(m.get("deviceName"))
|
|
1041
|
+
or _extract_ref(m.get("sipTrunkName"))
|
|
1042
|
+
)
|
|
1043
|
+
if trunk_name:
|
|
1044
|
+
trunks.append({
|
|
1045
|
+
"trunk_name": trunk_name,
|
|
1046
|
+
"priority": m.get("priority", 1),
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
return MigrationObject(
|
|
1050
|
+
canonical_id=f"route_group:{name}",
|
|
1051
|
+
provenance=_make_provenance(raw, cluster),
|
|
1052
|
+
status=MigrationStatus.NORMALIZED,
|
|
1053
|
+
pre_migration_state={
|
|
1054
|
+
"route_group_name": name,
|
|
1055
|
+
"description": raw.get("description"),
|
|
1056
|
+
"trunks": trunks,
|
|
1057
|
+
},
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
# ---------------------------------------------------------------------------
|
|
1062
|
+
# §2.4 — Route List normalizer (intermediate)
|
|
1063
|
+
# ---------------------------------------------------------------------------
|
|
1064
|
+
|
|
1065
|
+
def normalize_route_list(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
1066
|
+
"""Normalize a CUCM Route List dict into a MigrationObject.
|
|
1067
|
+
|
|
1068
|
+
(from 02b-cucm-extraction.md §2.4, Route List returnedTags)
|
|
1069
|
+
"""
|
|
1070
|
+
name = raw.get("name", "")
|
|
1071
|
+
|
|
1072
|
+
# Members: extractor returns flat list, zeep returns {'member': [...]}
|
|
1073
|
+
member_list = _to_list(raw.get("members"), "member")
|
|
1074
|
+
|
|
1075
|
+
route_groups = []
|
|
1076
|
+
for m in member_list:
|
|
1077
|
+
rg_name = _extract_ref(m.get("routeGroupName"))
|
|
1078
|
+
if rg_name:
|
|
1079
|
+
route_groups.append(rg_name)
|
|
1080
|
+
|
|
1081
|
+
return MigrationObject(
|
|
1082
|
+
canonical_id=f"route_list:{name}",
|
|
1083
|
+
provenance=_make_provenance(raw, cluster),
|
|
1084
|
+
status=MigrationStatus.NORMALIZED,
|
|
1085
|
+
pre_migration_state={
|
|
1086
|
+
"route_list_name": name,
|
|
1087
|
+
"description": raw.get("description"),
|
|
1088
|
+
"route_groups": route_groups,
|
|
1089
|
+
},
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
# ---------------------------------------------------------------------------
|
|
1094
|
+
# §2.6 — Voicemail Profile normalizer
|
|
1095
|
+
# ---------------------------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
def _extract_vm_pilot(field: dict | str | None) -> str | None:
|
|
1098
|
+
"""Extract the voicemail pilot number from the voiceMailPilot field.
|
|
1099
|
+
|
|
1100
|
+
Live CUCM returns voiceMailPilot as a complex type {dirn, cssName, uuid},
|
|
1101
|
+
NOT the simple {_value_1, uuid} reference pattern. The pilot number is
|
|
1102
|
+
in the 'dirn' key.
|
|
1103
|
+
(Verified via live CUCM Phase 03 — 02b spec had this wrong)
|
|
1104
|
+
"""
|
|
1105
|
+
if field is None:
|
|
1106
|
+
return None
|
|
1107
|
+
if isinstance(field, dict):
|
|
1108
|
+
# Real structure: {dirn: str, cssName: str, uuid: str}
|
|
1109
|
+
if "dirn" in field:
|
|
1110
|
+
return field["dirn"]
|
|
1111
|
+
# Fallback: design spec structure {_value_1: str, uuid: str}
|
|
1112
|
+
if "_value_1" in field:
|
|
1113
|
+
return field["_value_1"]
|
|
1114
|
+
if isinstance(field, str):
|
|
1115
|
+
return field
|
|
1116
|
+
return None
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def normalize_voicemail_profile(
|
|
1120
|
+
raw: dict, cluster: str = "default"
|
|
1121
|
+
) -> CanonicalVoicemailProfile:
|
|
1122
|
+
"""Normalize a CUCM Voicemail Profile dict into a CanonicalVoicemailProfile.
|
|
1123
|
+
|
|
1124
|
+
(from 02b-cucm-extraction.md §2.6, Voicemail Profile mapping)
|
|
1125
|
+
(from 03b-transform-mappers.md §9, voicemail_mapper field table)
|
|
1126
|
+
"""
|
|
1127
|
+
name = raw.get("name", "")
|
|
1128
|
+
vm_pilot_field = raw.get("voiceMailPilot")
|
|
1129
|
+
|
|
1130
|
+
return CanonicalVoicemailProfile(
|
|
1131
|
+
canonical_id=f"voicemail_profile:{name}",
|
|
1132
|
+
provenance=_make_provenance(raw, cluster),
|
|
1133
|
+
status=MigrationStatus.NORMALIZED,
|
|
1134
|
+
cucm_voicemail_profile_name=name,
|
|
1135
|
+
pre_migration_state={
|
|
1136
|
+
"description": raw.get("description"),
|
|
1137
|
+
"voicemail_pilot": _extract_vm_pilot(vm_pilot_field),
|
|
1138
|
+
"voicemail_pilot_css": vm_pilot_field.get("cssName")
|
|
1139
|
+
if isinstance(vm_pilot_field, dict)
|
|
1140
|
+
else None,
|
|
1141
|
+
"voicemail_box_mask": raw.get("voiceMailboxMask"),
|
|
1142
|
+
},
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
# ---------------------------------------------------------------------------
|
|
1147
|
+
# §2.6 — Unity Connection per-user VM settings normalizer
|
|
1148
|
+
# ---------------------------------------------------------------------------
|
|
1149
|
+
|
|
1150
|
+
def normalize_unity_vm_settings(
|
|
1151
|
+
userid: str, settings: dict, cluster: str = "default"
|
|
1152
|
+
) -> MigrationObject:
|
|
1153
|
+
"""Normalize Unity Connection per-user voicemail settings into a MigrationObject.
|
|
1154
|
+
|
|
1155
|
+
These are the ~12 per-user fields from extract_user_vm_settings() in
|
|
1156
|
+
unity_connection.py. Stored separately from the AXL voicemail profile
|
|
1157
|
+
because profiles are shared (many users → one profile) while UC settings
|
|
1158
|
+
are per-user.
|
|
1159
|
+
|
|
1160
|
+
The voicemail_mapper (Phase 05) joins these with the profile-level data
|
|
1161
|
+
via cross_ref user_has_voicemail_profile + these per-user settings.
|
|
1162
|
+
|
|
1163
|
+
(from Phase 03 unity_connection.py extract_user_vm_settings())
|
|
1164
|
+
"""
|
|
1165
|
+
return MigrationObject(
|
|
1166
|
+
canonical_id=f"unity_vm:{userid}",
|
|
1167
|
+
provenance=Provenance(
|
|
1168
|
+
source_system="unity_connection",
|
|
1169
|
+
source_id=settings.get("uc_object_id", ""),
|
|
1170
|
+
source_name=userid,
|
|
1171
|
+
cluster=cluster,
|
|
1172
|
+
extracted_at=_now(),
|
|
1173
|
+
),
|
|
1174
|
+
status=MigrationStatus.NORMALIZED,
|
|
1175
|
+
pre_migration_state={
|
|
1176
|
+
"userid": userid,
|
|
1177
|
+
"vm_enabled": settings.get("vm_enabled", False),
|
|
1178
|
+
"call_handler_id": settings.get("call_handler_id"),
|
|
1179
|
+
# Notification settings
|
|
1180
|
+
"notification_enabled": settings.get("notification_enabled", False),
|
|
1181
|
+
"notification_destination": settings.get("notification_destination"),
|
|
1182
|
+
# Call handler transfer rules
|
|
1183
|
+
"send_all_calls": settings.get("send_all_calls"),
|
|
1184
|
+
"send_busy_calls": settings.get("send_busy_calls"),
|
|
1185
|
+
"send_unanswered_calls": settings.get("send_unanswered_calls"),
|
|
1186
|
+
"unanswered_rings": settings.get("unanswered_rings"),
|
|
1187
|
+
"busy_greeting_type": settings.get("busy_greeting_type"),
|
|
1188
|
+
# Message store
|
|
1189
|
+
"storage_type": settings.get("storage_type"),
|
|
1190
|
+
"mwi_enabled": settings.get("mwi_enabled"),
|
|
1191
|
+
"external_email": settings.get("external_email"),
|
|
1192
|
+
"email_copy_enabled": settings.get("email_copy_enabled"),
|
|
1193
|
+
# Transfer to zero
|
|
1194
|
+
"transfer_to_zero_enabled": settings.get("transfer_to_zero_enabled"),
|
|
1195
|
+
"transfer_to_zero_destination": settings.get(
|
|
1196
|
+
"transfer_to_zero_destination"
|
|
1197
|
+
),
|
|
1198
|
+
# Fax
|
|
1199
|
+
"fax_enabled": settings.get("fax_enabled"),
|
|
1200
|
+
"fax_number": settings.get("fax_number"),
|
|
1201
|
+
},
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
# ---------------------------------------------------------------------------
|
|
1206
|
+
# §2.6 — Unity Connection shared/group mailbox normalizer
|
|
1207
|
+
# (from docs/superpowers/specs/2026-04-10-voicemail-groups.md)
|
|
1208
|
+
# ---------------------------------------------------------------------------
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def normalize_voicemail_group(
|
|
1212
|
+
raw: dict, cluster: str = "default"
|
|
1213
|
+
) -> MigrationObject | None:
|
|
1214
|
+
"""Normalize a Unity Connection shared mailbox (call handler) into a
|
|
1215
|
+
MigrationObject for later consumption by VoicemailGroupMapper.
|
|
1216
|
+
|
|
1217
|
+
Returns None when DisplayName is missing — shared mailboxes without a
|
|
1218
|
+
display name can't be mapped reliably and should be skipped.
|
|
1219
|
+
|
|
1220
|
+
(from Unity Connection CUPI /vmrest/handlers/callhandlers)
|
|
1221
|
+
"""
|
|
1222
|
+
name = raw.get("DisplayName") or raw.get("display_name") or ""
|
|
1223
|
+
if not name:
|
|
1224
|
+
return None
|
|
1225
|
+
|
|
1226
|
+
extension = (
|
|
1227
|
+
raw.get("DtmfAccessId")
|
|
1228
|
+
or raw.get("Extension")
|
|
1229
|
+
or raw.get("extension")
|
|
1230
|
+
)
|
|
1231
|
+
object_id = raw.get("ObjectId") or raw.get("pkid") or ""
|
|
1232
|
+
|
|
1233
|
+
return MigrationObject(
|
|
1234
|
+
canonical_id=f"voicemail_group:{name}",
|
|
1235
|
+
provenance=Provenance(
|
|
1236
|
+
source_system="unity_connection",
|
|
1237
|
+
source_id=object_id,
|
|
1238
|
+
source_name=name,
|
|
1239
|
+
cluster=cluster,
|
|
1240
|
+
extracted_at=_now(),
|
|
1241
|
+
),
|
|
1242
|
+
status=MigrationStatus.NORMALIZED,
|
|
1243
|
+
pre_migration_state={
|
|
1244
|
+
"name": name,
|
|
1245
|
+
"extension": str(extension) if extension is not None else None,
|
|
1246
|
+
"cucm_object_id": object_id,
|
|
1247
|
+
"notification_destination": (
|
|
1248
|
+
raw.get("SmtpAddress")
|
|
1249
|
+
or raw.get("notification_destination")
|
|
1250
|
+
),
|
|
1251
|
+
"language_code": raw.get("language_code", "en_us"),
|
|
1252
|
+
"greeting_type": raw.get("greeting_type", "DEFAULT"),
|
|
1253
|
+
"transfer_destination": raw.get("transfer_destination"),
|
|
1254
|
+
"fax_enabled": bool(raw.get("fax_enabled")),
|
|
1255
|
+
"fax_number": raw.get("fax_number"),
|
|
1256
|
+
},
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
# ---------------------------------------------------------------------------
|
|
1261
|
+
# §2.6 — Voicemail Pilot normalizer (intermediate, for UUID→dirn resolution)
|
|
1262
|
+
# ---------------------------------------------------------------------------
|
|
1263
|
+
|
|
1264
|
+
def normalize_voicemail_pilot(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
1265
|
+
"""Normalize a CUCM Voicemail Pilot dict into a MigrationObject.
|
|
1266
|
+
|
|
1267
|
+
Voicemail pilots are intermediate objects used to resolve the
|
|
1268
|
+
voiceMailPilot.uuid on profiles to an actual pilot dirn number.
|
|
1269
|
+
The profile's voiceMailPilot.dirn is often None — the UUID must be
|
|
1270
|
+
matched against these pilot objects to get the actual dirn.
|
|
1271
|
+
(Confirmed by Phase 03 live CUCM validation)
|
|
1272
|
+
"""
|
|
1273
|
+
dirn = raw.get("dirn", "")
|
|
1274
|
+
pkid = raw.get("pkid", "")
|
|
1275
|
+
return MigrationObject(
|
|
1276
|
+
canonical_id=f"voicemail_pilot:{dirn or pkid}",
|
|
1277
|
+
provenance=_make_provenance(raw, cluster),
|
|
1278
|
+
status=MigrationStatus.NORMALIZED,
|
|
1279
|
+
pre_migration_state={
|
|
1280
|
+
"dirn": dirn,
|
|
1281
|
+
"description": raw.get("description"),
|
|
1282
|
+
# Store UUID for profile→pilot resolution
|
|
1283
|
+
"uuid": pkid,
|
|
1284
|
+
},
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
# ---------------------------------------------------------------------------
|
|
1289
|
+
# §2.1 — CUCM Location normalizer (CAC bandwidth objects, no address data)
|
|
1290
|
+
# ---------------------------------------------------------------------------
|
|
1291
|
+
|
|
1292
|
+
def normalize_cucm_location(raw: dict, cluster: str = "default") -> MigrationObject:
|
|
1293
|
+
"""Normalize a CUCM Location entity (CAC object) into a MigrationObject.
|
|
1294
|
+
|
|
1295
|
+
These are Call Admission Control bandwidth objects, NOT physical locations.
|
|
1296
|
+
No address fields exist. The location_mapper's MISSING_DATA decision for
|
|
1297
|
+
addresses is the correct path.
|
|
1298
|
+
(Confirmed by Phase 03 live CUCM validation: shape is {name, id, uuid,
|
|
1299
|
+
withinAudioBandwidth, withinVideoBandwidth, withinImmersiveKbits})
|
|
1300
|
+
"""
|
|
1301
|
+
name = raw.get("name", "")
|
|
1302
|
+
return MigrationObject(
|
|
1303
|
+
canonical_id=f"cucm_location:{name}",
|
|
1304
|
+
provenance=_make_provenance(raw, cluster),
|
|
1305
|
+
status=MigrationStatus.NORMALIZED,
|
|
1306
|
+
pre_migration_state={
|
|
1307
|
+
"cucm_location_name": name,
|
|
1308
|
+
# No address fields — confirmed via live CUCM
|
|
1309
|
+
},
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
# ---------------------------------------------------------------------------
|
|
1314
|
+
# §2.1 — Phone Button Template normalizer
|
|
1315
|
+
# ---------------------------------------------------------------------------
|
|
1316
|
+
|
|
1317
|
+
def normalize_button_template(raw: dict, cluster: str = "default") -> MigrationObject | None:
|
|
1318
|
+
"""Normalize a CUCM PhoneButtonTemplate dict.
|
|
1319
|
+
|
|
1320
|
+
(from tier2-phase2-phone-config-design.md §2.1)
|
|
1321
|
+
"""
|
|
1322
|
+
name = raw.get("name")
|
|
1323
|
+
if not name:
|
|
1324
|
+
return None
|
|
1325
|
+
|
|
1326
|
+
raw_buttons = raw.get("buttons")
|
|
1327
|
+
button_list = _to_list(raw_buttons, "button") if isinstance(raw_buttons, dict) else []
|
|
1328
|
+
buttons = []
|
|
1329
|
+
for btn in button_list:
|
|
1330
|
+
if not isinstance(btn, dict):
|
|
1331
|
+
continue
|
|
1332
|
+
# AXL getPhoneButtonTemplate uses <buttonNumber>, not <index>
|
|
1333
|
+
idx = btn.get("buttonNumber") or btn.get("index")
|
|
1334
|
+
feature = btn.get("feature", "")
|
|
1335
|
+
buttons.append({"index": int(idx) if idx else 0, "feature": feature})
|
|
1336
|
+
buttons.sort(key=lambda b: b["index"])
|
|
1337
|
+
|
|
1338
|
+
return MigrationObject(
|
|
1339
|
+
canonical_id=f"button_template:{name}",
|
|
1340
|
+
provenance=_make_provenance(raw, cluster),
|
|
1341
|
+
status=MigrationStatus.NORMALIZED,
|
|
1342
|
+
pre_migration_state={
|
|
1343
|
+
"name": name,
|
|
1344
|
+
"base_template": _extract_ref(raw.get("basePhoneTemplateName")),
|
|
1345
|
+
"buttons": buttons,
|
|
1346
|
+
},
|
|
1347
|
+
)
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
# ---------------------------------------------------------------------------
|
|
1351
|
+
# §2.2 — Softkey Template normalizer
|
|
1352
|
+
# ---------------------------------------------------------------------------
|
|
1353
|
+
|
|
1354
|
+
def normalize_softkey_template(raw: dict, cluster: str = "default") -> MigrationObject | None:
|
|
1355
|
+
"""Normalize a CUCM SoftkeyTemplate dict.
|
|
1356
|
+
|
|
1357
|
+
(from tier2-phase2-phone-config-design.md §2.2)
|
|
1358
|
+
"""
|
|
1359
|
+
name = raw.get("name")
|
|
1360
|
+
if not name:
|
|
1361
|
+
return None
|
|
1362
|
+
|
|
1363
|
+
# Softkey templates come from SQL (AXL operations don't exist in v15.0).
|
|
1364
|
+
# The TemplateExtractor pre-parses SQL rows into structured dicts with
|
|
1365
|
+
# call_states already decoded. Pass through the parsed data.
|
|
1366
|
+
call_states = raw.get("call_states", {})
|
|
1367
|
+
available_softkeys = raw.get("available_softkeys", [])
|
|
1368
|
+
|
|
1369
|
+
return MigrationObject(
|
|
1370
|
+
canonical_id=f"softkey_template:{name}",
|
|
1371
|
+
provenance=_make_provenance(raw, cluster),
|
|
1372
|
+
status=MigrationStatus.NORMALIZED,
|
|
1373
|
+
pre_migration_state={
|
|
1374
|
+
"name": name,
|
|
1375
|
+
"description": raw.get("description") or "",
|
|
1376
|
+
"default_template": (
|
|
1377
|
+
raw.get("base_template_pkid")
|
|
1378
|
+
or _extract_ref(raw.get("defaultSoftkeyTemplateName"))
|
|
1379
|
+
or ""
|
|
1380
|
+
),
|
|
1381
|
+
"call_states": call_states,
|
|
1382
|
+
"available_softkeys": available_softkeys,
|
|
1383
|
+
},
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
# ---------------------------------------------------------------------------
|
|
1388
|
+
# Device Profile normalizer (Tier 2 — §2.2 Extension Mobility)
|
|
1389
|
+
# ---------------------------------------------------------------------------
|
|
1390
|
+
|
|
1391
|
+
def normalize_device_profile(
|
|
1392
|
+
raw: dict[str, Any],
|
|
1393
|
+
cluster: str = "unknown",
|
|
1394
|
+
) -> MigrationObject:
|
|
1395
|
+
"""Normalize a CUCM Device Profile to a MigrationObject.
|
|
1396
|
+
|
|
1397
|
+
(from tier2-enterprise-expansion.md §5)
|
|
1398
|
+
"""
|
|
1399
|
+
name = raw.get("name") or ""
|
|
1400
|
+
product = raw.get("product") or ""
|
|
1401
|
+
protocol = raw.get("protocol") or ""
|
|
1402
|
+
device_pool = _extract_ref(raw.get("devicePoolName"))
|
|
1403
|
+
|
|
1404
|
+
# Extract line info
|
|
1405
|
+
lines_raw = raw.get("lines") or []
|
|
1406
|
+
if isinstance(lines_raw, dict):
|
|
1407
|
+
lines_raw = lines_raw.get("line", [])
|
|
1408
|
+
if isinstance(lines_raw, dict):
|
|
1409
|
+
lines_raw = [lines_raw]
|
|
1410
|
+
|
|
1411
|
+
lines = []
|
|
1412
|
+
for line in (lines_raw if isinstance(lines_raw, list) else []):
|
|
1413
|
+
if not isinstance(line, dict):
|
|
1414
|
+
continue
|
|
1415
|
+
dirn = line.get("dirn") or {}
|
|
1416
|
+
if isinstance(dirn, dict):
|
|
1417
|
+
pattern = dirn.get("pattern") or ""
|
|
1418
|
+
partition = _extract_ref(dirn.get("routePartitionName"))
|
|
1419
|
+
else:
|
|
1420
|
+
pattern = str(dirn)
|
|
1421
|
+
partition = None
|
|
1422
|
+
lines.append({
|
|
1423
|
+
"dn_pattern": pattern,
|
|
1424
|
+
"partition": partition,
|
|
1425
|
+
"index": line.get("index"),
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1428
|
+
# Count speed dials and BLFs
|
|
1429
|
+
sd = raw.get("speeddials") or []
|
|
1430
|
+
blf = raw.get("busyLampFields") or []
|
|
1431
|
+
sd_count = len(sd) if isinstance(sd, list) else 0
|
|
1432
|
+
blf_count = len(blf) if isinstance(blf, list) else 0
|
|
1433
|
+
|
|
1434
|
+
return MigrationObject(
|
|
1435
|
+
canonical_id=f"device_profile:{name}",
|
|
1436
|
+
provenance=_make_provenance(raw, cluster),
|
|
1437
|
+
status=MigrationStatus.NORMALIZED,
|
|
1438
|
+
pre_migration_state={
|
|
1439
|
+
"profile_name": name,
|
|
1440
|
+
"model": product,
|
|
1441
|
+
"protocol": protocol,
|
|
1442
|
+
"device_pool_name": device_pool,
|
|
1443
|
+
"lines": lines,
|
|
1444
|
+
"speed_dial_count": sd_count,
|
|
1445
|
+
"blf_count": blf_count,
|
|
1446
|
+
},
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
# ---------------------------------------------------------------------------
|
|
1451
|
+
# E911 normalizers (Tier 2 — §2.6 E911/ELIN)
|
|
1452
|
+
# ---------------------------------------------------------------------------
|
|
1453
|
+
|
|
1454
|
+
def normalize_elin_group(
|
|
1455
|
+
raw: dict[str, Any],
|
|
1456
|
+
cluster: str = "unknown",
|
|
1457
|
+
) -> MigrationObject:
|
|
1458
|
+
"""Normalize a CUCM ELIN Group to a MigrationObject.
|
|
1459
|
+
|
|
1460
|
+
(from tier2-enterprise-expansion.md §6)
|
|
1461
|
+
"""
|
|
1462
|
+
name = raw.get("name") or ""
|
|
1463
|
+
# ELIN numbers may come as a nested structure or flat list
|
|
1464
|
+
elin_raw = raw.get("elinNumbers") or raw.get("elinNumber") or []
|
|
1465
|
+
elin_numbers = []
|
|
1466
|
+
if isinstance(elin_raw, list):
|
|
1467
|
+
for e in elin_raw:
|
|
1468
|
+
if isinstance(e, str):
|
|
1469
|
+
elin_numbers.append(e)
|
|
1470
|
+
elif isinstance(e, dict):
|
|
1471
|
+
num = e.get("_value_1") or e.get("number") or e.get("dirn") or ""
|
|
1472
|
+
if num:
|
|
1473
|
+
elin_numbers.append(str(num))
|
|
1474
|
+
elif isinstance(elin_raw, str) and elin_raw:
|
|
1475
|
+
elin_numbers = [elin_raw]
|
|
1476
|
+
|
|
1477
|
+
return MigrationObject(
|
|
1478
|
+
canonical_id=f"elin_group:{name}",
|
|
1479
|
+
provenance=_make_provenance(raw, cluster),
|
|
1480
|
+
status=MigrationStatus.NORMALIZED,
|
|
1481
|
+
pre_migration_state={
|
|
1482
|
+
"name": name,
|
|
1483
|
+
"description": raw.get("description") or "",
|
|
1484
|
+
"elin_numbers": elin_numbers,
|
|
1485
|
+
},
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def normalize_geo_location(
|
|
1490
|
+
raw: dict[str, Any],
|
|
1491
|
+
cluster: str = "unknown",
|
|
1492
|
+
) -> MigrationObject:
|
|
1493
|
+
"""Normalize a CUCM Geographic Location to a MigrationObject.
|
|
1494
|
+
|
|
1495
|
+
(from tier2-enterprise-expansion.md §6)
|
|
1496
|
+
"""
|
|
1497
|
+
name = raw.get("name") or ""
|
|
1498
|
+
return MigrationObject(
|
|
1499
|
+
canonical_id=f"geo_location:{name}",
|
|
1500
|
+
provenance=_make_provenance(raw, cluster),
|
|
1501
|
+
status=MigrationStatus.NORMALIZED,
|
|
1502
|
+
pre_migration_state={
|
|
1503
|
+
"name": name,
|
|
1504
|
+
"description": raw.get("description") or "",
|
|
1505
|
+
"country": raw.get("country") or "",
|
|
1506
|
+
},
|
|
1507
|
+
)
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
# ---------------------------------------------------------------------------
|
|
1511
|
+
# Remote Destination normalizer (Tier 2 — §2.7 SNR)
|
|
1512
|
+
# ---------------------------------------------------------------------------
|
|
1513
|
+
|
|
1514
|
+
def normalize_remote_destination(
|
|
1515
|
+
raw: dict[str, Any],
|
|
1516
|
+
cluster: str = "unknown",
|
|
1517
|
+
) -> MigrationObject:
|
|
1518
|
+
"""Normalize a CUCM Remote Destination to a MigrationObject.
|
|
1519
|
+
|
|
1520
|
+
Preserves raw fields for the SNR mapper to consume.
|
|
1521
|
+
(from tier2-enterprise-expansion.md §4)
|
|
1522
|
+
"""
|
|
1523
|
+
name = raw.get("name") or ""
|
|
1524
|
+
owner = raw.get("ownerUserId") or ""
|
|
1525
|
+
dest = raw.get("destination") or ""
|
|
1526
|
+
|
|
1527
|
+
return MigrationObject(
|
|
1528
|
+
canonical_id=f"remote_destination:{owner}:{name}" if owner else f"remote_destination:{name}",
|
|
1529
|
+
provenance=_make_provenance(raw, cluster),
|
|
1530
|
+
status=MigrationStatus.NORMALIZED,
|
|
1531
|
+
pre_migration_state={
|
|
1532
|
+
"name": name,
|
|
1533
|
+
"destination": dest,
|
|
1534
|
+
"ownerUserId": owner,
|
|
1535
|
+
"isMobilePhone": raw.get("isMobilePhone"),
|
|
1536
|
+
"enableMobileConnect": raw.get("enableMobileConnect"),
|
|
1537
|
+
"answerTooSoonTimer": raw.get("answerTooSoonTimer"),
|
|
1538
|
+
"answerTooLateTimer": raw.get("answerTooLateTimer"),
|
|
1539
|
+
"lineAssociations": raw.get("lineAssociations"),
|
|
1540
|
+
"remoteDestinationProfileName": raw.get("remoteDestinationProfileName"),
|
|
1541
|
+
},
|
|
1542
|
+
)
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
# ---------------------------------------------------------------------------
|
|
1546
|
+
# MOH normalizer (Tier 2 — §2.3 MOH Sources)
|
|
1547
|
+
# ---------------------------------------------------------------------------
|
|
1548
|
+
|
|
1549
|
+
def normalize_moh_source(
|
|
1550
|
+
raw: dict[str, Any],
|
|
1551
|
+
cluster: str = "unknown",
|
|
1552
|
+
) -> MigrationObject:
|
|
1553
|
+
"""Normalize a CUCM MOH Audio Source to a MigrationObject.
|
|
1554
|
+
|
|
1555
|
+
Preserves raw fields for the MOH mapper to consume.
|
|
1556
|
+
(from tier2-enterprise-expansion.md §2.3)
|
|
1557
|
+
"""
|
|
1558
|
+
name = raw.get("name") or ""
|
|
1559
|
+
source_file = raw.get("sourceFileName") or ""
|
|
1560
|
+
is_default_raw = raw.get("isDefault")
|
|
1561
|
+
# AXL returns isDefault as string "true"/"false" or bool
|
|
1562
|
+
if isinstance(is_default_raw, str):
|
|
1563
|
+
is_default = is_default_raw.lower() == "true"
|
|
1564
|
+
else:
|
|
1565
|
+
is_default = bool(is_default_raw) if is_default_raw is not None else False
|
|
1566
|
+
source_id = raw.get("sourceId") or ""
|
|
1567
|
+
|
|
1568
|
+
return MigrationObject(
|
|
1569
|
+
canonical_id=f"moh_source:{name}",
|
|
1570
|
+
provenance=_make_provenance(raw, cluster),
|
|
1571
|
+
status=MigrationStatus.NORMALIZED,
|
|
1572
|
+
pre_migration_state={
|
|
1573
|
+
"name": name,
|
|
1574
|
+
"source_file_name": source_file,
|
|
1575
|
+
"is_default": is_default,
|
|
1576
|
+
"source_id": source_id,
|
|
1577
|
+
},
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
# ---------------------------------------------------------------------------
|
|
1582
|
+
# Announcement normalizer (Tier 2 — §2.4 Announcements)
|
|
1583
|
+
# ---------------------------------------------------------------------------
|
|
1584
|
+
|
|
1585
|
+
def normalize_announcement(
|
|
1586
|
+
raw: dict[str, Any],
|
|
1587
|
+
cluster: str = "unknown",
|
|
1588
|
+
) -> MigrationObject:
|
|
1589
|
+
"""Normalize a CUCM Announcement to a MigrationObject.
|
|
1590
|
+
|
|
1591
|
+
Preserves name, description, and file name for the announcement mapper.
|
|
1592
|
+
(from tier2-enterprise-expansion.md §2.4)
|
|
1593
|
+
"""
|
|
1594
|
+
name = raw.get("name") or ""
|
|
1595
|
+
file_name = raw.get("announcementFile") or ""
|
|
1596
|
+
|
|
1597
|
+
return MigrationObject(
|
|
1598
|
+
canonical_id=f"announcement:{name}",
|
|
1599
|
+
provenance=_make_provenance(raw, cluster),
|
|
1600
|
+
status=MigrationStatus.NORMALIZED,
|
|
1601
|
+
pre_migration_state={
|
|
1602
|
+
"name": name,
|
|
1603
|
+
"description": raw.get("description") or "",
|
|
1604
|
+
"file_name": file_name,
|
|
1605
|
+
},
|
|
1606
|
+
)
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
# ---------------------------------------------------------------------------
|
|
1610
|
+
# Tier 4 informational normalizers (Wave 1: extract + flag, no mappers)
|
|
1611
|
+
# ---------------------------------------------------------------------------
|
|
1612
|
+
|
|
1613
|
+
def normalize_recording_profile(
|
|
1614
|
+
raw: dict[str, Any],
|
|
1615
|
+
cluster: str = "unknown",
|
|
1616
|
+
) -> MigrationObject:
|
|
1617
|
+
"""Normalize a CUCM Recording Profile to informational MigrationObject."""
|
|
1618
|
+
name = raw.get("name") or ""
|
|
1619
|
+
return MigrationObject(
|
|
1620
|
+
canonical_id=f"info_recording:{name}",
|
|
1621
|
+
provenance=_make_provenance(raw, cluster),
|
|
1622
|
+
status=MigrationStatus.NORMALIZED,
|
|
1623
|
+
pre_migration_state={
|
|
1624
|
+
"name": name,
|
|
1625
|
+
"recordingCssName": raw.get("recordingCssName") or "",
|
|
1626
|
+
"recorderDestination": raw.get("recorderDestination") or "",
|
|
1627
|
+
},
|
|
1628
|
+
)
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
def normalize_calling_party_xform(
|
|
1632
|
+
raw: dict[str, Any],
|
|
1633
|
+
cluster: str = "unknown",
|
|
1634
|
+
) -> MigrationObject:
|
|
1635
|
+
"""Normalize a CUCM Calling Party Transformation Pattern."""
|
|
1636
|
+
pattern = raw.get("pattern") or ""
|
|
1637
|
+
partition = raw.get("routePartitionName") or ""
|
|
1638
|
+
return MigrationObject(
|
|
1639
|
+
canonical_id=f"info_calling_xform:{pattern}:{partition}" if partition else f"info_calling_xform:{pattern}",
|
|
1640
|
+
provenance=_make_provenance(raw, cluster),
|
|
1641
|
+
status=MigrationStatus.NORMALIZED,
|
|
1642
|
+
pre_migration_state={
|
|
1643
|
+
"pattern": pattern,
|
|
1644
|
+
"description": raw.get("description") or "",
|
|
1645
|
+
"callingSearchSpaceName": raw.get("callingSearchSpaceName") or "",
|
|
1646
|
+
"routePartitionName": partition,
|
|
1647
|
+
"callingPartyTransformationMask": raw.get("callingPartyTransformationMask") or "",
|
|
1648
|
+
"callingPartyPrefixDigits": raw.get("callingPartyPrefixDigits") or "",
|
|
1649
|
+
"digitDiscardInstructionName": raw.get("digitDiscardInstructionName") or "",
|
|
1650
|
+
},
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def normalize_called_party_xform(
|
|
1655
|
+
raw: dict[str, Any],
|
|
1656
|
+
cluster: str = "unknown",
|
|
1657
|
+
) -> MigrationObject:
|
|
1658
|
+
"""Normalize a CUCM Called Party Transformation Pattern."""
|
|
1659
|
+
pattern = raw.get("pattern") or ""
|
|
1660
|
+
partition = raw.get("routePartitionName") or ""
|
|
1661
|
+
return MigrationObject(
|
|
1662
|
+
canonical_id=f"info_called_xform:{pattern}:{partition}" if partition else f"info_called_xform:{pattern}",
|
|
1663
|
+
provenance=_make_provenance(raw, cluster),
|
|
1664
|
+
status=MigrationStatus.NORMALIZED,
|
|
1665
|
+
pre_migration_state={
|
|
1666
|
+
"pattern": pattern,
|
|
1667
|
+
"description": raw.get("description") or "",
|
|
1668
|
+
"callingSearchSpaceName": raw.get("callingSearchSpaceName") or "",
|
|
1669
|
+
"routePartitionName": partition,
|
|
1670
|
+
"calledPartyTransformationMask": raw.get("calledPartyTransformationMask") or "",
|
|
1671
|
+
"calledPartyPrefixDigits": raw.get("calledPartyPrefixDigits") or "",
|
|
1672
|
+
"digitDiscardInstructionName": raw.get("digitDiscardInstructionName") or "",
|
|
1673
|
+
},
|
|
1674
|
+
)
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def normalize_info_device_profile(
|
|
1678
|
+
raw: dict[str, Any],
|
|
1679
|
+
cluster: str = "unknown",
|
|
1680
|
+
) -> MigrationObject:
|
|
1681
|
+
"""Normalize a CUCM Device Profile (Extension Mobility) — Tier 4 informational.
|
|
1682
|
+
|
|
1683
|
+
Note: NOT the same as the Tier 2 `normalize_device_profile` which creates
|
|
1684
|
+
`device_profile:*` objects for the full mapper pipeline. This creates
|
|
1685
|
+
`info_device_profile:*` objects for report-only flagging.
|
|
1686
|
+
"""
|
|
1687
|
+
name = raw.get("name") or ""
|
|
1688
|
+
return MigrationObject(
|
|
1689
|
+
canonical_id=f"info_device_profile:{name}",
|
|
1690
|
+
provenance=_make_provenance(raw, cluster),
|
|
1691
|
+
status=MigrationStatus.NORMALIZED,
|
|
1692
|
+
pre_migration_state={
|
|
1693
|
+
"name": name,
|
|
1694
|
+
"description": raw.get("description") or "",
|
|
1695
|
+
"product": raw.get("product") or "",
|
|
1696
|
+
"protocol": raw.get("protocol") or "",
|
|
1697
|
+
},
|
|
1698
|
+
)
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def normalize_intercept_candidate(
|
|
1704
|
+
raw: dict[str, Any],
|
|
1705
|
+
cluster: str = "unknown",
|
|
1706
|
+
) -> MigrationObject:
|
|
1707
|
+
"""Normalize a CUCM intercept candidate signal."""
|
|
1708
|
+
dn = raw.get("dn") or ""
|
|
1709
|
+
partition = raw.get("partition") or "<None>"
|
|
1710
|
+
return MigrationObject(
|
|
1711
|
+
canonical_id=f"intercept_candidate:{dn}:{partition}",
|
|
1712
|
+
provenance=_make_provenance(raw, cluster),
|
|
1713
|
+
status=MigrationStatus.NORMALIZED,
|
|
1714
|
+
pre_migration_state={
|
|
1715
|
+
"userid": raw.get("userid") or "",
|
|
1716
|
+
"dn": dn,
|
|
1717
|
+
"partition": partition if partition != "<None>" else "",
|
|
1718
|
+
"signal_type": raw.get("signal_type") or "unknown",
|
|
1719
|
+
"forward_destination": raw.get("forward_destination") or "",
|
|
1720
|
+
"voicemail_enabled": raw.get("voicemail_enabled", False),
|
|
1721
|
+
},
|
|
1722
|
+
)
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
# ---------------------------------------------------------------------------
|
|
1726
|
+
# Tier 3: Informational type normalizers (generic factory)
|
|
1727
|
+
# ---------------------------------------------------------------------------
|
|
1728
|
+
|
|
1729
|
+
def _make_info_normalizer(suffix: str):
|
|
1730
|
+
"""Factory: creates a normalizer for an informational type."""
|
|
1731
|
+
if suffix == "app_user":
|
|
1732
|
+
name_field = "userid"
|
|
1733
|
+
elif suffix == "intercom":
|
|
1734
|
+
name_field = "dnorpattern"
|
|
1735
|
+
else:
|
|
1736
|
+
name_field = "name"
|
|
1737
|
+
|
|
1738
|
+
def normalizer(item: dict, cluster: str = "default") -> MigrationObject | None:
|
|
1739
|
+
name_val = item.get(name_field)
|
|
1740
|
+
if not name_val:
|
|
1741
|
+
return None
|
|
1742
|
+
if isinstance(name_val, dict):
|
|
1743
|
+
name_val = name_val.get("_value_1", str(name_val))
|
|
1744
|
+
return MigrationObject(
|
|
1745
|
+
canonical_id=f"info_{suffix}:{name_val}",
|
|
1746
|
+
provenance=Provenance(
|
|
1747
|
+
source_system="cucm",
|
|
1748
|
+
source_id=item.get("pkid", ""),
|
|
1749
|
+
source_name=str(name_val),
|
|
1750
|
+
cluster=cluster,
|
|
1751
|
+
extracted_at=_now(),
|
|
1752
|
+
),
|
|
1753
|
+
status=MigrationStatus.NORMALIZED,
|
|
1754
|
+
pre_migration_state=item,
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
return normalizer
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
_INFO_SUFFIXES = [
|
|
1761
|
+
"region", "srst", "media_resource_group", "media_resource_list",
|
|
1762
|
+
"aar_group", "device_mobility_group", "conference_bridge",
|
|
1763
|
+
"softkey_template", "ip_phone_service", "intercom",
|
|
1764
|
+
"common_phone_config", "phone_button_template",
|
|
1765
|
+
"feature_control_policy", "credential_policy",
|
|
1766
|
+
"recording_profile", "ldap_directory",
|
|
1767
|
+
"app_user", "h323_gateway", "enterprise_params", "service_params",
|
|
1768
|
+
]
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
# ---------------------------------------------------------------------------
|
|
1772
|
+
# Executive/Assistant normalizers
|
|
1773
|
+
# ---------------------------------------------------------------------------
|
|
1774
|
+
|
|
1775
|
+
def normalize_executive_assistant_pair(
|
|
1776
|
+
raw: dict, cluster: str = "default",
|
|
1777
|
+
) -> MigrationObject:
|
|
1778
|
+
"""Normalize an executive/assistant relationship pair from SQL query."""
|
|
1779
|
+
exec_userid = raw.get("executive_userid", "")
|
|
1780
|
+
asst_userid = raw.get("assistant_userid", "")
|
|
1781
|
+
return MigrationObject(
|
|
1782
|
+
canonical_id=f"exec_asst_pair:{exec_userid}:{asst_userid}",
|
|
1783
|
+
provenance=_make_provenance(raw, cluster),
|
|
1784
|
+
status=MigrationStatus.NORMALIZED,
|
|
1785
|
+
pre_migration_state={
|
|
1786
|
+
"executive_userid": exec_userid,
|
|
1787
|
+
"assistant_userid": asst_userid,
|
|
1788
|
+
"executive_pkid": raw.get("executive_pkid"),
|
|
1789
|
+
"assistant_pkid": raw.get("assistant_pkid"),
|
|
1790
|
+
},
|
|
1791
|
+
)
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def normalize_executive_settings(
|
|
1795
|
+
raw: dict, cluster: str = "default",
|
|
1796
|
+
) -> MigrationObject:
|
|
1797
|
+
"""Normalize executive/assistant user service subscription."""
|
|
1798
|
+
userid = raw.get("userid", "")
|
|
1799
|
+
service_name = raw.get("service_name", "")
|
|
1800
|
+
role = "EXECUTIVE" if service_name == "Executive" else "EXECUTIVE_ASSISTANT"
|
|
1801
|
+
return MigrationObject(
|
|
1802
|
+
canonical_id=f"exec_setting:{userid}",
|
|
1803
|
+
provenance=_make_provenance(raw, cluster),
|
|
1804
|
+
status=MigrationStatus.NORMALIZED,
|
|
1805
|
+
pre_migration_state={
|
|
1806
|
+
"userid": userid,
|
|
1807
|
+
"role": role,
|
|
1808
|
+
"service_name": service_name,
|
|
1809
|
+
"servicetype": raw.get("servicetype"),
|
|
1810
|
+
},
|
|
1811
|
+
)
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
# ---------------------------------------------------------------------------
|
|
1815
|
+
# Post-normalization: DECT network grouping
|
|
1816
|
+
# ---------------------------------------------------------------------------
|
|
1817
|
+
|
|
1818
|
+
def normalize_dect_group(
|
|
1819
|
+
store: Any,
|
|
1820
|
+
dect_inventory: list[dict[str, str]] | None = None,
|
|
1821
|
+
) -> list[CanonicalDECTNetwork]:
|
|
1822
|
+
"""Group DECT handsets by device pool and create CanonicalDECTNetwork objects.
|
|
1823
|
+
|
|
1824
|
+
Post-normalization step — runs after Pass 2 CrossReferenceBuilder so that
|
|
1825
|
+
compatibility_tier has been classified on each CanonicalDevice. Reads all
|
|
1826
|
+
devices with compatibility_tier == "dect", groups them by device pool name,
|
|
1827
|
+
and creates one CanonicalDECTNetwork per group.
|
|
1828
|
+
|
|
1829
|
+
The mapper (DECTMapper, Task 3) will enrich each network with location refs,
|
|
1830
|
+
handset owner resolution, and access codes.
|
|
1831
|
+
|
|
1832
|
+
Args:
|
|
1833
|
+
store: MigrationStore instance with normalized + cross-ref'd objects.
|
|
1834
|
+
dect_inventory: Optional list of base station dicts from the
|
|
1835
|
+
--dect-inventory CLI flag. Each dict has keys: coverage_zone,
|
|
1836
|
+
base_station_mac, base_station_model. When provided, stations
|
|
1837
|
+
whose coverage_zone matches the network's device pool name are
|
|
1838
|
+
attached to the CanonicalDECTNetwork.base_stations list.
|
|
1839
|
+
|
|
1840
|
+
Returns:
|
|
1841
|
+
List of CanonicalDECTNetwork objects (also written to the store).
|
|
1842
|
+
"""
|
|
1843
|
+
# Build coverage_zone → base station list index for fast lookup
|
|
1844
|
+
inventory_by_zone: dict[str, list[dict[str, str]]] = {}
|
|
1845
|
+
for entry in (dect_inventory or []):
|
|
1846
|
+
zone = entry.get("coverage_zone", "")
|
|
1847
|
+
if zone:
|
|
1848
|
+
inventory_by_zone.setdefault(zone, []).append(entry)
|
|
1849
|
+
|
|
1850
|
+
# Collect all DECT-tier devices
|
|
1851
|
+
dect_by_pool: dict[str, list[CanonicalDevice]] = {}
|
|
1852
|
+
for obj in store.query_by_type("device"):
|
|
1853
|
+
if not isinstance(obj, CanonicalDevice):
|
|
1854
|
+
continue
|
|
1855
|
+
if obj.compatibility_tier != DeviceCompatibilityTier.DECT:
|
|
1856
|
+
continue
|
|
1857
|
+
pool_name = (obj.pre_migration_state or {}).get("cucm_device_pool") or "DEFAULT"
|
|
1858
|
+
dect_by_pool.setdefault(pool_name, []).append(obj)
|
|
1859
|
+
|
|
1860
|
+
networks: list[CanonicalDECTNetwork] = []
|
|
1861
|
+
for pool_name, devices in dect_by_pool.items():
|
|
1862
|
+
network_name = f"DECT-{pool_name}"
|
|
1863
|
+
handset_assignments = [
|
|
1864
|
+
{"device_canonical_id": d.canonical_id, "cucm_device_name": d.cucm_device_name}
|
|
1865
|
+
for d in devices
|
|
1866
|
+
]
|
|
1867
|
+
|
|
1868
|
+
# Attach base stations from inventory whose coverage_zone matches this pool
|
|
1869
|
+
base_stations: list[dict] = []
|
|
1870
|
+
for entry in inventory_by_zone.get(pool_name, []):
|
|
1871
|
+
base_stations.append({
|
|
1872
|
+
"mac": entry["base_station_mac"],
|
|
1873
|
+
"model": entry["base_station_model"],
|
|
1874
|
+
"coverage_zone": entry["coverage_zone"],
|
|
1875
|
+
})
|
|
1876
|
+
|
|
1877
|
+
# Derive DECT network model from inventory (first station's model; PENDING if absent)
|
|
1878
|
+
network_model = base_stations[0]["model"] if base_stations else "PENDING"
|
|
1879
|
+
|
|
1880
|
+
network = CanonicalDECTNetwork(
|
|
1881
|
+
canonical_id=f"dect_network:{pool_name}",
|
|
1882
|
+
provenance=Provenance(
|
|
1883
|
+
source_system="cucm",
|
|
1884
|
+
source_id=pool_name,
|
|
1885
|
+
source_name=network_name,
|
|
1886
|
+
cluster=devices[0].provenance.cluster if devices[0].provenance else "default",
|
|
1887
|
+
extracted_at=_now(),
|
|
1888
|
+
),
|
|
1889
|
+
status=MigrationStatus.NORMALIZED,
|
|
1890
|
+
network_name=network_name,
|
|
1891
|
+
display_name=network_name,
|
|
1892
|
+
model=network_model,
|
|
1893
|
+
access_code="",
|
|
1894
|
+
base_stations=base_stations,
|
|
1895
|
+
handset_assignments=handset_assignments,
|
|
1896
|
+
pre_migration_state={
|
|
1897
|
+
"cucm_device_pool": pool_name,
|
|
1898
|
+
"handset_count": len(devices),
|
|
1899
|
+
"base_station_count": len(base_stations),
|
|
1900
|
+
},
|
|
1901
|
+
)
|
|
1902
|
+
store.upsert_object(network)
|
|
1903
|
+
networks.append(network)
|
|
1904
|
+
|
|
1905
|
+
return networks
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
# ---------------------------------------------------------------------------
|
|
1909
|
+
# Registry: maps extractor object types to normalizer functions
|
|
1910
|
+
# ---------------------------------------------------------------------------
|
|
1911
|
+
|
|
1912
|
+
NORMALIZER_REGISTRY: dict[str, callable] = {
|
|
1913
|
+
"device_pool": normalize_device_pool,
|
|
1914
|
+
"datetime_group": normalize_datetime_group,
|
|
1915
|
+
"user": normalize_user,
|
|
1916
|
+
"phone": normalize_phone,
|
|
1917
|
+
"workspace": normalize_workspace,
|
|
1918
|
+
"css": normalize_css,
|
|
1919
|
+
"partition": normalize_partition,
|
|
1920
|
+
"route_pattern": normalize_route_pattern,
|
|
1921
|
+
"translation_pattern": normalize_translation_pattern,
|
|
1922
|
+
"hunt_pilot": normalize_hunt_pilot,
|
|
1923
|
+
"hunt_list": normalize_hunt_list,
|
|
1924
|
+
"line_group": normalize_line_group,
|
|
1925
|
+
"cti_route_point": normalize_cti_route_point,
|
|
1926
|
+
"call_park": normalize_call_park,
|
|
1927
|
+
"pickup_group": normalize_pickup_group,
|
|
1928
|
+
"time_schedule": normalize_time_schedule,
|
|
1929
|
+
"time_period": normalize_time_period,
|
|
1930
|
+
"gateway": normalize_gateway,
|
|
1931
|
+
"sip_trunk": normalize_sip_trunk,
|
|
1932
|
+
"route_group": normalize_route_group,
|
|
1933
|
+
"route_list": normalize_route_list,
|
|
1934
|
+
"voicemail_profile": normalize_voicemail_profile,
|
|
1935
|
+
"voicemail_pilot": normalize_voicemail_pilot,
|
|
1936
|
+
"voicemail_group": normalize_voicemail_group,
|
|
1937
|
+
"cucm_location": normalize_cucm_location,
|
|
1938
|
+
"button_template": normalize_button_template,
|
|
1939
|
+
"softkey_template": normalize_softkey_template,
|
|
1940
|
+
"remote_destination": normalize_remote_destination,
|
|
1941
|
+
"recording_profile": normalize_recording_profile,
|
|
1942
|
+
"calling_party_xform": normalize_calling_party_xform,
|
|
1943
|
+
"called_party_xform": normalize_called_party_xform,
|
|
1944
|
+
"info_device_profile": normalize_info_device_profile,
|
|
1945
|
+
"intercept_candidate": normalize_intercept_candidate,
|
|
1946
|
+
"elin_group": normalize_elin_group,
|
|
1947
|
+
"geo_location": normalize_geo_location,
|
|
1948
|
+
"device_profile": normalize_device_profile,
|
|
1949
|
+
"moh_source": normalize_moh_source,
|
|
1950
|
+
"announcement": normalize_announcement,
|
|
1951
|
+
"executive_assistant_pair": normalize_executive_assistant_pair,
|
|
1952
|
+
"executive_settings": normalize_executive_settings,
|
|
1953
|
+
**{f"info_{s}": _make_info_normalizer(s) for s in _INFO_SUFFIXES},
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
# ---------------------------------------------------------------------------
|
|
1958
|
+
# DiscoveryResult.raw_data sub-key → normalizer mapping
|
|
1959
|
+
# Maps each raw_data path to the normalizer function to call on each item.
|
|
1960
|
+
# (from Phase 03 DiscoveryResult docstring)
|
|
1961
|
+
# ---------------------------------------------------------------------------
|
|
1962
|
+
|
|
1963
|
+
RAW_DATA_MAPPING: list[tuple[str, str, str]] = [
|
|
1964
|
+
# (extractor_key, sub_key, normalizer_registry_key)
|
|
1965
|
+
("locations", "device_pools", "device_pool"),
|
|
1966
|
+
("locations", "datetime_groups", "datetime_group"),
|
|
1967
|
+
("locations", "cucm_locations", "cucm_location"),
|
|
1968
|
+
("users", "users", "user"),
|
|
1969
|
+
("devices", "phones", "phone"),
|
|
1970
|
+
# workspace classification happens after phone normalization (see normalize_discovery)
|
|
1971
|
+
("routing", "partitions", "partition"),
|
|
1972
|
+
("routing", "css_list", "css"),
|
|
1973
|
+
("routing", "route_patterns", "route_pattern"),
|
|
1974
|
+
("routing", "gateways", "gateway"),
|
|
1975
|
+
("routing", "sip_trunks", "sip_trunk"),
|
|
1976
|
+
("routing", "route_groups", "route_group"),
|
|
1977
|
+
("routing", "route_lists", "route_list"),
|
|
1978
|
+
("routing", "translation_patterns", "translation_pattern"),
|
|
1979
|
+
("features", "hunt_pilots", "hunt_pilot"),
|
|
1980
|
+
("features", "hunt_lists", "hunt_list"),
|
|
1981
|
+
("features", "line_groups", "line_group"),
|
|
1982
|
+
("features", "cti_route_points", "cti_route_point"),
|
|
1983
|
+
("features", "call_parks", "call_park"),
|
|
1984
|
+
("features", "pickup_groups", "pickup_group"),
|
|
1985
|
+
("features", "time_schedules", "time_schedule"),
|
|
1986
|
+
("features", "time_periods", "time_period"),
|
|
1987
|
+
("voicemail", "voicemail_profiles", "voicemail_profile"),
|
|
1988
|
+
("voicemail", "voicemail_pilots", "voicemail_pilot"),
|
|
1989
|
+
("voicemail", "shared_mailboxes", "voicemail_group"),
|
|
1990
|
+
("templates", "button_templates", "button_template"),
|
|
1991
|
+
("templates", "softkey_templates", "softkey_template"),
|
|
1992
|
+
("remote_destinations", "remote_destinations", "remote_destination"),
|
|
1993
|
+
("tier4", "recording_profiles", "recording_profile"),
|
|
1994
|
+
("tier4", "remote_destination_profiles", "remote_destination"), # reuses existing normalizer
|
|
1995
|
+
("tier4", "calling_party_transformations", "calling_party_xform"),
|
|
1996
|
+
("tier4", "called_party_transformations", "called_party_xform"),
|
|
1997
|
+
("tier4", "device_profiles", "info_device_profile"),
|
|
1998
|
+
("tier4", "intercept_candidates", "intercept_candidate"),
|
|
1999
|
+
("e911", "elin_groups", "elin_group"),
|
|
2000
|
+
("e911", "geo_locations", "geo_location"),
|
|
2001
|
+
("device_profiles", "device_profiles", "device_profile"),
|
|
2002
|
+
("moh", "moh_sources", "moh_source"),
|
|
2003
|
+
("announcements", "announcements", "announcement"),
|
|
2004
|
+
("features", "executive_assistant_pairs", "executive_assistant_pair"),
|
|
2005
|
+
("features", "executive_settings", "executive_settings"),
|
|
2006
|
+
# Tier 3: Informational types (all under "informational" extractor key)
|
|
2007
|
+
*[("informational", suffix, f"info_{suffix}") for suffix in _INFO_SUFFIXES],
|
|
2008
|
+
]
|