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.
Files changed (413) hide show
  1. wxcli/__init__.py +4 -0
  2. wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
  3. wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
  4. wxcli/_playbook/.claude/rules/cleanup.md +29 -0
  5. wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
  6. wxcli/_playbook/.claude/rules/org-health.md +14 -0
  7. wxcli/_playbook/.claude/settings.json +8 -0
  8. wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
  9. wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
  10. wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
  11. wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
  12. wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
  13. wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
  14. wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
  15. wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
  16. wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
  17. wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
  18. wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
  19. wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
  20. wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
  21. wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
  22. wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
  23. wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
  24. wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
  25. wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
  26. wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
  27. wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
  28. wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
  29. wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
  30. wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
  31. wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
  32. wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
  33. wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
  34. wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
  35. wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
  36. wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
  37. wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
  38. wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
  39. wxcli/_playbook/CLAUDE.md +373 -0
  40. wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
  41. wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
  42. wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
  43. wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
  44. wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
  45. wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
  46. wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
  47. wxcli/_playbook/docs/reference/admin-partner.md +357 -0
  48. wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
  49. wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
  50. wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
  51. wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
  52. wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
  53. wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
  54. wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
  55. wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
  56. wxcli/_playbook/docs/reference/authentication.md +965 -0
  57. wxcli/_playbook/docs/reference/call-control.md +1205 -0
  58. wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
  59. wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
  60. wxcli/_playbook/docs/reference/call-routing.md +2304 -0
  61. wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
  62. wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
  63. wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
  64. wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
  65. wxcli/_playbook/docs/reference/devices-core.md +1869 -0
  66. wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
  67. wxcli/_playbook/docs/reference/devices-platform.md +467 -0
  68. wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
  69. wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
  70. wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
  71. wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
  72. wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
  73. wxcli/_playbook/docs/reference/meetings-content.md +473 -0
  74. wxcli/_playbook/docs/reference/meetings-core.md +903 -0
  75. wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
  76. wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
  77. wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
  78. wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
  79. wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
  80. wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
  81. wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
  82. wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
  83. wxcli/_playbook/docs/reference/provisioning.md +1366 -0
  84. wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
  85. wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
  86. wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
  87. wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
  88. wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
  89. wxcli/_version.py +24 -0
  90. wxcli/auth.py +137 -0
  91. wxcli/commands/__init__.py +0 -0
  92. wxcli/commands/_registry.py +181 -0
  93. wxcli/commands/activation_email.py +103 -0
  94. wxcli/commands/admin_recordings.py +585 -0
  95. wxcli/commands/analytics.py +105 -0
  96. wxcli/commands/announcement_playlists.py +219 -0
  97. wxcli/commands/announcements.py +519 -0
  98. wxcli/commands/archive_users.py +36 -0
  99. wxcli/commands/attachment_actions.py +60 -0
  100. wxcli/commands/audit_events.py +83 -0
  101. wxcli/commands/authorizations.py +113 -0
  102. wxcli/commands/auto_attendant.py +642 -0
  103. wxcli/commands/broadworks_billing_reports.py +126 -0
  104. wxcli/commands/broadworks_enterprises.py +160 -0
  105. wxcli/commands/broadworks_subscribers.py +319 -0
  106. wxcli/commands/broadworks_workspaces.py +108 -0
  107. wxcli/commands/call_controls.py +1304 -0
  108. wxcli/commands/call_park.py +494 -0
  109. wxcli/commands/call_pickup.py +229 -0
  110. wxcli/commands/call_queue.py +1878 -0
  111. wxcli/commands/call_recording.py +707 -0
  112. wxcli/commands/call_routing.py +1610 -0
  113. wxcli/commands/call_settings_for_me_phase_5.py +212 -0
  114. wxcli/commands/caller_reputation.py +169 -0
  115. wxcli/commands/calling_service.py +305 -0
  116. wxcli/commands/cc_address_book.py +770 -0
  117. wxcli/commands/cc_agent_greetings.py +242 -0
  118. wxcli/commands/cc_agent_summaries.py +39 -0
  119. wxcli/commands/cc_agent_wellbeing.py +216 -0
  120. wxcli/commands/cc_agents.py +425 -0
  121. wxcli/commands/cc_ai_assistant.py +55 -0
  122. wxcli/commands/cc_ai_feature.py +294 -0
  123. wxcli/commands/cc_audio_files.py +149 -0
  124. wxcli/commands/cc_auto_csat.py +311 -0
  125. wxcli/commands/cc_aux_code.py +458 -0
  126. wxcli/commands/cc_business_hour.py +353 -0
  127. wxcli/commands/cc_call_monitoring.py +228 -0
  128. wxcli/commands/cc_callbacks.py +194 -0
  129. wxcli/commands/cc_campaign.py +212 -0
  130. wxcli/commands/cc_captures.py +39 -0
  131. wxcli/commands/cc_contact_list.py +169 -0
  132. wxcli/commands/cc_contact_number.py +295 -0
  133. wxcli/commands/cc_data_sources.py +235 -0
  134. wxcli/commands/cc_desktop_layout.py +380 -0
  135. wxcli/commands/cc_desktop_profile.py +322 -0
  136. wxcli/commands/cc_dial_number.py +511 -0
  137. wxcli/commands/cc_dial_plan.py +347 -0
  138. wxcli/commands/cc_dnc.py +101 -0
  139. wxcli/commands/cc_entry_point.py +545 -0
  140. wxcli/commands/cc_ewt.py +51 -0
  141. wxcli/commands/cc_flow.py +477 -0
  142. wxcli/commands/cc_global_vars.py +430 -0
  143. wxcli/commands/cc_holiday_list.py +335 -0
  144. wxcli/commands/cc_journey.py +1261 -0
  145. wxcli/commands/cc_legacy_flows.py +49 -0
  146. wxcli/commands/cc_multimedia_profile.py +436 -0
  147. wxcli/commands/cc_notification.py +51 -0
  148. wxcli/commands/cc_outdial_ani.py +604 -0
  149. wxcli/commands/cc_overrides.py +341 -0
  150. wxcli/commands/cc_queue.py +1083 -0
  151. wxcli/commands/cc_queue_stats.py +55 -0
  152. wxcli/commands/cc_realtime.py +51 -0
  153. wxcli/commands/cc_resource_collection.py +312 -0
  154. wxcli/commands/cc_search.py +46 -0
  155. wxcli/commands/cc_site.py +400 -0
  156. wxcli/commands/cc_skill.py +350 -0
  157. wxcli/commands/cc_skill_profile.py +289 -0
  158. wxcli/commands/cc_subscriptions.py +426 -0
  159. wxcli/commands/cc_summaries.py +130 -0
  160. wxcli/commands/cc_tasks.py +844 -0
  161. wxcli/commands/cc_team.py +325 -0
  162. wxcli/commands/cc_user_profiles.py +349 -0
  163. wxcli/commands/cc_users.py +701 -0
  164. wxcli/commands/cc_work_types.py +363 -0
  165. wxcli/commands/cdr.py +89 -0
  166. wxcli/commands/classifications.py +38 -0
  167. wxcli/commands/cleanup.py +1427 -0
  168. wxcli/commands/client_settings.py +72 -0
  169. wxcli/commands/conference.py +328 -0
  170. wxcli/commands/configure.py +72 -0
  171. wxcli/commands/converged_recordings.py +347 -0
  172. wxcli/commands/converged_recordings_export.py +310 -0
  173. wxcli/commands/cq_playlists.py +42 -0
  174. wxcli/commands/cucm.py +2794 -0
  175. wxcli/commands/cucm_config.py +129 -0
  176. wxcli/commands/customer_assist.py +373 -0
  177. wxcli/commands/data_sources.py +227 -0
  178. wxcli/commands/dect_devices.py +787 -0
  179. wxcli/commands/device_configurations.py +77 -0
  180. wxcli/commands/device_dynamic_settings.py +320 -0
  181. wxcli/commands/device_settings.py +1489 -0
  182. wxcli/commands/devices.py +280 -0
  183. wxcli/commands/domains.py +142 -0
  184. wxcli/commands/ecm.py +178 -0
  185. wxcli/commands/emergency_services.py +827 -0
  186. wxcli/commands/events.py +83 -0
  187. wxcli/commands/external_voicemail.py +52 -0
  188. wxcli/commands/groups.py +206 -0
  189. wxcli/commands/guest_management.py +76 -0
  190. wxcli/commands/hds.py +340 -0
  191. wxcli/commands/hot_desk.py +67 -0
  192. wxcli/commands/hot_desking_members.py +118 -0
  193. wxcli/commands/hot_desking_portal.py +124 -0
  194. wxcli/commands/hunt_group.py +577 -0
  195. wxcli/commands/hybrid_clusters.py +71 -0
  196. wxcli/commands/hybrid_connectors.py +67 -0
  197. wxcli/commands/identity_org.py +159 -0
  198. wxcli/commands/init_playbook.py +155 -0
  199. wxcli/commands/licenses.py +106 -0
  200. wxcli/commands/live_monitoring.py +40 -0
  201. wxcli/commands/location_call_handling.py +577 -0
  202. wxcli/commands/location_schedules.py +341 -0
  203. wxcli/commands/location_settings.py +1297 -0
  204. wxcli/commands/location_voicemail.py +494 -0
  205. wxcli/commands/locations.py +336 -0
  206. wxcli/commands/meeting_captions.py +110 -0
  207. wxcli/commands/meeting_chats.py +63 -0
  208. wxcli/commands/meeting_invitees.py +234 -0
  209. wxcli/commands/meeting_messages.py +28 -0
  210. wxcli/commands/meeting_participants.py +278 -0
  211. wxcli/commands/meeting_polls.py +111 -0
  212. wxcli/commands/meeting_preferences.py +515 -0
  213. wxcli/commands/meeting_qa.py +78 -0
  214. wxcli/commands/meeting_qualities.py +43 -0
  215. wxcli/commands/meeting_reports.py +101 -0
  216. wxcli/commands/meeting_session_types.py +105 -0
  217. wxcli/commands/meeting_site.py +57 -0
  218. wxcli/commands/meeting_slido.py +44 -0
  219. wxcli/commands/meeting_summaries.py +92 -0
  220. wxcli/commands/meeting_tracking_codes.py +233 -0
  221. wxcli/commands/meeting_transcripts.py +230 -0
  222. wxcli/commands/meetings.py +1997 -0
  223. wxcli/commands/memberships.py +164 -0
  224. wxcli/commands/messages.py +213 -0
  225. wxcli/commands/mode_management.py +244 -0
  226. wxcli/commands/my_call_settings.py +3577 -0
  227. wxcli/commands/numbers.py +405 -0
  228. wxcli/commands/operating_modes.py +410 -0
  229. wxcli/commands/org_contacts.py +284 -0
  230. wxcli/commands/org_health_cli.py +55 -0
  231. wxcli/commands/org_settings.py +71 -0
  232. wxcli/commands/organizations.py +83 -0
  233. wxcli/commands/paging_group.py +257 -0
  234. wxcli/commands/partner_admins.py +141 -0
  235. wxcli/commands/partner_reports.py +188 -0
  236. wxcli/commands/partner_tags.py +216 -0
  237. wxcli/commands/people.py +279 -0
  238. wxcli/commands/person_call_settings.py +71 -0
  239. wxcli/commands/pstn.py +280 -0
  240. wxcli/commands/recording_report.py +154 -0
  241. wxcli/commands/report_templates.py +38 -0
  242. wxcli/commands/reports.py +144 -0
  243. wxcli/commands/resource_group_memberships.py +160 -0
  244. wxcli/commands/resource_groups.py +67 -0
  245. wxcli/commands/roles.py +63 -0
  246. wxcli/commands/room_tabs.py +160 -0
  247. wxcli/commands/rooms.py +225 -0
  248. wxcli/commands/scim_bulk.py +46 -0
  249. wxcli/commands/scim_groups.py +246 -0
  250. wxcli/commands/scim_schemas.py +82 -0
  251. wxcli/commands/scim_users.py +283 -0
  252. wxcli/commands/security_audit.py +54 -0
  253. wxcli/commands/service_apps.py +51 -0
  254. wxcli/commands/single_number_reach.py +209 -0
  255. wxcli/commands/team_memberships.py +155 -0
  256. wxcli/commands/teams.py +153 -0
  257. wxcli/commands/ucm_profile.py +42 -0
  258. wxcli/commands/update.py +219 -0
  259. wxcli/commands/user_settings.py +5116 -0
  260. wxcli/commands/video_mesh.py +1090 -0
  261. wxcli/commands/virtual_extensions.py +502 -0
  262. wxcli/commands/virtual_line_settings.py +2072 -0
  263. wxcli/commands/webhooks.py +176 -0
  264. wxcli/commands/wholesale_billing_reports.py +147 -0
  265. wxcli/commands/wholesale_provisioning.py +523 -0
  266. wxcli/commands/workspace_locations.py +339 -0
  267. wxcli/commands/workspace_metrics.py +103 -0
  268. wxcli/commands/workspace_personalization.py +70 -0
  269. wxcli/commands/workspace_settings.py +3347 -0
  270. wxcli/commands/workspaces.py +279 -0
  271. wxcli/commands/xapi.py +114 -0
  272. wxcli/config.py +139 -0
  273. wxcli/errors.py +77 -0
  274. wxcli/main.py +210 -0
  275. wxcli/migration/CLAUDE.md +106 -0
  276. wxcli/migration/__init__.py +1 -0
  277. wxcli/migration/advisory/CLAUDE.md +196 -0
  278. wxcli/migration/advisory/__init__.py +46 -0
  279. wxcli/migration/advisory/advisor.py +107 -0
  280. wxcli/migration/advisory/advisory_patterns.py +2552 -0
  281. wxcli/migration/advisory/recommendation_rules.py +835 -0
  282. wxcli/migration/cucm/CLAUDE.md +74 -0
  283. wxcli/migration/cucm/__init__.py +5 -0
  284. wxcli/migration/cucm/connection.py +265 -0
  285. wxcli/migration/cucm/discovery.py +225 -0
  286. wxcli/migration/cucm/extractors/__init__.py +1 -0
  287. wxcli/migration/cucm/extractors/announcements.py +92 -0
  288. wxcli/migration/cucm/extractors/base.py +90 -0
  289. wxcli/migration/cucm/extractors/device_profiles.py +98 -0
  290. wxcli/migration/cucm/extractors/devices.py +193 -0
  291. wxcli/migration/cucm/extractors/e911.py +103 -0
  292. wxcli/migration/cucm/extractors/features.py +309 -0
  293. wxcli/migration/cucm/extractors/helpers.py +73 -0
  294. wxcli/migration/cucm/extractors/informational.py +295 -0
  295. wxcli/migration/cucm/extractors/locations.py +221 -0
  296. wxcli/migration/cucm/extractors/moh.py +100 -0
  297. wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
  298. wxcli/migration/cucm/extractors/routing.py +493 -0
  299. wxcli/migration/cucm/extractors/shared_lines.py +146 -0
  300. wxcli/migration/cucm/extractors/templates.py +229 -0
  301. wxcli/migration/cucm/extractors/tier4.py +173 -0
  302. wxcli/migration/cucm/extractors/users.py +240 -0
  303. wxcli/migration/cucm/extractors/voicemail.py +243 -0
  304. wxcli/migration/cucm/extractors/workspaces.py +88 -0
  305. wxcli/migration/cucm/unity_connection.py +304 -0
  306. wxcli/migration/execute/CLAUDE.md +372 -0
  307. wxcli/migration/execute/__init__.py +303 -0
  308. wxcli/migration/execute/batch.py +277 -0
  309. wxcli/migration/execute/dependency.py +483 -0
  310. wxcli/migration/execute/engine.py +964 -0
  311. wxcli/migration/execute/handlers.py +2232 -0
  312. wxcli/migration/execute/planner.py +1939 -0
  313. wxcli/migration/execute/runtime.py +571 -0
  314. wxcli/migration/export/CLAUDE.md +49 -0
  315. wxcli/migration/export/__init__.py +8 -0
  316. wxcli/migration/export/csv_export.py +126 -0
  317. wxcli/migration/export/deployment_plan.py +479 -0
  318. wxcli/migration/export/json_export.py +60 -0
  319. wxcli/migration/models.py +941 -0
  320. wxcli/migration/phone_models.py +50 -0
  321. wxcli/migration/preflight/CLAUDE.md +52 -0
  322. wxcli/migration/preflight/__init__.py +89 -0
  323. wxcli/migration/preflight/checks.py +911 -0
  324. wxcli/migration/preflight/runner.py +293 -0
  325. wxcli/migration/rate_limiter.py +144 -0
  326. wxcli/migration/report/CLAUDE.md +245 -0
  327. wxcli/migration/report/__init__.py +7 -0
  328. wxcli/migration/report/appendix.py +2437 -0
  329. wxcli/migration/report/assembler.py +183 -0
  330. wxcli/migration/report/charts.py +263 -0
  331. wxcli/migration/report/executive.py +776 -0
  332. wxcli/migration/report/explainer.py +704 -0
  333. wxcli/migration/report/helpers.py +74 -0
  334. wxcli/migration/report/ingest.py +152 -0
  335. wxcli/migration/report/notice_templates.py +192 -0
  336. wxcli/migration/report/score.py +375 -0
  337. wxcli/migration/report/styles.py +1052 -0
  338. wxcli/migration/report/user_diff.py +1052 -0
  339. wxcli/migration/report/user_notice.py +425 -0
  340. wxcli/migration/state.py +139 -0
  341. wxcli/migration/store.py +860 -0
  342. wxcli/migration/transform/CLAUDE.md +107 -0
  343. wxcli/migration/transform/__init__.py +12 -0
  344. wxcli/migration/transform/analysis_pipeline.py +453 -0
  345. wxcli/migration/transform/analyzers/__init__.py +142 -0
  346. wxcli/migration/transform/analyzers/css_permission.py +189 -0
  347. wxcli/migration/transform/analyzers/css_routing.py +335 -0
  348. wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
  349. wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
  350. wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
  351. wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
  352. wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
  353. wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
  354. wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
  355. wxcli/migration/transform/analyzers/missing_data.py +300 -0
  356. wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
  357. wxcli/migration/transform/analyzers/shared_line.py +170 -0
  358. wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
  359. wxcli/migration/transform/analyzers/workspace_license.py +212 -0
  360. wxcli/migration/transform/cross_reference.py +1136 -0
  361. wxcli/migration/transform/cucm_pattern.py +246 -0
  362. wxcli/migration/transform/decisions.py +301 -0
  363. wxcli/migration/transform/e164.py +145 -0
  364. wxcli/migration/transform/engine.py +256 -0
  365. wxcli/migration/transform/mappers/CLAUDE.md +123 -0
  366. wxcli/migration/transform/mappers/__init__.py +76 -0
  367. wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
  368. wxcli/migration/transform/mappers/base.py +190 -0
  369. wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
  370. wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
  371. wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
  372. wxcli/migration/transform/mappers/css_mapper.py +868 -0
  373. wxcli/migration/transform/mappers/dect_mapper.py +367 -0
  374. wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
  375. wxcli/migration/transform/mappers/device_mapper.py +227 -0
  376. wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
  377. wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
  378. wxcli/migration/transform/mappers/e911_mapper.py +129 -0
  379. wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
  380. wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
  381. wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
  382. wxcli/migration/transform/mappers/line_mapper.py +310 -0
  383. wxcli/migration/transform/mappers/location_mapper.py +235 -0
  384. wxcli/migration/transform/mappers/moh_mapper.py +114 -0
  385. wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
  386. wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
  387. wxcli/migration/transform/mappers/routing_mapper.py +821 -0
  388. wxcli/migration/transform/mappers/snr_mapper.py +201 -0
  389. wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
  390. wxcli/migration/transform/mappers/user_mapper.py +264 -0
  391. wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
  392. wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
  393. wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
  394. wxcli/migration/transform/normalizers.py +2008 -0
  395. wxcli/migration/transform/pattern_converter.py +106 -0
  396. wxcli/migration/transform/pipeline.py +271 -0
  397. wxcli/migration/transform/rules.py +255 -0
  398. wxcli/org_health/CLAUDE.md +63 -0
  399. wxcli/org_health/__init__.py +7 -0
  400. wxcli/org_health/analyze.py +93 -0
  401. wxcli/org_health/checks.py +458 -0
  402. wxcli/org_health/collector.py +62 -0
  403. wxcli/org_health/models.py +78 -0
  404. wxcli/org_health/report.py +300 -0
  405. wxcli/output.py +101 -0
  406. wxcli-1.2.0.dist-info/METADATA +505 -0
  407. wxcli-1.2.0.dist-info/RECORD +413 -0
  408. wxcli-1.2.0.dist-info/WHEEL +5 -0
  409. wxcli-1.2.0.dist-info/entry_points.txt +2 -0
  410. wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
  411. wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
  412. wxcli-1.2.0.dist-info/scm_version.json +8 -0
  413. wxcli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,776 @@
1
+ """Executive summary HTML generator for CUCM assessment reports (v4).
2
+
3
+ Generates a 4-page narrative: Verdict → Environment → Scope → Next Steps.
4
+ Depends on score, charts, explainer, and helpers modules.
5
+
6
+ One public function: generate_executive_summary().
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import html
12
+ import json
13
+ from typing import Any
14
+
15
+ from wxcli.migration.report.charts import donut_chart, gauge_chart, stacked_bar_chart
16
+ from wxcli.migration.report.explainer import (
17
+ DECISION_TYPE_DISPLAY_NAMES,
18
+ explain_decision,
19
+ generate_key_findings,
20
+ generate_verdict,
21
+ )
22
+ from wxcli.migration.report.helpers import friendly_site_name, strip_canonical_id
23
+ from wxcli.migration.report.score import compute_complexity_score
24
+ from wxcli.migration.store import MigrationStore
25
+
26
+
27
+ # Feature type display names
28
+ _FEATURE_DISPLAY_NAMES = {
29
+ "hunt_group": "Hunt Group",
30
+ "call_queue": "Call Queue",
31
+ "auto_attendant": "Auto Attendant",
32
+ "call_park": "Call Park",
33
+ "pickup_group": "Pickup Group",
34
+ "paging_group": "Paging Group",
35
+ "call_forwarding": "Call Forwarding",
36
+ "monitoring_list": "BLF / Monitoring",
37
+ "line_key_template": "Phone Button Templates",
38
+ "softkey_config": "Softkey Templates",
39
+ "executive_assistant": "Executive/Assistant",
40
+ }
41
+
42
+ # Webex equivalents for feature types
43
+ _WEBEX_EQUIVALENTS = {
44
+ "hunt_group": "Hunt Group",
45
+ "call_queue": "Call Queue",
46
+ "auto_attendant": "Auto Attendant",
47
+ "call_park": "Call Park",
48
+ "pickup_group": "Call Pickup",
49
+ "paging_group": "Paging Group",
50
+ "call_forwarding": "Per-person call forwarding",
51
+ "monitoring_list": "Per-person monitoring list",
52
+ "line_key_template": "Line Key Templates",
53
+ "softkey_config": "Programmable Softkeys (PSK)",
54
+ "executive_assistant": "Executive/Assistant (native)",
55
+ }
56
+
57
+ # Object types for counts
58
+ _REPORT_OBJECT_TYPES = [
59
+ "user", "device", "location", "hunt_group", "call_queue",
60
+ "auto_attendant", "call_park", "pickup_group", "paging_group",
61
+ "trunk", "route_group", "dial_plan", "translation_pattern",
62
+ "css", "partition", "shared_line", "workspace", "virtual_line",
63
+ "operating_mode", "schedule", "voicemail_profile", "calling_permission",
64
+ ]
65
+
66
+
67
+ def _count_selective_call_handling_candidates(store: MigrationStore) -> int:
68
+ """Count non-stale FEATURE_APPROXIMATION decisions tagged with the
69
+ selective_call_handling_pattern context key."""
70
+ count = 0
71
+ for d in store.get_all_decisions():
72
+ if d.get("type") != "FEATURE_APPROXIMATION":
73
+ continue
74
+ if d.get("chosen_option") == "__stale__":
75
+ continue
76
+ ctx = d.get("context", {}) or {}
77
+ if ctx.get("selective_call_handling_pattern"):
78
+ count += 1
79
+ return count
80
+
81
+
82
+ def generate_executive_summary(
83
+ store: MigrationStore,
84
+ brand: str,
85
+ prepared_by: str,
86
+ cluster_name: str = "",
87
+ cucm_version: str = "",
88
+ ) -> str:
89
+ """Generate the 4-page executive summary HTML from a populated store."""
90
+ sections = [
91
+ _page_verdict(store, brand, cluster_name, cucm_version),
92
+ _page_environment(store, brand, cluster_name, cucm_version),
93
+ _page_scope(store),
94
+ _page_next_steps(store, brand, prepared_by),
95
+ ]
96
+ return "\n\n".join(sections)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Page 1 — The Verdict
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def _page_verdict(store: MigrationStore, brand: str, cluster_name: str = "", cucm_version: str = "") -> str:
104
+ """Page 1: Answer 'should I be worried?' in 5 seconds."""
105
+ result = compute_complexity_score(store)
106
+ gauge_svg = gauge_chart(result.score, result.color, result.label)
107
+ verdict_text = generate_verdict(result, store)
108
+ findings = generate_key_findings(store)
109
+
110
+ parts = [
111
+ f'<section id="score">',
112
+ f'<h2>Migration Complexity Assessment</h2>',
113
+ f'<div class="verdict">{verdict_text}</div>',
114
+ ]
115
+
116
+ # Score layout: gauge left, factor bars right
117
+ parts.append('<div class="score-layout">')
118
+ parts.append(f'<div class="score-gauge">\n{gauge_svg}\n</div>')
119
+
120
+ if result.factors:
121
+ sorted_factors = sorted(result.factors, key=lambda f: f["raw_score"], reverse=True)
122
+ parts.append('<div class="score-breakdown">')
123
+ parts.append(
124
+ '<div class="factor-scale">'
125
+ '<span class="factor-scale-title">Complexity Impact</span>'
126
+ '<span class="factor-scale-low">Low</span>'
127
+ '<span class="factor-scale-high">High</span>'
128
+ '</div>'
129
+ )
130
+ for i, factor in enumerate(sorted_factors):
131
+ display = factor.get("display_name", factor["name"])
132
+ raw = factor["raw_score"]
133
+ color = "var(--primary)" if i == 0 and raw > 0 else "var(--slate-400)"
134
+ parts.append(
135
+ f'<div class="factor-row">'
136
+ f'<span class="factor-label">{html.escape(display)}</span>'
137
+ f'<div class="factor-bar">'
138
+ f'<div class="factor-fill" style="width:{raw}%;background:{color};"></div>'
139
+ f'</div>'
140
+ f'</div>'
141
+ )
142
+ parts.append('</div>')
143
+ parts.append('</div>') # close score-layout
144
+
145
+ # Calibration disclaimer
146
+ if not result.calibrated:
147
+ parts.append(
148
+ '<div class="callout info">'
149
+ '<p><strong>Note:</strong> This complexity score uses design-time weights '
150
+ 'that have not yet been calibrated against completed migrations. '
151
+ 'Use as a relative indicator, not an absolute measure.</p>'
152
+ '</div>'
153
+ )
154
+
155
+ # Key findings
156
+ if findings:
157
+ parts.append('<ul class="key-findings">')
158
+ for f in findings:
159
+ icon_class = "check" if f["icon"] == "✓" else "alert"
160
+ icon_text = "✓" if f["icon"] == "✓" else "!"
161
+ parts.append(
162
+ f'<li>'
163
+ f'<span class="finding-icon {icon_class}">{icon_text}</span>'
164
+ f'<span>{f["text"]}</span>'
165
+ f'</li>'
166
+ )
167
+ parts.append('</ul>')
168
+
169
+ # Stat grid
170
+ total_objects = _total_object_count(store)
171
+ parts.append('<div class="stat-grid">')
172
+ parts.append(_stat_card(html.escape(brand), "Customer"))
173
+ if cluster_name:
174
+ parts.append(_stat_card(html.escape(cluster_name), "CUCM Cluster"))
175
+ if cucm_version:
176
+ parts.append(_stat_card(html.escape(cucm_version), "CUCM Version"))
177
+ parts.append(_stat_card(str(total_objects), "Total Objects"))
178
+ parts.append(_stat_card(str(store.count_by_type("location")), "Sites"))
179
+ parts.append(_stat_card(str(store.count_by_type("user")), "Users"))
180
+ parts.append(_stat_card(str(len(store.get_objects("device"))), "Devices"))
181
+ parts.append('</div>')
182
+
183
+ parts.append('</section>')
184
+ return "\n".join(parts)
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Page 2 — Your Environment
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def _page_environment(
192
+ store: MigrationStore,
193
+ brand: str,
194
+ cluster_name: str,
195
+ cucm_version: str,
196
+ ) -> str:
197
+ """Page 2: Four semantic groups — People, Devices, Call Features, Sites."""
198
+ user_count = store.count_by_type("user")
199
+ device_count = len(store.get_objects("device"))
200
+ location_count = store.count_by_type("location")
201
+
202
+ parts = [
203
+ f'<section id="inventory">',
204
+ f'<h2>What You Have</h2>',
205
+ ]
206
+
207
+ # --- Donut chart floated right alongside People + Devices ---
208
+ devices = store.get_objects("device")
209
+ donut_html = ""
210
+ native = convertible = incompatible = 0
211
+ if devices:
212
+ native = sum(1 for d in devices if d.get("compatibility_tier") == "native_mpp")
213
+ convertible = sum(1 for d in devices if d.get("compatibility_tier") == "convertible")
214
+ incompatible = sum(1 for d in devices if d.get("compatibility_tier") == "incompatible")
215
+ webex_app = sum(1 for d in devices if d.get("compatibility_tier") == "webex_app")
216
+ dect = sum(1 for d in devices if d.get("compatibility_tier") == "dect")
217
+ donut_segments = [
218
+ {"label": "Native MPP", "value": native, "color": "#2E7D32"},
219
+ {"label": "Convertible", "value": convertible, "color": "#EF6C00"},
220
+ {"label": "Incompatible", "value": incompatible, "color": "#C62828"},
221
+ ]
222
+ if webex_app > 0:
223
+ donut_segments.append({"label": "Webex App", "value": webex_app, "color": "#0277BD"})
224
+ if dect > 0:
225
+ donut_segments.append({"label": "DECT", "value": dect, "color": "#42A5F5"})
226
+ donut_html = donut_chart(donut_segments)
227
+
228
+ if donut_html:
229
+ parts.append(f'<div class="env-donut">{donut_html}</div>')
230
+
231
+ # --- People ---
232
+ shared_line_count = store.count_by_type("shared_line")
233
+ line_count = store.count_by_type("line")
234
+
235
+ parts.append('<h3>People</h3>')
236
+ parts.append('<div class="stat-grid">')
237
+ parts.append(_stat_card(str(user_count), "Users"))
238
+ if shared_line_count:
239
+ parts.append(_stat_card(str(shared_line_count), "Shared Lines"))
240
+ if line_count:
241
+ parts.append(_stat_card(str(line_count), "Extensions"))
242
+ intercept_count = store.count_by_type("intercept_candidate")
243
+ if intercept_count:
244
+ parts.append(_stat_card(str(intercept_count), "Intercept Candidates"))
245
+ parts.append('</div>')
246
+
247
+ # --- Devices ---
248
+ if devices:
249
+ total = len(devices)
250
+
251
+ parts.append('<h3>Devices</h3>')
252
+ parts.append('<div class="stat-grid">')
253
+ parts.append(_stat_card(str(total), "Total Phones"))
254
+ parts.append(_stat_card(str(native), "Native MPP"))
255
+ if convertible:
256
+ parts.append(_stat_card(str(convertible), "Convertible"))
257
+ if incompatible:
258
+ parts.append(_stat_card(str(incompatible), "Incompatible"))
259
+ if dect:
260
+ parts.append(_stat_card(str(dect), "DECT"))
261
+ parts.append('</div>')
262
+
263
+ if donut_html:
264
+ parts.append('<div style="clear:both;"></div>')
265
+
266
+ # --- Analog Gateways (if present) ---
267
+ gateways = store.get_objects("gateway")
268
+ if gateways:
269
+ analog_count = 0
270
+ estimated_ports = 0
271
+ for gw in gateways:
272
+ state = gw.get("pre_migration_state", {})
273
+ product = (state.get("product", "") or "").upper()
274
+ protocol = (state.get("protocol", "") or "").upper()
275
+ is_analog = protocol in ("MGCP", "H.323", "H323") or any(
276
+ kw in product for kw in ("VG", "ATA", "ISR", "FXS", "FXO")
277
+ )
278
+ if is_analog:
279
+ analog_count += 1
280
+ # Quick port estimate from product name
281
+ for model, ports in [("ATA 191", 2), ("ATA 192", 2), ("VG310", 24),
282
+ ("VG350", 48), ("VG400", 8), ("VG450", 200)]:
283
+ if model.upper() in product:
284
+ estimated_ports += ports
285
+ break
286
+
287
+ if analog_count > 0:
288
+ port_text = f" with an estimated <strong>{estimated_ports} ports</strong>" if estimated_ports else ""
289
+ parts.append(
290
+ f'<div class="callout warning">'
291
+ f'<p><strong>{analog_count} analog gateway{"s" if analog_count != 1 else ""}</strong>'
292
+ f'{port_text} serving fax, paging, and analog devices. '
293
+ f'Each port requires manual mapping to a Webex workspace with ATA provisioning. '
294
+ f'See Gateway &amp; Analog Port Review in the Technical Appendix.</p>'
295
+ f'</div>'
296
+ )
297
+
298
+ # --- Custom Audio Assets (if present) ---
299
+ moh_sources = store.get_objects("music_on_hold")
300
+ announcements = store.get_objects("announcement")
301
+ custom_moh = [m for m in moh_sources if not m.get("is_default", False)]
302
+ if custom_moh or announcements:
303
+ parts.append('<h3>Custom Audio</h3>')
304
+ parts.append('<div class="stat-grid">')
305
+ if custom_moh:
306
+ parts.append(_stat_card(str(len(custom_moh)), "Custom MoH Sources"))
307
+ if announcements:
308
+ parts.append(_stat_card(str(len(announcements)), "Announcement Files"))
309
+ parts.append('</div>')
310
+ parts.append(
311
+ '<div class="callout warning">'
312
+ '<p>Custom audio files must be downloaded from CUCM and uploaded to '
313
+ 'Webex before migration. See Audio Assets in the Technical Appendix.</p>'
314
+ '</div>'
315
+ )
316
+
317
+ # --- Call Features ---
318
+ feature_types = ["hunt_group", "call_queue", "auto_attendant",
319
+ "call_park", "pickup_group", "paging_group",
320
+ "call_forwarding", "monitoring_list",
321
+ "executive_assistant"]
322
+ feature_data = []
323
+ for ft in feature_types:
324
+ count = store.count_by_type(ft)
325
+ if count > 0:
326
+ feature_data.append((ft, _FEATURE_DISPLAY_NAMES.get(ft, ft), count))
327
+
328
+ if feature_data:
329
+ parts.append('<h3>Call Features</h3>')
330
+ parts.append('<div class="stat-grid">')
331
+ for ft, display, count in feature_data:
332
+ parts.append(_stat_card(str(count), display))
333
+ parts.append('</div>')
334
+
335
+ # Feature mapping table with badges
336
+ feature_table = _build_feature_mapping_table(store)
337
+ if feature_table:
338
+ parts.append(feature_table)
339
+
340
+ # --- Sites ---
341
+ locations = store.get_objects("location")
342
+ if locations:
343
+ parts.append('<h3>Sites</h3>')
344
+ site_rows = _build_site_breakdown(store, locations)
345
+ parts.append(_build_table(
346
+ headers=["Site", "Users", "Devices", "Complexity"],
347
+ rows=site_rows,
348
+ ))
349
+
350
+ # Migration coverage callout — Tier 3 informational types
351
+ info_prefixes = [
352
+ "info_region", "info_srst", "info_media_resource_group",
353
+ "info_media_resource_list", "info_aar_group",
354
+ "info_device_mobility_group", "info_conference_bridge",
355
+ "info_softkey_template", "info_ip_phone_service", "info_intercom",
356
+ "info_common_phone_config", "info_phone_button_template",
357
+ "info_feature_control_policy", "info_credential_policy",
358
+ "info_recording_profile", "info_ldap_directory",
359
+ "info_app_user", "info_h323_gateway",
360
+ "info_enterprise_params", "info_service_params",
361
+ ]
362
+ info_total = sum(store.count_by_type(p) for p in info_prefixes)
363
+ if info_total > 0:
364
+ mapped_types = sum(1 for t in _REPORT_OBJECT_TYPES if store.count_by_type(t) > 0)
365
+ info_types = sum(1 for p in info_prefixes if store.count_by_type(p) > 0)
366
+ parts.append(
367
+ '<div class="callout">'
368
+ f'<p><strong>Migration Coverage:</strong> '
369
+ f'{mapped_types} object types fully mapped for migration, '
370
+ f'{info_types} additional CUCM types documented for reference '
371
+ f'(see Technical Appendix sections O\u2013R).</p>'
372
+ '</div>'
373
+ )
374
+
375
+ parts.append('</section>')
376
+ return "\n".join(parts)
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # Page 3 — Migration Scope (Effort Bands)
381
+ # ---------------------------------------------------------------------------
382
+
383
+ def _page_scope(store: MigrationStore) -> str:
384
+ """Page 3: Three effort bands — auto, planning, manual."""
385
+ decisions = store.get_all_decisions()
386
+
387
+ # Decision summary stat grid
388
+ total_decisions = len(decisions)
389
+ resolved = sum(1 for d in decisions if d.get("chosen_option"))
390
+ unresolved = total_decisions - resolved
391
+ critical = sum(1 for d in decisions if d.get("severity", "").upper() == "CRITICAL")
392
+
393
+ auto, planning, manual = _classify_decisions(decisions)
394
+
395
+ parts = [
396
+ f'<section id="decisions">',
397
+ f'<h2>What Needs Attention</h2>',
398
+ ]
399
+
400
+ parts.append('<div class="stat-grid">')
401
+ parts.append(_stat_card(str(resolved), "Auto-resolved"))
402
+ parts.append(_stat_card(str(unresolved), "Decisions Needed"))
403
+ if critical:
404
+ parts.append(_stat_card(str(critical), "Critical"))
405
+ parts.append('</div>')
406
+
407
+ # Effort band: Migrates Automatically
408
+ parts.append('<div class="effort-band auto">')
409
+ parts.append(f'<h4>Migrates Automatically</h4>')
410
+ parts.append(f'<p>{len(auto)} items migrate with no manual intervention.</p>')
411
+ if auto:
412
+ parts.append('<ul>')
413
+ for d in auto[:5]:
414
+ display_type = DECISION_TYPE_DISPLAY_NAMES.get(d["type"], d["type"])
415
+ summary = d.get("summary", "")
416
+ parts.append(f'<li><strong>{html.escape(display_type)}:</strong> {html.escape(summary)}</li>')
417
+ if len(auto) > 5:
418
+ parts.append(f'<li><em>...and {len(auto) - 5} more</em></li>')
419
+ parts.append('</ul>')
420
+ parts.append('</div>')
421
+
422
+ # Effort band: Needs Planning
423
+ parts.append('<div class="effort-band planning">')
424
+ parts.append(f'<h4>Needs Planning</h4>')
425
+ parts.append(f'<p>{len(planning)} items need configuration decisions during planning.</p>')
426
+ if planning:
427
+ parts.append('<ul>')
428
+ for d in planning[:5]:
429
+ display_type = DECISION_TYPE_DISPLAY_NAMES.get(d["type"], d["type"])
430
+ summary = d.get("summary", "")
431
+ parts.append(f'<li><strong>{html.escape(display_type)}:</strong> {html.escape(summary)}</li>')
432
+ if len(planning) > 5:
433
+ parts.append(f'<li><em>...and {len(planning) - 5} more</em></li>')
434
+ parts.append('</ul>')
435
+ parts.append('</div>')
436
+
437
+ # Effort band: Requires Manual Work
438
+ parts.append('<div class="effort-band manual">')
439
+ parts.append(f'<h4>Requires Manual Work</h4>')
440
+ parts.append(f'<p>{len(manual)} items require manual configuration or hardware changes.</p>')
441
+ if manual:
442
+ parts.append('<ul>')
443
+ for d in manual[:5]:
444
+ display_type = DECISION_TYPE_DISPLAY_NAMES.get(d["type"], d["type"])
445
+ summary = d.get("summary", "")
446
+ parts.append(f'<li><strong>{html.escape(display_type)}:</strong> {html.escape(summary)}</li>')
447
+ if len(manual) > 5:
448
+ parts.append(f'<li><em>...and {len(manual) - 5} more</em></li>')
449
+ parts.append('</ul>')
450
+ parts.append('</div>')
451
+
452
+ # Decision resolution bar
453
+ total = len(decisions)
454
+ resolved = sum(1 for d in decisions if d.get("chosen_option"))
455
+ if total > 0:
456
+ pct = round(resolved / total * 100)
457
+ parts.append(
458
+ f'<p class="small muted">'
459
+ f'Decision resolution: <strong>{resolved} of {total}</strong> auto-resolved ({pct}%)</p>'
460
+ )
461
+
462
+ sch_count = _count_selective_call_handling_candidates(store)
463
+ if sch_count:
464
+ parts.append(
465
+ f'<div class="callout">'
466
+ f'<p><strong>Selective Call Handling:</strong> {sch_count} '
467
+ f'candidate(s) for Webex Selective Forward / Accept / Reject. '
468
+ f'See appendix section AB for the per-user breakdown.</p>'
469
+ f'</div>'
470
+ )
471
+
472
+ parts.append('</section>')
473
+ return "\n".join(parts)
474
+
475
+
476
+ def _classify_decisions(
477
+ decisions: list[dict[str, Any]],
478
+ ) -> tuple[list[dict], list[dict], list[dict]]:
479
+ """Classify decisions into auto/planning/manual effort bands.
480
+
481
+ Returns:
482
+ (auto_resolved, needs_planning, requires_manual) — three lists.
483
+ """
484
+ auto = []
485
+ planning = []
486
+ manual = []
487
+
488
+ # Types that typically require manual work
489
+ manual_types = {"DEVICE_INCOMPATIBLE", "VOICEMAIL_INCOMPATIBLE", "MISSING_DATA"}
490
+ # Types that need planning decisions
491
+ planning_types = {
492
+ "CSS_ROUTING_MISMATCH", "CALLING_PERMISSION_MISMATCH", "LOCATION_AMBIGUOUS",
493
+ "SHARED_LINE_COMPLEX", "EXTENSION_CONFLICT", "DN_AMBIGUOUS",
494
+ "WORKSPACE_TYPE_UNCERTAIN", "WORKSPACE_LICENSE_TIER",
495
+ "HOTDESK_DN_CONFLICT", "NUMBER_CONFLICT", "DUPLICATE_USER",
496
+ }
497
+
498
+ for d in decisions:
499
+ if d.get("chosen_option"):
500
+ auto.append(d)
501
+ elif d.get("type") in manual_types:
502
+ # Webex App transitions are INFO severity and go to planning, not manual
503
+ if d.get("type") == "DEVICE_INCOMPATIBLE":
504
+ ctx = d.get("context", {})
505
+ if isinstance(ctx, str):
506
+ try:
507
+ ctx = json.loads(ctx)
508
+ except (json.JSONDecodeError, TypeError):
509
+ ctx = {}
510
+ if ctx.get("webex_app_transition") is True:
511
+ planning.append(d)
512
+ continue
513
+ manual.append(d)
514
+ elif d.get("type") in planning_types:
515
+ planning.append(d)
516
+ elif d.get("severity", "").upper() in ("CRITICAL", "HIGH"):
517
+ manual.append(d)
518
+ else:
519
+ planning.append(d)
520
+
521
+ return auto, planning, manual
522
+
523
+
524
+ # ---------------------------------------------------------------------------
525
+ # Page 4 — Next Steps
526
+ # ---------------------------------------------------------------------------
527
+
528
+ def _page_next_steps(
529
+ store: MigrationStore,
530
+ brand: str,
531
+ prepared_by: str,
532
+ ) -> str:
533
+ """Page 4: Prerequisites, planning phase items, CTA."""
534
+ decisions = store.get_all_decisions()
535
+ unresolved = [d for d in decisions if d.get("chosen_option") is None]
536
+ user_count = store.count_by_type("user")
537
+ device_count = len(store.get_objects("device"))
538
+
539
+ parts = [
540
+ f'<section id="next-steps">',
541
+ f'<h2>Next Steps</h2>',
542
+ ]
543
+
544
+ # Before Migration
545
+ parts.append('<h3>Before Migration</h3>')
546
+ parts.append('<table>')
547
+ parts.append('<thead><tr><th>Item</th><th class="num">Count</th><th>Status</th></tr></thead>')
548
+ parts.append('<tbody>')
549
+ parts.append(
550
+ f'<tr><td>Webex Calling licenses needed</td>'
551
+ f'<td class="num">{user_count}</td>'
552
+ f'<td>Verify with Cisco</td></tr>'
553
+ )
554
+ parts.append(
555
+ f'<tr><td>Phone numbers to port</td>'
556
+ f'<td class="num">{user_count}</td>'
557
+ f'<td>Confirm with carrier</td></tr>'
558
+ )
559
+ parts.append(
560
+ f'<tr><td>Decisions to resolve</td>'
561
+ f'<td class="num">{len(unresolved)}</td>'
562
+ f'<td>{"Pending review" if unresolved else "Complete"}</td></tr>'
563
+ )
564
+ parts.append('</tbody></table>')
565
+
566
+ # Voicemail greeting re-recording callout
567
+ greeting_count = _count_custom_greetings(store)
568
+ if greeting_count > 0:
569
+ parts.append(
570
+ f'<div class="callout warning">'
571
+ f'<p><strong>Voicemail Greeting Re-Recording:</strong> '
572
+ f'{greeting_count} user{"s" if greeting_count != 1 else ""} '
573
+ f'must re-record their voicemail greeting{"s" if greeting_count != 1 else ""} after migration. '
574
+ f'Send user communication at least 1 week before cutover. '
575
+ f'See Appendix H for email template.</p>'
576
+ f'</div>'
577
+ )
578
+
579
+ # Planning Phase
580
+ if unresolved:
581
+ parts.append('<h3>Planning Phase</h3>')
582
+ parts.append(f'<p>{len(unresolved)} decisions need review before migration can proceed:</p>')
583
+ parts.append('<ul>')
584
+ # Group by type
585
+ type_counts: dict[str, int] = {}
586
+ for d in unresolved:
587
+ dt = d.get("type", "UNKNOWN")
588
+ type_counts[dt] = type_counts.get(dt, 0) + 1
589
+ for dt, count in sorted(type_counts.items(), key=lambda x: -x[1]):
590
+ display = DECISION_TYPE_DISPLAY_NAMES.get(dt, dt)
591
+ parts.append(f'<li>{html.escape(display)}: {count}</li>')
592
+ parts.append('</ul>')
593
+
594
+ # CTA box
595
+ parts.append(
596
+ f'<div class="cta-box">'
597
+ f'<h3>Ready to Plan</h3>'
598
+ f'<p>For questions about this assessment or to start the migration planning engagement, '
599
+ f'contact <strong>{html.escape(prepared_by)}</strong>.</p>'
600
+ f'</div>'
601
+ )
602
+
603
+ parts.append('</section>')
604
+ return "\n".join(parts)
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # Helpers
609
+ # ---------------------------------------------------------------------------
610
+
611
+ def _stat_card(value: str, label: str) -> str:
612
+ """Render a stat card HTML fragment."""
613
+ return (
614
+ f'<div class="stat-card">'
615
+ f'<div class="stat-number">{html.escape(value)}</div>'
616
+ f'<div class="stat-label">{html.escape(label)}</div>'
617
+ f'</div>'
618
+ )
619
+
620
+
621
+ def _build_site_breakdown(
622
+ store: MigrationStore,
623
+ locations: list[dict[str, Any]],
624
+ ) -> list[tuple[str, ...]]:
625
+ """Build per-site breakdown rows."""
626
+ decisions = store.get_all_decisions()
627
+ all_users = store.get_objects("user")
628
+ all_devices = store.get_objects("device")
629
+ rows = []
630
+
631
+ for loc in locations:
632
+ loc_id = loc.get("canonical_id", "")
633
+ loc_name = loc.get("name", "")
634
+ if not loc_name:
635
+ loc_name = strip_canonical_id(loc_id)
636
+ friendly = friendly_site_name(loc_name) if loc_name.startswith("DP-") else loc_name
637
+
638
+ user_count = sum(1 for u in all_users if u.get("location_id") == loc_id)
639
+ loc_user_ids = {u.get("canonical_id") for u in all_users if u.get("location_id") == loc_id}
640
+ device_count = sum(1 for d in all_devices if d.get("owner_canonical_id") in loc_user_ids)
641
+ loc_decisions = _count_decisions_for_location(store, decisions, loc_id, loc_user_ids)
642
+
643
+ if loc_decisions == 0:
644
+ complexity = '<span class="badge badge-direct">Straightforward</span>'
645
+ elif loc_decisions <= 2:
646
+ complexity = '<span class="badge badge-approx">Moderate</span>'
647
+ else:
648
+ complexity = '<span class="badge badge-decision">Complex</span>'
649
+
650
+ rows.append((
651
+ html.escape(friendly),
652
+ str(user_count),
653
+ str(device_count),
654
+ complexity,
655
+ ))
656
+
657
+ return rows
658
+
659
+
660
+ def _count_custom_greetings(store: MigrationStore) -> int:
661
+ """Count MISSING_DATA decisions for custom voicemail greetings."""
662
+ count = 0
663
+ for d in store.get_all_decisions():
664
+ if d.get("type") != "MISSING_DATA":
665
+ continue
666
+ ctx = d.get("context", {})
667
+ if ctx.get("reason") == "custom_greeting_not_extractable":
668
+ count += 1
669
+ return count
670
+
671
+
672
+ def _count_decisions_for_location(
673
+ store: MigrationStore,
674
+ decisions: list[dict[str, Any]],
675
+ loc_id: str,
676
+ loc_user_ids: set[str],
677
+ ) -> int:
678
+ """Count decisions that reference a given location or its objects."""
679
+ count = 0
680
+ for d in decisions:
681
+ ctx = d.get("context", {})
682
+ obj_id = ctx.get("object_id", "")
683
+ css_id = ctx.get("css_id", "")
684
+
685
+ if obj_id:
686
+ obj = store.get_object(obj_id)
687
+ if obj and obj.get("location_id") == loc_id:
688
+ count += 1
689
+ elif obj_id in loc_user_ids:
690
+ count += 1
691
+ elif css_id:
692
+ count += 1
693
+ return count
694
+
695
+
696
+ def _build_feature_mapping_table(
697
+ store: MigrationStore,
698
+ ) -> str:
699
+ """Build feature mapping table with Direct/Approximation badges."""
700
+ decisions = store.get_all_decisions()
701
+ approx_by_object: dict[str, dict[str, Any]] = {}
702
+ for d in decisions:
703
+ if d["type"] == "FEATURE_APPROXIMATION":
704
+ obj_id = d.get("context", {}).get("object_id", "")
705
+ if obj_id:
706
+ approx_by_object[obj_id] = d
707
+
708
+ rows = []
709
+ for type_key, display_name in _FEATURE_DISPLAY_NAMES.items():
710
+ count = store.count_by_type(type_key)
711
+ if count == 0:
712
+ continue
713
+
714
+ webex_equiv = _WEBEX_EQUIVALENTS.get(type_key, display_name)
715
+ has_approx = False
716
+ has_unresolved = False
717
+
718
+ objects = store.get_objects(type_key)
719
+ for obj in objects:
720
+ obj_id = obj.get("canonical_id", "")
721
+ if obj_id in approx_by_object:
722
+ has_approx = True
723
+ d = approx_by_object[obj_id]
724
+ ctx_webex = d.get("context", {}).get("webex_feature", "")
725
+ if ctx_webex:
726
+ webex_equiv = ctx_webex
727
+ if d.get("chosen_option") is None:
728
+ has_unresolved = True
729
+
730
+ if not has_approx:
731
+ status = '<span class="badge badge-direct">Direct</span>'
732
+ elif has_unresolved:
733
+ status = '<span class="badge badge-decision">Decision needed</span>'
734
+ else:
735
+ status = '<span class="badge badge-approx">Approximation</span>'
736
+
737
+ rows.append((
738
+ display_name,
739
+ str(count),
740
+ html.escape(webex_equiv),
741
+ status,
742
+ ))
743
+
744
+ if not rows:
745
+ return ""
746
+
747
+ return _build_table(
748
+ headers=["CUCM Feature", "Count", "Webex Equivalent", "Status"],
749
+ rows=rows,
750
+ )
751
+
752
+
753
+ def _build_table(
754
+ headers: list[str],
755
+ rows: list[tuple[str, ...]],
756
+ ) -> str:
757
+ """Build an HTML table from headers and row tuples."""
758
+ parts = ['<table>']
759
+ parts.append('<thead><tr>')
760
+ for h in headers:
761
+ parts.append(f'<th>{html.escape(h)}</th>')
762
+ parts.append('</tr></thead>')
763
+ parts.append('<tbody>')
764
+ for row in rows:
765
+ parts.append('<tr>')
766
+ for cell in row:
767
+ parts.append(f'<td>{cell}</td>')
768
+ parts.append('</tr>')
769
+ parts.append('</tbody>')
770
+ parts.append('</table>')
771
+ return "\n".join(parts)
772
+
773
+
774
+ def _total_object_count(store: MigrationStore) -> int:
775
+ """Count all objects in the store across known report types."""
776
+ return sum(store.count_by_type(t) for t in _REPORT_OBJECT_TYPES)