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,2437 @@
|
|
|
1
|
+
"""Technical appendix HTML generator for CUCM assessment reports (v4).
|
|
2
|
+
|
|
3
|
+
Generates 30 lettered sections (A-AE), each as a collapsed <details> element:
|
|
4
|
+
A. Object Inventory, B. Decision Detail, C. CSS/Partitions, D. Device Inventory,
|
|
5
|
+
E. DN Analysis, F. User/Device Map, G. Routing Topology, H. Voicemail Analysis,
|
|
6
|
+
I. Audio Assets, J. Data Coverage, K. Gateways, L. Call Features,
|
|
7
|
+
M. Button Templates, N. Device Layouts, O. Softkey Migration,
|
|
8
|
+
P. Cloud-Managed Resources, Q. Feature Gaps, R. Manual Reconfiguration,
|
|
9
|
+
S. Planning Inputs, T. Call Recording, U. Single Number Reach,
|
|
10
|
+
V. Caller ID Transformations, W. Extension Mobility, X. DECT Networks,
|
|
11
|
+
Y. Intercept Candidates, Z. Executive/Assistant Pairings, AA. Receptionist Users,
|
|
12
|
+
AB. Selective Call Handling, AC. Device Settings Coverage,
|
|
13
|
+
AD. Feature Forwarding Status, AE. Workspace Settings Coverage.
|
|
14
|
+
|
|
15
|
+
One public function: generate_appendix().
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import html
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _axl_str(value: Any) -> str:
|
|
26
|
+
"""Coerce an AXL field value to a plain string.
|
|
27
|
+
|
|
28
|
+
AXL returns some reference fields (e.g. routePartitionName) as a dict
|
|
29
|
+
with a ``_value_1`` key and a ``uuid`` key rather than a bare string.
|
|
30
|
+
Extract the human-readable value; fall back to empty string for None/missing.
|
|
31
|
+
"""
|
|
32
|
+
if value is None:
|
|
33
|
+
return ""
|
|
34
|
+
if isinstance(value, dict):
|
|
35
|
+
return str(value.get("_value_1", ""))
|
|
36
|
+
return str(value)
|
|
37
|
+
|
|
38
|
+
from wxcli.migration.report.charts import stacked_bar_chart
|
|
39
|
+
from wxcli.migration.report.explainer import (
|
|
40
|
+
DECISION_TYPE_DISPLAY_NAMES,
|
|
41
|
+
explain_decision,
|
|
42
|
+
)
|
|
43
|
+
from wxcli.migration.report.helpers import friendly_site_name, strip_canonical_id
|
|
44
|
+
from wxcli.migration.store import MigrationStore
|
|
45
|
+
from wxcli.migration.cucm.extractors.informational import (
|
|
46
|
+
DISPLAY_NAMES,
|
|
47
|
+
NOT_MIGRATABLE_WORKAROUNDS,
|
|
48
|
+
WEBEX_EQUIVALENTS,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def generate_appendix(store: MigrationStore) -> str:
|
|
53
|
+
"""Generate the technical appendix HTML with lettered A-X sections."""
|
|
54
|
+
sections = [
|
|
55
|
+
("A", _object_inventory(store)),
|
|
56
|
+
("B", _decisions_group(store)),
|
|
57
|
+
("E911", _e911_readiness(store)),
|
|
58
|
+
("C", _css_partitions(store)),
|
|
59
|
+
("D", _device_inventory(store)),
|
|
60
|
+
("E", _dn_analysis(store)),
|
|
61
|
+
("F", _user_device_map(store)),
|
|
62
|
+
("G", _routing_group(store)),
|
|
63
|
+
("H", _voicemail_analysis(store)),
|
|
64
|
+
("I", _audio_assets(store)),
|
|
65
|
+
("J", _data_quality_group(store)),
|
|
66
|
+
("K", _gateways_group(store)),
|
|
67
|
+
("L", _features_group(store)),
|
|
68
|
+
("M", _button_template_group(store)),
|
|
69
|
+
("N", _device_layout_group(store)),
|
|
70
|
+
("O", _softkey_group(store)),
|
|
71
|
+
("P", _cloud_managed_group(store)),
|
|
72
|
+
("Q", _feature_gaps_group(store)),
|
|
73
|
+
("R", _manual_reconfig_group(store)),
|
|
74
|
+
("S", _planning_inputs_group(store)),
|
|
75
|
+
("T", _recording_inventory(store)),
|
|
76
|
+
("U", _snr_inventory(store)),
|
|
77
|
+
("V", _caller_id_xforms(store)),
|
|
78
|
+
("W", _extension_mobility_group(store)),
|
|
79
|
+
("X", _dect_networks_group(store)),
|
|
80
|
+
("Y", _intercept_candidates(store)),
|
|
81
|
+
("Z", _executive_assistant_group(store)),
|
|
82
|
+
("AA", _receptionist_group(store)),
|
|
83
|
+
("AB", _selective_call_handling(store)),
|
|
84
|
+
("AC", _device_settings_coverage(store)),
|
|
85
|
+
("AD", _feature_forwarding_status(store)),
|
|
86
|
+
("AE", _workspace_settings_coverage(store)),
|
|
87
|
+
]
|
|
88
|
+
# Filter out empty sections
|
|
89
|
+
sections = [(letter, section_html) for letter, section_html in sections if section_html]
|
|
90
|
+
if not sections:
|
|
91
|
+
return '<section id="appendix"></section>'
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
'<section id="appendix">\n'
|
|
95
|
+
+ "\n".join(section_html for _, section_html in sections)
|
|
96
|
+
+ "\n</section>"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# A. Object Inventory
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _object_inventory(store: MigrationStore) -> str:
|
|
105
|
+
"""A. Object Inventory — total counts by type."""
|
|
106
|
+
object_types = [
|
|
107
|
+
"user", "device", "line", "shared_line", "location",
|
|
108
|
+
"hunt_group", "call_queue", "auto_attendant", "call_park",
|
|
109
|
+
"pickup_group", "paging_group", "trunk", "route_group",
|
|
110
|
+
"css", "partition", "translation_pattern", "voicemail_profile",
|
|
111
|
+
"schedule", "gateway", "workspace", "virtual_line",
|
|
112
|
+
"line_key_template", "device_layout", "softkey_config",
|
|
113
|
+
]
|
|
114
|
+
rows = []
|
|
115
|
+
total = 0
|
|
116
|
+
for ot in object_types:
|
|
117
|
+
count = store.count_by_type(ot)
|
|
118
|
+
if count > 0:
|
|
119
|
+
display = ot.replace("_", " ").title()
|
|
120
|
+
rows.append((display, count))
|
|
121
|
+
total += count
|
|
122
|
+
|
|
123
|
+
if not rows:
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
parts = [
|
|
127
|
+
f'<details id="objects">',
|
|
128
|
+
f'<summary>A. Object Inventory <span class="summary-count">— {total} objects across {len(rows)} types</span></summary>',
|
|
129
|
+
'<div class="details-content">',
|
|
130
|
+
'<table>',
|
|
131
|
+
'<thead><tr><th>Object Type</th><th class="num">Count</th></tr></thead>',
|
|
132
|
+
'<tbody>',
|
|
133
|
+
]
|
|
134
|
+
for display, count in rows:
|
|
135
|
+
parts.append(f'<tr><td>{html.escape(display)}</td><td class="num">{count}</td></tr>')
|
|
136
|
+
parts.append('</tbody></table>')
|
|
137
|
+
|
|
138
|
+
# By-location breakdown
|
|
139
|
+
locations = store.get_objects("location")
|
|
140
|
+
if locations:
|
|
141
|
+
parts.append('<h4>By Location</h4>')
|
|
142
|
+
parts.append('<table>')
|
|
143
|
+
parts.append('<thead><tr><th>Location</th><th class="num">Users</th><th class="num">Devices</th></tr></thead>')
|
|
144
|
+
parts.append('<tbody>')
|
|
145
|
+
all_users = store.get_objects("user")
|
|
146
|
+
all_devices = store.get_objects("device")
|
|
147
|
+
for loc in locations:
|
|
148
|
+
loc_id = loc.get("canonical_id", "")
|
|
149
|
+
loc_name = loc.get("name", "")
|
|
150
|
+
if not loc_name:
|
|
151
|
+
loc_name = strip_canonical_id(loc_id)
|
|
152
|
+
friendly = friendly_site_name(loc_name) if loc_name.startswith("DP-") else loc_name
|
|
153
|
+
u_count = sum(1 for u in all_users if u.get("location_id") == loc_id)
|
|
154
|
+
loc_user_ids = {u.get("canonical_id") for u in all_users if u.get("location_id") == loc_id}
|
|
155
|
+
d_count = sum(1 for d in all_devices if d.get("owner_canonical_id") in loc_user_ids)
|
|
156
|
+
parts.append(f'<tr><td>{html.escape(friendly)}</td><td class="num">{u_count}</td><td class="num">{d_count}</td></tr>')
|
|
157
|
+
parts.append('</tbody></table>')
|
|
158
|
+
|
|
159
|
+
parts.append('</div></details>')
|
|
160
|
+
return "\n".join(parts)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# B. Decision Detail
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def _decisions_group(store: MigrationStore) -> str:
|
|
168
|
+
"""B. Decisions grouped by type with aggregated counts."""
|
|
169
|
+
decisions = store.get_all_decisions()
|
|
170
|
+
if not decisions:
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
total = len(decisions)
|
|
174
|
+
resolved = sum(1 for d in decisions if d.get("chosen_option"))
|
|
175
|
+
|
|
176
|
+
# Group by type
|
|
177
|
+
by_type: dict[str, list[dict]] = defaultdict(list)
|
|
178
|
+
for d in decisions:
|
|
179
|
+
by_type[d.get("type", "UNKNOWN")].append(d)
|
|
180
|
+
|
|
181
|
+
parts = [
|
|
182
|
+
f'<details id="decision-detail">',
|
|
183
|
+
f'<summary>B. Decision Detail <span class="summary-count">— {total} total, {resolved} auto-resolved</span></summary>',
|
|
184
|
+
'<div class="details-content">',
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
for dtype in sorted(by_type.keys()):
|
|
188
|
+
type_decisions = by_type[dtype]
|
|
189
|
+
display_name = DECISION_TYPE_DISPLAY_NAMES.get(dtype, dtype.replace("_", " ").title())
|
|
190
|
+
type_resolved = sum(1 for d in type_decisions if d.get("chosen_option"))
|
|
191
|
+
resolution = f"{type_resolved}/{len(type_decisions)} resolved"
|
|
192
|
+
|
|
193
|
+
parts.append(f'<div class="explanation">')
|
|
194
|
+
parts.append(f'<h4>{html.escape(display_name)} ({len(type_decisions)}) <span class="muted small">— {resolution}</span></h4>')
|
|
195
|
+
|
|
196
|
+
# Show explainer for the type
|
|
197
|
+
sample = type_decisions[0]
|
|
198
|
+
explained = explain_decision(
|
|
199
|
+
decision_type=sample["type"],
|
|
200
|
+
severity=sample.get("severity", "MEDIUM"),
|
|
201
|
+
summary=sample.get("summary", ""),
|
|
202
|
+
context=sample.get("context", {}),
|
|
203
|
+
)
|
|
204
|
+
parts.append(f'<p>{explained["explanation"]}</p>')
|
|
205
|
+
parts.append(f'<p class="reassurance">{explained["reassurance"]}</p>')
|
|
206
|
+
|
|
207
|
+
# Summary table if more than 1
|
|
208
|
+
if len(type_decisions) > 1:
|
|
209
|
+
parts.append('<table>')
|
|
210
|
+
parts.append('<thead><tr><th>Summary</th><th>Severity</th><th>Status</th></tr></thead>')
|
|
211
|
+
parts.append('<tbody>')
|
|
212
|
+
for d in type_decisions:
|
|
213
|
+
severity = d.get("severity", "MEDIUM")
|
|
214
|
+
status = "Auto-resolved" if d.get("chosen_option") else "Pending"
|
|
215
|
+
parts.append(
|
|
216
|
+
f'<tr><td>{html.escape(d.get("summary", ""))}</td>'
|
|
217
|
+
f'<td><span class="badge badge-{severity.lower()}">{html.escape(severity)}</span></td>'
|
|
218
|
+
f'<td>{status}</td></tr>'
|
|
219
|
+
)
|
|
220
|
+
parts.append('</tbody></table>')
|
|
221
|
+
|
|
222
|
+
parts.append('</div>')
|
|
223
|
+
|
|
224
|
+
parts.append('</div></details>')
|
|
225
|
+
return "\n".join(parts)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# E911 Readiness (appendix placement after Decision Detail)
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _e911_readiness(store: MigrationStore) -> str:
|
|
233
|
+
"""E911 Readiness appendix section — ECBN auto-config counts + decision counts."""
|
|
234
|
+
ecbn_configs = store.get_objects("ecbn_config")
|
|
235
|
+
direct_line = [c for c in ecbn_configs if c.get("ecbn_selection") == "DIRECT_LINE"]
|
|
236
|
+
location_ecbn = [c for c in ecbn_configs if c.get("ecbn_selection") == "LOCATION_ECBN"]
|
|
237
|
+
|
|
238
|
+
ambiguous = 0
|
|
239
|
+
mismatch = 0
|
|
240
|
+
for d in store.get_all_decisions():
|
|
241
|
+
if d.get("chosen_option") == "__stale__":
|
|
242
|
+
continue
|
|
243
|
+
t = d.get("type", "")
|
|
244
|
+
if t == "E911_ECBN_AMBIGUOUS":
|
|
245
|
+
ambiguous += 1
|
|
246
|
+
elif t == "E911_LOCATION_MISMATCH":
|
|
247
|
+
mismatch += 1
|
|
248
|
+
|
|
249
|
+
# Skip section entirely when there is nothing to report.
|
|
250
|
+
if not ecbn_configs and ambiguous == 0 and mismatch == 0:
|
|
251
|
+
return ""
|
|
252
|
+
|
|
253
|
+
if not ecbn_configs:
|
|
254
|
+
status_label = "SKIP"
|
|
255
|
+
status_class = "badge-info"
|
|
256
|
+
elif ambiguous == 0 and mismatch == 0 and len(location_ecbn) == 0:
|
|
257
|
+
status_label = "PASS"
|
|
258
|
+
status_class = "badge-success"
|
|
259
|
+
elif ambiguous == 0 and mismatch == 0:
|
|
260
|
+
status_label = "WARN"
|
|
261
|
+
status_class = "badge-warning"
|
|
262
|
+
else:
|
|
263
|
+
status_label = "REVIEW"
|
|
264
|
+
status_class = "badge-warning"
|
|
265
|
+
|
|
266
|
+
rows = [
|
|
267
|
+
("Users with DID (auto-ECBN / DIRECT_LINE)", len(direct_line)),
|
|
268
|
+
("Extension-only users (LOCATION_ECBN)", len(location_ecbn)),
|
|
269
|
+
("Ambiguous ECBN (multi-DID) decisions", ambiguous),
|
|
270
|
+
("Location-mismatch decisions", mismatch),
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
row_html = "\n".join(
|
|
274
|
+
f"<tr><td>{label}</td><td>{count}</td></tr>"
|
|
275
|
+
for label, count in rows
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return f"""
|
|
279
|
+
<details id="appendix-e911-readiness">
|
|
280
|
+
<summary><strong>E911 Readiness</strong> <span class="badge {status_class}">{status_label}</span></summary>
|
|
281
|
+
<p>Emergency Callback Number (ECBN) breakdown. Kari's Law and RAY BAUM's Act require that every user has a valid ECBN before dialing 911.</p>
|
|
282
|
+
<table class="data-table">
|
|
283
|
+
<thead>
|
|
284
|
+
<tr><th>Metric</th><th>Count</th></tr>
|
|
285
|
+
</thead>
|
|
286
|
+
<tbody>
|
|
287
|
+
{row_html}
|
|
288
|
+
</tbody>
|
|
289
|
+
</table>
|
|
290
|
+
</details>
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# C. CSS / Partitions
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def _css_partitions(store: MigrationStore) -> str:
|
|
299
|
+
"""C. CSS / Partition topology."""
|
|
300
|
+
css_count = store.count_by_type("css")
|
|
301
|
+
pt_count = store.count_by_type("partition")
|
|
302
|
+
if css_count == 0 and pt_count == 0:
|
|
303
|
+
return ""
|
|
304
|
+
|
|
305
|
+
parts = [
|
|
306
|
+
f'<details id="css-partitions">',
|
|
307
|
+
f'<summary>C. CSS / Partitions <span class="summary-count">— {css_count} CSSes, {pt_count} partitions</span></summary>',
|
|
308
|
+
'<div class="details-content">',
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
css_objects = store.get_objects("css")
|
|
312
|
+
if css_objects:
|
|
313
|
+
parts.append('<ul class="css-topology">')
|
|
314
|
+
for css in css_objects:
|
|
315
|
+
css_id = css.get("canonical_id", "")
|
|
316
|
+
css_name = strip_canonical_id(css_id)
|
|
317
|
+
parts.append(f'<li>{html.escape(css_name)}')
|
|
318
|
+
css_pt_refs = store.get_cross_refs(
|
|
319
|
+
relationship="css_contains_partition",
|
|
320
|
+
from_id=css_id,
|
|
321
|
+
)
|
|
322
|
+
if css_pt_refs:
|
|
323
|
+
for ref in sorted(css_pt_refs, key=lambda r: r.get("ordinal", 0)):
|
|
324
|
+
pt_name = strip_canonical_id(ref.get("to_id", ""))
|
|
325
|
+
parts.append(f'<li class="partition">{html.escape(pt_name)}</li>')
|
|
326
|
+
parts.append('</li>')
|
|
327
|
+
parts.append('</ul>')
|
|
328
|
+
|
|
329
|
+
parts.append('</div></details>')
|
|
330
|
+
return "\n".join(parts)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
# D. Device Inventory
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def _device_inventory(store: MigrationStore) -> str:
|
|
338
|
+
"""D. Device Inventory — by model with stacked bar chart."""
|
|
339
|
+
devices = store.get_objects("device")
|
|
340
|
+
if not devices:
|
|
341
|
+
return ""
|
|
342
|
+
|
|
343
|
+
total = len(devices)
|
|
344
|
+
|
|
345
|
+
# Count by model
|
|
346
|
+
model_counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
347
|
+
for d in devices:
|
|
348
|
+
model = d.get("model", "Unknown")
|
|
349
|
+
tier = d.get("compatibility_tier", "unknown")
|
|
350
|
+
if hasattr(tier, "value"):
|
|
351
|
+
tier = tier.value
|
|
352
|
+
model_counts[model][str(tier)] += 1
|
|
353
|
+
|
|
354
|
+
native = sum(1 for d in devices if d.get("compatibility_tier") == "native_mpp")
|
|
355
|
+
convertible = sum(1 for d in devices if d.get("compatibility_tier") == "convertible")
|
|
356
|
+
webex_app = sum(1 for d in devices if d.get("compatibility_tier") == "webex_app")
|
|
357
|
+
infrastructure = sum(1 for d in devices if d.get("compatibility_tier") == "infrastructure")
|
|
358
|
+
incompatible = sum(1 for d in devices if d.get("compatibility_tier") == "incompatible")
|
|
359
|
+
dect = sum(1 for d in devices if d.get("compatibility_tier") == "dect")
|
|
360
|
+
|
|
361
|
+
summary_parts = [f"{total} phones"]
|
|
362
|
+
if native:
|
|
363
|
+
summary_parts.append(f"{native} native")
|
|
364
|
+
if convertible:
|
|
365
|
+
summary_parts.append(f"{convertible} convertible")
|
|
366
|
+
if webex_app:
|
|
367
|
+
summary_parts.append(f"{webex_app} Webex App")
|
|
368
|
+
if infrastructure:
|
|
369
|
+
summary_parts.append(f"{infrastructure} infrastructure")
|
|
370
|
+
if incompatible:
|
|
371
|
+
summary_parts.append(f"{incompatible} incompatible")
|
|
372
|
+
if dect:
|
|
373
|
+
summary_parts.append(f"{dect} DECT")
|
|
374
|
+
summary = " — ".join(summary_parts[:1]) + " — " + ", ".join(summary_parts[1:])
|
|
375
|
+
|
|
376
|
+
parts = [
|
|
377
|
+
f'<details id="device-detail">',
|
|
378
|
+
f'<summary>D. Device Inventory <span class="summary-count">— {summary}</span></summary>',
|
|
379
|
+
'<div class="details-content">',
|
|
380
|
+
'<h4>Device Inventory by Model</h4>',
|
|
381
|
+
'<table>',
|
|
382
|
+
'<thead><tr><th>Model</th><th class="num">Count</th><th>Compatibility</th></tr></thead>',
|
|
383
|
+
'<tbody>',
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
for model in sorted(model_counts.keys()):
|
|
387
|
+
tiers = model_counts[model]
|
|
388
|
+
total_model = sum(tiers.values())
|
|
389
|
+
tier_list = []
|
|
390
|
+
_TIER_DISPLAY = {
|
|
391
|
+
"native_mpp": "Native MPP",
|
|
392
|
+
"convertible": "Convertible",
|
|
393
|
+
"webex_app": "Webex App",
|
|
394
|
+
"infrastructure": "Infrastructure (not migrated)",
|
|
395
|
+
"incompatible": "Incompatible",
|
|
396
|
+
"dect": "DECT (wireless)",
|
|
397
|
+
}
|
|
398
|
+
for tier_name in ["native_mpp", "convertible", "webex_app", "infrastructure", "incompatible", "dect"]:
|
|
399
|
+
if tiers.get(tier_name, 0) > 0:
|
|
400
|
+
tier_list.append(f'{_TIER_DISPLAY.get(tier_name, tier_name)}: {tiers[tier_name]}')
|
|
401
|
+
tier_str = ", ".join(tier_list) if tier_list else "Unknown"
|
|
402
|
+
|
|
403
|
+
parts.append(
|
|
404
|
+
f'<tr><td>{html.escape(model)}</td>'
|
|
405
|
+
f'<td class="num">{total_model}</td>'
|
|
406
|
+
f'<td>{html.escape(tier_str)}</td></tr>'
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
parts.append('</tbody></table>')
|
|
410
|
+
|
|
411
|
+
# Stacked bar chart for device compatibility
|
|
412
|
+
segments = [
|
|
413
|
+
{"label": "Native MPP", "value": native, "color": "#2E7D32"},
|
|
414
|
+
{"label": "Convertible", "value": convertible, "color": "#EF6C00"},
|
|
415
|
+
{"label": "Incompatible", "value": incompatible, "color": "#C62828"},
|
|
416
|
+
]
|
|
417
|
+
if dect:
|
|
418
|
+
segments.append({"label": "DECT", "value": dect, "color": "#42A5F5"})
|
|
419
|
+
bar_html = stacked_bar_chart(segments)
|
|
420
|
+
if bar_html:
|
|
421
|
+
parts.append(bar_html)
|
|
422
|
+
|
|
423
|
+
parts.append('</div></details>')
|
|
424
|
+
return "\n".join(parts)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# E. DN Analysis
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def _dn_analysis(store: MigrationStore) -> str:
|
|
432
|
+
"""E. DN Analysis — extension classification breakdown."""
|
|
433
|
+
lines = store.get_objects("line")
|
|
434
|
+
if not lines:
|
|
435
|
+
return ""
|
|
436
|
+
|
|
437
|
+
classification_counts: dict[str, int] = defaultdict(int)
|
|
438
|
+
for line in lines:
|
|
439
|
+
cls = line.get("classification", "UNKNOWN")
|
|
440
|
+
if hasattr(cls, "value"):
|
|
441
|
+
cls = cls.value
|
|
442
|
+
classification_counts[str(cls)] += 1
|
|
443
|
+
|
|
444
|
+
parts = [
|
|
445
|
+
f'<details id="dn-analysis">',
|
|
446
|
+
f'<summary>E. DN Analysis <span class="summary-count">— {len(lines)} extensions</span></summary>',
|
|
447
|
+
'<div class="details-content">',
|
|
448
|
+
'<table>',
|
|
449
|
+
'<thead><tr><th>Classification</th><th class="num">Count</th></tr></thead>',
|
|
450
|
+
'<tbody>',
|
|
451
|
+
]
|
|
452
|
+
for cls, count in sorted(classification_counts.items(), key=lambda x: -x[1]):
|
|
453
|
+
parts.append(f'<tr><td>{html.escape(cls)}</td><td class="num">{count}</td></tr>')
|
|
454
|
+
parts.append('</tbody></table>')
|
|
455
|
+
parts.append('</div></details>')
|
|
456
|
+
return "\n".join(parts)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ---------------------------------------------------------------------------
|
|
460
|
+
# F. User/Device Map
|
|
461
|
+
# ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
def _user_device_map(store: MigrationStore) -> str:
|
|
464
|
+
"""F. User/Device Map — user-device-line assignments."""
|
|
465
|
+
user_device_refs = store.get_cross_refs(relationship="user_has_device")
|
|
466
|
+
if not user_device_refs:
|
|
467
|
+
return ""
|
|
468
|
+
|
|
469
|
+
parts = [
|
|
470
|
+
f'<details id="user-device-map">',
|
|
471
|
+
f'<summary>F. User/Device Map <span class="summary-count">— {len(user_device_refs)} assignments</span></summary>',
|
|
472
|
+
'<div class="details-content">',
|
|
473
|
+
'<table>',
|
|
474
|
+
'<thead><tr><th>User</th><th>Device</th><th>Model</th><th>Line</th><th>Partition</th></tr></thead>',
|
|
475
|
+
'<tbody>',
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
for ref in user_device_refs:
|
|
479
|
+
user_id = ref.get("from_id", "")
|
|
480
|
+
device_id = ref.get("to_id", "")
|
|
481
|
+
|
|
482
|
+
user_obj = store.get_object(user_id)
|
|
483
|
+
device_obj = store.get_object(device_id)
|
|
484
|
+
|
|
485
|
+
user_name = ""
|
|
486
|
+
if user_obj:
|
|
487
|
+
first = user_obj.get("first_name", "")
|
|
488
|
+
last = user_obj.get("last_name", "")
|
|
489
|
+
user_name = f"{first} {last}".strip() or strip_canonical_id(user_id)
|
|
490
|
+
else:
|
|
491
|
+
user_name = strip_canonical_id(user_id)
|
|
492
|
+
|
|
493
|
+
model = device_obj.get("model", "") if device_obj else ""
|
|
494
|
+
|
|
495
|
+
device_line_refs = store.get_cross_refs(
|
|
496
|
+
relationship="device_has_dn", from_id=device_id,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if device_line_refs:
|
|
500
|
+
for dl_ref in device_line_refs:
|
|
501
|
+
line_id = dl_ref.get("to_id", "")
|
|
502
|
+
line_obj = store.get_object(line_id)
|
|
503
|
+
line_ext = ""
|
|
504
|
+
partition = ""
|
|
505
|
+
if line_obj:
|
|
506
|
+
line_ext = line_obj.get("extension", "") or line_obj.get("cucm_pattern", "")
|
|
507
|
+
partition = line_obj.get("route_partition_name", "")
|
|
508
|
+
if not partition:
|
|
509
|
+
dn_pt_refs = store.get_cross_refs(
|
|
510
|
+
relationship="dn_in_partition", from_id=line_id,
|
|
511
|
+
)
|
|
512
|
+
if dn_pt_refs:
|
|
513
|
+
partition = strip_canonical_id(dn_pt_refs[0].get("to_id", ""))
|
|
514
|
+
parts.append(
|
|
515
|
+
f'<tr><td>{html.escape(user_name)}</td>'
|
|
516
|
+
f'<td>{html.escape(strip_canonical_id(device_id))}</td>'
|
|
517
|
+
f'<td>{html.escape(model)}</td>'
|
|
518
|
+
f'<td>{html.escape(line_ext)}</td>'
|
|
519
|
+
f'<td>{html.escape(partition)}</td></tr>'
|
|
520
|
+
)
|
|
521
|
+
else:
|
|
522
|
+
parts.append(
|
|
523
|
+
f'<tr><td>{html.escape(user_name)}</td>'
|
|
524
|
+
f'<td>{html.escape(strip_canonical_id(device_id))}</td>'
|
|
525
|
+
f'<td>{html.escape(model)}</td>'
|
|
526
|
+
f'<td>—</td><td>—</td></tr>'
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
parts.append('</tbody></table>')
|
|
530
|
+
parts.append('</div></details>')
|
|
531
|
+
return "\n".join(parts)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
# G. Routing Topology
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
def _routing_group(store: MigrationStore) -> str:
|
|
539
|
+
"""G. Routing — trunks and route groups (CSS/partition topology in Section C)."""
|
|
540
|
+
trunk_count = store.count_by_type("trunk")
|
|
541
|
+
rg_count = store.count_by_type("route_group")
|
|
542
|
+
dp_count = store.count_by_type("dial_plan")
|
|
543
|
+
tp_count = store.count_by_type("translation_pattern")
|
|
544
|
+
|
|
545
|
+
total = trunk_count + rg_count + dp_count + tp_count
|
|
546
|
+
if total == 0:
|
|
547
|
+
return ""
|
|
548
|
+
|
|
549
|
+
summary_parts = []
|
|
550
|
+
if trunk_count:
|
|
551
|
+
summary_parts.append(f"{trunk_count} trunks")
|
|
552
|
+
if rg_count:
|
|
553
|
+
summary_parts.append(f"{rg_count} route groups")
|
|
554
|
+
if dp_count:
|
|
555
|
+
summary_parts.append(f"{dp_count} dial plans")
|
|
556
|
+
if tp_count:
|
|
557
|
+
summary_parts.append(f"{tp_count} translation patterns")
|
|
558
|
+
|
|
559
|
+
parts = [
|
|
560
|
+
f'<details id="routing">',
|
|
561
|
+
f'<summary>G. Routing Topology <span class="summary-count">— {", ".join(summary_parts)}</span></summary>',
|
|
562
|
+
'<div class="details-content">',
|
|
563
|
+
]
|
|
564
|
+
|
|
565
|
+
# Trunks
|
|
566
|
+
trunks = store.get_objects("trunk")
|
|
567
|
+
if trunks:
|
|
568
|
+
parts.append('<h4>Trunks</h4>')
|
|
569
|
+
parts.append('<table>')
|
|
570
|
+
parts.append('<thead><tr><th>Name</th><th>Type</th><th>Location</th></tr></thead>')
|
|
571
|
+
parts.append('<tbody>')
|
|
572
|
+
for t in trunks:
|
|
573
|
+
name = t.get("name", strip_canonical_id(t.get("canonical_id", "")))
|
|
574
|
+
trunk_type = t.get("trunk_type", "—")
|
|
575
|
+
loc_id = t.get("location_id", "")
|
|
576
|
+
loc_obj = store.get_object(loc_id) if loc_id else None
|
|
577
|
+
loc_name = loc_obj.get("name", strip_canonical_id(loc_id)) if loc_obj else "—"
|
|
578
|
+
parts.append(
|
|
579
|
+
f'<tr><td>{html.escape(name)}</td>'
|
|
580
|
+
f'<td>{html.escape(str(trunk_type))}</td>'
|
|
581
|
+
f'<td>{html.escape(loc_name)}</td></tr>'
|
|
582
|
+
)
|
|
583
|
+
parts.append('</tbody></table>')
|
|
584
|
+
|
|
585
|
+
parts.append('</div></details>')
|
|
586
|
+
return "\n".join(parts)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
# H. Voicemail Analysis
|
|
591
|
+
# ---------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
def _voicemail_analysis(store: MigrationStore) -> str:
|
|
594
|
+
"""H. Voicemail Analysis — voicemail profiles + custom greeting count."""
|
|
595
|
+
profiles = store.get_objects("voicemail_profile")
|
|
596
|
+
if not profiles:
|
|
597
|
+
return ""
|
|
598
|
+
|
|
599
|
+
# Count custom greeting decisions
|
|
600
|
+
decisions = store.get_all_decisions()
|
|
601
|
+
greeting_count = 0
|
|
602
|
+
for d in decisions:
|
|
603
|
+
if d.get("type") != "MISSING_DATA":
|
|
604
|
+
continue
|
|
605
|
+
ctx = d.get("context", {})
|
|
606
|
+
if ctx.get("reason") == "custom_greeting_not_extractable":
|
|
607
|
+
greeting_count += 1
|
|
608
|
+
|
|
609
|
+
parts = [
|
|
610
|
+
'<details id="voicemail">',
|
|
611
|
+
f'<summary>H. Voicemail Analysis <span class="summary-count">— {len(profiles)} profiles</span></summary>',
|
|
612
|
+
'<div class="details-content">',
|
|
613
|
+
'<table>',
|
|
614
|
+
'<thead><tr><th>Profile</th><th>Description</th></tr></thead>',
|
|
615
|
+
'<tbody>',
|
|
616
|
+
]
|
|
617
|
+
for p in profiles:
|
|
618
|
+
name = p.get("name", strip_canonical_id(p.get("canonical_id", "")))
|
|
619
|
+
desc = p.get("description", "—")
|
|
620
|
+
parts.append(f'<tr><td>{html.escape(name)}</td><td>{html.escape(str(desc))}</td></tr>')
|
|
621
|
+
parts.append('</tbody></table>')
|
|
622
|
+
|
|
623
|
+
if greeting_count > 0:
|
|
624
|
+
parts.append(
|
|
625
|
+
f'<div class="callout warning">'
|
|
626
|
+
f'<p><strong>Custom Greetings: {greeting_count} users</strong> have personalized '
|
|
627
|
+
f'voicemail greetings that will revert to the system default after migration. '
|
|
628
|
+
f'Each user must re-record their greeting.</p>'
|
|
629
|
+
f'</div>'
|
|
630
|
+
)
|
|
631
|
+
parts.append(
|
|
632
|
+
'<h4>User Action Required — Voicemail Greeting Re-Recording</h4>'
|
|
633
|
+
'<p>Send the following communication to affected users at least 1 week before migration:</p>'
|
|
634
|
+
'<blockquote>'
|
|
635
|
+
'<p><strong>Subject: Action Required — Re-record Your Voicemail Greeting After Migration</strong></p>'
|
|
636
|
+
'<p>As part of our phone system migration to Webex Calling, your voicemail '
|
|
637
|
+
'greeting will reset to the system default. After the migration is complete, '
|
|
638
|
+
'please re-record your personalized greeting:</p>'
|
|
639
|
+
'<ul>'
|
|
640
|
+
'<li>Open the Webex App</li>'
|
|
641
|
+
'<li>Go to Settings > Calling > Voicemail</li>'
|
|
642
|
+
'<li>Select "Greeting" and record your new greeting</li>'
|
|
643
|
+
'</ul>'
|
|
644
|
+
'<p>Alternatively, dial the voicemail access number from your desk phone and '
|
|
645
|
+
'follow the prompts to record a new greeting.</p>'
|
|
646
|
+
'<p>If you have a script for your greeting, please have it ready before '
|
|
647
|
+
're-recording. We recommend completing this within the first day after migration.</p>'
|
|
648
|
+
'</blockquote>'
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
parts.append('</div></details>')
|
|
652
|
+
return "\n".join(parts)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# ---------------------------------------------------------------------------
|
|
656
|
+
# I. Audio Assets (MoH + Announcements)
|
|
657
|
+
# ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
def _audio_assets(store: MigrationStore) -> str:
|
|
660
|
+
"""I. Audio Assets — custom MoH sources and announcements."""
|
|
661
|
+
moh_sources = store.get_objects("music_on_hold")
|
|
662
|
+
announcements = store.get_objects("announcement")
|
|
663
|
+
|
|
664
|
+
custom_moh = [m for m in moh_sources if not m.get("is_default", False)]
|
|
665
|
+
|
|
666
|
+
if not custom_moh and not announcements:
|
|
667
|
+
return ""
|
|
668
|
+
|
|
669
|
+
total = len(custom_moh) + len(announcements)
|
|
670
|
+
parts = [
|
|
671
|
+
'<details id="audio-assets">',
|
|
672
|
+
f'<summary>I. Audio Assets <span class="summary-count">— {total} custom audio assets</span></summary>',
|
|
673
|
+
'<div class="details-content">',
|
|
674
|
+
'<div class="callout warning">'
|
|
675
|
+
'<p><strong>Action Required:</strong> Download these files from CUCM '
|
|
676
|
+
'before migration day. MoH and AA greetings are customer-facing — '
|
|
677
|
+
'losing them on cutover is a P1 experience issue.</p>'
|
|
678
|
+
'</div>',
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
if custom_moh:
|
|
682
|
+
parts.append('<h4>Music on Hold Sources</h4>')
|
|
683
|
+
parts.append('<table>')
|
|
684
|
+
parts.append('<thead><tr><th>Source Name</th><th>File Name</th><th>Features Using</th></tr></thead>')
|
|
685
|
+
parts.append('<tbody>')
|
|
686
|
+
for m in custom_moh:
|
|
687
|
+
name = m.get("source_name") or strip_canonical_id(m.get("canonical_id", ""))
|
|
688
|
+
fname = m.get("source_file_name", "\u2014")
|
|
689
|
+
mid = m.get("canonical_id", "")
|
|
690
|
+
refs = store.get_cross_refs(to_id=mid, relationship="feature_uses_moh_source")
|
|
691
|
+
if refs:
|
|
692
|
+
feature_names = [strip_canonical_id(r["from_id"]) for r in refs]
|
|
693
|
+
loc_text = ", ".join(feature_names)
|
|
694
|
+
else:
|
|
695
|
+
loc_text = "\u2014"
|
|
696
|
+
parts.append(
|
|
697
|
+
f'<tr><td>{html.escape(str(name))}</td>'
|
|
698
|
+
f'<td>{html.escape(str(fname))}</td>'
|
|
699
|
+
f'<td>{html.escape(loc_text)}</td></tr>'
|
|
700
|
+
)
|
|
701
|
+
parts.append('</tbody></table>')
|
|
702
|
+
|
|
703
|
+
if announcements:
|
|
704
|
+
parts.append('<h4>Announcements</h4>')
|
|
705
|
+
parts.append('<table>')
|
|
706
|
+
parts.append('<thead><tr><th>Name</th><th>File Name</th><th>Type</th></tr></thead>')
|
|
707
|
+
parts.append('<tbody>')
|
|
708
|
+
for a in announcements:
|
|
709
|
+
name = a.get("name") or strip_canonical_id(a.get("canonical_id", ""))
|
|
710
|
+
fname = a.get("file_name", "\u2014")
|
|
711
|
+
media_type = a.get("media_type", "\u2014")
|
|
712
|
+
parts.append(
|
|
713
|
+
f'<tr><td>{html.escape(str(name))}</td>'
|
|
714
|
+
f'<td>{html.escape(str(fname))}</td>'
|
|
715
|
+
f'<td>{html.escape(str(media_type))}</td></tr>'
|
|
716
|
+
)
|
|
717
|
+
parts.append('</tbody></table>')
|
|
718
|
+
|
|
719
|
+
parts.append(f'<p><strong>{total} custom audio asset(s)</strong> requiring manual migration.</p>')
|
|
720
|
+
parts.append('</div></details>')
|
|
721
|
+
return "\n".join(parts)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# ---------------------------------------------------------------------------
|
|
725
|
+
# J. Data Coverage
|
|
726
|
+
# ---------------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
def _data_quality_group(store: MigrationStore) -> str:
|
|
729
|
+
"""J. Data coverage and completeness."""
|
|
730
|
+
check_types = [
|
|
731
|
+
"user", "device", "line", "location",
|
|
732
|
+
"hunt_group", "call_queue", "auto_attendant",
|
|
733
|
+
"trunk", "css", "partition",
|
|
734
|
+
"voicemail_profile", "schedule",
|
|
735
|
+
]
|
|
736
|
+
|
|
737
|
+
total_types_checked = len(check_types)
|
|
738
|
+
types_with_data = sum(1 for t in check_types if store.count_by_type(t) > 0)
|
|
739
|
+
|
|
740
|
+
if types_with_data == 0:
|
|
741
|
+
return ""
|
|
742
|
+
|
|
743
|
+
parts = [
|
|
744
|
+
f'<details id="coverage">',
|
|
745
|
+
f'<summary>J. Data Coverage <span class="summary-count">— {types_with_data}/{total_types_checked} object types populated</span></summary>',
|
|
746
|
+
'<div class="details-content">',
|
|
747
|
+
'<table>',
|
|
748
|
+
'<thead><tr><th>Object Type</th><th class="num">Count</th><th>Status</th></tr></thead>',
|
|
749
|
+
'<tbody>',
|
|
750
|
+
]
|
|
751
|
+
|
|
752
|
+
for t in check_types:
|
|
753
|
+
count = store.count_by_type(t)
|
|
754
|
+
status = "✓" if count > 0 else "—"
|
|
755
|
+
display = t.replace("_", " ").title()
|
|
756
|
+
parts.append(
|
|
757
|
+
f'<tr><td>{html.escape(display)}</td>'
|
|
758
|
+
f'<td class="num">{count}</td>'
|
|
759
|
+
f'<td>{status}</td></tr>'
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
parts.append('</tbody></table>')
|
|
763
|
+
parts.append('</div></details>')
|
|
764
|
+
return "\n".join(parts)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
# ---------------------------------------------------------------------------
|
|
768
|
+
# K. Gateways & Analog Ports
|
|
769
|
+
# ---------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
# Product model → estimated FXS port count
|
|
772
|
+
_ANALOG_PORT_ESTIMATES: dict[str, int] = {
|
|
773
|
+
"ATA 191": 2,
|
|
774
|
+
"ATA 192": 2,
|
|
775
|
+
"VG202": 2,
|
|
776
|
+
"VG204": 4,
|
|
777
|
+
"VG310": 24,
|
|
778
|
+
"VG320": 48,
|
|
779
|
+
"VG350": 48,
|
|
780
|
+
"VG400": 8,
|
|
781
|
+
"VG450": 200,
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
# Products or keywords that indicate analog-capable gateways
|
|
785
|
+
_ANALOG_KEYWORDS = ("VG", "ATA", "ISR", "FXS", "FXO", "analog")
|
|
786
|
+
|
|
787
|
+
# Protocols that need conversion warnings
|
|
788
|
+
_UNSUPPORTED_PROTOCOLS = ("MGCP", "H.323", "H323")
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _is_analog_capable(product: str, protocol: str) -> bool:
|
|
792
|
+
"""Check if a gateway is analog-capable based on product or protocol."""
|
|
793
|
+
product_upper = (product or "").upper()
|
|
794
|
+
protocol_upper = (protocol or "").upper()
|
|
795
|
+
if protocol_upper in ("MGCP", "H.323", "H323"):
|
|
796
|
+
return True
|
|
797
|
+
return any(kw.upper() in product_upper for kw in _ANALOG_KEYWORDS)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _estimate_ports(product: str) -> int | None:
|
|
801
|
+
"""Estimate FXS port count from product model name."""
|
|
802
|
+
if not product:
|
|
803
|
+
return None
|
|
804
|
+
for model, ports in _ANALOG_PORT_ESTIMATES.items():
|
|
805
|
+
if model.upper() in product.upper():
|
|
806
|
+
return ports
|
|
807
|
+
if "ISR" in product.upper():
|
|
808
|
+
return None
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _gateways_group(store: MigrationStore) -> str:
|
|
813
|
+
"""K. Gateway & analog port review."""
|
|
814
|
+
gateways = store.get_objects("gateway")
|
|
815
|
+
if not gateways:
|
|
816
|
+
return ""
|
|
817
|
+
|
|
818
|
+
analog_gws = []
|
|
819
|
+
sip_gws = []
|
|
820
|
+
total_estimated_ports = 0
|
|
821
|
+
|
|
822
|
+
for gw in gateways:
|
|
823
|
+
state = gw.get("pre_migration_state", {})
|
|
824
|
+
product = state.get("product", "")
|
|
825
|
+
protocol = state.get("protocol", "")
|
|
826
|
+
|
|
827
|
+
if _is_analog_capable(product, protocol):
|
|
828
|
+
ports = _estimate_ports(product)
|
|
829
|
+
if ports:
|
|
830
|
+
total_estimated_ports += ports
|
|
831
|
+
analog_gws.append((gw, ports))
|
|
832
|
+
else:
|
|
833
|
+
sip_gws.append(gw)
|
|
834
|
+
|
|
835
|
+
if not analog_gws:
|
|
836
|
+
summary = f"{len(gateways)} gateways — all SIP, no analog review needed"
|
|
837
|
+
parts = [
|
|
838
|
+
f'<details id="gateways">',
|
|
839
|
+
f'<summary>K. Gateways <span class="summary-count">— {summary}</span></summary>',
|
|
840
|
+
'<div class="details-content">',
|
|
841
|
+
f'<p>{len(sip_gws)} SIP gateways detected. No analog ports requiring manual review.</p>',
|
|
842
|
+
'</div></details>',
|
|
843
|
+
]
|
|
844
|
+
return "\n".join(parts)
|
|
845
|
+
|
|
846
|
+
port_note = f", ~{total_estimated_ports} estimated analog ports" if total_estimated_ports else ""
|
|
847
|
+
summary = f"{len(gateways)} gateways — {len(analog_gws)} analog-capable{port_note}"
|
|
848
|
+
|
|
849
|
+
parts = [
|
|
850
|
+
f'<details id="gateways">',
|
|
851
|
+
f'<summary>K. Gateways & Analog Ports <span class="summary-count">— {summary}</span></summary>',
|
|
852
|
+
'<div class="details-content">',
|
|
853
|
+
]
|
|
854
|
+
|
|
855
|
+
# Warning callout
|
|
856
|
+
parts.append(
|
|
857
|
+
'<div class="callout warning">'
|
|
858
|
+
'<p><strong>Analog ports require manual review.</strong> Each FXS/FXO port must be mapped to a '
|
|
859
|
+
'Webex workspace with an ATA device. Analog ports commonly serve fax machines, elevator phones, '
|
|
860
|
+
'alarm panels, overhead paging, and door entry systems — these are often undocumented and cause '
|
|
861
|
+
'day-one outages when missed.</p>'
|
|
862
|
+
'</div>'
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# Analog gateway table
|
|
866
|
+
parts.append('<h4>Analog-Capable Gateways</h4>')
|
|
867
|
+
parts.append('<table>')
|
|
868
|
+
parts.append('<thead><tr><th>Gateway</th><th>Product</th><th>Protocol</th>'
|
|
869
|
+
'<th class="num">Est. Ports</th><th>Location</th><th>Notes</th></tr></thead>')
|
|
870
|
+
parts.append('<tbody>')
|
|
871
|
+
|
|
872
|
+
for gw, ports in analog_gws:
|
|
873
|
+
state = gw.get("pre_migration_state", {})
|
|
874
|
+
name = state.get("gateway_name", strip_canonical_id(gw.get("canonical_id", "")))
|
|
875
|
+
product = state.get("product", "—")
|
|
876
|
+
protocol = state.get("protocol", "—")
|
|
877
|
+
device_pool = state.get("cucm_device_pool", "")
|
|
878
|
+
location = friendly_site_name(device_pool) if device_pool else "—"
|
|
879
|
+
port_str = str(ports) if ports else "Unknown"
|
|
880
|
+
|
|
881
|
+
notes = []
|
|
882
|
+
if (protocol or "").upper() in _UNSUPPORTED_PROTOCOLS:
|
|
883
|
+
notes.append(f"{protocol} not supported in Webex — convert to SIP LGW or replace with ATA")
|
|
884
|
+
if not ports:
|
|
885
|
+
notes.append("Port count varies by NIM configuration — manual review needed")
|
|
886
|
+
note_str = "; ".join(notes) if notes else "Map each port to a Webex workspace"
|
|
887
|
+
|
|
888
|
+
parts.append(
|
|
889
|
+
f'<tr><td>{html.escape(name)}</td>'
|
|
890
|
+
f'<td>{html.escape(product)}</td>'
|
|
891
|
+
f'<td>{html.escape(protocol)}</td>'
|
|
892
|
+
f'<td class="num">{html.escape(port_str)}</td>'
|
|
893
|
+
f'<td>{html.escape(location)}</td>'
|
|
894
|
+
f'<td>{html.escape(note_str)}</td></tr>'
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
parts.append('</tbody></table>')
|
|
898
|
+
|
|
899
|
+
# MGCP-specific warning if applicable
|
|
900
|
+
mgcp_gws = [gw for gw, _ in analog_gws if (gw.get("pre_migration_state", {}).get("protocol", "")).upper() == "MGCP"]
|
|
901
|
+
if mgcp_gws:
|
|
902
|
+
parts.append(
|
|
903
|
+
'<div class="callout critical">'
|
|
904
|
+
f'<p><strong>{len(mgcp_gws)} MGCP gateway{"s" if len(mgcp_gws) != 1 else ""} detected.</strong> '
|
|
905
|
+
'MGCP protocol is not supported in Webex Calling. These must be converted to SIP Local Gateway '
|
|
906
|
+
'or replaced with Cisco ATA devices before migration.</p>'
|
|
907
|
+
'</div>'
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
parts.append('</div></details>')
|
|
911
|
+
return "\n".join(parts)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
# ---------------------------------------------------------------------------
|
|
915
|
+
# L. Call Features
|
|
916
|
+
# ---------------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
def _features_group(store: MigrationStore) -> str:
|
|
919
|
+
"""L. Feature inventory with counts."""
|
|
920
|
+
feature_types = [
|
|
921
|
+
("hunt_group", "Hunt Groups"),
|
|
922
|
+
("call_queue", "Call Queues"),
|
|
923
|
+
("auto_attendant", "Auto Attendants"),
|
|
924
|
+
("call_park", "Call Parks"),
|
|
925
|
+
("pickup_group", "Pickup Groups"),
|
|
926
|
+
("paging_group", "Paging Groups"),
|
|
927
|
+
("call_forwarding", "Call Forwarding Rules"),
|
|
928
|
+
("monitoring_list", "Monitoring Lists"),
|
|
929
|
+
]
|
|
930
|
+
|
|
931
|
+
feature_rows = []
|
|
932
|
+
total = 0
|
|
933
|
+
for type_key, display_name in feature_types:
|
|
934
|
+
count = store.count_by_type(type_key)
|
|
935
|
+
if count > 0:
|
|
936
|
+
feature_rows.append((type_key, display_name, count))
|
|
937
|
+
total += count
|
|
938
|
+
|
|
939
|
+
if not feature_rows:
|
|
940
|
+
return ""
|
|
941
|
+
|
|
942
|
+
parts = [
|
|
943
|
+
f'<details id="call-features">',
|
|
944
|
+
f'<summary>L. Call Features <span class="summary-count">— {total} features across {len(feature_rows)} types</span></summary>',
|
|
945
|
+
'<div class="details-content">',
|
|
946
|
+
'<table>',
|
|
947
|
+
'<thead><tr><th>Feature Type</th><th class="num">Count</th></tr></thead>',
|
|
948
|
+
'<tbody>',
|
|
949
|
+
]
|
|
950
|
+
|
|
951
|
+
for type_key, display_name, count in feature_rows:
|
|
952
|
+
parts.append(f'<tr><td>{html.escape(display_name)}</td><td class="num">{count}</td></tr>')
|
|
953
|
+
|
|
954
|
+
parts.append('</tbody></table>')
|
|
955
|
+
|
|
956
|
+
# Feature detail tables for specific types
|
|
957
|
+
for type_key, display_name, _ in feature_rows:
|
|
958
|
+
objects = store.get_objects(type_key)
|
|
959
|
+
if not objects:
|
|
960
|
+
continue
|
|
961
|
+
|
|
962
|
+
parts.append(f'<h4>{html.escape(display_name)}</h4>')
|
|
963
|
+
parts.append('<table>')
|
|
964
|
+
parts.append('<thead><tr><th>Name</th><th>Extension</th><th>Location</th></tr></thead>')
|
|
965
|
+
parts.append('<tbody>')
|
|
966
|
+
for obj in objects:
|
|
967
|
+
name = obj.get("name", strip_canonical_id(obj.get("canonical_id", "")))
|
|
968
|
+
ext = obj.get("extension", "—")
|
|
969
|
+
loc_id = obj.get("location_id", "")
|
|
970
|
+
loc_obj = store.get_object(loc_id) if loc_id else None
|
|
971
|
+
loc_name = loc_obj.get("name", strip_canonical_id(loc_id)) if loc_obj else strip_canonical_id(loc_id) if loc_id else "—"
|
|
972
|
+
parts.append(
|
|
973
|
+
f'<tr><td>{html.escape(name)}</td>'
|
|
974
|
+
f'<td>{html.escape(str(ext))}</td>'
|
|
975
|
+
f'<td>{html.escape(loc_name)}</td></tr>'
|
|
976
|
+
)
|
|
977
|
+
parts.append('</tbody></table>')
|
|
978
|
+
|
|
979
|
+
parts.append('</div></details>')
|
|
980
|
+
return "\n".join(parts)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
# ---------------------------------------------------------------------------
|
|
984
|
+
# M. Button Templates
|
|
985
|
+
# ---------------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
def _button_template_group(store: MigrationStore) -> str:
|
|
988
|
+
"""M. Phone button template inventory — template names, button types, usage counts."""
|
|
989
|
+
templates = store.get_objects("line_key_template")
|
|
990
|
+
if not templates:
|
|
991
|
+
return ""
|
|
992
|
+
|
|
993
|
+
total_templates = len(templates)
|
|
994
|
+
total_phones = sum(t.get("phones_using", 0) for t in templates)
|
|
995
|
+
total_unmapped = sum(len(t.get("unmapped_buttons", [])) for t in templates)
|
|
996
|
+
|
|
997
|
+
summary_parts = [f"{total_templates} templates"]
|
|
998
|
+
if total_phones:
|
|
999
|
+
summary_parts.append(f"{total_phones} phones")
|
|
1000
|
+
if total_unmapped:
|
|
1001
|
+
summary_parts.append(f"{total_unmapped} unmapped buttons")
|
|
1002
|
+
|
|
1003
|
+
parts = [
|
|
1004
|
+
f'<details id="button-templates">',
|
|
1005
|
+
f'<summary>M. Button Templates <span class="summary-count">— {", ".join(summary_parts)}</span></summary>',
|
|
1006
|
+
'<div class="details-content">',
|
|
1007
|
+
'<table>',
|
|
1008
|
+
'<thead><tr><th>Template</th><th>Base</th><th class="num">Buttons</th>'
|
|
1009
|
+
'<th class="num">Phones</th><th>Key Type Breakdown</th><th>Unmapped</th></tr></thead>',
|
|
1010
|
+
'<tbody>',
|
|
1011
|
+
]
|
|
1012
|
+
|
|
1013
|
+
for t in sorted(templates, key=lambda x: -(x.get("phones_using", 0))):
|
|
1014
|
+
name = t.get("name") or strip_canonical_id(t.get("canonical_id", ""))
|
|
1015
|
+
base = t.get("cucm_template_name") or "—"
|
|
1016
|
+
line_keys = t.get("line_keys", [])
|
|
1017
|
+
phones = t.get("phones_using", 0)
|
|
1018
|
+
unmapped = t.get("unmapped_buttons", [])
|
|
1019
|
+
|
|
1020
|
+
# Key type breakdown
|
|
1021
|
+
type_counts: dict[str, int] = defaultdict(int)
|
|
1022
|
+
for key in line_keys:
|
|
1023
|
+
kt = key.get("key_type", "UNKNOWN")
|
|
1024
|
+
type_counts[kt] += 1
|
|
1025
|
+
breakdown = ", ".join(f"{v} {k}" for k, v in sorted(type_counts.items()))
|
|
1026
|
+
if not breakdown:
|
|
1027
|
+
breakdown = "—"
|
|
1028
|
+
|
|
1029
|
+
unmapped_str = ", ".join(
|
|
1030
|
+
html.escape(b.get("feature", b.get("key_type", "?")))
|
|
1031
|
+
for b in unmapped
|
|
1032
|
+
) if unmapped else "—"
|
|
1033
|
+
|
|
1034
|
+
parts.append(
|
|
1035
|
+
f'<tr>'
|
|
1036
|
+
f'<td>{html.escape(name)}</td>'
|
|
1037
|
+
f'<td>{html.escape(base)}</td>'
|
|
1038
|
+
f'<td class="num">{len(line_keys)}</td>'
|
|
1039
|
+
f'<td class="num">{phones}</td>'
|
|
1040
|
+
f'<td>{html.escape(breakdown)}</td>'
|
|
1041
|
+
f'<td>{unmapped_str}</td>'
|
|
1042
|
+
f'</tr>'
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
parts.append('</tbody></table>')
|
|
1046
|
+
|
|
1047
|
+
# Unmapped button types summary
|
|
1048
|
+
all_unmapped_features: dict[str, int] = defaultdict(int)
|
|
1049
|
+
for t in templates:
|
|
1050
|
+
for b in t.get("unmapped_buttons", []):
|
|
1051
|
+
feature = b.get("feature", b.get("key_type", "Unknown"))
|
|
1052
|
+
all_unmapped_features[feature] += t.get("phones_using", 0)
|
|
1053
|
+
|
|
1054
|
+
if all_unmapped_features:
|
|
1055
|
+
parts.append('<h4>Unmapped Button Types</h4>')
|
|
1056
|
+
parts.append('<table>')
|
|
1057
|
+
parts.append('<thead><tr><th>CUCM Button Type</th><th class="num">Affected Phones</th></tr></thead>')
|
|
1058
|
+
parts.append('<tbody>')
|
|
1059
|
+
for feature, count in sorted(all_unmapped_features.items(), key=lambda x: -x[1]):
|
|
1060
|
+
parts.append(
|
|
1061
|
+
f'<tr><td>{html.escape(feature)}</td>'
|
|
1062
|
+
f'<td class="num">{count}</td></tr>'
|
|
1063
|
+
)
|
|
1064
|
+
parts.append('</tbody></table>')
|
|
1065
|
+
|
|
1066
|
+
parts.append('</div></details>')
|
|
1067
|
+
return "\n".join(parts)
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
# ---------------------------------------------------------------------------
|
|
1071
|
+
# N. Device Layouts
|
|
1072
|
+
# ---------------------------------------------------------------------------
|
|
1073
|
+
|
|
1074
|
+
def _device_layout_group(store: MigrationStore) -> str:
|
|
1075
|
+
"""N. Per-device layout summary — shared lines, speed dials, BLF, KEM stats."""
|
|
1076
|
+
layouts = store.get_objects("device_layout")
|
|
1077
|
+
if not layouts:
|
|
1078
|
+
return ""
|
|
1079
|
+
|
|
1080
|
+
total = len(layouts)
|
|
1081
|
+
shared_count = 0
|
|
1082
|
+
speed_dial_count = 0
|
|
1083
|
+
blf_count = 0
|
|
1084
|
+
kem_count = 0
|
|
1085
|
+
total_speed_dials = 0
|
|
1086
|
+
|
|
1087
|
+
for layout in layouts:
|
|
1088
|
+
keys = layout.get("resolved_line_keys", [])
|
|
1089
|
+
kem_keys = layout.get("resolved_kem_keys", [])
|
|
1090
|
+
speed_dials = layout.get("speed_dials", [])
|
|
1091
|
+
|
|
1092
|
+
has_shared = any(k.get("key_type") == "SHARED_LINE" for k in keys)
|
|
1093
|
+
has_blf = any(k.get("key_type") == "MONITOR" for k in keys)
|
|
1094
|
+
has_kem = len(kem_keys) > 0
|
|
1095
|
+
has_speed_dials = len(speed_dials) > 0
|
|
1096
|
+
|
|
1097
|
+
if has_shared:
|
|
1098
|
+
shared_count += 1
|
|
1099
|
+
if has_blf:
|
|
1100
|
+
blf_count += 1
|
|
1101
|
+
if has_kem:
|
|
1102
|
+
kem_count += 1
|
|
1103
|
+
if has_speed_dials:
|
|
1104
|
+
speed_dial_count += 1
|
|
1105
|
+
total_speed_dials += len(speed_dials)
|
|
1106
|
+
|
|
1107
|
+
summary_parts = [f"{total} device layouts"]
|
|
1108
|
+
if shared_count:
|
|
1109
|
+
summary_parts.append(f"{shared_count} with shared lines")
|
|
1110
|
+
if blf_count:
|
|
1111
|
+
summary_parts.append(f"{blf_count} with BLF")
|
|
1112
|
+
|
|
1113
|
+
parts = [
|
|
1114
|
+
f'<details id="device-layouts">',
|
|
1115
|
+
f'<summary>N. Device Layouts <span class="summary-count">— {", ".join(summary_parts)}</span></summary>',
|
|
1116
|
+
'<div class="details-content">',
|
|
1117
|
+
'<table>',
|
|
1118
|
+
'<thead><tr><th>Metric</th><th class="num">Count</th></tr></thead>',
|
|
1119
|
+
'<tbody>',
|
|
1120
|
+
f'<tr><td>Total device layouts</td><td class="num">{total}</td></tr>',
|
|
1121
|
+
f'<tr><td>Phones with shared line appearances</td><td class="num">{shared_count}</td></tr>',
|
|
1122
|
+
f'<tr><td>Phones with speed dials</td><td class="num">{speed_dial_count}</td></tr>',
|
|
1123
|
+
]
|
|
1124
|
+
|
|
1125
|
+
if speed_dial_count > 0:
|
|
1126
|
+
avg = total_speed_dials / speed_dial_count
|
|
1127
|
+
parts.append(
|
|
1128
|
+
f'<tr><td>Average speed dials per phone</td><td class="num">{avg:.1f}</td></tr>'
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
parts.extend([
|
|
1132
|
+
f'<tr><td>Phones with BLF/monitoring keys</td><td class="num">{blf_count}</td></tr>',
|
|
1133
|
+
f'<tr><td>Phones with KEM modules</td><td class="num">{kem_count}</td></tr>',
|
|
1134
|
+
])
|
|
1135
|
+
|
|
1136
|
+
parts.append('</tbody></table>')
|
|
1137
|
+
parts.append('</div></details>')
|
|
1138
|
+
return "\n".join(parts)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
# ---------------------------------------------------------------------------
|
|
1142
|
+
# O. Softkey Migration
|
|
1143
|
+
# ---------------------------------------------------------------------------
|
|
1144
|
+
|
|
1145
|
+
def _softkey_group(store: MigrationStore) -> str:
|
|
1146
|
+
"""O. Softkey template migration status — PSK mapping, classic MPP flags."""
|
|
1147
|
+
configs = store.get_objects("softkey_config")
|
|
1148
|
+
if not configs:
|
|
1149
|
+
return ""
|
|
1150
|
+
|
|
1151
|
+
total = len(configs)
|
|
1152
|
+
psk_count = sum(1 for c in configs if c.get("is_psk_target"))
|
|
1153
|
+
classic_count = total - psk_count
|
|
1154
|
+
|
|
1155
|
+
summary_parts = [f"{total} softkey templates"]
|
|
1156
|
+
if psk_count:
|
|
1157
|
+
summary_parts.append(f"{psk_count} PSK-capable")
|
|
1158
|
+
if classic_count:
|
|
1159
|
+
summary_parts.append(f"{classic_count} classic MPP")
|
|
1160
|
+
|
|
1161
|
+
parts = [
|
|
1162
|
+
f'<details id="softkeys">',
|
|
1163
|
+
f'<summary>O. Softkey Migration <span class="summary-count">— {", ".join(summary_parts)}</span></summary>',
|
|
1164
|
+
'<div class="details-content">',
|
|
1165
|
+
'<table>',
|
|
1166
|
+
'<thead><tr><th>Template</th><th class="num">Phones</th>'
|
|
1167
|
+
'<th>PSK Target</th><th class="num">PSK Mappings</th>'
|
|
1168
|
+
'<th class="num">Unmapped</th></tr></thead>',
|
|
1169
|
+
'<tbody>',
|
|
1170
|
+
]
|
|
1171
|
+
|
|
1172
|
+
for c in sorted(configs, key=lambda x: -(x.get("phones_using", 0))):
|
|
1173
|
+
name = c.get("cucm_template_name") or strip_canonical_id(c.get("canonical_id", ""))
|
|
1174
|
+
phones = c.get("phones_using", 0)
|
|
1175
|
+
is_psk = c.get("is_psk_target", False)
|
|
1176
|
+
psk_mappings = c.get("psk_mappings", [])
|
|
1177
|
+
unmapped = c.get("unmapped_softkeys", [])
|
|
1178
|
+
|
|
1179
|
+
psk_str = "Yes (9800/8875)" if is_psk else "No (classic MPP)"
|
|
1180
|
+
|
|
1181
|
+
parts.append(
|
|
1182
|
+
f'<tr>'
|
|
1183
|
+
f'<td>{html.escape(name)}</td>'
|
|
1184
|
+
f'<td class="num">{phones}</td>'
|
|
1185
|
+
f'<td>{html.escape(psk_str)}</td>'
|
|
1186
|
+
f'<td class="num">{len(psk_mappings)}</td>'
|
|
1187
|
+
f'<td class="num">{len(unmapped)}</td>'
|
|
1188
|
+
f'</tr>'
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
parts.append('</tbody></table>')
|
|
1192
|
+
|
|
1193
|
+
# Classic MPP warning
|
|
1194
|
+
classic_phones = sum(
|
|
1195
|
+
c.get("phones_using", 0) for c in configs if not c.get("is_psk_target")
|
|
1196
|
+
)
|
|
1197
|
+
if classic_phones > 0:
|
|
1198
|
+
parts.append(
|
|
1199
|
+
'<div class="callout warning">'
|
|
1200
|
+
f'<p><strong>{classic_phones} phones with custom softkey templates on classic MPP.</strong> '
|
|
1201
|
+
'Classic MPP phones (non-9800/8875) do not support Programmable Softkeys. '
|
|
1202
|
+
'These phones will use Webex default softkey behavior after migration — '
|
|
1203
|
+
'custom softkey layouts will not be preserved.</p>'
|
|
1204
|
+
'</div>'
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
# Unmapped softkeys summary
|
|
1208
|
+
all_unmapped: dict[str, int] = defaultdict(int)
|
|
1209
|
+
for c in configs:
|
|
1210
|
+
for sk in c.get("unmapped_softkeys", []):
|
|
1211
|
+
cucm_name = sk.get("cucm_name", "Unknown")
|
|
1212
|
+
all_unmapped[cucm_name] += 1
|
|
1213
|
+
|
|
1214
|
+
if all_unmapped:
|
|
1215
|
+
parts.append('<h4>Unmapped CUCM Softkeys</h4>')
|
|
1216
|
+
parts.append('<table>')
|
|
1217
|
+
parts.append('<thead><tr><th>CUCM Softkey</th><th class="num">Templates Using</th></tr></thead>')
|
|
1218
|
+
parts.append('<tbody>')
|
|
1219
|
+
for sk_name, count in sorted(all_unmapped.items(), key=lambda x: -x[1]):
|
|
1220
|
+
parts.append(
|
|
1221
|
+
f'<tr><td>{html.escape(sk_name)}</td>'
|
|
1222
|
+
f'<td class="num">{count}</td></tr>'
|
|
1223
|
+
)
|
|
1224
|
+
parts.append('</tbody></table>')
|
|
1225
|
+
|
|
1226
|
+
parts.append('</div></details>')
|
|
1227
|
+
return "\n".join(parts)
|
|
1228
|
+
|
|
1229
|
+
# ---------------------------------------------------------------------------
|
|
1230
|
+
# P. Cloud-Managed Resources (Tier 3)
|
|
1231
|
+
# ---------------------------------------------------------------------------
|
|
1232
|
+
|
|
1233
|
+
_CLOUD_MANAGED_TYPES = [
|
|
1234
|
+
"info_region", "info_srst", "info_media_resource_group",
|
|
1235
|
+
"info_media_resource_list", "info_aar_group",
|
|
1236
|
+
"info_device_mobility_group", "info_conference_bridge",
|
|
1237
|
+
]
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _cloud_managed_group(store: MigrationStore) -> str:
|
|
1241
|
+
"""P. Cloud-Managed Resources — CUCM objects handled automatically by Webex."""
|
|
1242
|
+
rows = []
|
|
1243
|
+
total = 0
|
|
1244
|
+
for ot in _CLOUD_MANAGED_TYPES:
|
|
1245
|
+
count = store.count_by_type(ot)
|
|
1246
|
+
if count > 0:
|
|
1247
|
+
suffix = ot.replace("info_", "")
|
|
1248
|
+
display = DISPLAY_NAMES.get(suffix, suffix.replace("_", " ").title())
|
|
1249
|
+
rows.append((display, count))
|
|
1250
|
+
total += count
|
|
1251
|
+
|
|
1252
|
+
if not rows:
|
|
1253
|
+
return ""
|
|
1254
|
+
|
|
1255
|
+
parts = [
|
|
1256
|
+
'<details id="cloud-managed">',
|
|
1257
|
+
f'<summary>P. Cloud-Managed Resources <span class="summary-count">'
|
|
1258
|
+
f'— {total} objects across {len(rows)} types</span></summary>',
|
|
1259
|
+
'<div class="details-content">',
|
|
1260
|
+
'<p>These CUCM resources are managed automatically by the Webex cloud. '
|
|
1261
|
+
'No migration action is needed.</p>',
|
|
1262
|
+
'<table>',
|
|
1263
|
+
'<thead><tr><th>Resource Type</th><th class="num">Count</th><th>Note</th></tr></thead>',
|
|
1264
|
+
'<tbody>',
|
|
1265
|
+
]
|
|
1266
|
+
for display, count in rows:
|
|
1267
|
+
parts.append(
|
|
1268
|
+
f'<tr><td>{html.escape(display)}</td>'
|
|
1269
|
+
f'<td class="num">{count}</td>'
|
|
1270
|
+
f'<td>Webex manages this automatically</td></tr>'
|
|
1271
|
+
)
|
|
1272
|
+
parts.append('</tbody></table>')
|
|
1273
|
+
parts.append('</div></details>')
|
|
1274
|
+
return "\n".join(parts)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
# ---------------------------------------------------------------------------
|
|
1278
|
+
# Q. Feature Gaps (Tier 3)
|
|
1279
|
+
# ---------------------------------------------------------------------------
|
|
1280
|
+
|
|
1281
|
+
_NOT_MIGRATABLE_TYPES = [
|
|
1282
|
+
"info_softkey_template", "info_ip_phone_service", "info_intercom",
|
|
1283
|
+
]
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _feature_gaps_group(store: MigrationStore) -> str:
|
|
1287
|
+
"""Q. Feature Gaps — CUCM features with no Webex equivalent."""
|
|
1288
|
+
rows = []
|
|
1289
|
+
total = 0
|
|
1290
|
+
for ot in _NOT_MIGRATABLE_TYPES:
|
|
1291
|
+
count = store.count_by_type(ot)
|
|
1292
|
+
if count > 0:
|
|
1293
|
+
suffix = ot.replace("info_", "")
|
|
1294
|
+
display = DISPLAY_NAMES.get(suffix, suffix.replace("_", " ").title())
|
|
1295
|
+
workaround = NOT_MIGRATABLE_WORKAROUNDS.get(suffix, "None")
|
|
1296
|
+
rows.append((display, count, workaround))
|
|
1297
|
+
total += count
|
|
1298
|
+
|
|
1299
|
+
if not rows:
|
|
1300
|
+
return ""
|
|
1301
|
+
|
|
1302
|
+
parts = [
|
|
1303
|
+
'<details id="feature-gaps">',
|
|
1304
|
+
f'<summary>Q. Feature Gaps <span class="summary-count">'
|
|
1305
|
+
f'— {total} objects across {len(rows)} types</span></summary>',
|
|
1306
|
+
'<div class="details-content">',
|
|
1307
|
+
'<div class="callout warning">'
|
|
1308
|
+
'<p>These CUCM features have no direct Webex equivalent. '
|
|
1309
|
+
'Functionality may be lost or require workarounds.</p></div>',
|
|
1310
|
+
'<table>',
|
|
1311
|
+
'<thead><tr><th>Feature</th><th class="num">Count</th>'
|
|
1312
|
+
'<th>Impact</th><th>Workaround</th></tr></thead>',
|
|
1313
|
+
'<tbody>',
|
|
1314
|
+
]
|
|
1315
|
+
for display, count, workaround in rows:
|
|
1316
|
+
parts.append(
|
|
1317
|
+
f'<tr><td>{html.escape(display)}</td>'
|
|
1318
|
+
f'<td class="num">{count}</td>'
|
|
1319
|
+
f'<td>No Webex equivalent</td>'
|
|
1320
|
+
f'<td>{html.escape(workaround)}</td></tr>'
|
|
1321
|
+
)
|
|
1322
|
+
parts.append('</tbody></table>')
|
|
1323
|
+
parts.append('</div></details>')
|
|
1324
|
+
return "\n".join(parts)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
# ---------------------------------------------------------------------------
|
|
1328
|
+
# R. Manual Reconfiguration (Tier 3)
|
|
1329
|
+
# ---------------------------------------------------------------------------
|
|
1330
|
+
|
|
1331
|
+
_DIFFERENT_ARCH_TYPES = [
|
|
1332
|
+
"info_common_phone_config", "info_phone_button_template",
|
|
1333
|
+
"info_feature_control_policy", "info_credential_policy",
|
|
1334
|
+
"info_recording_profile", "info_ldap_directory",
|
|
1335
|
+
]
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _manual_reconfig_group(store: MigrationStore) -> str:
|
|
1339
|
+
"""R. Manual Reconfiguration — features that must be reconfigured in Webex."""
|
|
1340
|
+
rows = []
|
|
1341
|
+
total = 0
|
|
1342
|
+
for ot in _DIFFERENT_ARCH_TYPES:
|
|
1343
|
+
count = store.count_by_type(ot)
|
|
1344
|
+
if count > 0:
|
|
1345
|
+
suffix = ot.replace("info_", "")
|
|
1346
|
+
display = DISPLAY_NAMES.get(suffix, suffix.replace("_", " ").title())
|
|
1347
|
+
webex_eq = WEBEX_EQUIVALENTS.get(suffix, "Manual configuration required")
|
|
1348
|
+
rows.append((display, count, webex_eq))
|
|
1349
|
+
total += count
|
|
1350
|
+
|
|
1351
|
+
if not rows:
|
|
1352
|
+
return ""
|
|
1353
|
+
|
|
1354
|
+
parts = [
|
|
1355
|
+
'<details id="manual-reconfig">',
|
|
1356
|
+
f'<summary>R. Manual Reconfiguration <span class="summary-count">'
|
|
1357
|
+
f'— {total} objects across {len(rows)} types</span></summary>',
|
|
1358
|
+
'<div class="details-content">',
|
|
1359
|
+
'<p>These CUCM features have Webex equivalents but are configured differently. '
|
|
1360
|
+
'They must be set up manually in Webex Control Hub.</p>',
|
|
1361
|
+
'<table>',
|
|
1362
|
+
'<thead><tr><th>CUCM Feature</th><th class="num">Count</th>'
|
|
1363
|
+
'<th>Webex Equivalent</th></tr></thead>',
|
|
1364
|
+
'<tbody>',
|
|
1365
|
+
]
|
|
1366
|
+
for display, count, webex_eq in rows:
|
|
1367
|
+
parts.append(
|
|
1368
|
+
f'<tr><td>{html.escape(display)}</td>'
|
|
1369
|
+
f'<td class="num">{count}</td>'
|
|
1370
|
+
f'<td>{html.escape(webex_eq)}</td></tr>'
|
|
1371
|
+
)
|
|
1372
|
+
parts.append('</tbody></table>')
|
|
1373
|
+
parts.append('</div></details>')
|
|
1374
|
+
return "\n".join(parts)
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
# ---------------------------------------------------------------------------
|
|
1378
|
+
# S. Planning Inputs (Tier 3)
|
|
1379
|
+
# ---------------------------------------------------------------------------
|
|
1380
|
+
|
|
1381
|
+
_INTEGRATION_KEYWORDS = ("jtapi", "tapi", "cer", "recording", "finesse", "uccx", "cuic")
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _planning_inputs_group(store: MigrationStore) -> str:
|
|
1385
|
+
"""S. Planning Inputs — data for migration planning decisions."""
|
|
1386
|
+
app_users = store.get_objects("info_app_user")
|
|
1387
|
+
h323_gws = store.get_objects("info_h323_gateway")
|
|
1388
|
+
enterprise = store.get_objects("info_enterprise_params")
|
|
1389
|
+
service_params = store.get_objects("info_service_params")
|
|
1390
|
+
|
|
1391
|
+
total = len(app_users) + len(h323_gws) + len(enterprise) + len(service_params)
|
|
1392
|
+
if total == 0:
|
|
1393
|
+
return ""
|
|
1394
|
+
|
|
1395
|
+
parts = [
|
|
1396
|
+
'<details id="planning-inputs">',
|
|
1397
|
+
f'<summary>S. Planning Inputs <span class="summary-count">'
|
|
1398
|
+
f'— {total} items</span></summary>',
|
|
1399
|
+
'<div class="details-content">',
|
|
1400
|
+
]
|
|
1401
|
+
|
|
1402
|
+
if app_users:
|
|
1403
|
+
integration_users = []
|
|
1404
|
+
other_users = []
|
|
1405
|
+
for u in app_users:
|
|
1406
|
+
state = u.get("pre_migration_state", {})
|
|
1407
|
+
uid = state.get("userid", "")
|
|
1408
|
+
desc = (state.get("description") or "").lower()
|
|
1409
|
+
is_integration = any(
|
|
1410
|
+
kw in uid.lower() or kw in desc for kw in _INTEGRATION_KEYWORDS
|
|
1411
|
+
)
|
|
1412
|
+
if is_integration:
|
|
1413
|
+
integration_users.append(u)
|
|
1414
|
+
else:
|
|
1415
|
+
other_users.append(u)
|
|
1416
|
+
|
|
1417
|
+
parts.append(f'<h4>Application Users ({len(app_users)})</h4>')
|
|
1418
|
+
if integration_users:
|
|
1419
|
+
parts.append(
|
|
1420
|
+
'<div class="callout warning">'
|
|
1421
|
+
f'<p><strong>{len(integration_users)} integration-related application user'
|
|
1422
|
+
f'{"s" if len(integration_users) != 1 else ""} detected.</strong> '
|
|
1423
|
+
'JTAPI/TAPI apps, CER, and recording integrations must be reviewed '
|
|
1424
|
+
'for Webex compatibility.</p></div>'
|
|
1425
|
+
)
|
|
1426
|
+
parts.append('<table>')
|
|
1427
|
+
parts.append(
|
|
1428
|
+
'<thead><tr><th>User ID</th><th>Description</th>'
|
|
1429
|
+
'<th>Devices</th><th>Integration?</th></tr></thead>'
|
|
1430
|
+
)
|
|
1431
|
+
parts.append('<tbody>')
|
|
1432
|
+
for u in app_users:
|
|
1433
|
+
state = u.get("pre_migration_state", {})
|
|
1434
|
+
uid = state.get("userid", "")
|
|
1435
|
+
desc = state.get("description", "")
|
|
1436
|
+
devices = state.get("associatedDevices", "")
|
|
1437
|
+
is_int = u in integration_users
|
|
1438
|
+
flag = "Yes" if is_int else ""
|
|
1439
|
+
parts.append(
|
|
1440
|
+
f'<tr><td>{html.escape(uid)}</td>'
|
|
1441
|
+
f'<td>{html.escape(desc)}</td>'
|
|
1442
|
+
f'<td>{html.escape(str(devices))}</td>'
|
|
1443
|
+
f'<td>{flag}</td></tr>'
|
|
1444
|
+
)
|
|
1445
|
+
parts.append('</tbody></table>')
|
|
1446
|
+
|
|
1447
|
+
if h323_gws:
|
|
1448
|
+
parts.append(f'<h4>H.323 Gateways ({len(h323_gws)})</h4>')
|
|
1449
|
+
parts.append(
|
|
1450
|
+
'<div class="callout warning">'
|
|
1451
|
+
'<p><strong>H.323 gateways require protocol conversion.</strong> '
|
|
1452
|
+
'Webex Calling uses SIP only — these gateways must be replaced or '
|
|
1453
|
+
'converted to SIP Local Gateway.</p></div>'
|
|
1454
|
+
)
|
|
1455
|
+
parts.append('<table>')
|
|
1456
|
+
parts.append('<thead><tr><th>Name</th><th>Product</th><th>Description</th></tr></thead>')
|
|
1457
|
+
parts.append('<tbody>')
|
|
1458
|
+
for gw in h323_gws:
|
|
1459
|
+
state = gw.get("pre_migration_state", {})
|
|
1460
|
+
parts.append(
|
|
1461
|
+
f'<tr><td>{html.escape(state.get("name", ""))}</td>'
|
|
1462
|
+
f'<td>{html.escape(state.get("product", ""))}</td>'
|
|
1463
|
+
f'<td>{html.escape(state.get("description", ""))}</td></tr>'
|
|
1464
|
+
)
|
|
1465
|
+
parts.append('</tbody></table>')
|
|
1466
|
+
|
|
1467
|
+
if enterprise:
|
|
1468
|
+
parts.append('<h4>Enterprise Parameters</h4>')
|
|
1469
|
+
parts.append('<p>Enterprise parameter baseline captured for reference. '
|
|
1470
|
+
'Review cluster-wide settings during migration planning.</p>')
|
|
1471
|
+
|
|
1472
|
+
if service_params:
|
|
1473
|
+
parts.append(f'<h4>Service Parameters ({len(service_params)})</h4>')
|
|
1474
|
+
parts.append('<p>Telephony-related service parameters captured. '
|
|
1475
|
+
'Key parameters may need equivalent Webex configuration.</p>')
|
|
1476
|
+
parts.append('<table>')
|
|
1477
|
+
parts.append('<thead><tr><th>Parameter</th><th>Service</th><th>Value</th></tr></thead>')
|
|
1478
|
+
parts.append('<tbody>')
|
|
1479
|
+
for sp in service_params[:20]:
|
|
1480
|
+
state = sp.get("pre_migration_state", {})
|
|
1481
|
+
parts.append(
|
|
1482
|
+
f'<tr><td>{html.escape(state.get("name", ""))}</td>'
|
|
1483
|
+
f'<td>{html.escape(state.get("service", ""))}</td>'
|
|
1484
|
+
f'<td>{html.escape(str(state.get("value", "")))}</td></tr>'
|
|
1485
|
+
)
|
|
1486
|
+
parts.append('</tbody></table>')
|
|
1487
|
+
if len(service_params) > 20:
|
|
1488
|
+
parts.append(f'<p class="muted">{len(service_params) - 20} additional parameters not shown.</p>')
|
|
1489
|
+
|
|
1490
|
+
parts.append('</div></details>')
|
|
1491
|
+
return "\n".join(parts)
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
# ---------------------------------------------------------------------------
|
|
1495
|
+
# T. Call Recording Inventory (Tier 4 Item 1)
|
|
1496
|
+
# ---------------------------------------------------------------------------
|
|
1497
|
+
|
|
1498
|
+
def _recording_inventory(store: MigrationStore) -> str:
|
|
1499
|
+
"""T. Users with call recording enabled -- from phone line data."""
|
|
1500
|
+
phones = store.get_objects("phone")
|
|
1501
|
+
if not phones:
|
|
1502
|
+
return ""
|
|
1503
|
+
|
|
1504
|
+
recording_entries: list[tuple[str, str, str, str, str]] = [] # (user, phone, dn, flag, profile)
|
|
1505
|
+
|
|
1506
|
+
for phone in phones:
|
|
1507
|
+
pre = phone.get("pre_migration_state", {}) or {}
|
|
1508
|
+
owner_raw = pre.get("ownerUserName", "")
|
|
1509
|
+
if isinstance(owner_raw, dict):
|
|
1510
|
+
owner = owner_raw.get("_value_1", "") or ""
|
|
1511
|
+
else:
|
|
1512
|
+
owner = owner_raw or ""
|
|
1513
|
+
phone_name = pre.get("name", strip_canonical_id(phone.get("canonical_id", "")))
|
|
1514
|
+
for line in (pre.get("lines") or []):
|
|
1515
|
+
if not isinstance(line, dict):
|
|
1516
|
+
continue
|
|
1517
|
+
flag = line.get("recordingFlag", "Call Recording Disabled")
|
|
1518
|
+
if flag and flag != "Call Recording Disabled":
|
|
1519
|
+
dirn = line.get("dirn", {})
|
|
1520
|
+
pattern = dirn.get("pattern", "") if isinstance(dirn, dict) else ""
|
|
1521
|
+
profile_ref = line.get("recordingProfileName")
|
|
1522
|
+
profile = ""
|
|
1523
|
+
if isinstance(profile_ref, dict):
|
|
1524
|
+
profile = profile_ref.get("_value_1", "")
|
|
1525
|
+
elif isinstance(profile_ref, str):
|
|
1526
|
+
profile = profile_ref
|
|
1527
|
+
recording_entries.append((owner or "\u2014", phone_name, pattern, flag, profile))
|
|
1528
|
+
|
|
1529
|
+
if not recording_entries:
|
|
1530
|
+
return ""
|
|
1531
|
+
|
|
1532
|
+
users = set(e[0] for e in recording_entries if e[0] != "\u2014")
|
|
1533
|
+
summary = f"{len(recording_entries)} lines with recording enabled across {len(users)} users"
|
|
1534
|
+
|
|
1535
|
+
parts = [
|
|
1536
|
+
'<details id="recording-inventory">',
|
|
1537
|
+
f'<summary>T. Call Recording Inventory <span class="summary-count">'
|
|
1538
|
+
f'\u2014 {summary}</span></summary>',
|
|
1539
|
+
'<div class="details-content">',
|
|
1540
|
+
'<div class="callout warning">',
|
|
1541
|
+
'<p><strong>Call recording requires separate Webex configuration.</strong> '
|
|
1542
|
+
'Each recording-enabled user needs: (1) Webex Call Recording license, '
|
|
1543
|
+
'(2) location-level recording vendor configuration, and '
|
|
1544
|
+
'(3) person-level recording settings enabled.</p>',
|
|
1545
|
+
'</div>',
|
|
1546
|
+
'<table>',
|
|
1547
|
+
'<thead><tr><th>User</th><th>Phone</th><th>DN</th><th>Recording Mode</th><th>Recording Profile</th></tr></thead>',
|
|
1548
|
+
'<tbody>',
|
|
1549
|
+
]
|
|
1550
|
+
|
|
1551
|
+
em_dash = "\u2014"
|
|
1552
|
+
for owner, phone_name, pattern, flag, profile in sorted(recording_entries):
|
|
1553
|
+
mode = flag.replace("Call Recording ", "").replace(" Enabled", "")
|
|
1554
|
+
profile_display = html.escape(profile) if profile else em_dash
|
|
1555
|
+
parts.append(
|
|
1556
|
+
f'<tr><td>{html.escape(owner)}</td>'
|
|
1557
|
+
f'<td>{html.escape(phone_name)}</td>'
|
|
1558
|
+
f'<td>{html.escape(pattern)}</td>'
|
|
1559
|
+
f'<td>{html.escape(mode)}</td>'
|
|
1560
|
+
f'<td>{profile_display}</td></tr>'
|
|
1561
|
+
)
|
|
1562
|
+
|
|
1563
|
+
parts.append('</tbody></table>')
|
|
1564
|
+
parts.append('</div></details>')
|
|
1565
|
+
return "\n".join(parts)
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
# ---------------------------------------------------------------------------
|
|
1569
|
+
# U. Single Number Reach Inventory (Tier 4 Item 2)
|
|
1570
|
+
# ---------------------------------------------------------------------------
|
|
1571
|
+
|
|
1572
|
+
def _snr_inventory(store: MigrationStore) -> str:
|
|
1573
|
+
"""U. Remote destination profiles -- Single Number Reach."""
|
|
1574
|
+
rdps = store.get_objects("remote_destination")
|
|
1575
|
+
if not rdps:
|
|
1576
|
+
return ""
|
|
1577
|
+
|
|
1578
|
+
owners: dict[str, list[dict]] = defaultdict(list)
|
|
1579
|
+
for rdp in rdps:
|
|
1580
|
+
pre = rdp.get("pre_migration_state", {}) or {}
|
|
1581
|
+
owner = pre.get("ownerUserId", "") or "\u2014"
|
|
1582
|
+
owners[owner].append(pre)
|
|
1583
|
+
|
|
1584
|
+
summary = f"{len(rdps)} remote destinations for {len(owners)} users"
|
|
1585
|
+
|
|
1586
|
+
parts = [
|
|
1587
|
+
'<details id="snr-inventory">',
|
|
1588
|
+
f'<summary>U. Single Number Reach <span class="summary-count">'
|
|
1589
|
+
f'\u2014 {summary}</span></summary>',
|
|
1590
|
+
'<div class="details-content">',
|
|
1591
|
+
'<div class="callout">',
|
|
1592
|
+
'<p><strong>Webex SNR is simpler than CUCM.</strong> '
|
|
1593
|
+
'CUCM timer controls (answer-too-soon, answer-too-late thresholds) '
|
|
1594
|
+
'do not have Webex equivalents. Manual setup required per user.</p>',
|
|
1595
|
+
'</div>',
|
|
1596
|
+
'<table>',
|
|
1597
|
+
'<thead><tr><th>User</th><th>Profile Name</th><th>Destination</th></tr></thead>',
|
|
1598
|
+
'<tbody>',
|
|
1599
|
+
]
|
|
1600
|
+
|
|
1601
|
+
for owner in sorted(owners):
|
|
1602
|
+
for rdp in owners[owner]:
|
|
1603
|
+
name = rdp.get("name", "\u2014")
|
|
1604
|
+
dest = rdp.get("destination", "\u2014")
|
|
1605
|
+
parts.append(
|
|
1606
|
+
f'<tr><td>{html.escape(owner)}</td>'
|
|
1607
|
+
f'<td>{html.escape(name)}</td>'
|
|
1608
|
+
f'<td>{html.escape(dest)}</td></tr>'
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
parts.append('</tbody></table>')
|
|
1612
|
+
parts.append('</div></details>')
|
|
1613
|
+
return "\n".join(parts)
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
# ---------------------------------------------------------------------------
|
|
1617
|
+
# V. Caller ID Transformation Patterns (Tier 4 Item 4)
|
|
1618
|
+
# ---------------------------------------------------------------------------
|
|
1619
|
+
|
|
1620
|
+
def _caller_id_xforms(store: MigrationStore) -> str:
|
|
1621
|
+
"""V. Calling/Called party transformation patterns."""
|
|
1622
|
+
calling = store.get_objects("info_calling_xform")
|
|
1623
|
+
called = store.get_objects("info_called_xform")
|
|
1624
|
+
total = len(calling) + len(called)
|
|
1625
|
+
|
|
1626
|
+
if total == 0:
|
|
1627
|
+
return ""
|
|
1628
|
+
|
|
1629
|
+
summary = f"{len(calling)} calling party, {len(called)} called party"
|
|
1630
|
+
|
|
1631
|
+
parts = [
|
|
1632
|
+
'<details id="caller-id-xforms">',
|
|
1633
|
+
f'<summary>V. Caller ID Transformations <span class="summary-count">'
|
|
1634
|
+
f'\u2014 {summary}</span></summary>',
|
|
1635
|
+
'<div class="details-content">',
|
|
1636
|
+
'<div class="callout">',
|
|
1637
|
+
'<p><strong>CUCM caller ID transformations → Webex location-level outbound caller ID.</strong> '
|
|
1638
|
+
'Each pattern requires manual review to determine the equivalent Webex configuration.</p>',
|
|
1639
|
+
'</div>',
|
|
1640
|
+
]
|
|
1641
|
+
|
|
1642
|
+
if calling:
|
|
1643
|
+
parts.append('<h4>Calling Party Transformations</h4>')
|
|
1644
|
+
parts.append('<table>')
|
|
1645
|
+
parts.append('<thead><tr><th>Pattern</th><th>Partition</th><th>Mask</th><th>Description</th></tr></thead>')
|
|
1646
|
+
parts.append('<tbody>')
|
|
1647
|
+
for obj in calling:
|
|
1648
|
+
pre = obj.get("pre_migration_state", {}) or {}
|
|
1649
|
+
parts.append(
|
|
1650
|
+
f'<tr><td>{html.escape(_axl_str(pre.get("pattern", "")))}</td>'
|
|
1651
|
+
f'<td>{html.escape(_axl_str(pre.get("routePartitionName", "")))}</td>'
|
|
1652
|
+
f'<td>{html.escape(_axl_str(pre.get("callingPartyTransformationMask", "")))}</td>'
|
|
1653
|
+
f'<td>{html.escape(_axl_str(pre.get("description", "")))}</td></tr>'
|
|
1654
|
+
)
|
|
1655
|
+
parts.append('</tbody></table>')
|
|
1656
|
+
|
|
1657
|
+
if called:
|
|
1658
|
+
parts.append('<h4>Called Party Transformations</h4>')
|
|
1659
|
+
parts.append('<table>')
|
|
1660
|
+
parts.append('<thead><tr><th>Pattern</th><th>Partition</th><th>Mask</th><th>Description</th></tr></thead>')
|
|
1661
|
+
parts.append('<tbody>')
|
|
1662
|
+
for obj in called:
|
|
1663
|
+
pre = obj.get("pre_migration_state", {}) or {}
|
|
1664
|
+
parts.append(
|
|
1665
|
+
f'<tr><td>{html.escape(_axl_str(pre.get("pattern", "")))}</td>'
|
|
1666
|
+
f'<td>{html.escape(_axl_str(pre.get("routePartitionName", "")))}</td>'
|
|
1667
|
+
f'<td>{html.escape(_axl_str(pre.get("calledPartyTransformationMask", "")))}</td>'
|
|
1668
|
+
f'<td>{html.escape(_axl_str(pre.get("description", "")))}</td></tr>'
|
|
1669
|
+
)
|
|
1670
|
+
parts.append('</tbody></table>')
|
|
1671
|
+
|
|
1672
|
+
parts.append('</div></details>')
|
|
1673
|
+
return "\n".join(parts)
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
# ---------------------------------------------------------------------------
|
|
1677
|
+
# W. Extension Mobility Usage (Tier 4 Item 6)
|
|
1678
|
+
# ---------------------------------------------------------------------------
|
|
1679
|
+
|
|
1680
|
+
def _extension_mobility_group(store: MigrationStore) -> str:
|
|
1681
|
+
"""W. Extension Mobility device profiles -- hot desking with feature loss detail."""
|
|
1682
|
+
profiles = store.get_objects("info_device_profile")
|
|
1683
|
+
if not profiles:
|
|
1684
|
+
return ""
|
|
1685
|
+
|
|
1686
|
+
summary = f"{len(profiles)} device profiles"
|
|
1687
|
+
|
|
1688
|
+
parts = [
|
|
1689
|
+
'<details id="extension-mobility">',
|
|
1690
|
+
f'<summary>W. Extension Mobility <span class="summary-count">'
|
|
1691
|
+
f'\u2014 {summary}</span></summary>',
|
|
1692
|
+
'<div class="details-content">',
|
|
1693
|
+
'<div class="callout">',
|
|
1694
|
+
'<p><strong>Extension Mobility → Webex hot desking.</strong> '
|
|
1695
|
+
'CUCM device profile switching does not carry over. Webex hot desking provides '
|
|
1696
|
+
'user login/logout with primary line on shared workspaces. '
|
|
1697
|
+
'Multi-line profiles, speed dials, and BLF entries are not available during '
|
|
1698
|
+
'hot desk sessions.</p>',
|
|
1699
|
+
'</div>',
|
|
1700
|
+
'<table>',
|
|
1701
|
+
'<thead><tr>'
|
|
1702
|
+
'<th>Profile Name</th><th>Product</th><th>Lines</th>'
|
|
1703
|
+
'<th>Speed Dials</th><th>BLF</th><th>Feature Loss</th>'
|
|
1704
|
+
'</tr></thead>',
|
|
1705
|
+
'<tbody>',
|
|
1706
|
+
]
|
|
1707
|
+
|
|
1708
|
+
for p in profiles:
|
|
1709
|
+
pre = p.get("pre_migration_state", {}) or {}
|
|
1710
|
+
name = pre.get("name", strip_canonical_id(p.get("canonical_id", "")))
|
|
1711
|
+
product = pre.get("product", "\u2014")
|
|
1712
|
+
lines = pre.get("lines") or []
|
|
1713
|
+
line_count = len(lines) if lines else pre.get("line_count", 0)
|
|
1714
|
+
sd_count = pre.get("speed_dial_count", 0)
|
|
1715
|
+
blf_count_val = pre.get("blf_count", 0)
|
|
1716
|
+
|
|
1717
|
+
if line_count > 1 or blf_count_val > 0:
|
|
1718
|
+
loss = "Medium"
|
|
1719
|
+
loss_class = "badge-warning"
|
|
1720
|
+
elif sd_count > 0:
|
|
1721
|
+
loss = "Low"
|
|
1722
|
+
loss_class = "badge-info"
|
|
1723
|
+
else:
|
|
1724
|
+
loss = "None"
|
|
1725
|
+
loss_class = "badge-success"
|
|
1726
|
+
|
|
1727
|
+
parts.append(
|
|
1728
|
+
f'<tr><td>{html.escape(name)}</td>'
|
|
1729
|
+
f'<td>{html.escape(product)}</td>'
|
|
1730
|
+
f'<td>{line_count}</td>'
|
|
1731
|
+
f'<td>{sd_count}</td>'
|
|
1732
|
+
f'<td>{blf_count_val}</td>'
|
|
1733
|
+
f'<td><span class="{loss_class}">{loss}</span></td></tr>'
|
|
1734
|
+
)
|
|
1735
|
+
|
|
1736
|
+
parts.append('</tbody></table>')
|
|
1737
|
+
parts.append('</div></details>')
|
|
1738
|
+
return "\n".join(parts)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _dect_networks_group(store: MigrationStore) -> str:
|
|
1742
|
+
"""X. DECT Networks — inventory, coverage zones, and design inputs."""
|
|
1743
|
+
devices = store.get_objects("device")
|
|
1744
|
+
dect_devices = [d for d in devices if d.get("compatibility_tier") == "dect"]
|
|
1745
|
+
if not dect_devices:
|
|
1746
|
+
return ""
|
|
1747
|
+
|
|
1748
|
+
parts: list[str] = []
|
|
1749
|
+
parts.append('<details id="dect-networks">')
|
|
1750
|
+
parts.append('<summary><span class="section-indicator">X</span> DECT Networks</summary>')
|
|
1751
|
+
|
|
1752
|
+
# --- Subsection 1: Inventory table ---
|
|
1753
|
+
parts.append("<h4>DECT Handset Inventory</h4>")
|
|
1754
|
+
parts.append("<table><thead><tr>")
|
|
1755
|
+
parts.append("<th>Device Name</th><th>Model</th><th>Owner</th>"
|
|
1756
|
+
"<th>Extension</th><th>Device Pool</th>")
|
|
1757
|
+
parts.append("</tr></thead><tbody>")
|
|
1758
|
+
|
|
1759
|
+
for d in sorted(dect_devices, key=lambda x: x.get("cucm_device_name") or ""):
|
|
1760
|
+
state = d.get("pre_migration_state") or {}
|
|
1761
|
+
device_name = d.get("cucm_device_name") or strip_canonical_id(d.get("canonical_id", ""))
|
|
1762
|
+
model = d.get("model", "Unknown")
|
|
1763
|
+
owner = state.get("ownerUserName") or "<em>unowned</em>"
|
|
1764
|
+
lines = state.get("line_appearances") or d.get("line_appearances") or []
|
|
1765
|
+
ext = ""
|
|
1766
|
+
if lines:
|
|
1767
|
+
dirn = lines[0].get("dirn", {}) if isinstance(lines[0], dict) else {}
|
|
1768
|
+
ext = dirn.get("pattern", "")
|
|
1769
|
+
dp = state.get("cucm_device_pool", "\u2014")
|
|
1770
|
+
|
|
1771
|
+
parts.append(f"<tr><td>{device_name}</td><td>{model}</td>"
|
|
1772
|
+
f"<td>{owner}</td><td>{ext}</td><td>{dp}</td></tr>")
|
|
1773
|
+
|
|
1774
|
+
parts.append("</tbody></table>")
|
|
1775
|
+
|
|
1776
|
+
# --- Subsection 2: Coverage zone analysis ---
|
|
1777
|
+
zones: dict[str, list[dict]] = {}
|
|
1778
|
+
for d in dect_devices:
|
|
1779
|
+
dp = (d.get("pre_migration_state") or {}).get("cucm_device_pool", "Unknown")
|
|
1780
|
+
zones.setdefault(dp, []).append(d)
|
|
1781
|
+
|
|
1782
|
+
parts.append("<h4>Coverage Zone Analysis</h4>")
|
|
1783
|
+
parts.append("<table><thead><tr>")
|
|
1784
|
+
parts.append("<th>Coverage Zone (Device Pool)</th><th>Handset Count</th>"
|
|
1785
|
+
"<th>Owned</th><th>Unowned</th>")
|
|
1786
|
+
parts.append("</tr></thead><tbody>")
|
|
1787
|
+
|
|
1788
|
+
for zone_name in sorted(zones):
|
|
1789
|
+
handsets = zones[zone_name]
|
|
1790
|
+
total = len(handsets)
|
|
1791
|
+
owned = sum(1 for h in handsets if (h.get("pre_migration_state") or {}).get("ownerUserName"))
|
|
1792
|
+
unowned = total - owned
|
|
1793
|
+
parts.append(f"<tr><td>{zone_name}</td><td>{total}</td>"
|
|
1794
|
+
f"<td>{owned}</td><td>{unowned}</td></tr>")
|
|
1795
|
+
|
|
1796
|
+
parts.append("</tbody></table>")
|
|
1797
|
+
|
|
1798
|
+
# --- Subsection 3: Design inputs ---
|
|
1799
|
+
total_handsets = len(dect_devices)
|
|
1800
|
+
zone_count = len(zones)
|
|
1801
|
+
parts.append("<h4>DECT Network Design Inputs</h4>")
|
|
1802
|
+
parts.append('<div class="callout">')
|
|
1803
|
+
parts.append(f"<p><strong>Total DECT handsets:</strong> {total_handsets}</p>")
|
|
1804
|
+
parts.append(f"<p><strong>Implied coverage zones:</strong> {zone_count} "
|
|
1805
|
+
f"(from device pool grouping)</p>")
|
|
1806
|
+
parts.append("<p><strong>Recommended:</strong> Provide base station inventory "
|
|
1807
|
+
"(MAC addresses and model) for each coverage zone.</p>")
|
|
1808
|
+
|
|
1809
|
+
for zone_name in sorted(zones):
|
|
1810
|
+
count = len(zones[zone_name])
|
|
1811
|
+
if count > 30:
|
|
1812
|
+
parts.append(f"<p><strong>{zone_name}:</strong> {count} handsets \u2014 "
|
|
1813
|
+
f"multi-cell DBS-210 network recommended</p>")
|
|
1814
|
+
else:
|
|
1815
|
+
parts.append(f"<p><strong>{zone_name}:</strong> {count} handsets \u2014 "
|
|
1816
|
+
f"single-cell DBS-110 or multi-cell DBS-210</p>")
|
|
1817
|
+
|
|
1818
|
+
parts.append("</div>")
|
|
1819
|
+
|
|
1820
|
+
# --- Subsection 4: Shared handset warning (conditional) ---
|
|
1821
|
+
unowned_total = sum(
|
|
1822
|
+
1 for d in dect_devices
|
|
1823
|
+
if not (d.get("pre_migration_state") or {}).get("ownerUserName")
|
|
1824
|
+
)
|
|
1825
|
+
if unowned_total:
|
|
1826
|
+
parts.append("<h4>Shared Handset Warnings</h4>")
|
|
1827
|
+
parts.append('<div class="callout">')
|
|
1828
|
+
parts.append(
|
|
1829
|
+
f"<p><strong>{unowned_total} DECT handset{'s' if unowned_total != 1 else ''} "
|
|
1830
|
+
f"with no owner.</strong> These may be shared or roaming handsets that need "
|
|
1831
|
+
f"workspace assignment in Webex.</p>"
|
|
1832
|
+
)
|
|
1833
|
+
parts.append("</div>")
|
|
1834
|
+
|
|
1835
|
+
parts.append("</details>")
|
|
1836
|
+
return "\n".join(parts)
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _intercept_candidates(store: MigrationStore) -> str:
|
|
1840
|
+
"""Y. Call Intercept Candidates."""
|
|
1841
|
+
candidates = store.get_objects("intercept_candidate")
|
|
1842
|
+
if not candidates:
|
|
1843
|
+
return ""
|
|
1844
|
+
parts = [
|
|
1845
|
+
'<details id="intercept-candidates">',
|
|
1846
|
+
f'<summary>Y. Call Intercept Candidates <span class="summary-count">'
|
|
1847
|
+
f'\u2014 {len(candidates)} candidates</span></summary>',
|
|
1848
|
+
'<div class="details-content">',
|
|
1849
|
+
'<table>',
|
|
1850
|
+
'<thead><tr><th>User</th><th>DN</th><th>Partition</th>'
|
|
1851
|
+
'<th>Signal Type</th><th>Forward Destination</th></tr></thead>',
|
|
1852
|
+
'<tbody>',
|
|
1853
|
+
]
|
|
1854
|
+
em = "—"
|
|
1855
|
+
for ic in sorted(candidates, key=lambda c: c.get("pre_migration_state", {}).get("userid", "")):
|
|
1856
|
+
pre = ic.get("pre_migration_state", {}) or {}
|
|
1857
|
+
userid = pre.get("userid") or em
|
|
1858
|
+
dn = pre.get("dn") or em
|
|
1859
|
+
partition = pre.get("partition") or em
|
|
1860
|
+
signal = (pre.get("signal_type") or "unknown").replace("_", " ").title()
|
|
1861
|
+
fwd = pre.get("forward_destination") or em
|
|
1862
|
+
parts.append(
|
|
1863
|
+
f'<tr><td>{html.escape(userid)}</td>'
|
|
1864
|
+
f'<td>{html.escape(dn)}</td>'
|
|
1865
|
+
f'<td>{html.escape(partition)}</td>'
|
|
1866
|
+
f'<td>{html.escape(signal)}</td>'
|
|
1867
|
+
f'<td>{html.escape(fwd)}</td></tr>'
|
|
1868
|
+
)
|
|
1869
|
+
parts.extend(['</tbody></table>', '</div></details>'])
|
|
1870
|
+
return "\n".join(parts)
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
# ---------------------------------------------------------------------------
|
|
1874
|
+
# Z. Executive/Assistant Pairings
|
|
1875
|
+
# ---------------------------------------------------------------------------
|
|
1876
|
+
|
|
1877
|
+
def _executive_assistant_group(store: MigrationStore) -> str:
|
|
1878
|
+
"""Z. Executive/Assistant Pairings — delegation relationships."""
|
|
1879
|
+
ea_objs = store.get_objects("executive_assistant")
|
|
1880
|
+
if not ea_objs:
|
|
1881
|
+
return ""
|
|
1882
|
+
|
|
1883
|
+
parts = [
|
|
1884
|
+
'<details id="section-exec-assistant">',
|
|
1885
|
+
f'<summary>Z. Executive/Assistant Pairings '
|
|
1886
|
+
f'<span class="summary-count">— {len(ea_objs)} executive(s)</span></summary>',
|
|
1887
|
+
'<div class="details-content">',
|
|
1888
|
+
"<p>Executive/assistant pairings configured in CUCM. Each executive has one or "
|
|
1889
|
+
"more assistants who can answer, screen, and place calls on their behalf.</p>",
|
|
1890
|
+
"<table>",
|
|
1891
|
+
"<thead><tr>"
|
|
1892
|
+
"<th>Executive</th>"
|
|
1893
|
+
"<th>Assistants</th>"
|
|
1894
|
+
"<th>Alerting</th>"
|
|
1895
|
+
"<th>Filtering</th>"
|
|
1896
|
+
"<th>Screening</th>"
|
|
1897
|
+
"</tr></thead>",
|
|
1898
|
+
"<tbody>",
|
|
1899
|
+
]
|
|
1900
|
+
|
|
1901
|
+
for ea in ea_objs:
|
|
1902
|
+
exec_id = ea.get("executive_canonical_id", "")
|
|
1903
|
+
exec_name = strip_canonical_id(exec_id)
|
|
1904
|
+
assistants = ea.get("assistant_canonical_ids", [])
|
|
1905
|
+
asst_names = ", ".join(strip_canonical_id(a) for a in assistants)
|
|
1906
|
+
alerting = ea.get("alerting_mode", "SIMULTANEOUS")
|
|
1907
|
+
filtering = "No"
|
|
1908
|
+
if ea.get("filter_enabled"):
|
|
1909
|
+
filter_type = ea.get("filter_type", "")
|
|
1910
|
+
filtering = f"Yes ({html.escape(filter_type)})" if filter_type else "Yes"
|
|
1911
|
+
screening = "Yes" if ea.get("screening_enabled") else "No"
|
|
1912
|
+
|
|
1913
|
+
parts.append(
|
|
1914
|
+
f"<tr>"
|
|
1915
|
+
f"<td>{html.escape(exec_name)}</td>"
|
|
1916
|
+
f"<td>{html.escape(asst_names)}</td>"
|
|
1917
|
+
f"<td>{html.escape(alerting)}</td>"
|
|
1918
|
+
f"<td>{filtering}</td>"
|
|
1919
|
+
f"<td>{screening}</td>"
|
|
1920
|
+
f"</tr>"
|
|
1921
|
+
)
|
|
1922
|
+
|
|
1923
|
+
parts.extend(["</tbody>", "</table>", "</div>", "</details>"])
|
|
1924
|
+
return "\n".join(parts)
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
# ---------------------------------------------------------------------------
|
|
1928
|
+
# AA. Receptionist Users
|
|
1929
|
+
# ---------------------------------------------------------------------------
|
|
1930
|
+
|
|
1931
|
+
def _receptionist_group(store: MigrationStore) -> str:
|
|
1932
|
+
"""AA. Receptionist Users — detected users with receptionist-style BLF/KEM/template configs."""
|
|
1933
|
+
receptionists = store.get_objects("receptionist_config")
|
|
1934
|
+
if not receptionists:
|
|
1935
|
+
return ""
|
|
1936
|
+
|
|
1937
|
+
total = len(receptionists)
|
|
1938
|
+
sorted_recs = sorted(receptionists, key=lambda r: -(r.get("detection_score") or 0))
|
|
1939
|
+
|
|
1940
|
+
em_dash = "\u2014"
|
|
1941
|
+
|
|
1942
|
+
parts = [
|
|
1943
|
+
'<details id="receptionist-users">',
|
|
1944
|
+
f'<summary>AA. Receptionist Users <span class="summary-count">'
|
|
1945
|
+
f'\u2014 {total} detected</span></summary>',
|
|
1946
|
+
'<div class="details-content">',
|
|
1947
|
+
'<p>These users were detected as likely receptionists based on BLF monitoring lists, '
|
|
1948
|
+
'KEM modules, and phone button templates. They may be using Cisco Unified Attendant '
|
|
1949
|
+
'Console (CUAC) or CUCM receptionist console features. Each requires attention during '
|
|
1950
|
+
'migration to preserve answering workflows and directory visibility.</p>',
|
|
1951
|
+
'<table>',
|
|
1952
|
+
'<thead><tr>'
|
|
1953
|
+
'<th>User</th>'
|
|
1954
|
+
'<th>Location</th>'
|
|
1955
|
+
'<th class="num">BLF Count</th>'
|
|
1956
|
+
'<th>KEM</th>'
|
|
1957
|
+
'<th>Template</th>'
|
|
1958
|
+
'<th class="num">Score</th>'
|
|
1959
|
+
'<th>Main #</th>'
|
|
1960
|
+
'</tr></thead>',
|
|
1961
|
+
'<tbody>',
|
|
1962
|
+
]
|
|
1963
|
+
|
|
1964
|
+
for rec in sorted_recs:
|
|
1965
|
+
user_raw = rec.get("user_canonical_id", "")
|
|
1966
|
+
loc_raw = rec.get("location_canonical_id", "")
|
|
1967
|
+
user_display = strip_canonical_id(user_raw) if user_raw else em_dash
|
|
1968
|
+
loc_display = strip_canonical_id(loc_raw) if loc_raw else em_dash
|
|
1969
|
+
blf_count = rec.get("blf_count") or 0
|
|
1970
|
+
has_kem = rec.get("has_kem") or False
|
|
1971
|
+
template = rec.get("template_name") or em_dash
|
|
1972
|
+
score = rec.get("detection_score") or 0
|
|
1973
|
+
is_main = rec.get("is_main_number_holder") or False
|
|
1974
|
+
|
|
1975
|
+
parts.append(
|
|
1976
|
+
f'<tr>'
|
|
1977
|
+
f'<td>{html.escape(user_display)}</td>'
|
|
1978
|
+
f'<td>{html.escape(loc_display)}</td>'
|
|
1979
|
+
f'<td class="num">{blf_count}</td>'
|
|
1980
|
+
f'<td>{"Yes" if has_kem else em_dash}</td>'
|
|
1981
|
+
f'<td>{html.escape(str(template))}</td>'
|
|
1982
|
+
f'<td class="num">{score}</td>'
|
|
1983
|
+
f'<td>{"Yes" if is_main else em_dash}</td>'
|
|
1984
|
+
f'</tr>'
|
|
1985
|
+
)
|
|
1986
|
+
|
|
1987
|
+
parts.append('</tbody></table>')
|
|
1988
|
+
|
|
1989
|
+
# Migration Impact sub-table
|
|
1990
|
+
parts.extend([
|
|
1991
|
+
'<h4>Migration Impact</h4>',
|
|
1992
|
+
'<table>',
|
|
1993
|
+
'<thead><tr>'
|
|
1994
|
+
'<th>CUCM Feature</th>'
|
|
1995
|
+
'<th>Webex Equivalent</th>'
|
|
1996
|
+
'<th>Action</th>'
|
|
1997
|
+
'</tr></thead>',
|
|
1998
|
+
'<tbody>',
|
|
1999
|
+
'<tr>'
|
|
2000
|
+
'<td>BLF monitoring list</td>'
|
|
2001
|
+
'<td>Receptionist Client monitored members</td>'
|
|
2002
|
+
'<td>Automatic \u2014 migrated from BLF entries</td>'
|
|
2003
|
+
'</tr>',
|
|
2004
|
+
'<tr>'
|
|
2005
|
+
'<td>Phone button template layout</td>'
|
|
2006
|
+
'<td>Line key template</td>'
|
|
2007
|
+
'<td>Automatic \u2014 migrated by DeviceLayoutMapper</td>'
|
|
2008
|
+
'</tr>',
|
|
2009
|
+
'<tr>'
|
|
2010
|
+
'<td>CUAC desktop application</td>'
|
|
2011
|
+
'<td>Webex Receptionist Console (web)</td>'
|
|
2012
|
+
'<td>Manual \u2014 requires user training</td>'
|
|
2013
|
+
'</tr>',
|
|
2014
|
+
'<tr>'
|
|
2015
|
+
'<td>CUAC directory search</td>'
|
|
2016
|
+
'<td>Receptionist Contact Directories</td>'
|
|
2017
|
+
'<td>Semi-automatic \u2014 directories created during execution</td>'
|
|
2018
|
+
'</tr>',
|
|
2019
|
+
'<tr>'
|
|
2020
|
+
'<td>CTI application user routing</td>'
|
|
2021
|
+
'<td>No direct equivalent</td>'
|
|
2022
|
+
'<td>Rebuild \u2014 use Auto Attendant or Call Queue</td>'
|
|
2023
|
+
'</tr>',
|
|
2024
|
+
'</tbody></table>',
|
|
2025
|
+
'</div></details>',
|
|
2026
|
+
])
|
|
2027
|
+
|
|
2028
|
+
return "\n".join(parts)
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
# ---------------------------------------------------------------------------
|
|
2032
|
+
# AB. Selective Call Handling Opportunities
|
|
2033
|
+
# ---------------------------------------------------------------------------
|
|
2034
|
+
|
|
2035
|
+
_SCH_PATTERN_DISPLAY = {
|
|
2036
|
+
"multi_partition_dn": "Multi-Partition DN",
|
|
2037
|
+
"low_membership_partition": "Low-Membership Partition",
|
|
2038
|
+
"naming_convention": "Naming Convention",
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
def _selective_call_handling(store: MigrationStore) -> str:
|
|
2043
|
+
"""AB. Selective Call Handling Opportunities — CUCM CSS patterns."""
|
|
2044
|
+
candidates: list[dict[str, Any]] = []
|
|
2045
|
+
for d in store.get_all_decisions():
|
|
2046
|
+
if d.get("type") != "FEATURE_APPROXIMATION":
|
|
2047
|
+
continue
|
|
2048
|
+
if d.get("chosen_option") == "__stale__":
|
|
2049
|
+
continue
|
|
2050
|
+
ctx = d.get("context", {}) or {}
|
|
2051
|
+
pattern = ctx.get("selective_call_handling_pattern")
|
|
2052
|
+
if not pattern:
|
|
2053
|
+
continue
|
|
2054
|
+
candidates.append(d)
|
|
2055
|
+
|
|
2056
|
+
if not candidates:
|
|
2057
|
+
return ""
|
|
2058
|
+
|
|
2059
|
+
parts = [
|
|
2060
|
+
'<details id="selective-call-handling">',
|
|
2061
|
+
f'<summary>AB. Selective Call Handling '
|
|
2062
|
+
f'<span class="summary-count">— {len(candidates)} candidate(s)</span>'
|
|
2063
|
+
f'</summary>',
|
|
2064
|
+
'<div class="details-content">',
|
|
2065
|
+
'<p>CUCM CSS/partition patterns suggest caller-specific routing. '
|
|
2066
|
+
'Webex Calling offers four explicit per-person features that can '
|
|
2067
|
+
'replicate this behaviour: Selective Forward, Selective Accept, '
|
|
2068
|
+
'Selective Reject, and Priority Alert. The first three are '
|
|
2069
|
+
'admin-configurable; Priority Alert requires per-user OAuth.</p>',
|
|
2070
|
+
'<table>',
|
|
2071
|
+
'<thead><tr>'
|
|
2072
|
+
'<th>Affected Object</th>'
|
|
2073
|
+
'<th>Pattern Type</th>'
|
|
2074
|
+
'<th>Partitions</th>'
|
|
2075
|
+
'<th>Recommended Webex Feature</th>'
|
|
2076
|
+
'</tr></thead>',
|
|
2077
|
+
'<tbody>',
|
|
2078
|
+
]
|
|
2079
|
+
|
|
2080
|
+
for d in sorted(candidates, key=lambda x: x.get("decision_id", "")):
|
|
2081
|
+
ctx = d.get("context", {}) or {}
|
|
2082
|
+
pattern = ctx.get("selective_call_handling_pattern", "")
|
|
2083
|
+
pattern_display = _SCH_PATTERN_DISPLAY.get(
|
|
2084
|
+
pattern, pattern.replace("_", " ").title()
|
|
2085
|
+
)
|
|
2086
|
+
affected_ids = ctx.get("_affected_objects", []) or []
|
|
2087
|
+
affected_display = ", ".join(
|
|
2088
|
+
strip_canonical_id(obj_id) for obj_id in affected_ids
|
|
2089
|
+
) or "—"
|
|
2090
|
+
partitions = ", ".join(ctx.get("partitions", [])) or "—"
|
|
2091
|
+
feature = ctx.get("recommended_webex_feature", "—")
|
|
2092
|
+
parts.append(
|
|
2093
|
+
f'<tr>'
|
|
2094
|
+
f'<td>{html.escape(affected_display)}</td>'
|
|
2095
|
+
f'<td>{html.escape(pattern_display)}</td>'
|
|
2096
|
+
f'<td>{html.escape(partitions)}</td>'
|
|
2097
|
+
f'<td>{html.escape(feature)}</td>'
|
|
2098
|
+
f'</tr>'
|
|
2099
|
+
)
|
|
2100
|
+
|
|
2101
|
+
parts.extend(['</tbody></table>', '</div></details>'])
|
|
2102
|
+
return "\n".join(parts)
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
# ---------------------------------------------------------------------------
|
|
2106
|
+
# AC. Device Settings Coverage
|
|
2107
|
+
# ---------------------------------------------------------------------------
|
|
2108
|
+
|
|
2109
|
+
_AUTOMATED_SETTINGS_BY_FAMILY: dict[str, list[str]] = {
|
|
2110
|
+
"9800": ["Line keys", "PSK mappings", "Screen layout", "DND", "Language", "Backlight timer", "USB ports", "CDP/LLDP"],
|
|
2111
|
+
"8875": ["Line keys", "PSK mappings", "Screen layout", "DND", "Language", "Backlight timer", "USB ports", "CDP/LLDP"],
|
|
2112
|
+
"78xx": ["Line keys", "DND", "Language", "Backlight timer", "CDP/LLDP"],
|
|
2113
|
+
"68xx": ["Line keys", "DND", "Language", "Backlight timer"],
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
_MANUAL_SETTINGS_BY_FAMILY: dict[str, list[str]] = {
|
|
2117
|
+
"9800": ["Wallpaper / background image", "Wi-Fi credentials", "Bluetooth pairing", "Network VLAN"],
|
|
2118
|
+
"8875": ["Wallpaper / background image", "Wi-Fi credentials", "Bluetooth pairing", "Network VLAN"],
|
|
2119
|
+
"78xx": ["Wallpaper / background image", "Network VLAN"],
|
|
2120
|
+
"68xx": ["Wallpaper / background image", "Network VLAN"],
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _device_settings_coverage(store: MigrationStore) -> str:
|
|
2125
|
+
"""AC. Device Settings Coverage — automated vs manual settings by model family."""
|
|
2126
|
+
templates = store.get_objects("device_settings_template")
|
|
2127
|
+
if not templates:
|
|
2128
|
+
return ""
|
|
2129
|
+
|
|
2130
|
+
# Count phones per model family across only native_mpp / convertible devices
|
|
2131
|
+
family_phone_counts: dict[str, int] = defaultdict(int)
|
|
2132
|
+
family_override_counts: dict[str, int] = defaultdict(int)
|
|
2133
|
+
family_unmappable: dict[str, list[str]] = defaultdict(list)
|
|
2134
|
+
|
|
2135
|
+
for t in templates:
|
|
2136
|
+
family = t.get("model_family") or "unknown"
|
|
2137
|
+
family_phone_counts[family] += t.get("phones_using") or 0
|
|
2138
|
+
overrides = t.get("per_device_overrides") or []
|
|
2139
|
+
family_override_counts[family] += len(overrides)
|
|
2140
|
+
unmappable = t.get("unmappable_settings") or []
|
|
2141
|
+
for field in unmappable:
|
|
2142
|
+
if field not in family_unmappable[family]:
|
|
2143
|
+
family_unmappable[family].append(field)
|
|
2144
|
+
|
|
2145
|
+
total_phones = sum(family_phone_counts.values())
|
|
2146
|
+
total_automated = sum(
|
|
2147
|
+
len(_AUTOMATED_SETTINGS_BY_FAMILY.get(f, []))
|
|
2148
|
+
for f in family_phone_counts
|
|
2149
|
+
)
|
|
2150
|
+
total_manual = sum(
|
|
2151
|
+
len(_MANUAL_SETTINGS_BY_FAMILY.get(f, []))
|
|
2152
|
+
for f in family_phone_counts
|
|
2153
|
+
)
|
|
2154
|
+
|
|
2155
|
+
summary = (
|
|
2156
|
+
f"{len(family_phone_counts)} phone model {'families' if len(family_phone_counts) != 1 else 'family'}, "
|
|
2157
|
+
f"{total_phones} phones, "
|
|
2158
|
+
f"{total_automated} automated settings, "
|
|
2159
|
+
f"{total_manual} require manual Control Hub configuration"
|
|
2160
|
+
)
|
|
2161
|
+
|
|
2162
|
+
parts = [
|
|
2163
|
+
'<details id="device-settings-coverage">',
|
|
2164
|
+
f'<summary>AC. Device Settings Coverage <span class="summary-count">— {summary}</span></summary>',
|
|
2165
|
+
'<div class="details-content">',
|
|
2166
|
+
'<p>Settings the pipeline configures automatically (from CUCM <code>productSpecificConfiguration</code>) '
|
|
2167
|
+
'vs settings that require manual action in Control Hub after migration.</p>',
|
|
2168
|
+
'<table>',
|
|
2169
|
+
'<thead><tr>'
|
|
2170
|
+
'<th>Model Family</th>'
|
|
2171
|
+
'<th class="num">Phones</th>'
|
|
2172
|
+
'<th>Automated Settings</th>'
|
|
2173
|
+
'<th>Manual Settings</th>'
|
|
2174
|
+
'<th class="num">Overrides</th>'
|
|
2175
|
+
'</tr></thead>',
|
|
2176
|
+
'<tbody>',
|
|
2177
|
+
]
|
|
2178
|
+
|
|
2179
|
+
for family in sorted(family_phone_counts.keys()):
|
|
2180
|
+
phone_count = family_phone_counts[family]
|
|
2181
|
+
automated = _AUTOMATED_SETTINGS_BY_FAMILY.get(family, ["Line keys", "Language"])
|
|
2182
|
+
manual = _MANUAL_SETTINGS_BY_FAMILY.get(family, ["Wallpaper", "Network VLAN"])
|
|
2183
|
+
overrides = family_override_counts.get(family, 0)
|
|
2184
|
+
automated_str = ", ".join(automated)
|
|
2185
|
+
manual_str = ", ".join(manual)
|
|
2186
|
+
unmappable = family_unmappable.get(family, [])
|
|
2187
|
+
if unmappable:
|
|
2188
|
+
manual_str += f"; also unmappable from CUCM: {', '.join(unmappable)}"
|
|
2189
|
+
parts.append(
|
|
2190
|
+
f'<tr>'
|
|
2191
|
+
f'<td><strong>{html.escape(family)}</strong></td>'
|
|
2192
|
+
f'<td class="num">{phone_count}</td>'
|
|
2193
|
+
f'<td>{html.escape(automated_str)}</td>'
|
|
2194
|
+
f'<td>{html.escape(manual_str)}</td>'
|
|
2195
|
+
f'<td class="num">{overrides}</td>'
|
|
2196
|
+
f'</tr>'
|
|
2197
|
+
)
|
|
2198
|
+
|
|
2199
|
+
parts.extend(['</tbody></table>'])
|
|
2200
|
+
|
|
2201
|
+
# Callout for manual steps
|
|
2202
|
+
parts.append(
|
|
2203
|
+
'<div class="callout">'
|
|
2204
|
+
'<p><strong>Manual Control Hub steps required post-migration:</strong> '
|
|
2205
|
+
'Wallpaper images, Wi-Fi credentials, and Bluetooth pairing must be configured '
|
|
2206
|
+
'directly in Control Hub or via device configuration templates. These settings '
|
|
2207
|
+
'cannot be extracted from CUCM automatically.</p>'
|
|
2208
|
+
'</div>'
|
|
2209
|
+
)
|
|
2210
|
+
|
|
2211
|
+
parts.extend(['</div></details>'])
|
|
2212
|
+
return "\n".join(parts)
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
# ---------------------------------------------------------------------------
|
|
2216
|
+
# AD. Feature Forwarding Status
|
|
2217
|
+
# ---------------------------------------------------------------------------
|
|
2218
|
+
|
|
2219
|
+
def _feature_forwarding_status(store: MigrationStore) -> str:
|
|
2220
|
+
"""AD. Feature Forwarding Status — AA/HG/CQ with forwarding configuration gaps."""
|
|
2221
|
+
feature_rows: list[dict] = []
|
|
2222
|
+
|
|
2223
|
+
# Hunt groups: check for forwarding destinations from CUCM
|
|
2224
|
+
for hg in store.get_objects("hunt_group"):
|
|
2225
|
+
name = hg.get("name") or strip_canonical_id(hg.get("canonical_id", ""))
|
|
2226
|
+
pre = hg.get("pre_migration_state") or {}
|
|
2227
|
+
|
|
2228
|
+
# CUCM forwarding: forwardHuntNoAnswer, forwardHuntBusy on hunt pilot
|
|
2229
|
+
cucm_fwd_no_answer = _axl_str(pre.get("forwardHuntNoAnswer", {}).get("destination") if isinstance(pre.get("forwardHuntNoAnswer"), dict) else pre.get("forwardHuntNoAnswer"))
|
|
2230
|
+
cucm_fwd_busy = _axl_str(pre.get("forwardHuntBusy", {}).get("destination") if isinstance(pre.get("forwardHuntBusy"), dict) else pre.get("forwardHuntBusy"))
|
|
2231
|
+
has_cucm_fwd = bool(cucm_fwd_no_answer or cucm_fwd_busy)
|
|
2232
|
+
|
|
2233
|
+
# Webex: check canonical fields populated by FeatureMapper
|
|
2234
|
+
webex_fwd_dest = hg.get("forward_no_answer_destination") or hg.get("forward_busy_destination") or hg.get("forward_always_destination")
|
|
2235
|
+
has_webex_fwd = bool(webex_fwd_dest)
|
|
2236
|
+
|
|
2237
|
+
gap = has_cucm_fwd and not has_webex_fwd
|
|
2238
|
+
feature_rows.append({
|
|
2239
|
+
"name": name,
|
|
2240
|
+
"type": "Hunt Group",
|
|
2241
|
+
"has_cucm_fwd": has_cucm_fwd,
|
|
2242
|
+
"cucm_detail": cucm_fwd_no_answer or cucm_fwd_busy or "",
|
|
2243
|
+
"has_webex_fwd": has_webex_fwd,
|
|
2244
|
+
"gap": gap,
|
|
2245
|
+
})
|
|
2246
|
+
|
|
2247
|
+
# Call queues: check queue_full_destination, max_wait_time_destination, etc.
|
|
2248
|
+
for cq in store.get_objects("call_queue"):
|
|
2249
|
+
name = cq.get("name") or strip_canonical_id(cq.get("canonical_id", ""))
|
|
2250
|
+
pre = cq.get("pre_migration_state") or {}
|
|
2251
|
+
|
|
2252
|
+
# CUCM forwarding on queueCalls
|
|
2253
|
+
queue_calls = pre.get("queueCalls") or {}
|
|
2254
|
+
if isinstance(queue_calls, dict):
|
|
2255
|
+
cucm_full = _axl_str(queue_calls.get("queueFullDestination"))
|
|
2256
|
+
cucm_wait = _axl_str(queue_calls.get("maxWaitTimeDestination"))
|
|
2257
|
+
else:
|
|
2258
|
+
cucm_full = ""
|
|
2259
|
+
cucm_wait = ""
|
|
2260
|
+
has_cucm_fwd = bool(cucm_full or cucm_wait)
|
|
2261
|
+
|
|
2262
|
+
webex_fwd = cq.get("queue_full_destination") or cq.get("forward_always_destination")
|
|
2263
|
+
has_webex_fwd = bool(webex_fwd)
|
|
2264
|
+
|
|
2265
|
+
gap = has_cucm_fwd and not has_webex_fwd
|
|
2266
|
+
cucm_detail = cucm_full or cucm_wait or ""
|
|
2267
|
+
feature_rows.append({
|
|
2268
|
+
"name": name,
|
|
2269
|
+
"type": "Call Queue",
|
|
2270
|
+
"has_cucm_fwd": has_cucm_fwd,
|
|
2271
|
+
"cucm_detail": cucm_detail,
|
|
2272
|
+
"has_webex_fwd": has_webex_fwd,
|
|
2273
|
+
"gap": gap,
|
|
2274
|
+
})
|
|
2275
|
+
|
|
2276
|
+
# Auto attendants: check callForwardAll from CTI route point
|
|
2277
|
+
for aa in store.get_objects("auto_attendant"):
|
|
2278
|
+
name = aa.get("name") or strip_canonical_id(aa.get("canonical_id", ""))
|
|
2279
|
+
pre = aa.get("pre_migration_state") or {}
|
|
2280
|
+
|
|
2281
|
+
cfa = pre.get("callForwardAll") or {}
|
|
2282
|
+
if isinstance(cfa, dict):
|
|
2283
|
+
cucm_dest = _axl_str(cfa.get("destination"))
|
|
2284
|
+
else:
|
|
2285
|
+
cucm_dest = _axl_str(cfa)
|
|
2286
|
+
has_cucm_fwd = bool(cucm_dest)
|
|
2287
|
+
|
|
2288
|
+
webex_fwd = aa.get("forward_always_destination")
|
|
2289
|
+
has_webex_fwd = bool(webex_fwd)
|
|
2290
|
+
|
|
2291
|
+
gap = has_cucm_fwd and not has_webex_fwd
|
|
2292
|
+
feature_rows.append({
|
|
2293
|
+
"name": name,
|
|
2294
|
+
"type": "Auto Attendant",
|
|
2295
|
+
"has_cucm_fwd": has_cucm_fwd,
|
|
2296
|
+
"cucm_detail": cucm_dest,
|
|
2297
|
+
"has_webex_fwd": has_webex_fwd,
|
|
2298
|
+
"gap": gap,
|
|
2299
|
+
})
|
|
2300
|
+
|
|
2301
|
+
if not feature_rows:
|
|
2302
|
+
return ""
|
|
2303
|
+
|
|
2304
|
+
gap_count = sum(1 for r in feature_rows if r["gap"])
|
|
2305
|
+
total = len(feature_rows)
|
|
2306
|
+
summary = f"{total} features checked, {gap_count} forwarding gap{'s' if gap_count != 1 else ''}"
|
|
2307
|
+
|
|
2308
|
+
parts = [
|
|
2309
|
+
'<details id="feature-forwarding-status">',
|
|
2310
|
+
f'<summary>AD. Feature Forwarding Status <span class="summary-count">— {summary}</span></summary>',
|
|
2311
|
+
'<div class="details-content">',
|
|
2312
|
+
'<p>Hunt groups, call queues, and auto attendants with forwarding/overflow rules in CUCM. '
|
|
2313
|
+
'A Gap means CUCM had a forwarding destination configured that was not mapped to Webex — '
|
|
2314
|
+
'these features will use Webex defaults for overflow behavior after migration.</p>',
|
|
2315
|
+
'<table>',
|
|
2316
|
+
'<thead><tr>'
|
|
2317
|
+
'<th>Feature Name</th>'
|
|
2318
|
+
'<th>Type</th>'
|
|
2319
|
+
'<th>CUCM Forwarding?</th>'
|
|
2320
|
+
'<th>Webex Configured?</th>'
|
|
2321
|
+
'<th>Status</th>'
|
|
2322
|
+
'</tr></thead>',
|
|
2323
|
+
'<tbody>',
|
|
2324
|
+
]
|
|
2325
|
+
|
|
2326
|
+
for row in sorted(feature_rows, key=lambda r: (not r["gap"], r["type"], r["name"])):
|
|
2327
|
+
cucm_cell = f'Yes ({html.escape(row["cucm_detail"])})' if row["cucm_detail"] else ("Yes" if row["has_cucm_fwd"] else "No")
|
|
2328
|
+
webex_cell = "Yes" if row["has_webex_fwd"] else "No"
|
|
2329
|
+
if row["gap"]:
|
|
2330
|
+
status_cell = '<span class="badge badge-warning">Gap</span>'
|
|
2331
|
+
elif row["has_cucm_fwd"] and row["has_webex_fwd"]:
|
|
2332
|
+
status_cell = '<span class="badge badge-success">Mapped</span>'
|
|
2333
|
+
else:
|
|
2334
|
+
status_cell = '<span class="badge badge-info">No forwarding</span>'
|
|
2335
|
+
|
|
2336
|
+
parts.append(
|
|
2337
|
+
f'<tr>'
|
|
2338
|
+
f'<td>{html.escape(row["name"])}</td>'
|
|
2339
|
+
f'<td>{html.escape(row["type"])}</td>'
|
|
2340
|
+
f'<td>{cucm_cell}</td>'
|
|
2341
|
+
f'<td>{webex_cell}</td>'
|
|
2342
|
+
f'<td>{status_cell}</td>'
|
|
2343
|
+
f'</tr>'
|
|
2344
|
+
)
|
|
2345
|
+
|
|
2346
|
+
parts.extend(['</tbody></table>'])
|
|
2347
|
+
|
|
2348
|
+
if gap_count > 0:
|
|
2349
|
+
parts.append(
|
|
2350
|
+
'<div class="callout warning">'
|
|
2351
|
+
f'<p><strong>{gap_count} forwarding gap{"s" if gap_count != 1 else ""} detected.</strong> '
|
|
2352
|
+
'These features had overflow or forwarding rules in CUCM that could not be '
|
|
2353
|
+
'automatically mapped to Webex. After migration, affected features will use '
|
|
2354
|
+
'Webex default overflow behavior. Review each gap and configure forwarding '
|
|
2355
|
+
'destinations manually in Control Hub if required.</p>'
|
|
2356
|
+
'</div>'
|
|
2357
|
+
)
|
|
2358
|
+
|
|
2359
|
+
parts.extend(['</div></details>'])
|
|
2360
|
+
return "\n".join(parts)
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
# ---------------------------------------------------------------------------
|
|
2364
|
+
# AE. Workspace Settings Coverage
|
|
2365
|
+
# ---------------------------------------------------------------------------
|
|
2366
|
+
|
|
2367
|
+
def _workspace_settings_coverage(store: MigrationStore) -> str:
|
|
2368
|
+
"""AE. Workspace Settings Coverage — common-area phones with/without configured settings."""
|
|
2369
|
+
workspaces = store.get_objects("workspace")
|
|
2370
|
+
if not workspaces:
|
|
2371
|
+
return ""
|
|
2372
|
+
|
|
2373
|
+
total = len(workspaces)
|
|
2374
|
+
configured = [w for w in workspaces if w.get("call_settings")]
|
|
2375
|
+
unconfigured = [w for w in workspaces if not w.get("call_settings")]
|
|
2376
|
+
|
|
2377
|
+
summary = (
|
|
2378
|
+
f"{total} workspace{'s' if total != 1 else ''}, "
|
|
2379
|
+
f"{len(configured)} with configured settings, "
|
|
2380
|
+
f"{len(unconfigured)} using defaults"
|
|
2381
|
+
)
|
|
2382
|
+
|
|
2383
|
+
parts = [
|
|
2384
|
+
'<details id="workspace-settings-coverage">',
|
|
2385
|
+
f'<summary>AE. Workspace Settings Coverage <span class="summary-count">— {summary}</span></summary>',
|
|
2386
|
+
'<div class="details-content">',
|
|
2387
|
+
'<p>Common-area phones (conference rooms, lobby phones, shared desks) mapped to Webex Workspaces. '
|
|
2388
|
+
'Settings count shows how many call settings were extracted from CUCM and will be configured '
|
|
2389
|
+
'automatically. Workspaces with 0 settings will use Webex defaults.</p>',
|
|
2390
|
+
'<table>',
|
|
2391
|
+
'<thead><tr>'
|
|
2392
|
+
'<th>Workspace</th>'
|
|
2393
|
+
'<th>Location</th>'
|
|
2394
|
+
'<th>License</th>'
|
|
2395
|
+
'<th class="num">Settings Count</th>'
|
|
2396
|
+
'</tr></thead>',
|
|
2397
|
+
'<tbody>',
|
|
2398
|
+
]
|
|
2399
|
+
|
|
2400
|
+
for ws in sorted(workspaces, key=lambda w: w.get("display_name") or ""):
|
|
2401
|
+
name = ws.get("display_name") or strip_canonical_id(ws.get("canonical_id", ""))
|
|
2402
|
+
loc_id = ws.get("location_id") or ws.get("location_canonical_id") or ""
|
|
2403
|
+
loc_obj = store.get_object(loc_id) if loc_id else None
|
|
2404
|
+
loc_name = loc_obj.get("name", strip_canonical_id(loc_id)) if loc_obj else (strip_canonical_id(loc_id) if loc_id else "\u2014")
|
|
2405
|
+
license_tier = ws.get("license_tier") or "Workspace"
|
|
2406
|
+
call_settings = ws.get("call_settings") or {}
|
|
2407
|
+
settings_count = len(call_settings)
|
|
2408
|
+
|
|
2409
|
+
if settings_count == 0:
|
|
2410
|
+
count_cell = '<span class="muted">0 — defaults</span>'
|
|
2411
|
+
else:
|
|
2412
|
+
count_cell = str(settings_count)
|
|
2413
|
+
|
|
2414
|
+
parts.append(
|
|
2415
|
+
f'<tr>'
|
|
2416
|
+
f'<td>{html.escape(name)}</td>'
|
|
2417
|
+
f'<td>{html.escape(loc_name)}</td>'
|
|
2418
|
+
f'<td>{html.escape(license_tier)}</td>'
|
|
2419
|
+
f'<td class="num">{count_cell}</td>'
|
|
2420
|
+
f'</tr>'
|
|
2421
|
+
)
|
|
2422
|
+
|
|
2423
|
+
parts.extend(['</tbody></table>'])
|
|
2424
|
+
|
|
2425
|
+
if unconfigured:
|
|
2426
|
+
parts.append(
|
|
2427
|
+
'<div class="callout">'
|
|
2428
|
+
f'<p><strong>{len(unconfigured)} workspace{"s" if len(unconfigured) != 1 else ""} '
|
|
2429
|
+
f'will use default call settings.</strong> '
|
|
2430
|
+
'If custom settings were configured in CUCM for these devices, review them in '
|
|
2431
|
+
'Control Hub after migration. Common defaults to verify: DND, call forwarding, '
|
|
2432
|
+
'and music on hold.</p>'
|
|
2433
|
+
'</div>'
|
|
2434
|
+
)
|
|
2435
|
+
|
|
2436
|
+
parts.extend(['</div></details>'])
|
|
2437
|
+
return "\n".join(parts)
|