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,1179 @@
|
|
|
1
|
+
"""Feature mapper: CUCM call features -> Webex Calling features.
|
|
2
|
+
|
|
3
|
+
Maps CUCM Hunt Pilots (+ Hunt Lists + Line Groups), CTI Route Points,
|
|
4
|
+
Call Park Numbers, Pickup Groups, Paging Groups, and Time Schedules to
|
|
5
|
+
their Webex Calling equivalents.
|
|
6
|
+
|
|
7
|
+
(from 03b-transform-mappers.md §8, feature_mapper)
|
|
8
|
+
|
|
9
|
+
Cross-ref reads:
|
|
10
|
+
hunt_pilot_has_hunt_list (#21) — HuntPilot -> HuntList
|
|
11
|
+
hunt_list_has_line_group (#22) — HuntList -> LineGroup
|
|
12
|
+
line_group_has_members (#23) — LineGroup -> DN/Line (agent resolution)
|
|
13
|
+
cti_rp_has_script (#24) — CTI Route Point -> Script (AA approximation)
|
|
14
|
+
schedule_has_time_period (#25) — Schedule -> TimePeriod (OperatingMode)
|
|
15
|
+
|
|
16
|
+
Decisions generated:
|
|
17
|
+
FEATURE_APPROXIMATION — CTI RP with complex script, agent limit exceeded
|
|
18
|
+
MISSING_DATA — unresolvable hunt pilot DN
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from wxcli.migration.models import (
|
|
27
|
+
CanonicalAutoAttendant,
|
|
28
|
+
CanonicalCallPark,
|
|
29
|
+
CanonicalCallQueue,
|
|
30
|
+
CanonicalHuntGroup,
|
|
31
|
+
CanonicalLocationSchedule,
|
|
32
|
+
CanonicalOperatingMode,
|
|
33
|
+
CanonicalPagingGroup,
|
|
34
|
+
CanonicalPickupGroup,
|
|
35
|
+
DecisionType,
|
|
36
|
+
MapperResult,
|
|
37
|
+
MigrationStatus,
|
|
38
|
+
)
|
|
39
|
+
from wxcli.migration.store import MigrationStore
|
|
40
|
+
from wxcli.migration.transform.mappers.base import (
|
|
41
|
+
Mapper,
|
|
42
|
+
accept_option,
|
|
43
|
+
decision_to_store_dict,
|
|
44
|
+
extract_provenance,
|
|
45
|
+
hash_id,
|
|
46
|
+
manual_option,
|
|
47
|
+
skip_option,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Shared helper: resolve DN canonical_id → owning user/workspace canonical_id
|
|
55
|
+
# (from 03b-transform-mappers.md §8: "each line group member DN → resolve to
|
|
56
|
+
# Webex person/workspace/virtual line ID")
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _resolve_dn_to_owner(store: MigrationStore, dn_canonical_id: str) -> str | None:
|
|
60
|
+
"""Resolve a DN canonical_id to its owning user or workspace.
|
|
61
|
+
|
|
62
|
+
Chain: DN ← device_has_dn ← device → device_owned_by_user → user
|
|
63
|
+
If no user owner found, check for common-area device → workspace.
|
|
64
|
+
"""
|
|
65
|
+
# Find devices that have this DN (reverse lookup: to_id = dn_id)
|
|
66
|
+
device_refs = store.get_cross_refs(to_id=dn_canonical_id, relationship="device_has_dn")
|
|
67
|
+
for ref in device_refs:
|
|
68
|
+
device_id = ref["from_id"]
|
|
69
|
+
|
|
70
|
+
# Check if device is owned by a user
|
|
71
|
+
user_refs = store.find_cross_refs(device_id, "device_owned_by_user")
|
|
72
|
+
if user_refs:
|
|
73
|
+
return user_refs[0] # Return canonical user ID
|
|
74
|
+
|
|
75
|
+
# Check if device is a common-area device → workspace
|
|
76
|
+
# Common-area devices don't have device_owned_by_user; the device itself
|
|
77
|
+
# may be associated with a workspace object
|
|
78
|
+
ws_refs = store.get_cross_refs(to_id=device_id, relationship="workspace_has_device")
|
|
79
|
+
if ws_refs:
|
|
80
|
+
return ws_refs[0]["from_id"]
|
|
81
|
+
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_agents_from_dns(
|
|
86
|
+
store: MigrationStore, dn_ids: list[str]
|
|
87
|
+
) -> list[str]:
|
|
88
|
+
"""Resolve a list of DN canonical_ids to deduplicated owner canonical_ids.
|
|
89
|
+
|
|
90
|
+
Falls back to the raw DN canonical_id when the device→user chain isn't
|
|
91
|
+
available (e.g., cross-refs not yet built). This preserves backward
|
|
92
|
+
compatibility — the command builder handles both user IDs and DN IDs.
|
|
93
|
+
"""
|
|
94
|
+
seen: set[str] = set()
|
|
95
|
+
resolved: list[str] = []
|
|
96
|
+
for dn_id in dn_ids:
|
|
97
|
+
owner = _resolve_dn_to_owner(store, dn_id)
|
|
98
|
+
agent_id = owner if owner else dn_id
|
|
99
|
+
if agent_id not in seen:
|
|
100
|
+
seen.add(agent_id)
|
|
101
|
+
resolved.append(agent_id)
|
|
102
|
+
return resolved
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_feature_location(
|
|
106
|
+
store: MigrationStore,
|
|
107
|
+
agents: list[str],
|
|
108
|
+
provenance: dict | None = None,
|
|
109
|
+
) -> str | None:
|
|
110
|
+
"""Determine a feature's location from its agents.
|
|
111
|
+
|
|
112
|
+
1. Read each agent's location_id from the store
|
|
113
|
+
2. If all agents share the same location → use it
|
|
114
|
+
3. If agents span multiple locations → majority wins
|
|
115
|
+
4. If no agents have location_id → return None
|
|
116
|
+
|
|
117
|
+
Note: Features without agents (e.g., call parks) get location_id from
|
|
118
|
+
their pre_migration_state directly. AAs and schedules are location-scoped
|
|
119
|
+
at creation time via the command builder's LOCATION_ID placeholder.
|
|
120
|
+
"""
|
|
121
|
+
if not agents:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
location_counts: dict[str, int] = {}
|
|
125
|
+
for agent_cid in agents:
|
|
126
|
+
agent_obj = store.get_object(agent_cid)
|
|
127
|
+
if agent_obj:
|
|
128
|
+
loc = agent_obj.get("location_id")
|
|
129
|
+
if loc:
|
|
130
|
+
location_counts[loc] = location_counts.get(loc, 0) + 1
|
|
131
|
+
|
|
132
|
+
if location_counts:
|
|
133
|
+
# Return the most common location
|
|
134
|
+
return max(location_counts, key=location_counts.get) # type: ignore[arg-type]
|
|
135
|
+
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Agent limits by Webex policy
|
|
140
|
+
# (from 03b-transform-mappers.md §8: "SIMULTANEOUS max 50, WEIGHTED max 100, others max 1000")
|
|
141
|
+
_AGENT_LIMITS: dict[str, int] = {
|
|
142
|
+
"SIMULTANEOUS": 50,
|
|
143
|
+
"WEIGHTED": 100,
|
|
144
|
+
"REGULAR": 1000,
|
|
145
|
+
"CIRCULAR": 1000,
|
|
146
|
+
"UNIFORM": 1000,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# Paging group target limit
|
|
150
|
+
# (from 03b-transform-mappers.md §8: "Webex paging groups support up to 75 targets")
|
|
151
|
+
_MAX_PAGING_TARGETS = 75
|
|
152
|
+
|
|
153
|
+
# Algorithm mapping: CUCM hunt algorithm -> Webex policy
|
|
154
|
+
# (from 03b-transform-mappers.md §10: Algorithm Mapping Table)
|
|
155
|
+
_ALGORITHM_TO_POLICY: dict[str, str] = {
|
|
156
|
+
"Top Down": "REGULAR",
|
|
157
|
+
"Circular": "CIRCULAR",
|
|
158
|
+
"Longest Idle Time": "UNIFORM",
|
|
159
|
+
"Broadcast": "SIMULTANEOUS",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class FeatureMapper(Mapper):
|
|
164
|
+
"""Maps CUCM call features to Webex Calling feature objects.
|
|
165
|
+
|
|
166
|
+
Produces: CanonicalHuntGroup, CanonicalCallQueue, CanonicalAutoAttendant,
|
|
167
|
+
CanonicalCallPark, CanonicalPickupGroup, CanonicalPagingGroup,
|
|
168
|
+
CanonicalOperatingMode.
|
|
169
|
+
|
|
170
|
+
(from 03b-transform-mappers.md §8)
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
name = "feature_mapper"
|
|
174
|
+
depends_on = ["location_mapper", "line_mapper", "user_mapper"]
|
|
175
|
+
|
|
176
|
+
def map(self, store: MigrationStore) -> MapperResult:
|
|
177
|
+
"""Read CUCM feature objects and produce Webex feature canonical objects."""
|
|
178
|
+
result = MapperResult()
|
|
179
|
+
|
|
180
|
+
# Disable FK checks for cross-ref writing — feature_has_agent refs
|
|
181
|
+
# may point to agents (DN IDs) not in the objects table.
|
|
182
|
+
store.conn.execute("PRAGMA foreign_keys=OFF")
|
|
183
|
+
try:
|
|
184
|
+
self._map_hunt_pilots(store, result)
|
|
185
|
+
self._map_cti_route_points(store, result)
|
|
186
|
+
self._map_call_parks(store, result)
|
|
187
|
+
self._map_pickup_groups(store, result)
|
|
188
|
+
self._map_paging_groups(store, result)
|
|
189
|
+
self._map_schedules(store, result)
|
|
190
|
+
self._map_location_schedules(store, result)
|
|
191
|
+
finally:
|
|
192
|
+
store.conn.execute("PRAGMA foreign_keys=ON")
|
|
193
|
+
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# Hunt Pilots: classify -> HG or CQ
|
|
198
|
+
# (from 03b-transform-mappers.md §10: classify_hunt_pilot algorithm)
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def _map_hunt_pilots(self, store: MigrationStore, result: MapperResult) -> None:
|
|
202
|
+
"""Map CUCM hunt pilots to Hunt Groups or Call Queues.
|
|
203
|
+
|
|
204
|
+
Traverses 3-object chain: hunt_pilot -> hunt_list -> line_group -> members
|
|
205
|
+
"""
|
|
206
|
+
# Hoist voicemail_group presence check out of the per-hunt-pilot loop —
|
|
207
|
+
# the answer can't change inside the loop (nothing writes voicemail_group
|
|
208
|
+
# here), so computing it once avoids O(N*M) store queries.
|
|
209
|
+
has_vm_group = any(True for _ in store.get_objects("voicemail_group"))
|
|
210
|
+
|
|
211
|
+
# Index voicemail groups by extension for O(1) lookup — used to write
|
|
212
|
+
# feature_forwards_to_voicemail_group cross-refs so the dependency
|
|
213
|
+
# graph can enforce voicemail_group:create → feature:configure_*.
|
|
214
|
+
vg_by_ext: dict[str, str] = {}
|
|
215
|
+
for vg in store.get_objects("voicemail_group"):
|
|
216
|
+
vg_state = vg.get("pre_migration_state") or {}
|
|
217
|
+
vg_ext = vg_state.get("extension")
|
|
218
|
+
if vg_ext:
|
|
219
|
+
vg_by_ext[str(vg_ext)] = vg["canonical_id"]
|
|
220
|
+
|
|
221
|
+
for hp_data in store.get_objects("hunt_pilot"):
|
|
222
|
+
hp_id = hp_data["canonical_id"]
|
|
223
|
+
hp_state = hp_data.get("pre_migration_state") or {}
|
|
224
|
+
|
|
225
|
+
# --- Resolve hunt list via cross-ref (#21) ---
|
|
226
|
+
hunt_list_refs = store.find_cross_refs(hp_id, "hunt_pilot_has_hunt_list")
|
|
227
|
+
hunt_list_data = None
|
|
228
|
+
if hunt_list_refs:
|
|
229
|
+
hunt_list_data = store.get_object(hunt_list_refs[0])
|
|
230
|
+
|
|
231
|
+
hunt_list_state = (hunt_list_data or {}).get("pre_migration_state") or {}
|
|
232
|
+
|
|
233
|
+
# --- Resolve line groups via hunt_list -> line_group chain (#22) ---
|
|
234
|
+
line_group_ids: list[str] = []
|
|
235
|
+
if hunt_list_refs:
|
|
236
|
+
for hl_ref in hunt_list_refs:
|
|
237
|
+
lg_refs = store.find_cross_refs(hl_ref, "hunt_list_has_line_group")
|
|
238
|
+
line_group_ids.extend(lg_refs)
|
|
239
|
+
|
|
240
|
+
# --- Resolve members from all line groups (#23) ---
|
|
241
|
+
member_ids: list[str] = []
|
|
242
|
+
for lg_id in line_group_ids:
|
|
243
|
+
member_refs = store.find_cross_refs(lg_id, "line_group_has_members")
|
|
244
|
+
member_ids.extend(member_refs)
|
|
245
|
+
|
|
246
|
+
# Resolve DN references to canonical user/workspace IDs
|
|
247
|
+
# (Fix 1: DN agents → canonical user IDs via device chain)
|
|
248
|
+
unique_members = _resolve_agents_from_dns(store, member_ids)
|
|
249
|
+
|
|
250
|
+
# --- Load first line group state for algorithm + rna fields ---
|
|
251
|
+
first_lg_state: dict[str, Any] = {}
|
|
252
|
+
if line_group_ids:
|
|
253
|
+
first_lg_data = store.get_object(line_group_ids[0])
|
|
254
|
+
if first_lg_data:
|
|
255
|
+
first_lg_state = first_lg_data.get("pre_migration_state") or {}
|
|
256
|
+
|
|
257
|
+
# --- Classify hunt pilot ---
|
|
258
|
+
classification, policy = self._classify_hunt_pilot(
|
|
259
|
+
hp_state, hunt_list_state, first_lg_state
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# --- Phase A: voicemail-on-overflow gap detection ---
|
|
263
|
+
# If the hunt list forwards to voicemail but we have no matching
|
|
264
|
+
# voicemail_group object in the store, the shared mailbox was
|
|
265
|
+
# not extracted from Unity Connection. Surface this as a
|
|
266
|
+
# MISSING_DATA decision so the assessment report flags it.
|
|
267
|
+
# (from docs/superpowers/specs/2026-04-10-voicemail-groups.md Phase A)
|
|
268
|
+
vm_usage = hunt_list_state.get("voice_mail_usage", "NONE")
|
|
269
|
+
if vm_usage and vm_usage != "NONE":
|
|
270
|
+
hl_name = hunt_list_state.get("hunt_list_name") or ""
|
|
271
|
+
# Use the already-resolved cross-ref canonical_id instead of
|
|
272
|
+
# recomputing it from the hunt_list_name string — avoids silent
|
|
273
|
+
# breakage if the canonical_id format ever changes.
|
|
274
|
+
hl_id = hunt_list_refs[0] if hunt_list_refs else ""
|
|
275
|
+
if not has_vm_group:
|
|
276
|
+
gap_decision = self._create_decision(
|
|
277
|
+
store=store,
|
|
278
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
279
|
+
severity="MEDIUM",
|
|
280
|
+
summary=(
|
|
281
|
+
f"Hunt list '{hl_name}' forwards unanswered/overflow "
|
|
282
|
+
f"calls to a shared voicemail mailbox "
|
|
283
|
+
f"({vm_usage}) — the mailbox configuration was "
|
|
284
|
+
f"not extracted from Unity Connection and will "
|
|
285
|
+
f"not be migrated automatically"
|
|
286
|
+
),
|
|
287
|
+
context={
|
|
288
|
+
"hunt_list_id": hl_id,
|
|
289
|
+
"hunt_pilot_id": hp_id,
|
|
290
|
+
"voice_mail_usage": vm_usage,
|
|
291
|
+
"reason": "shared_voicemail_not_extracted",
|
|
292
|
+
},
|
|
293
|
+
options=[
|
|
294
|
+
accept_option(
|
|
295
|
+
"Accept loss — rebuild voicemail group manually "
|
|
296
|
+
"in Webex post-migration"
|
|
297
|
+
),
|
|
298
|
+
manual_option(
|
|
299
|
+
"Provide Unity Connection credentials and "
|
|
300
|
+
"re-run discovery to extract shared mailboxes"
|
|
301
|
+
),
|
|
302
|
+
],
|
|
303
|
+
affected_objects=[hp_id] + ([hl_id] if hl_id else []),
|
|
304
|
+
)
|
|
305
|
+
store.save_decision(decision_to_store_dict(gap_decision))
|
|
306
|
+
result.decisions.append(gap_decision)
|
|
307
|
+
|
|
308
|
+
# --- Extract common fields ---
|
|
309
|
+
name = (
|
|
310
|
+
hp_state.get("name")
|
|
311
|
+
or hp_state.get("description")
|
|
312
|
+
or hp_state.get("pattern")
|
|
313
|
+
or hp_id.split(":", 1)[-1]
|
|
314
|
+
)
|
|
315
|
+
extension = hp_state.get("extension") or hp_state.get("pattern")
|
|
316
|
+
phone_number = hp_state.get("phone_number")
|
|
317
|
+
|
|
318
|
+
# --- Ring no-answer ---
|
|
319
|
+
# rnaReversionTimeOut is on LineGroup, not HuntPilot
|
|
320
|
+
# (from 02b-cucm-extraction.md §2.5: rnaReversionTimeOut on LineGroup)
|
|
321
|
+
rna_timeout = first_lg_state.get("rnaReversionTimeOut")
|
|
322
|
+
if rna_timeout is None:
|
|
323
|
+
# Fallback: check other line groups
|
|
324
|
+
for lg_id in line_group_ids:
|
|
325
|
+
lg_data = store.get_object(lg_id)
|
|
326
|
+
if lg_data:
|
|
327
|
+
lg_state = lg_data.get("pre_migration_state") or {}
|
|
328
|
+
rna_timeout = lg_state.get("rnaReversionTimeOut")
|
|
329
|
+
if rna_timeout is not None:
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
no_answer_rings = None
|
|
333
|
+
if rna_timeout is not None:
|
|
334
|
+
try:
|
|
335
|
+
no_answer_rings = max(1, int(rna_timeout) // 6)
|
|
336
|
+
except (ValueError, TypeError):
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# --- Check agent limits ---
|
|
340
|
+
agent_limit = _AGENT_LIMITS.get(policy, 1000)
|
|
341
|
+
if len(unique_members) > agent_limit:
|
|
342
|
+
decision = self._create_decision(
|
|
343
|
+
store=store,
|
|
344
|
+
decision_type=DecisionType.FEATURE_APPROXIMATION,
|
|
345
|
+
severity="HIGH",
|
|
346
|
+
summary=(
|
|
347
|
+
f"Hunt pilot '{name}' has {len(unique_members)} agents "
|
|
348
|
+
f"but {policy} policy allows max {agent_limit}"
|
|
349
|
+
),
|
|
350
|
+
context={
|
|
351
|
+
"hunt_pilot_id": hp_id,
|
|
352
|
+
"name": name,
|
|
353
|
+
"policy": policy,
|
|
354
|
+
"agent_count": len(unique_members),
|
|
355
|
+
"agent_limit": agent_limit,
|
|
356
|
+
"reason": "agent_limit_exceeded",
|
|
357
|
+
},
|
|
358
|
+
options=[
|
|
359
|
+
accept_option(
|
|
360
|
+
f"Truncate to first {agent_limit} agents"
|
|
361
|
+
),
|
|
362
|
+
manual_option(
|
|
363
|
+
"Admin splits into multiple groups manually"
|
|
364
|
+
),
|
|
365
|
+
skip_option("Feature not migrated"),
|
|
366
|
+
],
|
|
367
|
+
affected_objects=[hp_id],
|
|
368
|
+
)
|
|
369
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
370
|
+
result.decisions.append(decision)
|
|
371
|
+
|
|
372
|
+
provenance = extract_provenance(hp_data)
|
|
373
|
+
|
|
374
|
+
# Fix 3: Resolve feature location from agents
|
|
375
|
+
location_id = _resolve_feature_location(
|
|
376
|
+
store, unique_members, hp_data.get("provenance")
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# --- Extract forwarding destinations (top-level on hunt pilot) ---
|
|
380
|
+
fwd_no_answer = hp_state.get("forwardHuntNoAnswer") or {}
|
|
381
|
+
fwd_busy = hp_state.get("forwardHuntBusy") or {}
|
|
382
|
+
forward_no_answer_dest = fwd_no_answer.get("destination") or None
|
|
383
|
+
forward_busy_dest = fwd_busy.get("destination") or None
|
|
384
|
+
|
|
385
|
+
# --- Extract queue overflow destinations (nested in queueCalls) ---
|
|
386
|
+
queue_calls_for_overflow = hp_state.get("queueCalls") or {}
|
|
387
|
+
queue_full_dest = queue_calls_for_overflow.get("queueFullDestination") or None
|
|
388
|
+
max_wait_dest = queue_calls_for_overflow.get("maxWaitTimeDestination") or None
|
|
389
|
+
no_agent_dest = queue_calls_for_overflow.get("noAgentDestination") or None
|
|
390
|
+
max_wait_time_val = queue_calls_for_overflow.get("maxWaitTime")
|
|
391
|
+
if max_wait_time_val is not None:
|
|
392
|
+
try:
|
|
393
|
+
max_wait_time_val = int(max_wait_time_val)
|
|
394
|
+
except (ValueError, TypeError):
|
|
395
|
+
max_wait_time_val = None
|
|
396
|
+
|
|
397
|
+
if classification == "CALL_QUEUE":
|
|
398
|
+
# --- Produce CanonicalCallQueue ---
|
|
399
|
+
# maxCallersInQueue is nested in queueCalls (XCallsQueue)
|
|
400
|
+
# (from 02b-cucm-extraction.md §6: queueCalls complex type)
|
|
401
|
+
queue_calls = hp_state.get("queueCalls") or {}
|
|
402
|
+
queue_size = queue_calls.get("maxCallersInQueue") or 25
|
|
403
|
+
try:
|
|
404
|
+
queue_size = int(queue_size)
|
|
405
|
+
except (ValueError, TypeError):
|
|
406
|
+
queue_size = 25
|
|
407
|
+
|
|
408
|
+
feature_cid = f"call_queue:{hash_id(hp_id)}"
|
|
409
|
+
cq = CanonicalCallQueue(
|
|
410
|
+
canonical_id=feature_cid,
|
|
411
|
+
provenance=provenance,
|
|
412
|
+
status=MigrationStatus.ANALYZED,
|
|
413
|
+
name=name,
|
|
414
|
+
extension=extension,
|
|
415
|
+
phone_number=phone_number,
|
|
416
|
+
policy=policy,
|
|
417
|
+
routing_type="PRIORITY_BASED",
|
|
418
|
+
agents=unique_members,
|
|
419
|
+
queue_size=queue_size,
|
|
420
|
+
enabled=hp_state.get("enabled", True),
|
|
421
|
+
location_id=location_id,
|
|
422
|
+
forward_always_enabled=False,
|
|
423
|
+
forward_always_destination=None,
|
|
424
|
+
queue_full_destination=queue_full_dest,
|
|
425
|
+
max_wait_time_destination=max_wait_dest,
|
|
426
|
+
max_wait_time=max_wait_time_val,
|
|
427
|
+
no_agent_destination=no_agent_dest,
|
|
428
|
+
)
|
|
429
|
+
store.upsert_object(cq)
|
|
430
|
+
result.objects_created += 1
|
|
431
|
+
|
|
432
|
+
# Fix 8: Write feature_has_agent cross-refs
|
|
433
|
+
for agent_cid in unique_members:
|
|
434
|
+
store.add_cross_ref(feature_cid, agent_cid, "feature_has_agent")
|
|
435
|
+
|
|
436
|
+
# Phase C: link call queue to voicemail group for overflow dependency
|
|
437
|
+
for dest in [queue_full_dest, max_wait_dest, no_agent_dest]:
|
|
438
|
+
if dest and str(dest) in vg_by_ext:
|
|
439
|
+
store.add_cross_ref(
|
|
440
|
+
feature_cid, vg_by_ext[str(dest)],
|
|
441
|
+
"feature_forwards_to_voicemail_group",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
else:
|
|
445
|
+
# --- Produce CanonicalHuntGroup ---
|
|
446
|
+
feature_cid = f"hunt_group:{hash_id(hp_id)}"
|
|
447
|
+
hg = CanonicalHuntGroup(
|
|
448
|
+
canonical_id=feature_cid,
|
|
449
|
+
provenance=provenance,
|
|
450
|
+
status=MigrationStatus.ANALYZED,
|
|
451
|
+
name=name,
|
|
452
|
+
extension=extension,
|
|
453
|
+
phone_number=phone_number,
|
|
454
|
+
policy=policy,
|
|
455
|
+
agents=unique_members,
|
|
456
|
+
no_answer_rings=no_answer_rings,
|
|
457
|
+
enabled=hp_state.get("enabled", True),
|
|
458
|
+
location_id=location_id,
|
|
459
|
+
forward_no_answer_enabled=bool(forward_no_answer_dest),
|
|
460
|
+
forward_no_answer_destination=forward_no_answer_dest,
|
|
461
|
+
forward_busy_enabled=bool(forward_busy_dest),
|
|
462
|
+
forward_busy_destination=forward_busy_dest,
|
|
463
|
+
)
|
|
464
|
+
store.upsert_object(hg)
|
|
465
|
+
result.objects_created += 1
|
|
466
|
+
|
|
467
|
+
# Fix 8: Write feature_has_agent cross-refs
|
|
468
|
+
for agent_cid in unique_members:
|
|
469
|
+
store.add_cross_ref(feature_cid, agent_cid, "feature_has_agent")
|
|
470
|
+
|
|
471
|
+
# Phase C: link hunt group to voicemail group for forwarding dependency
|
|
472
|
+
for dest in [forward_no_answer_dest, forward_busy_dest]:
|
|
473
|
+
if dest and str(dest) in vg_by_ext:
|
|
474
|
+
store.add_cross_ref(
|
|
475
|
+
feature_cid, vg_by_ext[str(dest)],
|
|
476
|
+
"feature_forwards_to_voicemail_group",
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# --- MISSING_DATA if no extension ---
|
|
480
|
+
if not extension:
|
|
481
|
+
decision = self._create_decision(
|
|
482
|
+
store=store,
|
|
483
|
+
decision_type=DecisionType.MISSING_DATA,
|
|
484
|
+
severity="HIGH",
|
|
485
|
+
summary=(
|
|
486
|
+
f"Hunt pilot '{name}' has no resolvable extension"
|
|
487
|
+
),
|
|
488
|
+
context={
|
|
489
|
+
"hunt_pilot_id": hp_id,
|
|
490
|
+
"name": name,
|
|
491
|
+
"reason": "no_extension",
|
|
492
|
+
},
|
|
493
|
+
options=[
|
|
494
|
+
manual_option("Admin provides extension"),
|
|
495
|
+
skip_option("Feature not migrated"),
|
|
496
|
+
],
|
|
497
|
+
affected_objects=[hp_id],
|
|
498
|
+
)
|
|
499
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
500
|
+
result.decisions.append(decision)
|
|
501
|
+
|
|
502
|
+
def _classify_hunt_pilot(
|
|
503
|
+
self,
|
|
504
|
+
hp_state: dict[str, Any],
|
|
505
|
+
hunt_list_state: dict[str, Any],
|
|
506
|
+
line_group_state: dict[str, Any],
|
|
507
|
+
) -> tuple[str, str]:
|
|
508
|
+
"""Determine Webex object type and policy for a CUCM hunt pilot.
|
|
509
|
+
|
|
510
|
+
Returns: (classification, policy) where classification is
|
|
511
|
+
'HUNT_GROUP' or 'CALL_QUEUE', and policy is the Webex callPolicies.policy value.
|
|
512
|
+
|
|
513
|
+
(from 03b-transform-mappers.md §10: classify_hunt_pilot algorithm)
|
|
514
|
+
"""
|
|
515
|
+
# Step 1: isCtiRp check is handled separately in _map_cti_route_points
|
|
516
|
+
# This method only handles actual hunt pilots.
|
|
517
|
+
|
|
518
|
+
# Step 2: Check for queue-style indicators
|
|
519
|
+
# All queue fields are nested in queueCalls (XCallsQueue) on HuntPilot.
|
|
520
|
+
# queueCalls is None when queuing is disabled.
|
|
521
|
+
# voiceMailUsage is on HuntList (from 02b-cucm-extraction.md §6).
|
|
522
|
+
# (from 02b-cucm-extraction.md §6: queueCalls is complex type XCallsQueue)
|
|
523
|
+
queue_calls = hp_state.get("queueCalls") or {}
|
|
524
|
+
has_queue_features = any([
|
|
525
|
+
queue_calls.get("enabled", False),
|
|
526
|
+
(queue_calls.get("maxCallersInQueue") or 0) > 0,
|
|
527
|
+
queue_calls.get("networkHoldMohAudioSourceID") is not None,
|
|
528
|
+
queue_calls.get("queueFullDestination") is not None
|
|
529
|
+
or queue_calls.get("maxWaitTimeDestination") is not None
|
|
530
|
+
or queue_calls.get("noAgentDestination") is not None,
|
|
531
|
+
hunt_list_state.get("voiceMailUsage", "NONE") != "NONE",
|
|
532
|
+
])
|
|
533
|
+
|
|
534
|
+
# Step 3: Map distribution algorithm to Webex policy
|
|
535
|
+
# distributionAlgorithm is on LineGroup, not HuntList
|
|
536
|
+
# (from 02b-cucm-extraction.md §2.5: distributionAlgorithm on LineGroup)
|
|
537
|
+
algorithm = line_group_state.get("distributionAlgorithm", "")
|
|
538
|
+
policy = _ALGORITHM_TO_POLICY.get(algorithm, "CIRCULAR")
|
|
539
|
+
|
|
540
|
+
if has_queue_features:
|
|
541
|
+
return "CALL_QUEUE", policy
|
|
542
|
+
return "HUNT_GROUP", policy
|
|
543
|
+
|
|
544
|
+
# ------------------------------------------------------------------
|
|
545
|
+
# CTI Route Points -> Auto Attendant
|
|
546
|
+
# (from 03b-transform-mappers.md §8: AA field mapping)
|
|
547
|
+
# ------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
def _map_cti_route_points(
|
|
550
|
+
self, store: MigrationStore, result: MapperResult
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Map CTI Route Points to CanonicalAutoAttendant objects.
|
|
553
|
+
|
|
554
|
+
Always produces a FEATURE_APPROXIMATION decision because IVR scripts
|
|
555
|
+
can't be fully parsed into simple AA menu keys.
|
|
556
|
+
|
|
557
|
+
(from 03b-transform-mappers.md §8: CTI RP -> AA mapping)
|
|
558
|
+
"""
|
|
559
|
+
for cti_data in store.get_objects("cti_rp"):
|
|
560
|
+
cti_id = cti_data["canonical_id"]
|
|
561
|
+
cti_state = cti_data.get("pre_migration_state") or {}
|
|
562
|
+
|
|
563
|
+
name = (
|
|
564
|
+
cti_state.get("name")
|
|
565
|
+
or cti_state.get("description")
|
|
566
|
+
or cti_id.split(":", 1)[-1]
|
|
567
|
+
)
|
|
568
|
+
extension = cti_state.get("extension") or cti_state.get("pattern")
|
|
569
|
+
|
|
570
|
+
# --- Check for script reference (#24) ---
|
|
571
|
+
script_refs = store.find_cross_refs(cti_id, "cti_rp_has_script")
|
|
572
|
+
has_script = bool(script_refs)
|
|
573
|
+
|
|
574
|
+
# --- Resolve schedule via CSS → partition → timeScheduleIdName ---
|
|
575
|
+
# CTI RP has NO schedule field. Time-based routing uses partition
|
|
576
|
+
# timeScheduleIdName. Resolve: CTI RP callingSearchSpaceName → CSS →
|
|
577
|
+
# partitions → partition's timeScheduleIdName → schedule
|
|
578
|
+
# (from 02b-cucm-extraction.md §2.5: CTI RP has no schedule field)
|
|
579
|
+
business_schedule = None
|
|
580
|
+
css_name = cti_state.get("callingSearchSpaceName")
|
|
581
|
+
if css_name:
|
|
582
|
+
css_id = f"css:{css_name}"
|
|
583
|
+
partition_refs = store.get_cross_refs(
|
|
584
|
+
from_id=css_id, relationship="css_contains_partition"
|
|
585
|
+
)
|
|
586
|
+
for pt_ref in sorted(partition_refs, key=lambda r: r.get("ordinal") or 0):
|
|
587
|
+
pt_data = store.get_object(pt_ref["to_id"])
|
|
588
|
+
if pt_data:
|
|
589
|
+
pt_state = pt_data.get("pre_migration_state") or {}
|
|
590
|
+
sched_name = pt_state.get("timeScheduleIdName")
|
|
591
|
+
if sched_name:
|
|
592
|
+
business_schedule = sched_name
|
|
593
|
+
break
|
|
594
|
+
|
|
595
|
+
# --- Default AA menus ---
|
|
596
|
+
# (from 03b-transform-mappers.md §8: "AA requires both menus")
|
|
597
|
+
business_hours_menu = {
|
|
598
|
+
"greeting": "DEFAULT",
|
|
599
|
+
"extensionEnabled": True,
|
|
600
|
+
"keyConfigurations": [
|
|
601
|
+
{"key": "0", "action": "TRANSFER_TO_OPERATOR", "value": "0"},
|
|
602
|
+
],
|
|
603
|
+
}
|
|
604
|
+
after_hours_menu = {
|
|
605
|
+
"greeting": "DEFAULT",
|
|
606
|
+
"extensionEnabled": True,
|
|
607
|
+
"keyConfigurations": [
|
|
608
|
+
{"key": "0", "action": "TRANSFER_TO_OPERATOR", "value": "0"},
|
|
609
|
+
],
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
# Extract call-forward-all destination from CTI Route Point
|
|
613
|
+
# (CUCM CallForwardAll is the closest analogue to a Webex AA always-forward)
|
|
614
|
+
cfa = cti_state.get("callForwardAll") or {}
|
|
615
|
+
cfa_destination = cfa.get("destination") or None
|
|
616
|
+
|
|
617
|
+
aa = CanonicalAutoAttendant(
|
|
618
|
+
canonical_id=f"auto_attendant:{hash_id(cti_id)}",
|
|
619
|
+
provenance=extract_provenance(cti_data),
|
|
620
|
+
status=MigrationStatus.ANALYZED,
|
|
621
|
+
name=name,
|
|
622
|
+
extension=extension,
|
|
623
|
+
business_schedule=business_schedule,
|
|
624
|
+
business_hours_menu=business_hours_menu,
|
|
625
|
+
after_hours_menu=after_hours_menu,
|
|
626
|
+
forward_always_enabled=bool(cfa_destination),
|
|
627
|
+
forward_always_destination=cfa_destination,
|
|
628
|
+
)
|
|
629
|
+
store.upsert_object(aa)
|
|
630
|
+
result.objects_created += 1
|
|
631
|
+
|
|
632
|
+
# --- FEATURE_APPROXIMATION decision ---
|
|
633
|
+
# (from 03b-transform-mappers.md §8: CTI RP always gets this decision)
|
|
634
|
+
script_detail = "with script reference" if has_script else "without script"
|
|
635
|
+
decision = self._create_decision(
|
|
636
|
+
store=store,
|
|
637
|
+
decision_type=DecisionType.FEATURE_APPROXIMATION,
|
|
638
|
+
severity="HIGH",
|
|
639
|
+
summary=(
|
|
640
|
+
f"CTI Route Point '{name}' ({script_detail}) mapped to basic "
|
|
641
|
+
f"Auto Attendant — IVR logic requires manual configuration"
|
|
642
|
+
),
|
|
643
|
+
context={
|
|
644
|
+
"cti_rp_id": cti_id,
|
|
645
|
+
"name": name,
|
|
646
|
+
"classification": "AUTO_ATTENDANT",
|
|
647
|
+
"complex_script": has_script,
|
|
648
|
+
"has_script": has_script,
|
|
649
|
+
"reason": "cti_rp_to_auto_attendant",
|
|
650
|
+
},
|
|
651
|
+
options=[
|
|
652
|
+
accept_option(
|
|
653
|
+
"Create basic AA with default menus; configure manually"
|
|
654
|
+
),
|
|
655
|
+
skip_option("Skip — handle AA creation manually post-migration"),
|
|
656
|
+
manual_option(
|
|
657
|
+
"Map to HG/CQ if routing is simple enough"
|
|
658
|
+
),
|
|
659
|
+
],
|
|
660
|
+
affected_objects=[cti_id],
|
|
661
|
+
)
|
|
662
|
+
store.save_decision(decision_to_store_dict(decision))
|
|
663
|
+
result.decisions.append(decision)
|
|
664
|
+
|
|
665
|
+
# ------------------------------------------------------------------
|
|
666
|
+
# Call Park: CUCM Call Park Number -> CanonicalCallPark
|
|
667
|
+
# (from 03b-transform-mappers.md §8: simple features)
|
|
668
|
+
# ------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
def _map_call_parks(self, store: MigrationStore, result: MapperResult) -> None:
|
|
671
|
+
"""Map CUCM Call Park numbers to CanonicalCallPark objects."""
|
|
672
|
+
for cp_data in store.get_objects("call_park"):
|
|
673
|
+
cp_id = cp_data["canonical_id"]
|
|
674
|
+
cp_state = cp_data.get("pre_migration_state") or {}
|
|
675
|
+
|
|
676
|
+
name = (
|
|
677
|
+
cp_state.get("name")
|
|
678
|
+
or cp_state.get("description")
|
|
679
|
+
or cp_id.split(":", 1)[-1]
|
|
680
|
+
)
|
|
681
|
+
extension = cp_state.get("extension") or cp_state.get("pattern")
|
|
682
|
+
location_id = cp_state.get("location_id")
|
|
683
|
+
|
|
684
|
+
cp = CanonicalCallPark(
|
|
685
|
+
canonical_id=f"call_park_ext:{hash_id(cp_id)}",
|
|
686
|
+
provenance=extract_provenance(cp_data),
|
|
687
|
+
status=MigrationStatus.ANALYZED,
|
|
688
|
+
name=name,
|
|
689
|
+
extension=extension,
|
|
690
|
+
location_id=location_id,
|
|
691
|
+
)
|
|
692
|
+
store.upsert_object(cp)
|
|
693
|
+
result.objects_created += 1
|
|
694
|
+
|
|
695
|
+
# ------------------------------------------------------------------
|
|
696
|
+
# Pickup Group: CUCM Pickup Group -> CanonicalPickupGroup
|
|
697
|
+
# ------------------------------------------------------------------
|
|
698
|
+
|
|
699
|
+
def _map_pickup_groups(self, store: MigrationStore, result: MapperResult) -> None:
|
|
700
|
+
"""Map CUCM Pickup Groups to CanonicalPickupGroup objects."""
|
|
701
|
+
for pg_data in store.get_objects("pickup_group"):
|
|
702
|
+
pg_id = pg_data["canonical_id"]
|
|
703
|
+
pg_state = pg_data.get("pre_migration_state") or {}
|
|
704
|
+
|
|
705
|
+
name = (
|
|
706
|
+
pg_state.get("name")
|
|
707
|
+
or pg_state.get("description")
|
|
708
|
+
or pg_id.split(":", 1)[-1]
|
|
709
|
+
)
|
|
710
|
+
members = pg_state.get("members") or []
|
|
711
|
+
# Defensive: extract UUID strings if members are still dicts
|
|
712
|
+
# (normalizer should have cleaned these, but handle pre-existing data)
|
|
713
|
+
raw_member_ids = _extract_agent_ids(members)
|
|
714
|
+
|
|
715
|
+
# Fix 2: Resolve CUCM UUID/DN refs to canonical user IDs.
|
|
716
|
+
# The normalizer stores member UUIDs which may correspond to DNs.
|
|
717
|
+
# Try to find DN objects by matching CUCM UUID in the store, then
|
|
718
|
+
# resolve DN→user via the same device chain as hunt groups.
|
|
719
|
+
agents = _resolve_pickup_members_to_owners(store, raw_member_ids)
|
|
720
|
+
|
|
721
|
+
# Fix 3: Resolve feature location from agents
|
|
722
|
+
location_id = _resolve_feature_location(
|
|
723
|
+
store, agents, pg_data.get("provenance")
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
feature_cid = f"pickup_group:{hash_id(pg_id)}"
|
|
727
|
+
pg = CanonicalPickupGroup(
|
|
728
|
+
canonical_id=feature_cid,
|
|
729
|
+
provenance=extract_provenance(pg_data),
|
|
730
|
+
status=MigrationStatus.ANALYZED,
|
|
731
|
+
name=name,
|
|
732
|
+
agents=agents,
|
|
733
|
+
location_id=location_id,
|
|
734
|
+
)
|
|
735
|
+
store.upsert_object(pg)
|
|
736
|
+
result.objects_created += 1
|
|
737
|
+
|
|
738
|
+
# Fix 8: Write feature_has_agent cross-refs
|
|
739
|
+
for agent_cid in agents:
|
|
740
|
+
store.add_cross_ref(feature_cid, agent_cid, "feature_has_agent")
|
|
741
|
+
|
|
742
|
+
# ------------------------------------------------------------------
|
|
743
|
+
# Paging Group
|
|
744
|
+
# ------------------------------------------------------------------
|
|
745
|
+
|
|
746
|
+
def _map_paging_groups(self, store: MigrationStore, result: MapperResult) -> None:
|
|
747
|
+
"""Map CUCM paging groups to CanonicalPagingGroup objects.
|
|
748
|
+
|
|
749
|
+
Webex paging groups support up to 75 targets. If CUCM exceeds this,
|
|
750
|
+
the mapper splits into multiple groups.
|
|
751
|
+
(from 03b-transform-mappers.md §8: "Paging Group members" edge case)
|
|
752
|
+
"""
|
|
753
|
+
for pag_data in store.get_objects("paging_group"):
|
|
754
|
+
pag_id = pag_data["canonical_id"]
|
|
755
|
+
pag_state = pag_data.get("pre_migration_state") or {}
|
|
756
|
+
|
|
757
|
+
name = (
|
|
758
|
+
pag_state.get("name")
|
|
759
|
+
or pag_state.get("description")
|
|
760
|
+
or pag_id.split(":", 1)[-1]
|
|
761
|
+
)
|
|
762
|
+
extension = pag_state.get("extension")
|
|
763
|
+
targets = pag_state.get("targets") or []
|
|
764
|
+
originators = pag_state.get("originators") or []
|
|
765
|
+
|
|
766
|
+
if len(targets) <= _MAX_PAGING_TARGETS:
|
|
767
|
+
pg = CanonicalPagingGroup(
|
|
768
|
+
canonical_id=f"paging_group:{hash_id(pag_id)}",
|
|
769
|
+
provenance=extract_provenance(pag_data),
|
|
770
|
+
status=MigrationStatus.ANALYZED,
|
|
771
|
+
name=name,
|
|
772
|
+
extension=extension,
|
|
773
|
+
targets=targets,
|
|
774
|
+
originators=originators,
|
|
775
|
+
)
|
|
776
|
+
store.upsert_object(pg)
|
|
777
|
+
result.objects_created += 1
|
|
778
|
+
else:
|
|
779
|
+
# Split targets into chunks of 75
|
|
780
|
+
for chunk_idx, start in enumerate(
|
|
781
|
+
range(0, len(targets), _MAX_PAGING_TARGETS)
|
|
782
|
+
):
|
|
783
|
+
chunk = targets[start:start + _MAX_PAGING_TARGETS]
|
|
784
|
+
split_name = f"{name}-{chunk_idx + 1}"
|
|
785
|
+
pg = CanonicalPagingGroup(
|
|
786
|
+
canonical_id=f"paging_group:{hash_id(f'{pag_id}-{chunk_idx}')}",
|
|
787
|
+
provenance=extract_provenance(pag_data),
|
|
788
|
+
status=MigrationStatus.ANALYZED,
|
|
789
|
+
name=split_name,
|
|
790
|
+
extension=extension if chunk_idx == 0 else None,
|
|
791
|
+
targets=chunk,
|
|
792
|
+
originators=originators,
|
|
793
|
+
)
|
|
794
|
+
store.upsert_object(pg)
|
|
795
|
+
result.objects_created += 1
|
|
796
|
+
|
|
797
|
+
# ------------------------------------------------------------------
|
|
798
|
+
# Schedules: Time Schedule + Time Period -> CanonicalOperatingMode
|
|
799
|
+
# (from 03b-transform-mappers.md §8: schedule -> OperatingMode)
|
|
800
|
+
# ------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
def _map_schedules(self, store: MigrationStore, result: MapperResult) -> None:
|
|
803
|
+
"""Map CUCM Time Schedules (with Time Periods) to CanonicalOperatingMode.
|
|
804
|
+
|
|
805
|
+
Reads schedule_has_time_period cross-ref (#25) to join schedules with
|
|
806
|
+
their time period entries.
|
|
807
|
+
|
|
808
|
+
(from 03b-transform-mappers.md §8: OperatingMode field mapping)
|
|
809
|
+
"""
|
|
810
|
+
for sched_data in store.get_objects("time_schedule"):
|
|
811
|
+
sched_id = sched_data["canonical_id"]
|
|
812
|
+
sched_state = sched_data.get("pre_migration_state") or {}
|
|
813
|
+
|
|
814
|
+
name = (
|
|
815
|
+
sched_state.get("name")
|
|
816
|
+
or sched_state.get("description")
|
|
817
|
+
or sched_id.split(":", 1)[-1]
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
# --- Resolve time periods via cross-ref (#25) ---
|
|
821
|
+
period_refs = store.find_cross_refs(sched_id, "schedule_has_time_period")
|
|
822
|
+
time_periods: list[dict[str, Any]] = []
|
|
823
|
+
for tp_ref in period_refs:
|
|
824
|
+
tp_data = store.get_object(tp_ref)
|
|
825
|
+
if tp_data:
|
|
826
|
+
tp_state = tp_data.get("pre_migration_state") or {}
|
|
827
|
+
time_periods.append(tp_state)
|
|
828
|
+
|
|
829
|
+
# --- Determine schedule type ---
|
|
830
|
+
# Heuristic: if any period has a specific date -> HOLIDAY
|
|
831
|
+
# if all periods have same start/end -> SAME_HOURS_DAILY
|
|
832
|
+
# otherwise DIFFERENT_HOURS_DAILY
|
|
833
|
+
schedule_type = sched_state.get("schedule_type")
|
|
834
|
+
if not schedule_type:
|
|
835
|
+
schedule_type = _infer_schedule_type(time_periods)
|
|
836
|
+
|
|
837
|
+
# --- Build schedule data based on type ---
|
|
838
|
+
same_hours_daily = None
|
|
839
|
+
different_hours_daily = None
|
|
840
|
+
holidays: list[dict[str, Any]] = []
|
|
841
|
+
|
|
842
|
+
if schedule_type == "HOLIDAY":
|
|
843
|
+
holidays = [
|
|
844
|
+
{
|
|
845
|
+
"name": tp.get("name", "Holiday"),
|
|
846
|
+
"date": tp.get("date"),
|
|
847
|
+
"recurrence": tp.get("recurrence"),
|
|
848
|
+
}
|
|
849
|
+
for tp in time_periods
|
|
850
|
+
]
|
|
851
|
+
elif schedule_type == "SAME_HOURS_DAILY" and time_periods:
|
|
852
|
+
# Use first period's hours for all days
|
|
853
|
+
tp = time_periods[0]
|
|
854
|
+
same_hours_daily = {
|
|
855
|
+
"startTime": tp.get("start_time"),
|
|
856
|
+
"endTime": tp.get("end_time"),
|
|
857
|
+
}
|
|
858
|
+
elif schedule_type == "DIFFERENT_HOURS_DAILY" and time_periods:
|
|
859
|
+
different_hours_daily = {
|
|
860
|
+
(tp.get("day_of_week") or f"day_{i}"): {
|
|
861
|
+
"startTime": tp.get("start_time"),
|
|
862
|
+
"endTime": tp.get("end_time"),
|
|
863
|
+
}
|
|
864
|
+
for i, tp in enumerate(time_periods)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
om = CanonicalOperatingMode(
|
|
868
|
+
canonical_id=f"operating_mode:{hash_id(sched_id)}",
|
|
869
|
+
provenance=extract_provenance(sched_data),
|
|
870
|
+
status=MigrationStatus.ANALYZED,
|
|
871
|
+
name=name,
|
|
872
|
+
level="ORGANIZATION",
|
|
873
|
+
schedule_type=schedule_type,
|
|
874
|
+
same_hours_daily=same_hours_daily,
|
|
875
|
+
different_hours_daily=different_hours_daily,
|
|
876
|
+
holidays=holidays,
|
|
877
|
+
)
|
|
878
|
+
store.upsert_object(om)
|
|
879
|
+
result.objects_created += 1
|
|
880
|
+
|
|
881
|
+
# ------------------------------------------------------------------
|
|
882
|
+
# Location Schedules: create for schedules referenced by AAs
|
|
883
|
+
# (from fix-schedule-mapping-and-skill-gaps.md Fix 2)
|
|
884
|
+
# ------------------------------------------------------------------
|
|
885
|
+
|
|
886
|
+
def _map_location_schedules(
|
|
887
|
+
self, store: MigrationStore, result: MapperResult
|
|
888
|
+
) -> None:
|
|
889
|
+
"""Create CanonicalLocationSchedule for schedules referenced by AAs.
|
|
890
|
+
|
|
891
|
+
AAs need location schedules (not operating modes) for businessSchedule.
|
|
892
|
+
This second pass finds AA-referenced schedules and creates corresponding
|
|
893
|
+
location schedule objects.
|
|
894
|
+
"""
|
|
895
|
+
# Collect schedule names referenced by auto attendants
|
|
896
|
+
# schedule_name -> (location_id, [aa_canonical_ids])
|
|
897
|
+
aa_schedule_refs: dict[str, tuple[str, list[str]]] = {}
|
|
898
|
+
for aa_data in store.get_objects("auto_attendant"):
|
|
899
|
+
aa_state = aa_data.get("pre_migration_state") or {}
|
|
900
|
+
sched = (
|
|
901
|
+
aa_data.get("business_schedule")
|
|
902
|
+
or aa_state.get("business_schedule")
|
|
903
|
+
)
|
|
904
|
+
if not sched:
|
|
905
|
+
continue
|
|
906
|
+
loc_id = aa_data.get("location_id")
|
|
907
|
+
if sched in aa_schedule_refs:
|
|
908
|
+
aa_schedule_refs[sched][1].append(aa_data["canonical_id"])
|
|
909
|
+
elif loc_id:
|
|
910
|
+
aa_schedule_refs[sched] = (loc_id, [aa_data["canonical_id"]])
|
|
911
|
+
|
|
912
|
+
if not aa_schedule_refs:
|
|
913
|
+
return
|
|
914
|
+
|
|
915
|
+
# For each operating mode, check if it's referenced by an AA
|
|
916
|
+
for om_data in store.get_objects("operating_mode"):
|
|
917
|
+
om_name = om_data.get("name")
|
|
918
|
+
if om_name not in aa_schedule_refs:
|
|
919
|
+
continue
|
|
920
|
+
|
|
921
|
+
location_id, aa_cids = aa_schedule_refs[om_name]
|
|
922
|
+
om_cid = om_data["canonical_id"]
|
|
923
|
+
sched_type = _om_type_to_schedule_type(om_data.get("schedule_type"))
|
|
924
|
+
events = _build_schedule_events(om_data)
|
|
925
|
+
|
|
926
|
+
sched = CanonicalLocationSchedule(
|
|
927
|
+
canonical_id=f"schedule:{hash_id(om_cid)}",
|
|
928
|
+
provenance=extract_provenance(om_data),
|
|
929
|
+
status=MigrationStatus.ANALYZED,
|
|
930
|
+
name=om_name,
|
|
931
|
+
schedule_type=sched_type,
|
|
932
|
+
location_id=location_id,
|
|
933
|
+
events=events,
|
|
934
|
+
operating_mode_canonical_id=om_cid,
|
|
935
|
+
)
|
|
936
|
+
store.upsert_object(sched)
|
|
937
|
+
result.objects_created += 1
|
|
938
|
+
|
|
939
|
+
# Write aa_has_schedule cross-ref (Fix 3a)
|
|
940
|
+
for aa_cid in aa_cids:
|
|
941
|
+
store.add_cross_ref(aa_cid, sched.canonical_id, "aa_has_schedule")
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
# ---------------------------------------------------------------------------
|
|
945
|
+
# Helpers
|
|
946
|
+
# ---------------------------------------------------------------------------
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _om_type_to_schedule_type(om_type: str | None) -> str:
|
|
950
|
+
"""Map operating mode schedule_type to location schedule type."""
|
|
951
|
+
if om_type == "HOLIDAY":
|
|
952
|
+
return "holidays"
|
|
953
|
+
return "businessHours"
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _build_schedule_events(om_data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
957
|
+
"""Convert operating mode time data to location schedule event format.
|
|
958
|
+
|
|
959
|
+
Location schedule API requires recurWeekly recurrence (NOT recurForEver).
|
|
960
|
+
"""
|
|
961
|
+
schedule_type = om_data.get("schedule_type")
|
|
962
|
+
events: list[dict[str, Any]] = []
|
|
963
|
+
|
|
964
|
+
if schedule_type == "HOLIDAY":
|
|
965
|
+
for holiday in om_data.get("holidays") or []:
|
|
966
|
+
date = holiday.get("date", "2026-01-01")
|
|
967
|
+
events.append({
|
|
968
|
+
"name": holiday.get("name", "Holiday"),
|
|
969
|
+
"startDate": date,
|
|
970
|
+
"endDate": date,
|
|
971
|
+
"startTime": "00:00",
|
|
972
|
+
"endTime": "23:59",
|
|
973
|
+
"allDayEnabled": True,
|
|
974
|
+
})
|
|
975
|
+
elif schedule_type == "SAME_HOURS_DAILY":
|
|
976
|
+
hours = om_data.get("same_hours_daily") or {}
|
|
977
|
+
start = hours.get("startTime", "08:00")
|
|
978
|
+
end = hours.get("endTime", "17:00")
|
|
979
|
+
events.append({
|
|
980
|
+
"name": om_data.get("name", "Business Hours"),
|
|
981
|
+
"startDate": "2026-01-01",
|
|
982
|
+
"endDate": "2030-12-31",
|
|
983
|
+
"startTime": start,
|
|
984
|
+
"endTime": end,
|
|
985
|
+
"recurrence": {
|
|
986
|
+
"recurWeekly": {
|
|
987
|
+
"sunday": False,
|
|
988
|
+
"monday": True,
|
|
989
|
+
"tuesday": True,
|
|
990
|
+
"wednesday": True,
|
|
991
|
+
"thursday": True,
|
|
992
|
+
"friday": True,
|
|
993
|
+
"saturday": False,
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
})
|
|
997
|
+
elif schedule_type == "DIFFERENT_HOURS_DAILY":
|
|
998
|
+
day_data = om_data.get("different_hours_daily") or {}
|
|
999
|
+
day_map = {
|
|
1000
|
+
"monday": "Monday",
|
|
1001
|
+
"tuesday": "Tuesday",
|
|
1002
|
+
"wednesday": "Wednesday",
|
|
1003
|
+
"thursday": "Thursday",
|
|
1004
|
+
"friday": "Friday",
|
|
1005
|
+
"saturday": "Saturday",
|
|
1006
|
+
"sunday": "Sunday",
|
|
1007
|
+
}
|
|
1008
|
+
for day_key, day_label in day_map.items():
|
|
1009
|
+
day_hours = day_data.get(day_key)
|
|
1010
|
+
if not day_hours:
|
|
1011
|
+
continue
|
|
1012
|
+
recurrence_days = {d: (d == day_key) for d in day_map}
|
|
1013
|
+
events.append({
|
|
1014
|
+
"name": f"{om_data.get('name', 'Hours')} - {day_label}",
|
|
1015
|
+
"startDate": "2026-01-01",
|
|
1016
|
+
"endDate": "2030-12-31",
|
|
1017
|
+
"startTime": day_hours.get("startTime", "08:00"),
|
|
1018
|
+
"endTime": day_hours.get("endTime", "17:00"),
|
|
1019
|
+
"recurrence": {"recurWeekly": recurrence_days},
|
|
1020
|
+
})
|
|
1021
|
+
else:
|
|
1022
|
+
# Fallback: all-the-time schedule
|
|
1023
|
+
events.append({
|
|
1024
|
+
"name": om_data.get("name", "All the time"),
|
|
1025
|
+
"startDate": "2026-01-01",
|
|
1026
|
+
"endDate": "2030-12-31",
|
|
1027
|
+
"startTime": "00:00",
|
|
1028
|
+
"endTime": "23:59",
|
|
1029
|
+
"recurrence": {
|
|
1030
|
+
"recurWeekly": {
|
|
1031
|
+
"sunday": True,
|
|
1032
|
+
"monday": True,
|
|
1033
|
+
"tuesday": True,
|
|
1034
|
+
"wednesday": True,
|
|
1035
|
+
"thursday": True,
|
|
1036
|
+
"friday": True,
|
|
1037
|
+
"saturday": True,
|
|
1038
|
+
}
|
|
1039
|
+
},
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
return events
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _infer_schedule_type(time_periods: list[dict[str, Any]]) -> str:
|
|
1046
|
+
"""Infer Webex OperatingMode schedule type from CUCM time periods.
|
|
1047
|
+
|
|
1048
|
+
Returns one of: SAME_HOURS_DAILY, DIFFERENT_HOURS_DAILY, HOLIDAY
|
|
1049
|
+
"""
|
|
1050
|
+
if not time_periods:
|
|
1051
|
+
return "SAME_HOURS_DAILY"
|
|
1052
|
+
|
|
1053
|
+
# Check for holiday indicators
|
|
1054
|
+
if any(tp.get("date") or tp.get("is_holiday") for tp in time_periods):
|
|
1055
|
+
return "HOLIDAY"
|
|
1056
|
+
|
|
1057
|
+
# Check if all periods have the same start/end time
|
|
1058
|
+
times = {(tp.get("start_time"), tp.get("end_time")) for tp in time_periods}
|
|
1059
|
+
if len(times) == 1:
|
|
1060
|
+
return "SAME_HOURS_DAILY"
|
|
1061
|
+
|
|
1062
|
+
return "DIFFERENT_HOURS_DAILY"
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _build_line_uuid_to_dn(store: MigrationStore) -> dict[str, str]:
|
|
1066
|
+
"""Build a lookup from CUCM line-level UUIDs to DN canonical IDs.
|
|
1067
|
+
|
|
1068
|
+
Raw phone objects (stored as phone:{name}) contain per-line dirn dicts
|
|
1069
|
+
with uuid, pattern, and routePartitionName. Pickup group members reference
|
|
1070
|
+
lines by these UUIDs. This builds the mapping so UUIDs can resolve to
|
|
1071
|
+
dn:{pattern}:{partition} canonical IDs.
|
|
1072
|
+
"""
|
|
1073
|
+
uuid_to_dn: dict[str, str] = {}
|
|
1074
|
+
for phone_data in store.get_objects("phone"):
|
|
1075
|
+
state = phone_data.get("pre_migration_state") or {}
|
|
1076
|
+
lines = state.get("lines") or {}
|
|
1077
|
+
line_list = lines if isinstance(lines, list) else lines.get("line", [])
|
|
1078
|
+
if isinstance(line_list, dict):
|
|
1079
|
+
line_list = [line_list]
|
|
1080
|
+
if not isinstance(line_list, list):
|
|
1081
|
+
continue
|
|
1082
|
+
for line_entry in line_list:
|
|
1083
|
+
if not isinstance(line_entry, dict):
|
|
1084
|
+
continue
|
|
1085
|
+
dirn = line_entry.get("dirn")
|
|
1086
|
+
if not isinstance(dirn, dict):
|
|
1087
|
+
continue
|
|
1088
|
+
uuid = dirn.get("uuid")
|
|
1089
|
+
pattern = dirn.get("pattern")
|
|
1090
|
+
if not uuid or not pattern:
|
|
1091
|
+
continue
|
|
1092
|
+
# Extract partition from routePartitionName (zeep ref or string)
|
|
1093
|
+
rp = dirn.get("routePartitionName")
|
|
1094
|
+
if isinstance(rp, dict):
|
|
1095
|
+
partition = rp.get("_value_1") or "<None>"
|
|
1096
|
+
elif isinstance(rp, str):
|
|
1097
|
+
partition = rp or "<None>"
|
|
1098
|
+
else:
|
|
1099
|
+
partition = "<None>"
|
|
1100
|
+
dn_id = f"dn:{pattern}:{partition}"
|
|
1101
|
+
uuid_to_dn[str(uuid)] = dn_id
|
|
1102
|
+
return uuid_to_dn
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _resolve_pickup_members_to_owners(
|
|
1106
|
+
store: MigrationStore, member_ids: list[str]
|
|
1107
|
+
) -> list[str]:
|
|
1108
|
+
"""Resolve pickup group member IDs (CUCM UUIDs) to canonical user/workspace IDs.
|
|
1109
|
+
|
|
1110
|
+
Pickup group members may be:
|
|
1111
|
+
- Already canonical IDs (e.g., user:alice) — pass through directly
|
|
1112
|
+
- DN canonical IDs (e.g., dn:1002:PT-Internal) — resolve via device chain
|
|
1113
|
+
- CUCM UUIDs (e.g., {5782CE0C-...}) — resolve via raw phone line data
|
|
1114
|
+
Falls back to the raw member_id if resolution fails.
|
|
1115
|
+
"""
|
|
1116
|
+
# Build one-time lookup: line-level UUID → DN canonical_id from raw phone data
|
|
1117
|
+
uuid_to_dn: dict[str, str] | None = None
|
|
1118
|
+
|
|
1119
|
+
seen: set[str] = set()
|
|
1120
|
+
resolved: list[str] = []
|
|
1121
|
+
|
|
1122
|
+
for member_id in member_ids:
|
|
1123
|
+
owner = None
|
|
1124
|
+
|
|
1125
|
+
if member_id.startswith("user:") or member_id.startswith("workspace:"):
|
|
1126
|
+
# Already a canonical user/workspace ID — use directly
|
|
1127
|
+
owner = member_id
|
|
1128
|
+
elif member_id.startswith("dn:"):
|
|
1129
|
+
# DN canonical_id — resolve via device chain
|
|
1130
|
+
owner = _resolve_dn_to_owner(store, member_id)
|
|
1131
|
+
else:
|
|
1132
|
+
# CUCM UUID — resolve via raw phone line data
|
|
1133
|
+
if uuid_to_dn is None:
|
|
1134
|
+
uuid_to_dn = _build_line_uuid_to_dn(store)
|
|
1135
|
+
|
|
1136
|
+
dn_cid = uuid_to_dn.get(member_id)
|
|
1137
|
+
if dn_cid:
|
|
1138
|
+
owner = _resolve_dn_to_owner(store, dn_cid)
|
|
1139
|
+
|
|
1140
|
+
agent_id = owner if owner else member_id
|
|
1141
|
+
if agent_id not in seen:
|
|
1142
|
+
seen.add(agent_id)
|
|
1143
|
+
resolved.append(agent_id)
|
|
1144
|
+
|
|
1145
|
+
return resolved
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _extract_agent_ids(members: list) -> list[str]:
|
|
1149
|
+
"""Extract string IDs from a member list that may contain raw CUCM dicts.
|
|
1150
|
+
|
|
1151
|
+
If members are already strings, they pass through unchanged. If they are
|
|
1152
|
+
dicts (e.g. from pre-existing store data not re-normalized), extract the
|
|
1153
|
+
UUID from nested zeep reference fields.
|
|
1154
|
+
"""
|
|
1155
|
+
result: list[str] = []
|
|
1156
|
+
for member in members:
|
|
1157
|
+
if isinstance(member, str):
|
|
1158
|
+
result.append(member)
|
|
1159
|
+
elif isinstance(member, dict):
|
|
1160
|
+
# Look for UUID in nested reference fields
|
|
1161
|
+
uuid_val = None
|
|
1162
|
+
for key, val in member.items():
|
|
1163
|
+
if key == "priority":
|
|
1164
|
+
continue
|
|
1165
|
+
if isinstance(val, dict) and "uuid" in val and val["uuid"] is not None:
|
|
1166
|
+
uuid_val = str(val["uuid"])
|
|
1167
|
+
break
|
|
1168
|
+
if isinstance(val, str) and (val.startswith("{") or len(val) == 36):
|
|
1169
|
+
uuid_val = val
|
|
1170
|
+
break
|
|
1171
|
+
if uuid_val is None and "uuid" in member:
|
|
1172
|
+
uuid_val = str(member["uuid"]) if member["uuid"] is not None else None
|
|
1173
|
+
if uuid_val:
|
|
1174
|
+
result.append(uuid_val)
|
|
1175
|
+
else:
|
|
1176
|
+
logger.warning("Could not extract UUID from pickup group member: %s", member)
|
|
1177
|
+
return result
|
|
1178
|
+
|
|
1179
|
+
|